File size: 21,684 Bytes
342e0fb
be3f38d
342e0fb
0890748
 
0de0dd3
342e0fb
 
 
 
0890748
 
 
be3f38d
3b06b5f
be3f38d
 
 
 
e9af3d2
342e0fb
e9af3d2
be3f38d
 
cd3139e
 
e9af3d2
 
 
 
6dda67c
cd3139e
e9af3d2
 
 
 
cd3139e
 
 
 
6dda67c
 
cd3139e
e9af3d2
 
 
 
 
 
 
6dda67c
be3f38d
e9af3d2
 
be3f38d
cd3139e
 
e9af3d2
6dda67c
e9af3d2
 
 
6dda67c
e9af3d2
6dda67c
 
cd3139e
 
6dda67c
cd3139e
 
342e0fb
e9af3d2
 
 
 
6dda67c
 
e9af3d2
 
 
6dda67c
e9af3d2
6dda67c
 
 
 
 
 
 
 
 
 
 
 
 
be3f38d
6dda67c
be3f38d
 
6dda67c
 
e9af3d2
 
6dda67c
 
 
 
 
be3f38d
6dda67c
 
be3f38d
6dda67c
 
 
 
 
 
 
 
 
 
 
 
 
be3f38d
6dda67c
 
 
 
 
 
e9af3d2
 
 
 
6dda67c
3b06b5f
e9af3d2
3b06b5f
0890748
 
3b06b5f
 
 
e9af3d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd3139e
 
342e0fb
e9af3d2
 
 
 
 
342e0fb
cd3139e
 
 
e9af3d2
cd3139e
e9af3d2
cd3139e
 
 
342e0fb
 
6dda67c
342e0fb
 
 
 
6dda67c
 
cd3139e
6dda67c
3b06b5f
e9af3d2
 
 
 
 
 
be3f38d
cd3139e
be3f38d
342e0fb
6dda67c
342e0fb
0890748
cd3139e
6dda67c
d392f23
e9af3d2
be3f38d
 
 
 
 
 
 
 
 
 
e9af3d2
be3f38d
e9af3d2
be3f38d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e9af3d2
be3f38d
 
 
 
 
 
e9af3d2
 
 
 
 
be3f38d
e9af3d2
 
be3f38d
e9af3d2
 
 
be3f38d
 
 
 
 
e9af3d2
 
be3f38d
 
 
 
 
 
e9af3d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be3f38d
e9af3d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be3f38d
 
 
e9af3d2
 
 
 
be3f38d
 
e9af3d2
be3f38d
 
e9af3d2
 
 
 
be3f38d
 
 
 
e9af3d2
be3f38d
 
 
 
 
 
e9af3d2
 
 
 
be3f38d
e9af3d2
be3f38d
 
 
e9af3d2
 
be3f38d
 
 
e9af3d2
 
be3f38d
 
 
 
e9af3d2
be3f38d
 
e9af3d2
be3f38d
 
e9af3d2
be3f38d
 
 
e9af3d2
 
 
 
be3f38d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e9af3d2
be3f38d
 
 
 
e9af3d2
be3f38d
 
 
 
 
 
161d310
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
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}")