alisaadhq commited on
Commit
e9af3d2
·
verified ·
1 Parent(s): be3f38d

Update core/analyze.py

Browse files
Files changed (1) hide show
  1. core/analyze.py +198 -125
core/analyze.py CHANGED
@@ -17,18 +17,23 @@ client = Groq(api_key=api_key)
17
 
18
  MIN_DURATION = 60 # seconds
19
  MAX_DURATION = 180 # seconds
20
- TARGET_DURATION = 90 # ideal segment length for extending short ones
21
 
22
- # ── Backtick fence markers (defined as variables to avoid markdown issues) ──
23
  _FENCE_JSON = "```json"
24
  _FENCE = "```"
25
 
26
 
 
 
 
 
27
  def validate_segments(segments, video_duration=None):
28
  """
29
- FIX: Instead of discarding short segments, try to EXTEND them
30
- symmetrically (pad before + after) to reach MIN_DURATION.
31
- Only discard if extension is impossible or duration > MAX_DURATION.
 
32
  """
33
  valid = []
34
  for seg in segments:
@@ -36,47 +41,27 @@ def validate_segments(segments, video_duration=None):
36
  end = seg.get("end_time", 0)
37
  dur = end - start
38
 
39
- # ── Too long: hard discard ────────────────────────────────────────────
 
 
 
 
 
 
40
  if dur > MAX_DURATION:
41
  logger.warning(
42
- f"⚠️ Skipped long segment: {dur:.1f}s "
43
- f"[{start}s–{end}s] ({seg.get('title', '')})"
44
  )
45
  continue
46
 
47
- # ── Too short: try to extend ──────────────────────────────────────────
48
  if dur < MIN_DURATION:
49
- needed = MIN_DURATION - dur
50
- pad_pre = needed / 2
51
- pad_post = needed / 2
52
-
53
- new_start = max(0.0, start - pad_pre)
54
- new_end = end + pad_post
55
-
56
- # Clamp to video duration if known
57
- if video_duration:
58
- new_end = min(video_duration, new_end)
59
- # If still not long enough, steal more from the front
60
- actual_dur = new_end - new_start
61
- if actual_dur < MIN_DURATION and new_start > 0:
62
- new_start = max(0.0, new_end - MIN_DURATION)
63
-
64
- actual_dur = new_end - new_start
65
- if actual_dur < MIN_DURATION:
66
- logger.warning(
67
- f"⚠️ Skipped unextendable segment: "
68
- f"{dur:.1f}s → {actual_dur:.1f}s "
69
- f"[{start}s–{end}s] ({seg.get('title', '')})"
70
- )
71
- continue
72
-
73
- logger.info(
74
- f"🔧 Extended short segment {dur:.1f}s → {actual_dur:.1f}s "
75
- f"[{start:.1f}s–{end:.1f}s] → [{new_start:.1f}s–{new_end:.1f}s]"
76
  )
77
- seg["start_time"] = round(new_start, 2)
78
- seg["end_time"] = round(new_end, 2)
79
- dur = actual_dur
80
 
81
  seg["duration"] = round(dur, 2)
82
  valid.append(seg)
@@ -85,13 +70,17 @@ def validate_segments(segments, video_duration=None):
85
  return valid
86
 
87
 
 
 
 
 
88
  def _fallback_segments_from_transcript(transcript: str, video_duration: float) -> list:
89
  """
90
- ✅ FALLBACK: If AI returns nothing useful, generate evenly-spaced segments
91
- from the transcript based on timestamp markers [start - end].
92
- Picks the most text-dense windows as a heuristic for 'interesting'.
93
  """
94
- logger.warning("⚠️ Using fallback segment generator from transcript timestamps")
95
 
96
  lines = []
97
  for match in re.finditer(r'\[(\d+\.?\d*)\s*-\s*(\d+\.?\d*)\]\s*(.*)', transcript):
@@ -111,8 +100,8 @@ def _fallback_segments_from_transcript(transcript: str, video_duration: float) -
111
  max_t = lines[-1]["end"]
112
 
113
  while t + MIN_DURATION <= max_t:
114
- w_end = min(t + window, max_t)
115
- in_window = [l for l in lines if l["start"] >= t and l["end"] <= w_end]
116
  word_count = sum(len(l["text"].split()) for l in in_window)
117
  candidates.append({
118
  "start_time": round(t, 2),
@@ -128,12 +117,10 @@ def _fallback_segments_from_transcript(transcript: str, video_duration: float) -
128
  if not candidates:
129
  return []
130
 
131
- # Top 3 by word density, then sort back by time
132
  candidates.sort(key=lambda x: x["word_count"], reverse=True)
133
  top = candidates[:3]
134
  top.sort(key=lambda x: x["start_time"])
135
 
136
- # Deduplicate overlapping windows
137
  deduped = []
138
  for c in top:
139
  if deduped and c["start_time"] < deduped[-1]["end_time"] - 20:
@@ -144,60 +131,76 @@ def _fallback_segments_from_transcript(transcript: str, video_duration: float) -
144
  return deduped
145
 
146
 
 
 
 
 
147
  def analyze_transcript(transcript, video_duration=None):
148
  """
149
- Analyze transcript using Groq API.
150
- ✅ FIX: Passes video_duration to validate_segments for smarter extension.
151
- ✅ FIX: Falls back to _fallback_segments_from_transcript on 0 valid results.
152
- ✅ FIX: Backtick fence strings stored in module-level variables to prevent
153
- SyntaxError when the source file is copy-pasted through markdown.
154
  """
155
 
156
  prompt = f"""
157
  You are a viral short-form video editor specializing in TikTok, Reels, and YouTube Shorts.
158
  Your job is to find COMPLETE, PUBLISH-READY segments — not just funny lines or punchlines.
159
 
160
- WARNING — CRITICAL DURATION RULE — VIOLATIONS WILL BE REJECTED:
161
- - end_time - start_time MUST be between {MIN_DURATION} and {MAX_DURATION} seconds
162
- - Segments shorter than {MIN_DURATION}s will be AUTOMATICALLY DISCARDED
163
- - If a funny moment is only 15s, you MUST expand it: go back ~45s for setup and forward ~30s for reaction
164
- - There are NO exceptions to this rule
165
-
166
- THINKING PROCESS follow these steps for every segment:
167
- 1. Spot an interesting or funny moment in the transcript
168
- 2. Go BACKWARDS to find where the setup or context begins (usually 30–90 seconds before the peak)
169
- 3. Go FORWARDS to find where the natural conclusion or audience reaction ends (usually 15–40 seconds after)
170
- 4. The full segment = setup + build-up + peak + conclusion = {MIN_DURATION} to {MAX_DURATION} seconds total
171
-
172
- EXAMPLE OF CORRECT THINKING:
173
- - You notice a funny moment at 150s
174
- - The story/setup started at 95s use that as start_time
175
- - The conclusion/reaction ends at 220s — use that as end_time
176
- - Duration = 220 - 95 = 125 seconds (within 60-180) CORRECT
177
- - WRONG: start_time=145, end_time=165 (20 seconds, just the punchline)
178
-
179
- A PUBLISH-READY segment must have ALL of these:
180
- - A hook in the first 5 seconds that makes viewers want to keep watching
181
- - A clear setup that gives context so viewers understand without prior knowledge
182
- - A build-up with tension, humor, or emotion
183
- - A satisfying payoff or conclusion — not an abrupt cut
184
- - Standalone: makes complete sense without watching anything before or after
185
-
186
- OUTPUT raw JSON only, no markdown, no explanation:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  {{
188
  "segments": [
189
  {{
190
- "start_time": <float, where the SETUP begins NOT the funny moment itself>,
191
- "end_time": <float, where the CONCLUSION ends NOT just the punchline>,
192
- "title": "<punchy YouTube Shorts title, max 60 chars>",
193
- "description": "<1-2 sentences describing the full story>",
194
- "reason": "<setup to peak to conclusion arc, max 25 words>"
195
  }}
196
  ]
197
  }}
198
 
199
- If no complete story qualifies, return: {{"segments": []}}
200
 
 
201
  TRANSCRIPT:
202
  {transcript}
203
  """
@@ -213,10 +216,12 @@ TRANSCRIPT:
213
  {
214
  "role": "system",
215
  "content": (
216
- f"You are a JSON-only assistant. "
217
- f"Output raw JSON only — no markdown, no code blocks, no explanation. "
218
- f"EVERY segment MUST be between {MIN_DURATION} and {MAX_DURATION} seconds. "
219
- f"Always include setup + peak + conclusion, never just the punchline."
 
 
220
  ),
221
  },
222
  {"role": "user", "content": prompt},
@@ -227,9 +232,7 @@ TRANSCRIPT:
227
  content = response.choices[0].message.content.strip()
228
  logger.info(f"🤖 AI Raw Response (first 300 chars): {content[:300]}...")
229
 
230
- # ── Strip markdown fences if model ignored system prompt ──────────
231
- # Using module-level variables instead of inline literals to prevent
232
- # SyntaxError when this file is copy-pasted through markdown editors.
233
  if _FENCE_JSON in content:
234
  content = content.split(_FENCE_JSON)[1].split(_FENCE)[0].strip()
235
  elif _FENCE in content:
@@ -238,12 +241,11 @@ TRANSCRIPT:
238
  data = json.loads(content)
239
  raw_segments = data.get("segments", [])
240
 
241
- # ✅ Pass video_duration so extension logic has a ceiling
242
  valid_segments = validate_segments(raw_segments, video_duration=video_duration)
243
 
244
- # FALLBACK: if AI returned nothing valid, use heuristic generator
245
  if not valid_segments:
246
- logger.warning("⚠️ AI returned 0 valid segments — trying fallback generator")
247
  fallback = _fallback_segments_from_transcript(
248
  transcript, video_duration or 0
249
  )
@@ -260,89 +262,160 @@ TRANSCRIPT:
260
  logger.error(f"❌ Error in Groq analysis (attempt {attempt + 1}): {e}")
261
  if attempt < max_retries - 1:
262
  wait = base_delay * (2 ** attempt)
263
- logger.warning(f"⚠️ Retrying in {wait}s...")
264
  time.sleep(wait)
265
 
266
  logger.error("❌ All retry attempts failed.")
267
  return {"content": '{"segments": []}'}
268
 
269
 
270
- def smart_chunk_transcript(transcript, max_tokens=4000):
 
 
 
 
271
  """
272
- Split transcript into coherent chunks at sentence boundaries.
273
- Adds overlap between chunks so stories that span chunk boundaries aren't lost.
274
  """
275
- sentences = transcript.replace('\n', ' ').split('. ')
276
- chunks = []
277
- current_chunk = []
278
  current_length = 0
279
- overlap_sentences = []
280
 
281
  for sentence in sentences:
282
  sentence_length = len(sentence.split())
283
-
284
  if current_length + sentence_length > max_tokens and current_chunk:
285
- chunk_text = '. '.join(current_chunk) + '.'
286
- chunks.append(chunk_text.strip())
287
- overlap_sentences = current_chunk[-5:]
288
- current_chunk = overlap_sentences + [sentence]
289
  current_length = sum(len(s.split()) for s in current_chunk)
290
  else:
291
  current_chunk.append(sentence)
292
  current_length += sentence_length
293
 
294
  if current_chunk:
295
- chunk_text = '. '.join(current_chunk) + '.'
296
- chunks.append(chunk_text.strip())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  return chunks
299
 
300
 
 
 
 
 
301
  def analyze_transcript_with_chunking(transcript, video_duration=None):
302
  """
303
- Analyze transcript using smart chunking for long content.
304
  Processes each chunk separately and merges + deduplicates results.
305
  """
306
- if len(transcript.split()) > 3000:
307
- logger.info("📦 Transcript too long, using smart chunking...")
 
 
308
  chunks = smart_chunk_transcript(transcript, max_tokens=3000)
309
  all_segments = []
310
 
311
  for i, chunk in enumerate(chunks):
312
- logger.info(f"🔄 Processing chunk {i+1}/{len(chunks)}...")
313
  result = analyze_transcript(chunk, video_duration=video_duration)
314
 
315
  try:
316
  data = json.loads(result["content"])
317
  if "segments" in data:
318
  all_segments.extend(data["segments"])
 
 
 
 
319
  except Exception as e:
320
- logger.warning(f"⚠️ Failed to parse chunk {i+1}: {e}")
321
  continue
322
 
323
  if all_segments:
 
 
324
  unique_segments = []
325
  seen_times = set()
326
 
327
- for seg in all_segments:
328
- time_key = (
329
- f"{round(seg.get('start_time', 0) / 10) * 10}-"
330
- f"{round(seg.get('end_time', 0) / 10) * 10}"
331
- )
332
  if time_key not in seen_times:
333
  unique_segments.append(seg)
334
  seen_times.add(time_key)
335
 
336
- logger.info(f"📊 Total unique valid segments: {len(unique_segments)}")
337
  return {"content": json.dumps({"segments": unique_segments[:10]})}
338
 
339
- logger.warning("⚠️ No valid segments found across all chunks.")
340
  return {"content": '{"segments": []}'}
341
 
 
342
  return analyze_transcript(transcript, video_duration=video_duration)
343
 
344
 
345
- # ── Testing ───────────────────────────────────────────────────────────────────
 
 
 
346
  if __name__ == "__main__":
347
  test_transcript = """
348
  [0.0 - 5.0] Welcome to today's video about productivity hacks that actually work.
@@ -364,12 +437,12 @@ if __name__ == "__main__":
364
  try:
365
  data = json.loads(result["content"])
366
  segments = data.get("segments", [])
367
- logger.info(f"✅ Found {len(segments)} publish-ready segments:\n")
368
 
369
  for i, seg in enumerate(segments):
370
  duration = seg["end_time"] - seg["start_time"]
371
  logger.info(
372
- f"#{i+1} [{seg['start_time']:.0f}s – {seg['end_time']:.0f}s] ({duration:.0f}s)\n"
373
  f" 📌 Title: {seg.get('title', 'N/A')}\n"
374
  f" 📝 Description: {seg.get('description', 'N/A')}\n"
375
  f" 💡 Story Arc: {seg.get('reason', 'N/A')}\n"
 
17
 
18
  MIN_DURATION = 60 # seconds
19
  MAX_DURATION = 180 # seconds
20
+ TARGET_DURATION = 90 # ideal segment length
21
 
22
+ # ── Backtick fence markers ──
23
  _FENCE_JSON = "```json"
24
  _FENCE = "```"
25
 
26
 
27
+ # ─────────────────────────────────────────────────────────────────────────────
28
+ # VALIDATION — no mechanical padding, only natural segments pass
29
+ # ─────────────────────────────────────────────────────────────────────────────
30
+
31
  def validate_segments(segments, video_duration=None):
32
  """
33
+ Accept only segments the AI correctly identified as complete scenes.
34
+ Short segments are REJECTED not padded — to avoid random cuts.
35
+ The AI is instructed to return full scenes, so if it returns something
36
+ short it means it only found the punchline, not the full story.
37
  """
38
  valid = []
39
  for seg in segments:
 
41
  end = seg.get("end_time", 0)
42
  dur = end - start
43
 
44
+ # Clamp end_time to video duration if known
45
+ if video_duration and end > video_duration:
46
+ end = video_duration
47
+ seg["end_time"] = round(end, 2)
48
+ dur = end - start
49
+
50
+ # Too long → hard reject
51
  if dur > MAX_DURATION:
52
  logger.warning(
53
+ f"⚠️ Too long ({dur:.1f}s) — skipped: "
54
+ f"'{seg.get('title', '')}' [{start}s–{end}s]"
55
  )
56
  continue
57
 
58
+ # Too short reject and log so we can tune the prompt
59
  if dur < MIN_DURATION:
60
+ logger.warning(
61
+ f"⚠️ Too short ({dur:.1f}s) — AI returned only punchline, not full scene. "
62
+ f"Skipped: '{seg.get('title', '')}' [{start}s–{end}s]"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  )
64
+ continue
 
 
65
 
66
  seg["duration"] = round(dur, 2)
67
  valid.append(seg)
 
70
  return valid
71
 
72
 
73
+ # ─────────────────────────────────────────────────────────────────────────────
74
+ # FALLBACK — heuristic when AI returns nothing
75
+ # ─────────────────────────────────────────────────────────────────────────────
76
+
77
  def _fallback_segments_from_transcript(transcript: str, video_duration: float) -> list:
78
  """
79
+ If AI returns nothing useful, generate segments from the transcript using
80
+ the densest word windows around timestamp markers.
81
+ Only used as a last resort.
82
  """
83
+ logger.warning("⚠️ Using fallback segment generator from transcript timestamps")
84
 
85
  lines = []
86
  for match in re.finditer(r'\[(\d+\.?\d*)\s*-\s*(\d+\.?\d*)\]\s*(.*)', transcript):
 
100
  max_t = lines[-1]["end"]
101
 
102
  while t + MIN_DURATION <= max_t:
103
+ w_end = min(t + window, max_t)
104
+ in_window = [l for l in lines if l["start"] >= t and l["end"] <= w_end]
105
  word_count = sum(len(l["text"].split()) for l in in_window)
106
  candidates.append({
107
  "start_time": round(t, 2),
 
117
  if not candidates:
118
  return []
119
 
 
120
  candidates.sort(key=lambda x: x["word_count"], reverse=True)
121
  top = candidates[:3]
122
  top.sort(key=lambda x: x["start_time"])
123
 
 
124
  deduped = []
125
  for c in top:
126
  if deduped and c["start_time"] < deduped[-1]["end_time"] - 20:
 
131
  return deduped
132
 
133
 
134
+ # ─────────────────────────────────────────────────────────────────────────────
135
+ # CORE ANALYSIS — single chunk
136
+ # ─────────────────────────────────────────────────────────────────────────────
137
+
138
  def analyze_transcript(transcript, video_duration=None):
139
  """
140
+ Send one transcript chunk to Groq and return validated segments.
 
 
 
 
141
  """
142
 
143
  prompt = f"""
144
  You are a viral short-form video editor specializing in TikTok, Reels, and YouTube Shorts.
145
  Your job is to find COMPLETE, PUBLISH-READY segments — not just funny lines or punchlines.
146
 
147
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
148
+ CRITICAL DURATION RULE VIOLATIONS WILL BE DELETED
149
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
150
+ - end_time start_time MUST be between {MIN_DURATION} and {MAX_DURATION} seconds
151
+ - Segments shorter than {MIN_DURATION}s will be DELETED, not fixed
152
+ - "I found a funny line at 150s" is NOT a valid segment
153
+ - "I found a complete story from 95s to 220s that includes setup + peak + conclusion" IS valid
154
+
155
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
156
+ MANDATORY THINKING PROCESS
157
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
158
+ For every candidate moment, follow these steps:
159
+
160
+ Step 1 Find the INTERESTING MOMENT (the peak/punchline/reveal)
161
+ Step 2 Scroll BACK in the transcript to find where the SETUP begins
162
+ (usually 30–90 seconds before the peak)
163
+ Ask: "When does the viewer first need to start watching to understand this?"
164
+ Step 3 Scroll FORWARD to find where the NATURAL CONCLUSION ends
165
+ (usually 15–40 seconds after the peak)
166
+ Ask: "Is the story/joke/argument fully resolved? Is there a reaction?"
167
+ Step 4 Check: end_time start_time is between {MIN_DURATION} and {MAX_DURATION} seconds
168
+
169
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
170
+ 📌 CORRECT vs WRONG EXAMPLE
171
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
172
+ Funny moment at 150s:
173
+ CORRECT: start=95s (setup), end=220s (reaction ends) → 125s duration
174
+ ❌ WRONG: start=145s (just before punchline), end=165s → 20s duration
175
+
176
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
177
+ 📋 A COMPLETE SEGMENT MUST HAVE ALL OF THESE
178
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
179
+ 1. HOOK — something in the first 5s that makes viewers keep watching
180
+ 2. SETUP — enough context so a stranger understands without watching anything before
181
+ 3. BUILD-UP — tension, humor, conflict, or emotion growing
182
+ 4. PAYOFF — the peak moment (punchline / reveal / climax)
183
+ 5. CONCLUSION — resolution, reaction, or natural pause — NOT an abrupt cut
184
+ 6. STANDALONE — makes complete sense on its own
185
+
186
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
187
+ 🔢 OUTPUT FORMAT — raw JSON only, no markdown, no explanation
188
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
189
  {{
190
  "segments": [
191
  {{
192
+ "start_time": <float where the SETUP begins, NOT the funny moment>,
193
+ "end_time": <float where the CONCLUSION ends, NOT just the punchline>,
194
+ "title": "<punchy YouTube Shorts title, max 60 chars>",
195
+ "description": "<1-2 sentences describing the full arc: setup → peak → conclusion>",
196
+ "reason": "<why this is complete and viral-worthy, max 25 words>"
197
  }}
198
  ]
199
  }}
200
 
201
+ If no complete story qualifies: {{"segments": []}}
202
 
203
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
204
  TRANSCRIPT:
205
  {transcript}
206
  """
 
216
  {
217
  "role": "system",
218
  "content": (
219
+ "You are a JSON-only assistant. "
220
+ "Output raw JSON only — no markdown, no code blocks, no explanation. "
221
+ f"Every segment MUST be between {MIN_DURATION} and {MAX_DURATION} seconds. "
222
+ "Short segments (punchlines without setup) will be DELETED not fixed. "
223
+ "Always include: full setup before the peak + reaction/conclusion after. "
224
+ "Use the timestamps in the transcript to pick natural scene boundaries."
225
  ),
226
  },
227
  {"role": "user", "content": prompt},
 
232
  content = response.choices[0].message.content.strip()
233
  logger.info(f"🤖 AI Raw Response (first 300 chars): {content[:300]}...")
234
 
235
+ # Strip markdown fences if model ignored system prompt
 
 
236
  if _FENCE_JSON in content:
237
  content = content.split(_FENCE_JSON)[1].split(_FENCE)[0].strip()
238
  elif _FENCE in content:
 
241
  data = json.loads(content)
242
  raw_segments = data.get("segments", [])
243
 
 
244
  valid_segments = validate_segments(raw_segments, video_duration=video_duration)
245
 
246
+ # Fallback if AI returned nothing valid
247
  if not valid_segments:
248
+ logger.warning("⚠️ AI returned 0 valid segments — trying fallback generator")
249
  fallback = _fallback_segments_from_transcript(
250
  transcript, video_duration or 0
251
  )
 
262
  logger.error(f"❌ Error in Groq analysis (attempt {attempt + 1}): {e}")
263
  if attempt < max_retries - 1:
264
  wait = base_delay * (2 ** attempt)
265
+ logger.warning(f"⚠️ Retrying in {wait}s...")
266
  time.sleep(wait)
267
 
268
  logger.error("❌ All retry attempts failed.")
269
  return {"content": '{"segments": []}'}
270
 
271
 
272
+ # ─────────────────────────────────────────────────────────────────────────────
273
+ # SCENE-AWARE CHUNKING
274
+ # ─────────────────────────────────────────────────────────────────────────────
275
+
276
+ def _sentence_chunk(transcript: str, max_tokens: int) -> list[str]:
277
  """
278
+ Fallback chunker when transcript has no timestamps.
279
+ Splits at sentence boundaries with overlap.
280
  """
281
+ sentences = transcript.replace('\n', ' ').split('. ')
282
+ chunks = []
283
+ current_chunk = []
284
  current_length = 0
 
285
 
286
  for sentence in sentences:
287
  sentence_length = len(sentence.split())
 
288
  if current_length + sentence_length > max_tokens and current_chunk:
289
+ chunks.append('. '.join(current_chunk) + '.')
290
+ current_chunk = current_chunk[-5:] + [sentence]
 
 
291
  current_length = sum(len(s.split()) for s in current_chunk)
292
  else:
293
  current_chunk.append(sentence)
294
  current_length += sentence_length
295
 
296
  if current_chunk:
297
+ chunks.append('. '.join(current_chunk) + '.')
298
+ return chunks
299
+
300
+
301
+ def smart_chunk_transcript(transcript: str, max_tokens: int = 4000) -> list[str]:
302
+ """
303
+ Split transcript into coherent chunks at SCENE BOUNDARIES.
304
+
305
+ Strategy:
306
+ - Parse all [start - end] timestamp lines
307
+ - Cut only when the chunk is full AND there is a natural gap (>3s) between
308
+ consecutive lines — a sign of a scene or topic change
309
+ - Keep 10-line overlap between chunks so cross-boundary stories aren't lost
310
+ - Falls back to sentence-level splitting if no timestamps found
311
+ """
312
+ lines = []
313
+ for match in re.finditer(r'\[(\d+\.?\d*)\s*-\s*(\d+\.?\d*)\]\s*(.*)', transcript):
314
+ lines.append({
315
+ "start": float(match.group(1)),
316
+ "end": float(match.group(2)),
317
+ "text": match.group(3).strip(),
318
+ })
319
+
320
+ if not lines:
321
+ logger.info("📦 No timestamps found — using sentence chunker")
322
+ return _sentence_chunk(transcript, max_tokens)
323
 
324
+ chunks = []
325
+ current_lines = []
326
+ current_words = 0
327
+
328
+ for i, line in enumerate(lines):
329
+ current_lines.append(line)
330
+ current_words += len(line["text"].split())
331
+
332
+ is_last = (i + 1 == len(lines))
333
+ is_full = (current_words >= max_tokens)
334
+
335
+ # Detect scene break: gap > 3 seconds between this line and the next
336
+ next_gap = (
337
+ lines[i + 1]["start"] - line["end"]
338
+ if not is_last else 999
339
+ )
340
+ at_scene_break = next_gap > 3.0
341
+
342
+ if is_last or (is_full and at_scene_break):
343
+ chunk_text = "\n".join(
344
+ f"[{l['start']} - {l['end']}] {l['text']}"
345
+ for l in current_lines
346
+ )
347
+ chunks.append(chunk_text)
348
+ logger.info(
349
+ f"📦 Chunk {len(chunks)}: "
350
+ f"{current_lines[0]['start']:.0f}s – {current_lines[-1]['end']:.0f}s "
351
+ f"({current_words} words)"
352
+ )
353
+ # Overlap: keep last 10 lines for context continuity
354
+ current_lines = current_lines[-10:]
355
+ current_words = sum(len(l["text"].split()) for l in current_lines)
356
+
357
+ logger.info(f"📦 Split into {len(chunks)} scene-aware chunk(s)")
358
  return chunks
359
 
360
 
361
+ # ─────────────────────────────────────────────────────────────────────────────
362
+ # MAIN ENTRY POINT
363
+ # ─────────────────────────────────────────────────────────────────────────────
364
+
365
  def analyze_transcript_with_chunking(transcript, video_duration=None):
366
  """
367
+ Analyze transcript using scene-aware chunking for long content.
368
  Processes each chunk separately and merges + deduplicates results.
369
  """
370
+ word_count = len(transcript.split())
371
+
372
+ if word_count > 3000:
373
+ logger.info(f"📦 Transcript is {word_count} words — using scene-aware chunking...")
374
  chunks = smart_chunk_transcript(transcript, max_tokens=3000)
375
  all_segments = []
376
 
377
  for i, chunk in enumerate(chunks):
378
+ logger.info(f"🔄 Processing chunk {i + 1}/{len(chunks)}...")
379
  result = analyze_transcript(chunk, video_duration=video_duration)
380
 
381
  try:
382
  data = json.loads(result["content"])
383
  if "segments" in data:
384
  all_segments.extend(data["segments"])
385
+ logger.info(
386
+ f"✅ Chunk {i + 1}: "
387
+ f"{len(data['segments'])} valid segment(s)"
388
+ )
389
  except Exception as e:
390
+ logger.warning(f"⚠️ Failed to parse chunk {i + 1}: {e}")
391
  continue
392
 
393
  if all_segments:
394
+ # Deduplicate: two segments are the same if their start times
395
+ # round to within 10 seconds of each other
396
  unique_segments = []
397
  seen_times = set()
398
 
399
+ for seg in sorted(all_segments, key=lambda s: s.get("start_time", 0)):
400
+ time_key = round(seg.get("start_time", 0) / 10) * 10
 
 
 
401
  if time_key not in seen_times:
402
  unique_segments.append(seg)
403
  seen_times.add(time_key)
404
 
405
+ logger.info(f"📊 Total unique viral segments found: {len(unique_segments)}")
406
  return {"content": json.dumps({"segments": unique_segments[:10]})}
407
 
408
+ logger.warning("⚠️ No valid segments found across all chunks.")
409
  return {"content": '{"segments": []}'}
410
 
411
+ # Short transcript — process directly without chunking
412
  return analyze_transcript(transcript, video_duration=video_duration)
413
 
414
 
415
+ # ─────────────────────────────────────────────────────────────────────────────
416
+ # TESTING
417
+ # ─────────────────────────────────────────────────────────────────────────────
418
+
419
  if __name__ == "__main__":
420
  test_transcript = """
421
  [0.0 - 5.0] Welcome to today's video about productivity hacks that actually work.
 
437
  try:
438
  data = json.loads(result["content"])
439
  segments = data.get("segments", [])
440
+ logger.info(f"✅ Found {len(segments)} publish-ready segment(s):\n")
441
 
442
  for i, seg in enumerate(segments):
443
  duration = seg["end_time"] - seg["start_time"]
444
  logger.info(
445
+ f"#{i + 1} [{seg['start_time']:.0f}s – {seg['end_time']:.0f}s] ({duration:.0f}s)\n"
446
  f" 📌 Title: {seg.get('title', 'N/A')}\n"
447
  f" 📝 Description: {seg.get('description', 'N/A')}\n"
448
  f" 💡 Story Arc: {seg.get('reason', 'N/A')}\n"