auto_cliper / core /analyze.py
alisaadhq's picture
Update core/analyze.py
161d310 verified
Raw
History Blame Contribute Delete
21.7 kB
import os
import re
import time
import json
import logging
from groq import Groq
from dotenv import load_dotenv
load_dotenv()
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
api_key = os.getenv("GROQ_API_KEY")
MODEL_NAME = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
client = Groq(api_key=api_key)
MIN_DURATION = 60 # seconds
MAX_DURATION = 180 # seconds
TARGET_DURATION = 90 # ideal segment length
# ── Backtick fence markers ──
_FENCE_JSON = "```json"
_FENCE = "```"
# ─────────────────────────────────────────────────────────────────────────────
# VALIDATION — no mechanical padding, only natural segments pass
# ─────────────────────────────────────────────────────────────────────────────
def validate_segments(segments, video_duration=None):
"""
Accept only segments the AI correctly identified as complete scenes.
Short segments are REJECTED — not padded — to avoid random cuts.
The AI is instructed to return full scenes, so if it returns something
short it means it only found the punchline, not the full story.
"""
valid = []
for seg in segments:
start = seg.get("start_time", 0)
end = seg.get("end_time", 0)
dur = end - start
# Clamp end_time to video duration if known
if video_duration and end > video_duration:
end = video_duration
seg["end_time"] = round(end, 2)
dur = end - start
# Too long → hard reject
if dur > MAX_DURATION:
logger.warning(
f"⚠️ Too long ({dur:.1f}s) — skipped: "
f"'{seg.get('title', '')}' [{start}s–{end}s]"
)
continue
# Too short → reject and log so we can tune the prompt
if dur < MIN_DURATION:
logger.warning(
f"⚠️ Too short ({dur:.1f}s) — AI returned only punchline, not full scene. "
f"Skipped: '{seg.get('title', '')}' [{start}s–{end}s]"
)
continue
seg["duration"] = round(dur, 2)
valid.append(seg)
logger.info(f"✅ Valid segments after filter: {len(valid)}/{len(segments)}")
return valid
# ─────────────────────────────────────────────────────────────────────────────
# FALLBACK — heuristic when AI returns nothing
# ─────────────────────────────────────────────────────────────────────────────
def _fallback_segments_from_transcript(transcript: str, video_duration: float) -> list:
"""
If AI returns nothing useful, generate segments from the transcript using
the densest word windows around timestamp markers.
Only used as a last resort.
"""
logger.warning("⚠️ Using fallback segment generator from transcript timestamps")
lines = []
for match in re.finditer(r'\[(\d+\.?\d*)\s*-\s*(\d+\.?\d*)\]\s*(.*)', transcript):
lines.append({
"start": float(match.group(1)),
"end": float(match.group(2)),
"text": match.group(3).strip(),
})
if not lines:
return []
candidates = []
step = 60
window = TARGET_DURATION
t = lines[0]["start"]
max_t = lines[-1]["end"]
while t + MIN_DURATION <= max_t:
w_end = min(t + window, max_t)
in_window = [l for l in lines if l["start"] >= t and l["end"] <= w_end]
word_count = sum(len(l["text"].split()) for l in in_window)
candidates.append({
"start_time": round(t, 2),
"end_time": round(w_end, 2),
"word_count": word_count,
"title": f"Highlight at {int(t // 60)}m{int(t % 60):02d}s",
"description": "Auto-detected highlight segment",
"reason": "Fallback: highest word-density window",
"viral_score": word_count,
})
t += step
if not candidates:
return []
candidates.sort(key=lambda x: x["word_count"], reverse=True)
top = candidates[:3]
top.sort(key=lambda x: x["start_time"])
deduped = []
for c in top:
if deduped and c["start_time"] < deduped[-1]["end_time"] - 20:
continue
deduped.append(c)
logger.info(f"🔧 Fallback generated {len(deduped)} segment(s)")
return deduped
# ─────────────────────────────────────────────────────────────────────────────
# CORE ANALYSIS — single chunk
# ─────────────────────────────────────────────────────────────────────────────
def analyze_transcript(transcript, video_duration=None):
"""
Send one transcript chunk to Groq and return validated segments.
"""
prompt = f"""
You are a viral short-form video editor specializing in TikTok, Reels, and YouTube Shorts.
Your job is to find COMPLETE, PUBLISH-READY segments — not just funny lines or punchlines.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⛔ CRITICAL DURATION RULE — VIOLATIONS WILL BE DELETED
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- end_time − start_time MUST be between {MIN_DURATION} and {MAX_DURATION} seconds
- Segments shorter than {MIN_DURATION}s will be DELETED, not fixed
- "I found a funny line at 150s" is NOT a valid segment
- "I found a complete story from 95s to 220s that includes setup + peak + conclusion" IS valid
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ MANDATORY THINKING PROCESS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
For every candidate moment, follow these steps:
Step 1 — Find the INTERESTING MOMENT (the peak/punchline/reveal)
Step 2 — Scroll BACK in the transcript to find where the SETUP begins
(usually 30–90 seconds before the peak)
Ask: "When does the viewer first need to start watching to understand this?"
Step 3 — Scroll FORWARD to find where the NATURAL CONCLUSION ends
(usually 15–40 seconds after the peak)
Ask: "Is the story/joke/argument fully resolved? Is there a reaction?"
Step 4 — Check: end_time − start_time is between {MIN_DURATION} and {MAX_DURATION} seconds
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📌 CORRECT vs WRONG EXAMPLE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Funny moment at 150s:
✅ CORRECT: start=95s (setup), end=220s (reaction ends) → 125s duration
❌ WRONG: start=145s (just before punchline), end=165s → 20s duration
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 A COMPLETE SEGMENT MUST HAVE ALL OF THESE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. HOOK — something in the first 5s that makes viewers keep watching
2. SETUP — enough context so a stranger understands without watching anything before
3. BUILD-UP — tension, humor, conflict, or emotion growing
4. PAYOFF — the peak moment (punchline / reveal / climax)
5. CONCLUSION — resolution, reaction, or natural pause — NOT an abrupt cut
6. STANDALONE — makes complete sense on its own
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔢 OUTPUT FORMAT — raw JSON only, no markdown, no explanation
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{{
"segments": [
{{
"start_time": <float — where the SETUP begins, NOT the funny moment>,
"end_time": <float — where the CONCLUSION ends, NOT just the punchline>,
"title": "<punchy YouTube Shorts title, max 60 chars>",
"description": "<1-2 sentences describing the full arc: setup → peak → conclusion>",
"reason": "<why this is complete and viral-worthy, max 25 words>"
}}
]
}}
If no complete story qualifies: {{"segments": []}}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TRANSCRIPT:
{transcript}
"""
max_retries = 3
base_delay = 5
for attempt in range(max_retries):
try:
response = client.chat.completions.create(
model = MODEL_NAME,
messages = [
{
"role": "system",
"content": (
"You are a JSON-only assistant. "
"Output raw JSON only — no markdown, no code blocks, no explanation. "
f"Every segment MUST be between {MIN_DURATION} and {MAX_DURATION} seconds. "
"Short segments (punchlines without setup) will be DELETED — not fixed. "
"Always include: full setup before the peak + reaction/conclusion after. "
"Use the timestamps in the transcript to pick natural scene boundaries."
),
},
{"role": "user", "content": prompt},
],
temperature = 0.3,
)
content = response.choices[0].message.content.strip()
logger.info(f"🤖 AI Raw Response (first 300 chars): {content[:300]}...")
# Strip markdown fences if model ignored system prompt
if _FENCE_JSON in content:
content = content.split(_FENCE_JSON)[1].split(_FENCE)[0].strip()
elif _FENCE in content:
content = content.split(_FENCE)[1].split(_FENCE)[0].strip()
data = json.loads(content)
raw_segments = data.get("segments", [])
valid_segments = validate_segments(raw_segments, video_duration=video_duration)
# Fallback if AI returned nothing valid
if not valid_segments:
logger.warning("⚠️ AI returned 0 valid segments — trying fallback generator")
fallback = _fallback_segments_from_transcript(
transcript, video_duration or 0
)
valid_segments = validate_segments(fallback, video_duration=video_duration)
data["segments"] = valid_segments
content = json.dumps(data)
logger.info(
f"🤖 Parsed: {len(raw_segments)} raw → {len(valid_segments)} valid segments"
)
return {"content": content}
except Exception as e:
logger.error(f"❌ Error in Groq analysis (attempt {attempt + 1}): {e}")
if attempt < max_retries - 1:
wait = base_delay * (2 ** attempt)
logger.warning(f"⚠️ Retrying in {wait}s...")
time.sleep(wait)
logger.error("❌ All retry attempts failed.")
return {"content": '{"segments": []}'}
# ─────────────────────────────────────────────────────────────────────────────
# SCENE-AWARE CHUNKING
# ─────────────────────────────────────────────────────────────────────────────
def _sentence_chunk(transcript: str, max_tokens: int) -> list[str]:
"""
Fallback chunker when transcript has no timestamps.
Splits at sentence boundaries with overlap.
"""
sentences = transcript.replace('\n', ' ').split('. ')
chunks = []
current_chunk = []
current_length = 0
for sentence in sentences:
sentence_length = len(sentence.split())
if current_length + sentence_length > max_tokens and current_chunk:
chunks.append('. '.join(current_chunk) + '.')
current_chunk = current_chunk[-5:] + [sentence]
current_length = sum(len(s.split()) for s in current_chunk)
else:
current_chunk.append(sentence)
current_length += sentence_length
if current_chunk:
chunks.append('. '.join(current_chunk) + '.')
return chunks
def smart_chunk_transcript(transcript: str, max_tokens: int = 4000) -> list[str]:
"""
Split transcript into coherent chunks at SCENE BOUNDARIES.
Strategy:
- Parse all [start - end] timestamp lines
- Cut only when the chunk is full AND there is a natural gap (>3s) between
consecutive lines — a sign of a scene or topic change
- Keep 10-line overlap between chunks so cross-boundary stories aren't lost
- Falls back to sentence-level splitting if no timestamps found
"""
lines = []
for match in re.finditer(r'\[(\d+\.?\d*)\s*-\s*(\d+\.?\d*)\]\s*(.*)', transcript):
lines.append({
"start": float(match.group(1)),
"end": float(match.group(2)),
"text": match.group(3).strip(),
})
if not lines:
logger.info("📦 No timestamps found — using sentence chunker")
return _sentence_chunk(transcript, max_tokens)
chunks = []
current_lines = []
current_words = 0
for i, line in enumerate(lines):
current_lines.append(line)
current_words += len(line["text"].split())
is_last = (i + 1 == len(lines))
is_full = (current_words >= max_tokens)
# Detect scene break: gap > 3 seconds between this line and the next
next_gap = (
lines[i + 1]["start"] - line["end"]
if not is_last else 999
)
at_scene_break = next_gap > 3.0
if is_last or (is_full and at_scene_break):
chunk_text = "\n".join(
f"[{l['start']} - {l['end']}] {l['text']}"
for l in current_lines
)
chunks.append(chunk_text)
logger.info(
f"📦 Chunk {len(chunks)}: "
f"{current_lines[0]['start']:.0f}s – {current_lines[-1]['end']:.0f}s "
f"({current_words} words)"
)
# Overlap: keep last 10 lines for context continuity
current_lines = current_lines[-10:]
current_words = sum(len(l["text"].split()) for l in current_lines)
logger.info(f"📦 Split into {len(chunks)} scene-aware chunk(s)")
return chunks
# ─────────────────────────────────────────────────────────────────────────────
# MAIN ENTRY POINT
# ─────────────────────────────────────────────────────────────────────────────
def analyze_transcript_with_chunking(transcript, video_duration=None):
"""
Analyze transcript using scene-aware chunking for long content.
Processes each chunk separately and merges + deduplicates results.
"""
word_count = len(transcript.split())
if word_count > 3000:
logger.info(f"📦 Transcript is {word_count} words — using scene-aware chunking...")
chunks = smart_chunk_transcript(transcript, max_tokens=3000)
all_segments = []
for i, chunk in enumerate(chunks):
logger.info(f"🔄 Processing chunk {i + 1}/{len(chunks)}...")
result = analyze_transcript(chunk, video_duration=video_duration)
try:
data = json.loads(result["content"])
if "segments" in data:
all_segments.extend(data["segments"])
logger.info(
f"✅ Chunk {i + 1}: "
f"{len(data['segments'])} valid segment(s)"
)
except Exception as e:
logger.warning(f"⚠️ Failed to parse chunk {i + 1}: {e}")
continue
if all_segments:
# Deduplicate: two segments are the same if their start times
# round to within 10 seconds of each other
unique_segments = []
seen_times = set()
for seg in sorted(all_segments, key=lambda s: s.get("start_time", 0)):
time_key = round(seg.get("start_time", 0) / 10) * 10
if time_key not in seen_times:
unique_segments.append(seg)
seen_times.add(time_key)
logger.info(f"📊 Total unique viral segments found: {len(unique_segments)}")
return {"content": json.dumps({"segments": unique_segments[:10]})}
logger.warning("⚠️ No valid segments found across all chunks.")
return {"content": '{"segments": []}'}
# Short transcript — process directly without chunking
return analyze_transcript(transcript, video_duration=video_duration)
# ─────────────────────────────────────────────────────────────────────────────
# TESTING
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
test_transcript = """
[0.0 - 5.0] Welcome to today's video about productivity hacks that actually work.
[5.0 - 15.0] The first hack is something I call the 2-minute rule. If something takes less than 2 minutes, do it immediately.
[15.0 - 30.0] This simple rule has transformed my life. I used to procrastinate on small tasks, but now I handle them right away.
[30.0 - 45.0] The second hack is batching similar tasks together. Instead of checking email 20 times a day, I check it twice.
[45.0 - 60.0] This has saved me hours every week. I batch my emails, phone calls, and even errands.
[60.0 - 90.0] The third hack is the Pomodoro Technique. Work for 25 minutes, then take a 5-minute break.
[90.0 - 120.0] This technique helps me stay focused and avoid burnout. I get more done in less time.
[120.0 - 150.0] The fourth hack is to eliminate distractions completely. Turn off notifications, close tabs, and focus.
[150.0 - 180.0] When you eliminate distractions, your productivity skyrockets. I finish in 2 hours what used to take 6.
[180.0 - 210.0] The fifth and final hack is to review your day every evening. Spend 5 minutes planning tomorrow.
[210.0 - 240.0] This evening review changed everything for me. I wake up knowing exactly what to do and I never waste morning time figuring out priorities.
"""
logger.info("🧪 Testing AI Analysis...")
result = analyze_transcript_with_chunking(test_transcript, video_duration=240.0)
try:
data = json.loads(result["content"])
segments = data.get("segments", [])
logger.info(f"✅ Found {len(segments)} publish-ready segment(s):\n")
for i, seg in enumerate(segments):
duration = seg["end_time"] - seg["start_time"]
logger.info(
f"#{i + 1} [{seg['start_time']:.0f}s – {seg['end_time']:.0f}s] ({duration:.0f}s)\n"
f" 📌 Title: {seg.get('title', 'N/A')}\n"
f" 📝 Description: {seg.get('description', 'N/A')}\n"
f" 💡 Story Arc: {seg.get('reason', 'N/A')}\n"
)
except Exception as e:
logger.error(f"❌ Error parsing result: {e}")
logger.info(f"Raw result: {result}")