shortsflow 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. engine/analysis.py +1562 -0
  2. engine/beat_sync.py +97 -0
  3. engine/branding.py +189 -0
  4. engine/comfy_bridge.py +286 -0
  5. engine/fillers.py +99 -0
  6. engine/find_moments.py +135 -0
  7. engine/kokoro_tts.py +133 -0
  8. engine/media_gen.py +972 -0
  9. engine/modes/__init__.py +1 -0
  10. engine/modes/clip_extract.py +413 -0
  11. engine/modes/explainer.py +104 -0
  12. engine/modes/generate.py +1324 -0
  13. engine/modes/music.py +183 -0
  14. engine/modes/photo_reel.py +123 -0
  15. engine/modes/video_filter.py +89 -0
  16. engine/paths.py +12 -0
  17. engine/pipeline.py +129 -0
  18. engine/publish.py +119 -0
  19. engine/remotion_renderer.py +405 -0
  20. engine/script_gen.py +2551 -0
  21. engine/social_gen.py +669 -0
  22. engine/speaker_focus.py +364 -0
  23. engine/storage.py +91 -0
  24. engine/trend_gen.py +50 -0
  25. engine/utils.py +59 -0
  26. engine/video_gen.py +3105 -0
  27. engine/voice_gen.py +418 -0
  28. shortsflow/__init__.py +2 -0
  29. shortsflow/__main__.py +4 -0
  30. shortsflow/_bundle/assets/avatars/cat.mp4 +0 -0
  31. shortsflow/_bundle/assets/avatars/mafia_cat.mp4 +0 -0
  32. shortsflow/_bundle/assets/avatars/mafia_cat.png +0 -0
  33. shortsflow/_bundle/assets/avatars/orange_cat.mp4 +0 -0
  34. shortsflow/_bundle/assets/avatars/rabbit.png +0 -0
  35. shortsflow/_bundle/assets/avatars/robot.png +0 -0
  36. shortsflow/_bundle/assets/avatars/squirrel.png +0 -0
  37. shortsflow/_bundle/assets/branding/tv_frame.jpg +0 -0
  38. shortsflow/_bundle/assets/models/face_detection_yunet_2023mar.onnx +0 -0
  39. shortsflow/_bundle/env.example +48 -0
  40. shortsflow/_bundle/music/bg_music.mp3 +0 -0
  41. shortsflow/_bundle/remotion-video/package-lock.json +5721 -0
  42. shortsflow/_bundle/remotion-video/package.json +35 -0
  43. shortsflow/_bundle/remotion-video/public/fonts/Anton-Regular.ttf +0 -0
  44. shortsflow/_bundle/remotion-video/public/fonts/LICENSE-Anton.txt +1 -0
  45. shortsflow/_bundle/remotion-video/remotion.config.ts +15 -0
  46. shortsflow/_bundle/remotion-video/src/HelloWorld/Arc.tsx +55 -0
  47. shortsflow/_bundle/remotion-video/src/HelloWorld/Atom.tsx +36 -0
  48. shortsflow/_bundle/remotion-video/src/HelloWorld/Logo.tsx +87 -0
  49. shortsflow/_bundle/remotion-video/src/HelloWorld/Subtitle.tsx +26 -0
  50. shortsflow/_bundle/remotion-video/src/HelloWorld/Title.tsx +58 -0
  51. shortsflow/_bundle/remotion-video/src/HelloWorld/constants.ts +5 -0
  52. shortsflow/_bundle/remotion-video/src/HelloWorld.tsx +76 -0
  53. shortsflow/_bundle/remotion-video/src/Root.tsx +54 -0
  54. shortsflow/_bundle/remotion-video/src/ShortFlow.tsx +1695 -0
  55. shortsflow/_bundle/remotion-video/src/captionEmoji.ts +19 -0
  56. shortsflow/_bundle/remotion-video/src/fonts.ts +11 -0
  57. shortsflow/_bundle/remotion-video/src/index.css +1 -0
  58. shortsflow/_bundle/remotion-video/src/index.ts +8 -0
  59. shortsflow/_bundle/remotion-video/tsconfig.json +16 -0
  60. shortsflow/_main.py +281 -0
  61. shortsflow/api/__init__.py +1 -0
  62. shortsflow/api/__main__.py +6 -0
  63. shortsflow/api/app.py +128 -0
  64. shortsflow/api/jobs.py +108 -0
  65. shortsflow/cli.py +68 -0
  66. shortsflow/workspace.py +89 -0
  67. shortsflow-0.1.0.dist-info/METADATA +308 -0
  68. shortsflow-0.1.0.dist-info/RECORD +71 -0
  69. shortsflow-0.1.0.dist-info/WHEEL +4 -0
  70. shortsflow-0.1.0.dist-info/entry_points.txt +2 -0
  71. shortsflow-0.1.0.dist-info/licenses/LICENSE +21 -0
engine/analysis.py ADDED
@@ -0,0 +1,1562 @@
1
+ import os
2
+ import json
3
+ import time
4
+ import subprocess
5
+ import numpy as np
6
+
7
+ # ─────────────────────────────────────────────────────────────────────────────
8
+ # VIDEO SOURCE INTELLIGENCE UTILITIES
9
+ # ─────────────────────────────────────────────────────────────────────────────
10
+
11
+ def detect_letterbox(video_path):
12
+ """
13
+ Detects letterbox/pillarbox black bars using FFmpeg cropdetect.
14
+ Returns an FFmpeg crop filter string (e.g. 'crop=1920:800:0:140') or None.
15
+ """
16
+ import imageio_ffmpeg
17
+ ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
18
+ try:
19
+ # Sample 5 frames evenly across the video for cropdetect
20
+ cmd = [
21
+ ffmpeg_exe, '-skip_frame', 'noref', '-i', video_path,
22
+ '-vf', 'cropdetect=24:16:0', '-frames:v', '30',
23
+ '-f', 'null', '-'
24
+ ]
25
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
26
+ # Parse crop= lines from stderr
27
+ crop_values = []
28
+ for line in result.stderr.split('\n'):
29
+ if 'crop=' in line:
30
+ part = [p for p in line.split() if p.startswith('crop=')]
31
+ if part:
32
+ crop_values.append(part[-1])
33
+ if not crop_values:
34
+ return None
35
+ # Use the most common value (mode)
36
+ from collections import Counter
37
+ most_common = Counter(crop_values).most_common(1)[0][0]
38
+ # Check if this actually removes bars (skip if it's the full frame)
39
+ try:
40
+ parts = most_common.replace('crop=', '').split(':')
41
+ cw, ch = int(parts[0]), int(parts[1])
42
+ # Probe original dimensions
43
+ probe = subprocess.run(
44
+ [ffmpeg_exe, '-i', video_path], capture_output=True, text=True, timeout=10
45
+ )
46
+ for pline in probe.stderr.split('\n'):
47
+ if 'Video:' in pline and 'x' in pline:
48
+ import re
49
+ m = re.search(r'(\d{3,})x(\d{3,})', pline)
50
+ if m:
51
+ ow, oh = int(m.group(1)), int(m.group(2))
52
+ if cw >= ow * 0.98 and ch >= oh * 0.98:
53
+ return None # No meaningful crop needed
54
+ break
55
+ except Exception:
56
+ pass
57
+ print(f"[Log] Letterbox detected — applying auto-strip: {most_common}")
58
+ return most_common
59
+ except Exception as e:
60
+ print(f"[Warning] Letterbox detection failed: {e}")
61
+ return None
62
+
63
+
64
+ def detect_orientation(video_path):
65
+ """
66
+ Returns 'portrait' if h > w (already 9:16), else 'landscape'.
67
+ Skips the 9:16 crop filter for portrait sources to avoid quality loss.
68
+ """
69
+ import imageio_ffmpeg, re
70
+ ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
71
+ try:
72
+ result = subprocess.run(
73
+ [ffmpeg_exe, '-i', video_path], capture_output=True, text=True, timeout=10
74
+ )
75
+ for line in result.stderr.split('\n'):
76
+ if 'Video:' in line:
77
+ m = re.search(r'(\d{3,})x(\d{3,})', line)
78
+ if m:
79
+ w, h = int(m.group(1)), int(m.group(2))
80
+ orientation = 'portrait' if h > w else 'landscape'
81
+ print(f"[Log] Source orientation detected: {orientation} ({w}x{h})")
82
+ return orientation
83
+ except Exception as e:
84
+ print(f"[Warning] Orientation detection failed: {e}")
85
+ return 'landscape'
86
+
87
+
88
+ def extract_youtube_chapters(info_json_path):
89
+ """
90
+ Reads YouTube chapter markers from a yt-dlp info.json file.
91
+ Returns list of {title, start, end} dicts, or empty list.
92
+ """
93
+ if not info_json_path or not os.path.exists(info_json_path):
94
+ return []
95
+ try:
96
+ with open(info_json_path, 'r', encoding='utf-8') as f:
97
+ info = json.load(f)
98
+ chapters_raw = info.get('chapters', [])
99
+ if not chapters_raw:
100
+ return []
101
+ chapters = []
102
+ for ch in chapters_raw:
103
+ chapters.append({
104
+ 'title': ch.get('title', 'Chapter'),
105
+ 'start': float(ch.get('start_time', 0)),
106
+ 'end': float(ch.get('end_time', ch.get('start_time', 0) + 60))
107
+ })
108
+ print(f"[Log] Loaded {len(chapters)} YouTube chapters from info.json")
109
+ return chapters
110
+ except Exception as e:
111
+ print(f"[Warning] Failed to load chapters: {e}")
112
+ return []
113
+
114
+
115
+ def llm_score_highlights(highlights, transcript_data, top_n=5):
116
+ """
117
+ Uses Gemini to rate the top N highlight candidates for viral potential (1-10).
118
+ Merges LLM score (60%) with existing heuristic score (40%) for final ranking.
119
+ """
120
+ if not highlights:
121
+ return highlights
122
+ try:
123
+ from engine.script_gen import get_llm_response, robust_json_parse
124
+ except ImportError:
125
+ return highlights
126
+
127
+ # Only score the top candidates to save API quota
128
+ sorted_h = sorted(highlights, key=lambda x: float(x.get('score', 0)), reverse=True)
129
+ to_score = sorted_h[:top_n]
130
+
131
+ # Build segment text map from transcript
132
+ seg_texts = {}
133
+ for seg in transcript_data.get('segments', []):
134
+ seg_texts[seg['start']] = seg.get('text', '')
135
+
136
+ def get_segment_text(h):
137
+ s, e = float(h['start']), float(h['end'])
138
+ parts = [v for k, v in seg_texts.items() if s - 1 <= k <= e + 1]
139
+ return ' '.join(parts).strip() or h.get('reason', 'No transcript')
140
+
141
+ print(f"[Log] LLM Virality Scoring: rating top {len(to_score)} candidate clips...")
142
+ for h in to_score:
143
+ text_snippet = get_segment_text(h)[:500]
144
+ prompt = (
145
+ f"You are a viral YouTube Shorts editor. Rate this video segment's viral potential on a scale of 1-10 "
146
+ f"(10 = guaranteed viral, 1 = boring). Consider hook strength, emotional intensity, pacing, and re-watchability.\n\n"
147
+ f"Segment transcript ({h['start']:.1f}s - {h['end']:.1f}s):\n{text_snippet}\n\n"
148
+ f"Reply ONLY with valid JSON: {{\"score\": <integer 1-10>, \"reason\": \"<one sentence why>\"}}"
149
+ )
150
+ try:
151
+ raw = get_llm_response(prompt, "You are a viral video scoring expert.", max_tokens=120)
152
+ parsed = robust_json_parse(raw)
153
+ if parsed and isinstance(parsed, dict) and 'score' in parsed:
154
+ llm_score = float(parsed['score'])
155
+ heuristic_score = float(h.get('score', 5))
156
+ # Weighted blend: 60% LLM + 40% heuristic (normalised to 0-100 range)
157
+ h['llm_score'] = round(llm_score, 1)
158
+ h['llm_reason'] = parsed.get('reason', '')
159
+ h['score'] = round((llm_score / 10.0 * 100) * 0.6 + heuristic_score * 0.4, 1)
160
+ print(f" Clip {h['start']:.1f}s-{h['end']:.1f}s → LLM:{llm_score}/10 — {h['llm_reason']}")
161
+ except Exception as e:
162
+ print(f" [Warning] LLM scoring failed for clip {h['start']:.1f}s: {e}")
163
+ h['llm_score'] = None
164
+ h['llm_reason'] = 'LLM unavailable'
165
+
166
+ # Re-sort by updated combined score
167
+ highlights = sorted(highlights, key=lambda x: float(x.get('score', 0)), reverse=True)
168
+ return highlights
169
+
170
+
171
+ def extract_audio_mono(video_path, output_dir):
172
+ """
173
+ Extracts mono 16kHz audio from video using FFmpeg.
174
+ Returns path to the WAV file.
175
+ """
176
+ os.makedirs(output_dir, exist_ok=True)
177
+ audio_path = os.path.join(output_dir, "audio_mono.wav")
178
+
179
+ if os.path.exists(audio_path):
180
+ # Optional: check if file is too small or corrupt, but usually cache is fine
181
+ return audio_path
182
+
183
+ print(f"[Log] Extracting mono audio for analysis: {audio_path}")
184
+ cmd = [
185
+ "ffmpeg", "-y", "-i", video_path,
186
+ "-ac", "1", "-ar", "16000", "-vn",
187
+ audio_path
188
+ ]
189
+ subprocess.run(cmd, check=True, capture_output=True)
190
+ return audio_path
191
+
192
+ def detect_silence_intervals(audio_path, threshold_db=25, min_silence_len=0.5, margin=0.1):
193
+ """
194
+ Returns a list of (start, end) intervals of 'keep' (non-silent) segments.
195
+ threshold_db: volume below this is considered silence.
196
+ min_silence_len: minimum duration (seconds) of silence to be cut.
197
+ margin: breathing room added before/after cuts.
198
+ """
199
+ import librosa
200
+ audio, sr = librosa.load(audio_path, sr=16000)
201
+
202
+ # librosa.effects.split returns non-silent intervals
203
+ # frame_length=2048, hop_length=512 are defaults
204
+ non_silent_intervals = librosa.effects.split(y=audio, top_db=threshold_db)
205
+
206
+ keep_intervals = []
207
+ total_samples = len(audio)
208
+
209
+ for start_idx, end_idx in non_silent_intervals:
210
+ start_sec = max(0, start_idx / sr - margin)
211
+ end_sec = min(total_samples / sr, end_idx / sr + margin)
212
+ keep_intervals.append((start_sec, end_sec))
213
+
214
+ # Merge overlapping intervals after margin expansion
215
+ if not keep_intervals: return []
216
+
217
+ merged = []
218
+ curr_start, curr_end = keep_intervals[0]
219
+ for next_start, next_end in keep_intervals[1:]:
220
+ if next_start < curr_end:
221
+ curr_end = max(curr_end, next_end)
222
+ else:
223
+ merged.append((curr_start, curr_end))
224
+ curr_start, curr_end = next_start, next_end
225
+ merged.append((curr_start, curr_end))
226
+
227
+ return merged
228
+
229
+ def detect_motion_intervals(video_path, threshold=0.03, skip_frames=15, candidate_intervals=None):
230
+ """
231
+ Identifies 'keep' intervals based on visual motion.
232
+ 🟢 OPTIMIZED: Support for candidate-only scanning and increased skip_frames.
233
+ """
234
+ import cv2
235
+ import numpy as np
236
+ cap = cv2.VideoCapture(video_path)
237
+ if not cap.isOpened(): return []
238
+ fps = cap.get(cv2.CAP_PROP_FPS)
239
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
240
+
241
+ ret, prev_frame = cap.read()
242
+ if not ret: return []
243
+
244
+ prev_gray = cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY)
245
+ prev_gray = cv2.GaussianBlur(prev_gray, (21, 21), 0)
246
+
247
+ motion_frames = []
248
+
249
+ def scan_range(cap_obj, start_sec, end_sec):
250
+ res = []
251
+ cap_obj.set(cv2.CAP_PROP_POS_MSEC, start_sec * 1000)
252
+ start_frame = int(start_sec * fps)
253
+ end_frame = int(end_sec * fps)
254
+
255
+ ret, prev_f = cap_obj.read()
256
+ if not ret: return []
257
+ p_gray = cv2.cvtColor(prev_f, cv2.COLOR_BGR2GRAY)
258
+ p_gray = cv2.GaussianBlur(p_gray, (21, 21), 0)
259
+
260
+ f_idx = start_frame + 1
261
+ m_values = []
262
+ while f_idx < end_frame:
263
+ for _ in range(skip_frames):
264
+ cap_obj.grab()
265
+ f_idx += 1
266
+ ret, frame = cap_obj.read()
267
+ f_idx += 1
268
+ if not ret: break
269
+
270
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
271
+ gray = cv2.GaussianBlur(gray, (21, 21), 0)
272
+ diff = cv2.absdiff(p_gray, gray)
273
+ thr = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)[1]
274
+ thr = cv2.dilate(thr, None, iterations=2)
275
+ m_pct = np.sum(thr) / (thr.shape[0] * thr.shape[1] * 255)
276
+ m_values.append((f_idx, m_pct))
277
+ p_gray = gray
278
+ return m_values
279
+
280
+ def worker_job(intervals):
281
+ import cv2
282
+ c = cv2.VideoCapture(video_path)
283
+ data = []
284
+ for s, e in intervals:
285
+ data.extend(scan_range(c, s, e))
286
+ c.release()
287
+ return data
288
+
289
+ raw_motion_data = []
290
+ if candidate_intervals:
291
+ from concurrent.futures import ThreadPoolExecutor
292
+ import os
293
+ # 🟢 OPTIMIZED: Adaptive worker count using CPU core count
294
+ num_workers = min(os.cpu_count() or 4, len(candidate_intervals), 6)
295
+ if num_workers > 1:
296
+ chunks = [candidate_intervals[i::num_workers] for i in range(num_workers)]
297
+ with ThreadPoolExecutor(max_workers=num_workers) as executor:
298
+ results = list(executor.map(worker_job, chunks))
299
+ for r in results: raw_motion_data.extend(r)
300
+ else:
301
+ raw_motion_data = worker_job(candidate_intervals)
302
+ else:
303
+ raw_motion_data = worker_job([(0, total_frames / fps)])
304
+
305
+ if not raw_motion_data:
306
+ cap.release()
307
+ return []
308
+
309
+ # 🟢 ADAPTIVE THRESHOLDING: Use 75th percentile to handle different video levels
310
+ all_pcts = [x[1] for x in raw_motion_data]
311
+ dynamic_threshold = max(threshold, np.percentile(all_pcts, 75))
312
+ motion_frames = [f for f, p in raw_motion_data if p >= dynamic_threshold]
313
+
314
+ cap.release()
315
+
316
+ if not motion_frames: return []
317
+
318
+ # Convert frames to time intervals
319
+ keep_intervals = []
320
+ start_frame = motion_frames[0]
321
+ last_frame = motion_frames[0]
322
+
323
+ # Margin of 1 second for motion continuity
324
+ frame_margin = int(fps * 1.0)
325
+
326
+ for f in motion_frames[1:]:
327
+ if f - last_frame > frame_margin:
328
+ keep_intervals.append((max(0, start_frame/fps), min(total_frames/fps, last_frame/fps)))
329
+ start_frame = f
330
+ last_frame = f
331
+ keep_intervals.append((max(0, start_frame/fps), min(total_frames/fps, last_frame/fps)))
332
+
333
+ return keep_intervals
334
+
335
+ def detect_interest_points(video_path, skip_frames=10, segments=None):
336
+ """
337
+ Detects the 'center of interest' (e.g., face position) using OpenCV Haar Cascades.
338
+ Uses IoU-based speaker identity lock so the camera doesn't jump between multiple faces.
339
+ Returns dict of {timestamp: x_center_percentage}.
340
+ """
341
+ import cv2
342
+
343
+ face_cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
344
+ face_cascade = cv2.CascadeClassifier(face_cascade_path)
345
+
346
+ cap = cv2.VideoCapture(video_path)
347
+ fps = cap.get(cv2.CAP_PROP_FPS)
348
+ interest_points = {}
349
+
350
+ frame_idx = 0
351
+ last_x = 0.5
352
+ ema_alpha = 0.15
353
+
354
+ # IoU speaker lock — track the primary speaker's bounding box between frames
355
+ locked_face = None # (x, y, w, h) in small_frame coordinates
356
+ iou_reset_threshold = 0.10 # Below this, assume new scene and pick largest face
357
+
358
+ def compute_iou(a, b):
359
+ ax1, ay1, ax2, ay2 = a[0], a[1], a[0]+a[2], a[1]+a[3]
360
+ bx1, by1, bx2, by2 = b[0], b[1], b[0]+b[2], b[1]+b[3]
361
+ ix1, iy1 = max(ax1, bx1), max(ay1, by1)
362
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
363
+ inter = max(0, ix2-ix1) * max(0, iy2-iy1)
364
+ union = a[2]*a[3] + b[2]*b[3] - inter
365
+ return inter / union if union > 0 else 0.0
366
+
367
+ def pick_primary_face(faces, locked):
368
+ if len(faces) == 0:
369
+ return None
370
+ if locked is None:
371
+ return max(faces, key=lambda f: f[2] * f[3])
372
+ best, best_iou = None, 0.0
373
+ for face in faces:
374
+ iou = compute_iou(locked, tuple(face))
375
+ if iou > best_iou:
376
+ best_iou = iou
377
+ best = face
378
+ # If match is too poor, reset to largest face (new scene or cut)
379
+ if best_iou < iou_reset_threshold:
380
+ return max(faces, key=lambda f: f[2] * f[3])
381
+ return best
382
+
383
+ def process_frame(frame):
384
+ nonlocal last_x, locked_face
385
+ small_frame = cv2.resize(frame, (0, 0), fx=0.5, fy=0.5)
386
+ gray = cv2.cvtColor(small_frame, cv2.COLOR_BGR2GRAY)
387
+ faces = face_cascade.detectMultiScale(gray, 1.1, 5)
388
+ if len(faces) > 0:
389
+ primary = pick_primary_face(list(faces), locked_face)
390
+ if primary is not None:
391
+ locked_face = tuple(primary)
392
+ x_center = (primary[0] + primary[2] / 2) / small_frame.shape[1]
393
+ last_x = last_x * (1 - ema_alpha * 2) + x_center * (ema_alpha * 2)
394
+ else:
395
+ last_x = last_x * (1 - ema_alpha) + 0.5 * ema_alpha
396
+
397
+ if segments:
398
+ for start, end in segments:
399
+ cap.set(cv2.CAP_PROP_POS_MSEC, start * 1000)
400
+ frame_idx = int(start * fps)
401
+ locked_face = None # Reset lock at each new segment
402
+ while frame_idx / fps < end:
403
+ for _ in range(skip_frames):
404
+ cap.grab()
405
+ frame_idx += 1
406
+ ret, frame = cap.read()
407
+ frame_idx += 1
408
+ if not ret: break
409
+ process_frame(frame)
410
+ interest_points[round(frame_idx / fps, 2)] = round(last_x, 3)
411
+ else:
412
+ while True:
413
+ for _ in range(skip_frames):
414
+ cap.grab()
415
+ frame_idx += 1
416
+ ret, frame = cap.read()
417
+ frame_idx += 1
418
+ if not ret: break
419
+ process_frame(frame)
420
+ interest_points[round(frame_idx / fps, 2)] = round(last_x, 3)
421
+
422
+ cap.release()
423
+ return interest_points
424
+
425
+
426
+
427
+ def extract_video_outline(transcript_path):
428
+ """
429
+ Generates a high-level outline of the video for structured analysis.
430
+ """
431
+ from engine.script_gen import get_llm_response
432
+ with open(transcript_path, 'r', encoding='utf-8') as f:
433
+ data = json.load(f)
434
+ segments = data.get('segments', [])
435
+ if not segments: return "Empty video."
436
+
437
+ # Take samples from start, middle, end to summarize
438
+ samples = []
439
+ step = max(1, len(segments) // 15)
440
+ for i in range(0, len(segments), step):
441
+ s = segments[i]
442
+ samples.append(f"[{s['start']:.1f}s]: {s['text']}")
443
+
444
+ text_sample = "\n".join(samples)
445
+ prompt = f"Summarize this video content into a high-level outline (topics and themes):\n\n{text_sample}"
446
+ try:
447
+ outline = get_llm_response(prompt, "You are a video analyst.")
448
+ except Exception as e:
449
+ print(f"[Warning] LLM Analysis for outline failed: {e}. Using fallback.")
450
+ outline = "No outline available (LLM Offline)."
451
+ return outline
452
+
453
+ def compute_global_audio_features(audio, sr):
454
+ """
455
+ 🟢 PERFORMANCE: Computes heavy audio features (RMSE + Silence) once per video.
456
+ """
457
+ import librosa
458
+ print("[Log] Computing global audio features (Energy + Silence)...")
459
+
460
+ hop_length = 512
461
+ rmse = librosa.feature.rms(y=audio, hop_length=hop_length)[0]
462
+ total_frames = len(rmse)
463
+
464
+ # Frame-level silence mask for memory efficiency
465
+ non_silent_intervals = librosa.effects.split(y=audio, top_db=25)
466
+ silence_mask_frames = np.ones(total_frames, dtype=bool) # True = Silent
467
+ for start_idx, end_idx in non_silent_intervals:
468
+ s_frame = start_idx // hop_length
469
+ e_frame = min(end_idx // hop_length, total_frames)
470
+ silence_mask_frames[s_frame:e_frame] = False
471
+
472
+ return {
473
+ "rmse": rmse,
474
+ "hop_length": hop_length,
475
+ "silence_mask_frames": silence_mask_frames,
476
+ "total_frames": total_frames
477
+ }
478
+
479
+ def compute_audio_score(chunk, sr, prev_energy=None, global_features=None, start_sec=0, end_sec=0, energy_baseline=None):
480
+ """
481
+ Computes a heuristic audio score, reusing global features if available.
482
+ """
483
+ import librosa
484
+ if len(chunk) == 0: return 0, 0, 0, False
485
+
486
+ # 1. Energy (Loudness)
487
+ if global_features:
488
+ hop = global_features["hop_length"]
489
+ total_frames = global_features["total_frames"]
490
+ s_idx = int(start_sec * sr / hop)
491
+ e_idx = int(end_sec * sr / hop)
492
+ s_idx = max(0, min(s_idx, total_frames - 1))
493
+ e_idx = max(s_idx + 1, min(e_idx, total_frames))
494
+ energy_slice = global_features["rmse"][s_idx:e_idx]
495
+ energy = np.mean(energy_slice**2) if len(energy_slice) > 0 else 0
496
+ else:
497
+ energy = np.mean(chunk ** 2)
498
+
499
+ # 2. Pitch variation
500
+ # 🟢 PERFORMANCE: Pitch tracking is slow. Skip if using global features for speed.
501
+ if global_features:
502
+ pitch_var = 0 # Sacrifice pitch for massive speedup on long videos
503
+ else:
504
+ safe_n_fft = 2048
505
+ if len(chunk) < 2048:
506
+ safe_n_fft = 1024 if len(chunk) >= 1024 else (512 if len(chunk) >= 512 else 256)
507
+
508
+ try:
509
+ if len(chunk) < safe_n_fft:
510
+ pitch_var = 0
511
+ else:
512
+ pitches, _ = librosa.piptrack(y=chunk, sr=sr, fmin=75, fmax=1600, n_fft=safe_n_fft)
513
+ pitches = pitches[pitches > 0]
514
+ pitch_var = np.var(pitches) if len(pitches) > 0 else 0
515
+ except:
516
+ pitch_var = 0
517
+
518
+ # 3. Silence Ratio
519
+ if global_features:
520
+ hop = global_features["hop_length"]
521
+ total_frames = global_features["total_frames"]
522
+ s_idx = int(start_sec * sr / hop)
523
+ e_idx = int(end_sec * sr / hop)
524
+ s_idx = max(0, min(s_idx, total_frames - 1))
525
+ e_idx = max(s_idx + 1, min(e_idx, total_frames))
526
+ silence_slice = global_features["silence_mask_frames"][s_idx:e_idx]
527
+ silence_ratio = np.mean(silence_slice) if len(silence_slice) > 0 else 0
528
+ valid_silence = True
529
+ else:
530
+ try:
531
+ split_frame = safe_n_fft
532
+ while split_frame > len(chunk) and split_frame > 128: split_frame //= 2
533
+ non_silent = librosa.effects.split(y=chunk, top_db=25, frame_length=split_frame)
534
+ active_len = sum(e - s for s, e in non_silent)
535
+ silence_ratio = 1 - (active_len / len(chunk))
536
+ except:
537
+ silence_ratio = 0.5
538
+ valid_silence = False
539
+ else:
540
+ valid_silence = True
541
+
542
+ # 🟢 Energy Delta (Sudden shifts)
543
+ raw_energy = energy
544
+ # Compare against EMA baseline for stability
545
+ if energy_baseline is not None:
546
+ delta = abs(raw_energy - energy_baseline)
547
+ elif prev_energy is not None:
548
+ delta = abs(raw_energy - prev_energy)
549
+ else:
550
+ delta = 0
551
+
552
+ # Log-Normalization
553
+ norm_energy = np.log1p(raw_energy)
554
+ norm_delta = np.log1p(delta)
555
+ # Pitch variation is only used if calculated (low CPU mode skips it)
556
+ norm_pitch = np.log1p(pitch_var) if pitch_var > 0 else 0
557
+
558
+ score = (norm_energy * 2.0) + (norm_delta * 3.0) + (norm_pitch * 1.5) - (silence_ratio * 2.0)
559
+ return max(0, score), raw_energy, silence_ratio, valid_silence
560
+
561
+ def score_segment(text, mode="shorts", start=None, end=None, target_duration=None):
562
+ """
563
+ Heuristic scoring to identify high-potential highlights.
564
+ - 'shorts': Focuses on intensity (shouting, punctuation) and viral keywords.
565
+ - 'long': Focuses on narrative weight and sentence density.
566
+ """
567
+ score = 0
568
+ text_lower = text.lower()
569
+ word_count = len(text.split())
570
+ duration = (end - start) if start is not None and end is not None else 0
571
+
572
+ if mode == "shorts":
573
+ # Intensity signals (Spikes)
574
+ score += text.count("!") * 8
575
+ score += sum(1 for w in text.split() if w.isupper() and len(w) > 3) * 5
576
+ # Viral keywords
577
+ keywords = ["no way", "wait", "what", "crazy", "insane", "oh my god", "unbelievable", "shocking", "finally"]
578
+ for k in keywords:
579
+ if k in text_lower: score += 12
580
+
581
+ # Hook Boosting (First 3 Seconds / First few words)
582
+ first_few_words = text_lower.split()[:8]
583
+ hook_words = ["what", "why", "how", "imagine", "did you know", "secret", "never", "always"]
584
+ if any(h in " ".join(first_few_words) for h in hook_words):
585
+ score += 20
586
+ if "?" in text[:30]:
587
+ score += 15
588
+
589
+ # Pacing
590
+ if 5 < word_count < 25: score += 10
591
+
592
+ # 🟢 EXPERT REFINEMENT: Duration Heuristics for Shorts
593
+ # Adaptive scoring based on target_duration
594
+ if target_duration and target_duration < 60:
595
+ target = target_duration
596
+ if abs(duration - target) < 5:
597
+ score += 15 # Very close to user target
598
+ elif duration > 60:
599
+ score -= 30 # YouTube Short physical limit
600
+ elif duration < 10:
601
+ score -= 5 # A bit too punchy if they asked for something specific
602
+ else:
603
+ # Default legacy behavior if no target or target is weird
604
+ if 5 < duration < 25:
605
+ score += 12 # Ideal punchy length
606
+ elif duration > 50:
607
+ score -= 15 # Getting risky for shorts
608
+ else:
609
+ # Narrative signals (Density & flow)
610
+ score += text.count(".") * 6
611
+ score += text.count(",") * 2
612
+
613
+ # 🟢 EXPERT REFINEMENT: Narrative Arc & Story Beats
614
+ narrative_keywords = [
615
+ "finally", "however", "consequently", "resolved", "conclusion",
616
+ "ending", "solved", "discovered", "because", "revealed",
617
+ "kratos", "athena", "zeus", "pandora", "god", "revenge", "hope" # Content specific but effective
618
+ ]
619
+ for k in narrative_keywords:
620
+ if k in text_lower: score += 15
621
+
622
+ # Dialogue density (Short sentences often imply back-and-forth narrative)
623
+ if word_count > 10:
624
+ avg_word_len = sum(len(w) for w in text.split()) / word_count
625
+ if 4 < avg_word_len < 7: # Standard conversational English
626
+ score += 8
627
+
628
+ # Length normalization for narrative flow
629
+ if 20 < duration < 60:
630
+ score += 10
631
+ conflict_words = ["but", "however", "problem", "issue", "failed", "wrong", "mistake", "challenge", "unbelievable"]
632
+ for k in conflict_words:
633
+ if k in text_lower: score += 10
634
+
635
+ story_keys = ["because", "then", "after", "finally", "decided", "discovered", "realized"]
636
+ for k in story_keys:
637
+ if k in text_lower: score += 8
638
+
639
+ if 20 < word_count < 60: score += 10
640
+
641
+ # 🟢 EXPERT REFINEMENT: Payoff Keywords (Narrative Weight)
642
+ story_peaks = ["finally", "in the end", "turns out", "the result", "this is why", "because of that"]
643
+ for k in story_peaks:
644
+ if k in text_lower:
645
+ score += 20
646
+
647
+ # 🟢 EXPERT REFINEMENT: Duration Penalty/Bonus
648
+ if duration > 60:
649
+ score -= 10
650
+ elif 15 < duration < 45:
651
+ score += 10 # Sweet spot for engagement
652
+
653
+ return score
654
+
655
+ def compress_transcript(segments, window=15.0):
656
+ """
657
+ Merges tiny Whisper fragments into larger context-rich windows.
658
+ This prevents the LLM from seeing fragmented noise and provides
659
+ better story/peak detection.
660
+ """
661
+ if not segments: return []
662
+ merged = []
663
+ # Initialize with first segment
664
+ curr = {
665
+ "start": segments[0]["start"],
666
+ "end": segments[0]["end"],
667
+ "text": segments[0]["text"]
668
+ }
669
+
670
+ for seg in segments[1:]:
671
+ # If adding this segment keeps us within the window, merge it
672
+ if seg["end"] - curr["start"] <= window:
673
+ curr["end"] = seg["end"]
674
+ curr["text"] += " " + seg["text"]
675
+ else:
676
+ # Otherwise, push current and start fresh
677
+ merged.append(curr)
678
+ curr = {
679
+ "start": seg["start"],
680
+ "end": seg["end"],
681
+ "text": seg["text"]
682
+ }
683
+ merged.append(curr)
684
+ return merged
685
+
686
+ def transcribe_video(video_path, output_dir, keep_fillers=False):
687
+ """
688
+ Lazy-loads Whisper and transcribes the video.
689
+ Returns path to transcript.json.
690
+ """
691
+ os.makedirs(output_dir, exist_ok=True)
692
+ transcript_path = os.path.join(output_dir, "transcript.json")
693
+
694
+ if os.path.exists(transcript_path):
695
+ try:
696
+ with open(transcript_path, 'r', encoding='utf-8') as f:
697
+ data = json.load(f)
698
+ # 🟢 EXPERT CACHE VALIDATION: Prevent using old transcript for new video
699
+ cached_source = data.get('source_video_path')
700
+ if cached_source == os.path.abspath(video_path):
701
+ print(f"[Log] Using valid cached transcript for: {video_path}")
702
+ return transcript_path
703
+ else:
704
+ print(f"[Warning] Session Cache Mismatch: Old={cached_source}, New={os.path.abspath(video_path)}. Re-transcribing...")
705
+ except:
706
+ pass
707
+
708
+ import stable_whisper as whisper
709
+ import torch
710
+
711
+ print(f"[Log] Starting AI Transcription (GPU Optimized - MEDIUM Model)...")
712
+ start_time = time.time()
713
+
714
+ device = "cuda" if torch.cuda.is_available() else "cpu"
715
+ # 🟢 UPGRADE: 'medium' model provides significantly better punctuation and nuance
716
+ model = whisper.load_model("medium", device=device)
717
+ if keep_fillers:
718
+ from engine.fillers import WHISPER_PROMPT
719
+ result = model.transcribe(video_path, language="en", initial_prompt=WHISPER_PROMPT)
720
+ else:
721
+ result = model.transcribe(video_path, language="en")
722
+
723
+ # Save the full result with source metadata
724
+ print(f"[Log] Saving transcript to {transcript_path}...")
725
+ # Add metadata to the whisper result before saving
726
+ result_dict = result.to_dict() if hasattr(result, 'to_dict') else result
727
+ if isinstance(result_dict, dict):
728
+ result_dict['source_video_path'] = os.path.abspath(video_path)
729
+ with open(transcript_path, 'w', encoding='utf-8') as f:
730
+ json.dump(result_dict, f, indent=2)
731
+ else:
732
+ # Fallback if stable_whisper result is weird
733
+ result.save_as_json(transcript_path)
734
+ print(f"[Log] Transcript saved successfully.")
735
+
736
+ print(f"[Log] Transcription completed in {time.time() - start_time:.1f}s")
737
+ return transcript_path
738
+
739
+ def merge_segments(segments, min_gap=6.0, max_dur=95.0, score_sensitive=False):
740
+ """
741
+ Merges segments that are close together.
742
+ If score_sensitive=True, prevents merging high-viral moments with low-quality filler.
743
+ """
744
+ if not segments: return []
745
+ sorted_segs = sorted(segments, key=lambda x: x['start'])
746
+ merged = []
747
+ curr = sorted_segs[0].copy()
748
+
749
+ for next_seg in sorted_segs[1:]:
750
+ gap = next_seg['start'] - curr['end']
751
+ combined_dur = next_seg['end'] - curr['start']
752
+
753
+ # 🟢 Phase 2: Score-aware merge to prevent peak dilution
754
+ score_diff = abs(curr.get('final_score', 0) - next_seg.get('final_score', 0))
755
+ similarity_gate = score_diff < 20 if score_sensitive else True
756
+
757
+ # Merge if gap is small, total duration is under max, and scores are compatible
758
+ if gap < min_gap and combined_dur <= max_dur and similarity_gate:
759
+ curr['end'] = max(curr['end'], next_seg['end'])
760
+ if len(curr.get('reason', '')) < 150:
761
+ curr['reason'] = f"{curr.get('reason', 'Segment')} & {next_seg.get('reason', 'Clip')}"
762
+ # Keep the highest viral score if merging
763
+ curr['viral_score'] = max(curr.get('viral_score', 0), next_seg.get('viral_score', 0))
764
+ else:
765
+ merged.append(curr)
766
+ curr = next_seg.copy()
767
+ merged.append(curr)
768
+ return merged
769
+
770
+ def identify_highlights(transcript_path, video_path=None, clip_count=5, mode="shorts", target_duration=None, min_duration=15, use_audio_detect=False, style=None, user_context=None, style_context=None):
771
+ from engine.script_gen import robust_json_parse, get_llm_response
772
+
773
+ with open(transcript_path, 'r', encoding='utf-8') as f:
774
+ transcript_data = json.load(f)
775
+
776
+ segments = transcript_data.get('segments', [])
777
+ if not segments:
778
+ print("[Warning] Empty transcript. Cannot identify highlights.")
779
+ return []
780
+
781
+ total_duration = segments[-1]['end'] if segments else 0
782
+
783
+ if mode == "CHALLENGE":
784
+ print("[Log] Mode: CHALLENGE. Skipping viral analysis, extracting a high-quality backdrop.")
785
+ # Pick a segment starting at 60s (to avoid intros) for the required duration
786
+ start_pt = min(60.0, total_duration * 0.1)
787
+ return [{
788
+ "start": start_pt,
789
+ "end": start_pt + (target_duration or 60),
790
+ "viral_score": 100,
791
+ "reason": "Challenge Backdrop"
792
+ }]
793
+
794
+ if mode == "EXPLAINER":
795
+ print("[Log] Mode: EXPLAINER. Extracting topic segment for Manim animation.")
796
+ return [{
797
+ "start": 0.0,
798
+ "end": min(total_duration, target_duration or 60),
799
+ "viral_score": 100,
800
+ "reason": "Explainer Animation Block"
801
+ }]
802
+
803
+ # 🟢 EXPERT REFINEMENT: transcript compression logic
804
+ if mode == "shorts":
805
+ # Shorts: Small windows (25-60s) for punchy peaks
806
+ window_size = float(max(25.0, min(60.0, (target_duration or 15.0) * 1.5)))
807
+ else:
808
+ # Long-form: Narrative windows (45-120s) to allow for story flow
809
+ # Capping at 120s prevents the AI from being forced into picking massive blocks
810
+ window_size = float(max(45.0, min(120.0, (target_duration or 300.0) / 5.0)))
811
+
812
+ print(f"[Log] Mode: {mode} | Target: {target_duration}s | Window: {window_size}s")
813
+ print(f"[Log] Compressing {len(segments)} fragments into {window_size}s context windows...")
814
+ compressed_segments = compress_transcript(segments, window=window_size)
815
+
816
+ # 🟢 PHASE 0: Parallel Analysis (Outline + Audio)
817
+ from concurrent.futures import ThreadPoolExecutor
818
+ video_outline = ""
819
+ audio_full = None
820
+ sr = 16000
821
+ global_audio_features = None
822
+
823
+ def get_outline():
824
+ if video_path:
825
+ try:
826
+ print("[Log] Generating video outline (Parallel)...")
827
+ return extract_video_outline(transcript_path)
828
+ except: return ""
829
+ return ""
830
+
831
+ def get_audio():
832
+ nonlocal use_audio_detect
833
+ if use_audio_detect and video_path:
834
+ try:
835
+ wav_path = extract_audio_mono(video_path, os.path.dirname(transcript_path))
836
+ print(f"[Log] Loading audio for analysis (Parallel)...")
837
+ import librosa
838
+ a, s = librosa.load(wav_path, sr=sr)
839
+ g = compute_global_audio_features(a, s)
840
+ return a, s, g
841
+ except Exception as e:
842
+ print(f"[Warning] Audio analysis failed: {e}")
843
+ use_audio_detect = False
844
+ return None, 16000, None
845
+
846
+ with ThreadPoolExecutor(max_workers=2) as executor:
847
+ f_outline = executor.submit(get_outline)
848
+ f_audio = executor.submit(get_audio)
849
+
850
+ video_outline = f_outline.result()
851
+ audio_full, sr, global_audio_features = f_audio.result()
852
+
853
+ # 🟢 PHASE 2: Signal Pre-Ranking (Text + Audio)
854
+ ENERGY_ALPHA = 0.3
855
+ energy_baseline = None
856
+ prev_energy = None
857
+ for i, seg in enumerate(compressed_segments):
858
+ seg['score'] = score_segment(seg['text'], mode=mode, start=seg['start'], end=seg['end'], target_duration=target_duration)
859
+ seg['audio_score'] = 0
860
+ if use_audio_detect and audio_full is not None:
861
+ s_idx = int(seg['start'] * sr)
862
+ e_idx = int(seg['end'] * sr)
863
+ chunk = audio_full[s_idx:e_idx]
864
+ a_score, raw_energy, s_ratio, v_silence = compute_audio_score(
865
+ chunk, sr, prev_energy=prev_energy,
866
+ global_features=global_audio_features, start_sec=seg['start'],
867
+ end_sec=seg['end'], energy_baseline=energy_baseline
868
+ )
869
+ seg['audio_score'] = round(a_score, 3)
870
+ seg['silence_ratio'] = s_ratio
871
+ seg['valid_silence'] = v_silence
872
+ if energy_baseline is None: energy_baseline = raw_energy
873
+ else: energy_baseline = ENERGY_ALPHA * raw_energy + (1 - ENERGY_ALPHA) * energy_baseline
874
+ prev_energy = raw_energy
875
+
876
+ # 🟢 PHASE 16: Motion Analysis (on Pre-Ranked Candidates)
877
+ if video_path:
878
+ try:
879
+ print("[Log] Analyzing visual motion on candidate segments...")
880
+ candidates = sorted(compressed_segments, key=lambda x: x.get('score', 0) + x.get('audio_score', 0), reverse=True)[:100]
881
+ candidate_intervals = [(c['start'], c['end']) for c in candidates]
882
+
883
+ motion_intervals = detect_motion_intervals(video_path, candidate_intervals=candidate_intervals)
884
+ for seg in compressed_segments:
885
+ seg['motion_score'] = 0
886
+ for m_start, m_end in motion_intervals:
887
+ overlap_s = max(seg['start'], m_start)
888
+ overlap_e = min(seg['end'], m_end)
889
+ if overlap_e > overlap_s:
890
+ seg['motion_score'] += (overlap_e - overlap_s) / (seg['end'] - seg['start'])
891
+ seg['motion_score'] = min(1.0, seg['motion_score'])
892
+ except Exception as e:
893
+ print(f"[Warning] Outline/Motion analysis failed: {e}")
894
+
895
+ # 🟢 PHASE 2: Final Signal & Delta (Contrast) Detection
896
+ # Now that we have all signals (Text, Audio, Motion), we calculate context-aware deltas.
897
+ for i, seg in enumerate(compressed_segments):
898
+ # 🟢 NEW: Silence Delta (Viral signal for sudden sound after pause)
899
+ if i > 0:
900
+ seg['delta'] = abs(seg['score'] - compressed_segments[i-1]['score'])
901
+ prev_audio = compressed_segments[i-1].get('audio_score', 0)
902
+ seg['audio_delta'] = abs(seg['audio_score'] - prev_audio)
903
+
904
+ # Robust silence delta check
905
+ if seg.get('valid_silence', True):
906
+ prev_silence = compressed_segments[i-1].get('silence_ratio', 0)
907
+ seg['silence_delta'] = abs(seg.get('silence_ratio', 0) - prev_silence)
908
+ else:
909
+ seg['silence_delta'] = 0
910
+ else:
911
+ seg['delta'] = 0
912
+ seg['audio_delta'] = 0
913
+ seg['silence_delta'] = 0
914
+
915
+ # 🟢 EXPERT REFINEMENT: Robust Signal Normalization (v2)
916
+ def get_robust_max(values):
917
+ if not values: return 1.0
918
+ p95 = np.percentile(values, 95)
919
+ p50 = np.percentile(values, 50)
920
+ return max(0.1, p95, p50 * 2)
921
+
922
+ max_orig_score = get_robust_max([s['score'] for s in compressed_segments])
923
+ max_audio_score = get_robust_max([s['audio_score'] for s in compressed_segments])
924
+ max_delta = get_robust_max([s['delta'] for s in compressed_segments])
925
+ max_motion = get_robust_max([s.get('motion_score', 0) for s in compressed_segments])
926
+ max_audio_delta = get_robust_max([s.get('audio_delta', 0) for s in compressed_segments])
927
+ max_silence_delta = get_robust_max([s.get('silence_delta', 0) for s in compressed_segments])
928
+
929
+ # 🟢 ADAPTIVE WEIGHTING: Motion can overpower dialogue in non-action videos (Moved out of loop)
930
+ avg_motion = sum(s.get('motion_score', 0) for s in compressed_segments) / len(compressed_segments) if compressed_segments else 0
931
+ motion_weight = 0.25 if avg_motion > 0.3 else 0.15
932
+ audio_weight = 0.30 if motion_weight == 0.15 else 0.20
933
+
934
+ for seg in compressed_segments:
935
+ seg['norm_score'] = round(min(1.0, seg['score'] / max_orig_score), 2)
936
+ seg['norm_audio'] = round(min(1.0, seg['audio_score'] / max_audio_score), 2)
937
+ seg['norm_delta'] = round(min(1.0, seg['delta'] / max_delta), 2)
938
+ seg['norm_motion'] = round(min(1.0, seg.get('motion_score', 0) / max_motion), 2)
939
+ seg['norm_audio_delta'] = round(min(1.0, seg.get('audio_delta', 0) / max_audio_delta), 2)
940
+
941
+ if seg.get('valid_silence', True):
942
+ seg['norm_silence_delta'] = round(min(1.0, seg.get('silence_delta', 0) / max_silence_delta), 2) if max_silence_delta > 0 else 0
943
+ else:
944
+ seg['norm_silence_delta'] = 0
945
+
946
+ # combined_signal: Text(35%) + Audio(Adaptive) + Motion(Adaptive) + Deltas(20%)
947
+ combined_signal = (
948
+ seg['norm_score'] * 0.35 +
949
+ seg['norm_audio'] * audio_weight +
950
+ seg['norm_motion'] * motion_weight +
951
+ seg['norm_delta'] * 0.10 +
952
+ seg['norm_audio_delta'] * 0.10
953
+ )
954
+ pos = round(seg['start'] / total_duration, 2) if total_duration > 0 else 0
955
+ seg['pruning_score'] = combined_signal * (0.8 + 0.4 * pos)
956
+
957
+ # 🟢 PHASE 15: Peak Sharpening (Local Maxima Boost)
958
+ for i in range(1, len(compressed_segments)-1):
959
+ prev_p = compressed_segments[i-1]['pruning_score']
960
+ curr_p = compressed_segments[i]['pruning_score']
961
+ nxt_p = compressed_segments[i+1]['pruning_score']
962
+ if curr_p > prev_p and curr_p > nxt_p:
963
+ compressed_segments[i]['pruning_score'] *= 1.2
964
+
965
+ # 🟢 EXPERT REFINEMENT: Updated Context Window & Limit
966
+ context_window = 3
967
+ limit = 100
968
+
969
+ if len(compressed_segments) > limit:
970
+ if mode == "long":
971
+ # 🟢 NEW: Ensure representation across the entire timeline
972
+ # Instead of top 75 absolute peaks, take top N from every 10% of video
973
+ seeds_per_decile = max(4, (limit // 2) // 10)
974
+ chunk_size = len(compressed_segments) // 10
975
+ distributed_indices = []
976
+
977
+ for d in range(10):
978
+ start_bin = d * chunk_size
979
+ end_bin = (d + 1) * chunk_size if d < 9 else len(compressed_segments)
980
+ if start_bin >= end_bin: continue
981
+
982
+ # 🟢 NARRATIVE: Remove absolute quality threshold to prevent story gaps
983
+ bin_candidates = range(start_bin, end_bin)
984
+
985
+ # Best peaks in this 10% of the video
986
+ bin_indices = sorted(bin_candidates, key=lambda i: compressed_segments[i]['pruning_score'], reverse=True)
987
+ distributed_indices.extend(bin_indices[:seeds_per_decile])
988
+
989
+ top_indices = sorted(list(set(distributed_indices)))
990
+ else:
991
+ top_indices = sorted(range(len(compressed_segments)), key=lambda i: compressed_segments[i]['pruning_score'], reverse=True)[:limit//2]
992
+
993
+ selected_indices = set()
994
+ for i in top_indices:
995
+ # Include context window
996
+ for offset in range(-context_window, context_window + 1):
997
+ idx = i + offset
998
+ if 0 <= idx < len(compressed_segments):
999
+ selected_indices.add(idx)
1000
+
1001
+ compressed_segments = [compressed_segments[i] for i in sorted(list(selected_indices))]
1002
+
1003
+ full_text_with_ts = ""
1004
+ for segment in compressed_segments:
1005
+ # 🟢 UPGRADE: Enriched signal richness for LLM
1006
+ # pos=timeline position, t=text, d=text_delta, a=audio, ad=audio_delta, sd=silence_delta
1007
+ pos = round(segment['start'] / total_duration, 2) if total_duration > 0 else 0
1008
+ sigs = f"pos={pos} t={segment['norm_score']} d={segment['norm_delta']} a={segment['norm_audio']} ad={segment['norm_audio_delta']} sd={segment['norm_silence_delta']}"
1009
+ full_text_with_ts += f"[{segment['start']:.2f}s - {segment['end']:.2f}s | {sigs}]: {segment['text']}\n"
1010
+
1011
+ # Style-specific injections (Handle list or string)
1012
+ styles_list = style if isinstance(style, list) else ([style] if style else [])
1013
+
1014
+ style_instr_parts = []
1015
+ if "sarcastic" in styles_list:
1016
+ style_instr_parts.append("FOCUS: Identify moments that are ironic, eye-rolling, or 'facepalm' fails.")
1017
+ if "action" in styles_list:
1018
+ style_instr_parts.append("FOCUS: Identify 'Action Peaks'—climactic battles, boss fights, or high-intensity gameplay moments.")
1019
+ if "meme" in styles_list:
1020
+ style_instr_parts.append("FOCUS: Identify moments with high meme potential—funny reactions or bizarre occurrences.")
1021
+ if "funny" in styles_list:
1022
+ style_instr_parts.append("FOCUS: Identify funny dialogue, situational comedy, or humorous fails.")
1023
+
1024
+ style_instructions = " ".join(style_instr_parts)
1025
+
1026
+ context_injection = ""
1027
+ if user_context:
1028
+ context_injection = f"\nUSER EXTRA CONTEXT: {user_context}\n(PRIORITIZE moments that match this context while maintaining virality rules.)"
1029
+
1030
+ style_injection = ""
1031
+ if style_context:
1032
+ style_injection = f"\nEDITING STYLE CONTEXT: {style_context}\n(Follow this editing/pacing style for the selected moments.)"
1033
+
1034
+ outline_injection = ""
1035
+ # 🟢 ADAPTIVE OUTLINE: Only include for long videos and label as low priority
1036
+ if video_outline and total_duration > 1800: # > 30 mins
1037
+ outline_injection = f"\n[LOW PRIORITY CONTEXT - MAY BE NOISY]:\n{video_outline}\n"
1038
+
1039
+ if mode == "shorts":
1040
+ system_prompt = f"You are a professional social media manager. Identify high-impact viral moments. {style_instructions} {context_injection} {style_injection} {outline_injection} YOU MUST PROVIDE TIMESTAMPS FOR EVERY CLIP."
1041
+ prompt = f"""Objective: Identify up to {clip_count} distinct viral moments for YouTube Shorts.
1042
+
1043
+ {context_injection}
1044
+ {style_injection}
1045
+ {outline_injection}
1046
+
1047
+ VIRALITY SIGNALS (Look for high 'signal' or high 'delta' in the log):
1048
+ 1. High Signal: Intense yelling, punctuation, or shock keywords.
1049
+ 2. High Delta (d_signal): Sudden shifts in intensity/tone (very viral!).
1050
+ 3. Audio Spikes (a_signal): Emotional peaks even if text is neutral.
1051
+
1052
+ CRITICAL RULES FOR WATCHABILITY:
1053
+ - **CLIP COUNT**: You MUST identify and extract exactly {clip_count} distinct viral clips (or as close to {clip_count} as possible, aiming for at least 12-15) covering different parts of the video. If the USER EXTRA CONTEXT mentions multiple scenes, timestamps, or parts, you MUST extract multiple separate, distinct clips for EACH requested scene/timestamp to meet the requested count. Do not be overly selective; include all moments of action, humor, or banter.
1054
+ - **HOOK FIRST**: The first 2 seconds of each clip MUST have a visual or audio 'hook'. Look for segments where 'ad' (audio delta) or 'd' (text delta) is high right at the 'start' timestamp.
1055
+ - **VIRALITY**: Prioritize high-energy, funny, or shocking moments. {style_instructions}
1056
+ - **ENGAGEMENT**: Each moment MUST be between {max(5, (target_duration or 15)-15)} and {min(59, (target_duration or 45)+15)} seconds long.
1057
+ - **TARGET**: Specifically aim for moments around {min(58, target_duration or 30)}s each for maximum flow. NEVER exceed 60 seconds.
1058
+ - **MANDATORY**: 'start' and 'end' timestamps MUST be provided as numbers (float/seconds).
1059
+ - **MANDATORY**: For each clip, provide a 'hook_text' (under 10 words).
1060
+ - **MANDATORY**: For each clip, identify the 'tone' (one of: action_peak, funny, fail, tense, educational, neutral).
1061
+ - **MANDATORY**: For each clip, provide a 'viral_score' (integer between 1 and 100, representing virality/engagement potential).
1062
+ - REJECT: slow setups, generic intros, filler conversation.
1063
+ - Wrap JSON array in START_JSON and END_JSON markers.
1064
+
1065
+ Log Data (Signal/Delta marked):
1066
+ {full_text_with_ts}"""
1067
+ else: # Long-Form Highlight Reel
1068
+ context_injection = ""
1069
+ if user_context:
1070
+ context_injection = f"\nUSER EXTRA CONTEXT: {user_context}\n(ENSURE these specific parts/styles are included in the summary reel.)"
1071
+
1072
+ style_injection = ""
1073
+ if style_context:
1074
+ style_injection = f"\nEDITING STYLE CONTEXT: {style_context}\n(Ensure the summary reel follows this specific narrative/pacing vibe.)"
1075
+
1076
+ system_prompt = f"You are a narrative editor. Provide TIMESTAMPS for all story beats. {context_injection} {style_injection}"
1077
+ if target_duration and target_duration > 1200:
1078
+ avg_seg_dur = 65
1079
+ segment_target = int(target_duration / avg_seg_dur)
1080
+ dur_msg = "Each segment MUST be between 45 and 95 seconds."
1081
+ else:
1082
+ avg_seg_dur = 35 # More segments → better chance of reaching target duration
1083
+ segment_target = int((target_duration or 300) / avg_seg_dur)
1084
+ dur_msg = f"Each segment MUST be between 20 and 90 seconds. Do NOT return segments shorter than 20 seconds. You MUST reach {target_duration}s total."
1085
+
1086
+ segment_target = max(8, min(segment_target, 250))
1087
+ prompt = f"""Objective: Generate a cohesive narrative summary reel.
1088
+ {context_injection}
1089
+ {style_injection}
1090
+
1091
+ Aim to identify approximately {segment_target} key segments to reach the target duration of {target_duration}s.
1092
+ Ensure you cover the story comprehensively. Do not be overly selective; include all meaningful story beats and reactions.
1093
+
1094
+ WATCHABILITY & RETENTION:
1095
+ - **REACTION HOOKS**: Include segments with high 'ad' (audio delta) as these often represent the most watchable reactions.
1096
+ - **NARRATIVE FLOW**: Ensure the 'start' of each segment feels like a fresh beat or hook.
1097
+ - **MANDATORY**: For each clip, provide a 'hook_text' (short teaser for the clip).
1098
+
1099
+ SIGNAL GUIDE:
1100
+ - t: Narrative density / flow
1101
+ - d: Signal change (important beat)
1102
+ - a: Audio intensity
1103
+ - ad: Sudden audio spike (VERY important for reactions/hooks)
1104
+ - sd: Silence to sound transition (highly viral hook potential)
1105
+
1106
+ STRICT RULES:
1107
+ 1. Wrap JSON in START_JSON and END_JSON.
1108
+ 2. Provide 'viral_score' (0-100) and 'hook_text' for each.
1109
+ 3. MANDATORY: include 'start' and 'end' timestamps.
1110
+ 4. RAW JSON ONLY. NO EXTRA TEXT.
1111
+
1112
+ Log Data (Signal/Delta marked):
1113
+ [OUTLINE]: {video_outline}
1114
+
1115
+ {full_text_with_ts}
1116
+
1117
+ MANDATORY: Return a JSON LIST of objects. Even if only one moment is found, wrap it in []."""
1118
+
1119
+ try:
1120
+ # 🟢 HEARTBEAT: Clear logging for user visibility on long runs
1121
+ print(f"[Log] Sending {len(compressed_segments)} logic-filtered segments to LLM for viral curation...")
1122
+ print(f"[Log] This may take up to 2-3 minutes for large 60+ min videos. Please wait...")
1123
+
1124
+ # 🟢 PERFORMANCE: Set dynamic_max to 8192 to prevent Gemini token truncation
1125
+ dynamic_max = 8192
1126
+
1127
+ response_text = get_llm_response(prompt, system_prompt, max_tokens=dynamic_max)
1128
+ print(f"[DEBUG] Raw LLM Response: {response_text}")
1129
+ print(f"[Log] LLM analysis received ({len(response_text)} chars). Processing results...")
1130
+ res_data = robust_json_parse(response_text)
1131
+
1132
+ highlights = []
1133
+ if isinstance(res_data, list):
1134
+ highlights = res_data
1135
+ elif isinstance(res_data, dict):
1136
+ for key in ["highlights", "segments", "data", "clips"]:
1137
+ if key in res_data:
1138
+ highlights = res_data[key]
1139
+ break
1140
+ if not highlights and 'start' in res_data:
1141
+ # 🟢 RECOVERY: LLM returned a single object instead of a list
1142
+ highlights = [res_data]
1143
+
1144
+ if not highlights:
1145
+ print(f"[DEBUG] Raw LLM output failed to parse: {response_text[:300]}...")
1146
+
1147
+ # 🟢 QUALITY FILTER, MERGING & SORTING
1148
+ valid_highlights = []
1149
+ min_floor = 8.0 if mode == "shorts" else 15.0 # Lowered for narrative flexibility
1150
+
1151
+ if isinstance(highlights, list) and highlights:
1152
+ for h in highlights:
1153
+ if isinstance(h, dict) and 'start' in h and 'end' in h:
1154
+ try:
1155
+ h['start'] = float(h['start'])
1156
+ h['end'] = float(h['end'])
1157
+ except (ValueError, TypeError):
1158
+ continue
1159
+ v_score = h.get('viral_score', 0)
1160
+ if isinstance(v_score, str): v_score = int(''.join(filter(str.isdigit, v_score)) or 0)
1161
+ h['viral_score'] = v_score
1162
+
1163
+ # 🟢 EXPERT REFINEMENT: Hybrid Signal Ranking (v3)
1164
+ # Align with pruning logic signals for consistency
1165
+ relevant_segs = [s for s in compressed_segments if not (s['end'] < h['start'] or s['start'] > h['end'])]
1166
+ if relevant_segs:
1167
+ avg_norm_score = sum(s['norm_score'] for s in relevant_segs) / len(relevant_segs)
1168
+ avg_norm_audio = sum(s['norm_audio'] for s in relevant_segs) / len(relevant_segs)
1169
+ avg_norm_motion = sum(s['norm_motion'] for s in relevant_segs) / len(relevant_segs)
1170
+ avg_norm_delta = sum(s['norm_delta'] for s in relevant_segs) / len(relevant_segs)
1171
+ avg_norm_ad = sum(s['norm_audio_delta'] for s in relevant_segs) / len(relevant_segs)
1172
+ avg_norm_sd = sum(s['norm_silence_delta'] for s in relevant_segs) / len(relevant_segs)
1173
+
1174
+ combined_signal = (
1175
+ avg_norm_score * 0.35 +
1176
+ avg_norm_audio * 0.20 +
1177
+ avg_norm_motion * 0.25 +
1178
+ avg_norm_delta * 0.10 +
1179
+ avg_norm_ad * 0.10
1180
+ )
1181
+ h['final_score'] = (v_score * 0.7) + (combined_signal * 100 * 0.3)
1182
+ else:
1183
+ h['final_score'] = v_score
1184
+
1185
+ valid_highlights.append(h)
1186
+ elif isinstance(h, (list, tuple)) and len(h) >= 2:
1187
+ valid_highlights.append({"start": float(h[0]), "end": float(h[1]), "viral_score": 50, "final_score": 50, "reason": "Action Sequence"})
1188
+
1189
+ # 🟢 NEW FLOW: Merge adjacent parts BEFORE trimming to count
1190
+ effective_target = target_duration if target_duration else 60.0
1191
+ max_merge_dur = min(59.9, effective_target) if mode == "shorts" else 150.0 # Strict cap for shorts
1192
+ print(f"[Log] Merging adjacent highlights (score-aware, max={max_merge_dur}s)...")
1193
+ valid_highlights = merge_segments(valid_highlights, min_gap=8.0, max_dur=max_merge_dur, score_sensitive=True)
1194
+
1195
+ # 🟢 STABILITY: Strict Duration Truncation for Shorts
1196
+ if mode == "shorts":
1197
+ for h in valid_highlights:
1198
+ actual_dur = h['end'] - h['start']
1199
+ if actual_dur > max_merge_dur:
1200
+ print(f"[Warning] Truncating segment from {actual_dur:.1f}s to {max_merge_dur}s")
1201
+ h['end'] = h['start'] + max_merge_dur
1202
+
1203
+ # 🟢 OPUS-STYLE SORTING: Prioritize high hybrid scores
1204
+ valid_highlights.sort(key=lambda x: x.get('final_score', 0), reverse=True)
1205
+
1206
+ # Limit segments for shorts mode AFTER merge to keep only the best substantial clips
1207
+ if mode == "shorts":
1208
+ valid_highlights = valid_highlights[:clip_count]
1209
+ elif mode == "long" and target_duration:
1210
+ # 🟢 EXPERT REFINEMENT: Soft Duration Capping for Highlight Reels
1211
+ valid_highlights.sort(key=lambda x: x.get('final_score', 0), reverse=True)
1212
+ capped_highlights = []
1213
+ current_total = 0
1214
+ for h in valid_highlights:
1215
+ dur = h['end'] - h['start']
1216
+ if current_total < target_duration * 1.3: # Allow 30% overage for impact
1217
+ capped_highlights.append(h)
1218
+ current_total += dur
1219
+ else:
1220
+ break
1221
+ valid_highlights = capped_highlights
1222
+
1223
+ # 🟢 FILL-UP: Backfill from pruned segments if LLM under-delivered
1224
+ current_total = sum(h['end'] - h['start'] for h in valid_highlights)
1225
+ if current_total < target_duration * 0.95:
1226
+ print(f"[Log] Duration shortfall ({current_total:.0f}s < {target_duration}s). Backfilling from signal peaks...")
1227
+ used_ranges = [(h['start'], h['end']) for h in valid_highlights]
1228
+ for seg in sorted(compressed_segments, key=lambda x: x['pruning_score'], reverse=True):
1229
+ overlaps = any(not (seg['end'] < u[0] or seg['start'] > u[1]) for u in used_ranges)
1230
+ seg_dur = seg['end'] - seg['start']
1231
+ if overlaps or seg_dur < 15: continue
1232
+ valid_highlights.append({'start': seg['start'], 'end': seg['end'],
1233
+ 'viral_score': int(seg['pruning_score'] * 100),
1234
+ 'final_score': int(seg['pruning_score'] * 100), 'reason': 'Signal Peak (Backfill)'})
1235
+ used_ranges.append((seg['start'], seg['end']))
1236
+ current_total += seg_dur
1237
+ if current_total >= target_duration * 1.05: break
1238
+ print(f"[Log] After backfill: {current_total:.0f}s, {len(valid_highlights)} segments")
1239
+
1240
+ if mode == "shorts" and len(valid_highlights) < clip_count:
1241
+ print(f"[Log] 🎬 Highlight count ({len(valid_highlights)}) is below requested count ({clip_count}). Backfilling top signal peaks...")
1242
+ used_ranges = [(h['start'], h['end']) for h in valid_highlights]
1243
+ fallback_candidates = sorted(compressed_segments, key=lambda x: x['norm_score'] + x.get('norm_audio', 0) + x.get('norm_motion', 0), reverse=True)
1244
+ for seg in fallback_candidates:
1245
+ seg_dur = seg['end'] - seg['start']
1246
+ if seg_dur < 10.0: continue
1247
+ overlaps = any(not (seg['end'] < u[0] or seg['start'] > u[1]) for u in used_ranges)
1248
+ if overlaps: continue
1249
+ valid_highlights.append({
1250
+ 'start': seg['start'], 'end': seg['end'],
1251
+ 'viral_score': int((seg['norm_score'] + seg.get('norm_audio', 0)) * 50),
1252
+ 'final_score': int((seg['norm_score'] + seg.get('norm_audio', 0)) * 50),
1253
+ 'reason': 'Energy Peak (Backfill)'
1254
+ })
1255
+ used_ranges.append((seg['start'], seg['end']))
1256
+ if len(valid_highlights) >= clip_count:
1257
+ break
1258
+
1259
+ # 🟢 MANDATORY: Re-sort chronologically to ensure narrative flow and fix timing mismatch
1260
+ # This ensures that even if we picked top scores, they play back in order.
1261
+ valid_highlights.sort(key=lambda x: x['start'])
1262
+
1263
+ # Final filter by floor
1264
+ valid_highlights = [h for h in valid_highlights if (h['end'] - h['start']) >= max(8.0, min_duration - 5.0)]
1265
+
1266
+ if not valid_highlights:
1267
+ print("[Warning] LLM analysis failed. Using Heuristic Signal Fallback (Top Peaks)...")
1268
+ # 🟢 DYNAMIC RECOVERY: Use top heuristic signal peaks (Text + Audio)
1269
+ fallback_candidates = sorted(compressed_segments, key=lambda x: x['norm_score'] + x.get('norm_audio', 0), reverse=True)
1270
+
1271
+ if mode == "long" and target_duration:
1272
+ accumulated_duration = 0
1273
+ for f in fallback_candidates:
1274
+ seg_dur = f['end'] - f['start']
1275
+ if accumulated_duration >= target_duration:
1276
+ break
1277
+ valid_highlights.append({
1278
+ "start": f['start'],
1279
+ "end": f['end'],
1280
+ "viral_score": int(f['norm_score'] * 100),
1281
+ "final_score": int(f['norm_score'] * 100),
1282
+ "reason": "High-Signal Moment (Heuristic Fallback)"
1283
+ })
1284
+ accumulated_duration += seg_dur
1285
+ else:
1286
+ for f in fallback_candidates[:clip_count]:
1287
+ valid_highlights.append({
1288
+ "start": f['start'],
1289
+ "end": f['end'],
1290
+ "viral_score": int(f['norm_score'] * 100),
1291
+ "final_score": int(f['norm_score'] * 100),
1292
+ "reason": "High-Signal Moment (Heuristic Fallback)"
1293
+ })
1294
+
1295
+ valid_highlights.sort(key=lambda x: x['start'])
1296
+ valid_highlights = deduplicate_highlights(valid_highlights)
1297
+
1298
+ if not valid_highlights:
1299
+ # 🟢 ABSOLUTE FAILSAFE: Simple duration-based slice
1300
+ fallback_start = min(45.0, total_duration * 0.1)
1301
+ fallback_duration = 35.0 if mode == "shorts" else (target_duration or 180.0)
1302
+ return [{"start": fallback_start, "end": fallback_start + fallback_duration, "viral_score": 85, "hook_text": "Check this out!", "reason": "Draft Extraction (Failsafe)"}]
1303
+
1304
+ # Final deduplication check
1305
+ valid_highlights = deduplicate_highlights(valid_highlights)
1306
+ print(f"[Log] Curation complete: Selected {len(valid_highlights)} high-viral deduplicated segments.")
1307
+ return valid_highlights
1308
+ except Exception as e:
1309
+ print(f"[Error] Highlight identification failed: {e}")
1310
+ import traceback
1311
+ traceback.print_exc()
1312
+ return [{"start": 10.0, "end": 40.0, "viral_score": 80, "hook_text": "Epic Moment", "reason": "Epic Highlight"}]
1313
+
1314
+ def deduplicate_highlights(highlights, min_overlap_sec=5.0):
1315
+ """
1316
+ Deduplicates overlapping highlight clips by comparing time ranges.
1317
+ When two clips overlap significantly (>= min_overlap_sec),
1318
+ keeps the candidate with the higher viral_score / final_score.
1319
+ """
1320
+ if not highlights or len(highlights) <= 1:
1321
+ return highlights
1322
+
1323
+ # Sort by score descending so higher-rated clips take priority
1324
+ sorted_highlights = sorted(
1325
+ highlights,
1326
+ key=lambda x: float(x.get('viral_score', x.get('final_score', 80)) or 80),
1327
+ reverse=True
1328
+ )
1329
+
1330
+ kept = []
1331
+ for candidate in sorted_highlights:
1332
+ cand_start = float(candidate['start'])
1333
+ cand_end = float(candidate['end'])
1334
+
1335
+ is_overlapping = False
1336
+ for existing in kept:
1337
+ ex_start = float(existing['start'])
1338
+ ex_end = float(existing['end'])
1339
+
1340
+ # Calculate overlap duration
1341
+ overlap_start = max(cand_start, ex_start)
1342
+ overlap_end = min(cand_end, ex_end)
1343
+ overlap_duration = max(0.0, overlap_end - overlap_start)
1344
+
1345
+ if overlap_duration >= min_overlap_sec:
1346
+ is_overlapping = True
1347
+ break
1348
+
1349
+ if not is_overlapping:
1350
+ kept.append(candidate)
1351
+
1352
+ # Re-sort chronologically by start time for natural playback order
1353
+ kept.sort(key=lambda x: float(x['start']))
1354
+ return kept
1355
+
1356
+ def process_source_video(video_path, output_dir, mode="shorts", clip_count=5, target_duration=None, min_duration=15, use_audio_detect=False, style=None, user_context=None, style_context=None, smart_crop=False, tighten=False, use_cache=False, chapters_path=None, use_llm_scoring=True, find_query=None, keep_fillers=False):
1357
+ if not os.path.exists(video_path):
1358
+ raise FileNotFoundError(f"Source video Not Found: {video_path}")
1359
+
1360
+ transcript_path = transcribe_video(video_path, output_dir, keep_fillers=keep_fillers)
1361
+
1362
+ # Load YouTube chapters if available
1363
+ chapters = []
1364
+ if chapters_path:
1365
+ chapters = extract_youtube_chapters(chapters_path)
1366
+
1367
+ highlights_cache_path = os.path.join(output_dir, "highlights.json")
1368
+ highlights = None
1369
+ if find_query:
1370
+ from engine.find_moments import find_moments, llm_ask
1371
+ with open(transcript_path, 'r', encoding='utf-8') as f:
1372
+ segments = json.load(f).get('segments', [])
1373
+ print(f"[Log] Searching transcript for: {find_query!r}")
1374
+ highlights = find_moments(segments, find_query, clip_count=clip_count,
1375
+ target_duration=target_duration or 45, min_duration=min_duration, ask=llm_ask())
1376
+ if not highlights:
1377
+ raise RuntimeError(f"No moments matching {find_query!r} were found in the transcript.")
1378
+ elif use_cache and os.path.exists(highlights_cache_path):
1379
+ try:
1380
+ with open(highlights_cache_path, "r", encoding="utf-8") as f:
1381
+ highlights = json.load(f)
1382
+ print(f"[Log] Loaded cached highlights from {highlights_cache_path}")
1383
+ except Exception as e:
1384
+ print(f"[Warning] Failed to load cached highlights: {e}")
1385
+
1386
+ if highlights is None:
1387
+ highlights = identify_highlights(
1388
+ transcript_path, video_path=video_path, clip_count=clip_count,
1389
+ mode=mode, target_duration=target_duration, min_duration=min_duration, use_audio_detect=use_audio_detect,
1390
+ style=style, user_context=user_context, style_context=style_context
1391
+ )
1392
+
1393
+ # Boost chapter-overlapping segments
1394
+ if chapters:
1395
+ print(f"[Log] Applying chapter-based score boosting to {len(highlights)} candidates...")
1396
+ for h in highlights:
1397
+ hs, he = float(h['start']), float(h['end'])
1398
+ for ch in chapters:
1399
+ cs, ce = ch['start'], ch['end']
1400
+ overlap = max(0, min(he, ce) - max(hs, cs))
1401
+ if overlap > 2.0: # At least 2s overlap with a chapter
1402
+ h['score'] = float(h.get('score', 0)) + 25
1403
+ h['reason'] = h.get('reason', '') + f" [Chapter: {ch['title']}]"
1404
+ break
1405
+ highlights.sort(key=lambda x: float(x.get('score', 0)), reverse=True)
1406
+
1407
+ # LLM virality scoring
1408
+ if use_llm_scoring:
1409
+ try:
1410
+ with open(transcript_path, 'r', encoding='utf-8') as f:
1411
+ transcript_data = json.load(f)
1412
+ highlights = llm_score_highlights(highlights, transcript_data)
1413
+ except Exception as e:
1414
+ print(f"[Warning] LLM scoring skipped: {e}")
1415
+
1416
+ try:
1417
+ with open(highlights_cache_path, "w", encoding="utf-8") as f:
1418
+ json.dump(highlights, f, indent=4)
1419
+ print(f"[Log] Saved highlights to cache: {highlights_cache_path}")
1420
+ except Exception as e:
1421
+ print(f"[Warning] Failed to cache highlights: {e}")
1422
+
1423
+ interest_points_cache_path = os.path.join(output_dir, "interest_points.json")
1424
+ interest_points = {}
1425
+ interest_points_loaded = False
1426
+ if use_cache and smart_crop and os.path.exists(interest_points_cache_path):
1427
+ try:
1428
+ with open(interest_points_cache_path, "r", encoding="utf-8") as f:
1429
+ raw_pts = json.load(f)
1430
+ interest_points = {float(k): v for k, v in raw_pts.items()}
1431
+ interest_points_loaded = True
1432
+ print(f"[Log] Loaded cached interest points from {interest_points_cache_path}")
1433
+ except Exception as e:
1434
+ print(f"[Warning] Failed to load cached interest points: {e}")
1435
+
1436
+ if smart_crop and not interest_points_loaded:
1437
+ highlight_intervals = [(h['start'], h['end']) for h in highlights]
1438
+ print(f"[Log] Extracting interest points for {len(highlights)} segments (Auto-Crop with IoU Speaker Lock)...")
1439
+ interest_points = detect_interest_points(video_path, segments=highlight_intervals)
1440
+ try:
1441
+ with open(interest_points_cache_path, "w", encoding="utf-8") as f:
1442
+ json.dump(interest_points, f, indent=4)
1443
+ print(f"[Log] Saved interest points to cache: {interest_points_cache_path}")
1444
+ except Exception as e:
1445
+ print(f"[Warning] Failed to cache interest points: {e}")
1446
+ elif not smart_crop:
1447
+ print("[Log] Smart crop disabled. Skipping interest point detection to speed up extraction.")
1448
+
1449
+ silence_intervals_cache_path = os.path.join(output_dir, "silence_intervals.json")
1450
+ silence_intervals = []
1451
+ silence_intervals_loaded = False
1452
+ if use_cache and tighten and os.path.exists(silence_intervals_cache_path):
1453
+ try:
1454
+ with open(silence_intervals_cache_path, "r", encoding="utf-8") as f:
1455
+ raw_silence = json.load(f)
1456
+ silence_intervals = [tuple(item) for item in raw_silence]
1457
+ silence_intervals_loaded = True
1458
+ print(f"[Log] Loaded cached silence intervals from {silence_intervals_cache_path}")
1459
+ except Exception as e:
1460
+ print(f"[Warning] Failed to load cached silence intervals: {e}")
1461
+
1462
+ if tighten and not silence_intervals_loaded:
1463
+ print("[Log] Extracting silence intervals for Auto-Edit...")
1464
+ audio_path = os.path.join(output_dir, "audio_mono.wav")
1465
+ if not os.path.exists(audio_path):
1466
+ audio_path = extract_audio_mono(video_path, output_dir)
1467
+ silence_intervals = detect_silence_intervals(audio_path)
1468
+ try:
1469
+ with open(silence_intervals_cache_path, "w", encoding="utf-8") as f:
1470
+ json.dump(silence_intervals, f, indent=4)
1471
+ print(f"[Log] Saved silence intervals to cache: {silence_intervals_cache_path}")
1472
+ except Exception as e:
1473
+ print(f"[Warning] Failed to cache silence intervals: {e}")
1474
+ elif not tighten:
1475
+ print("[Log] Tighten (silence removal) disabled. Skipping silence interval detection to speed up extraction.")
1476
+
1477
+ return highlights, transcript_path, interest_points, silence_intervals
1478
+
1479
+ if not os.path.exists(video_path):
1480
+ raise FileNotFoundError(f"Source video Not Found: {video_path}")
1481
+
1482
+ transcript_path = transcribe_video(video_path, output_dir)
1483
+
1484
+ highlights_cache_path = os.path.join(output_dir, "highlights.json")
1485
+ highlights = None
1486
+ if use_cache and os.path.exists(highlights_cache_path):
1487
+ try:
1488
+ with open(highlights_cache_path, "r", encoding="utf-8") as f:
1489
+ highlights = json.load(f)
1490
+ print(f"[Log] Loaded cached highlights from {highlights_cache_path}")
1491
+ except Exception as e:
1492
+ print(f"[Warning] Failed to load cached highlights: {e}")
1493
+
1494
+ if highlights is None:
1495
+ highlights = identify_highlights(
1496
+ transcript_path, video_path=video_path, clip_count=clip_count,
1497
+ mode=mode, target_duration=target_duration, use_audio_detect=use_audio_detect,
1498
+ style=style, user_context=user_context, style_context=style_context
1499
+ )
1500
+ try:
1501
+ with open(highlights_cache_path, "w", encoding="utf-8") as f:
1502
+ json.dump(highlights, f, indent=4)
1503
+ print(f"[Log] Saved highlights to cache: {highlights_cache_path}")
1504
+ except Exception as e:
1505
+ print(f"[Warning] Failed to cache highlights: {e}")
1506
+
1507
+ # 🟢 UPGRADE: Extra AI Signals for Auto-Edit & Auto-Crop (Optimized)
1508
+ interest_points_cache_path = os.path.join(output_dir, "interest_points.json")
1509
+ interest_points = {}
1510
+ interest_points_loaded = False
1511
+ if use_cache and smart_crop and os.path.exists(interest_points_cache_path):
1512
+ try:
1513
+ with open(interest_points_cache_path, "r", encoding="utf-8") as f:
1514
+ raw_pts = json.load(f)
1515
+ interest_points = {float(k): v for k, v in raw_pts.items()}
1516
+ interest_points_loaded = True
1517
+ print(f"[Log] Loaded cached interest points from {interest_points_cache_path}")
1518
+ except Exception as e:
1519
+ print(f"[Warning] Failed to load cached interest points: {e}")
1520
+
1521
+ if smart_crop and not interest_points_loaded:
1522
+ highlight_intervals = [(h['start'], h['end']) for h in highlights]
1523
+ print(f"[Log] Extracting interest points for {len(highlights)} segments (Auto-Crop)...")
1524
+ interest_points = detect_interest_points(video_path, segments=highlight_intervals)
1525
+ try:
1526
+ with open(interest_points_cache_path, "w", encoding="utf-8") as f:
1527
+ json.dump(interest_points, f, indent=4)
1528
+ print(f"[Log] Saved interest points to cache: {interest_points_cache_path}")
1529
+ except Exception as e:
1530
+ print(f"[Warning] Failed to cache interest points: {e}")
1531
+ elif not smart_crop:
1532
+ print("[Log] Smart crop disabled. Skipping interest point detection to speed up extraction.")
1533
+
1534
+ silence_intervals_cache_path = os.path.join(output_dir, "silence_intervals.json")
1535
+ silence_intervals = []
1536
+ silence_intervals_loaded = False
1537
+ if use_cache and tighten and os.path.exists(silence_intervals_cache_path):
1538
+ try:
1539
+ with open(silence_intervals_cache_path, "r", encoding="utf-8") as f:
1540
+ raw_silence = json.load(f)
1541
+ silence_intervals = [tuple(item) for item in raw_silence]
1542
+ silence_intervals_loaded = True
1543
+ print(f"[Log] Loaded cached silence intervals from {silence_intervals_cache_path}")
1544
+ except Exception as e:
1545
+ print(f"[Warning] Failed to load cached silence intervals: {e}")
1546
+
1547
+ if tighten and not silence_intervals_loaded:
1548
+ print("[Log] Extracting silence intervals for Auto-Edit...")
1549
+ audio_path = os.path.join(output_dir, "audio_mono.wav")
1550
+ if not os.path.exists(audio_path):
1551
+ audio_path = extract_audio_mono(video_path, output_dir)
1552
+ silence_intervals = detect_silence_intervals(audio_path)
1553
+ try:
1554
+ with open(silence_intervals_cache_path, "w", encoding="utf-8") as f:
1555
+ json.dump(silence_intervals, f, indent=4)
1556
+ print(f"[Log] Saved silence intervals to cache: {silence_intervals_cache_path}")
1557
+ except Exception as e:
1558
+ print(f"[Warning] Failed to cache silence intervals: {e}")
1559
+ elif not tighten:
1560
+ print("[Log] Tighten (silence removal) disabled. Skipping silence interval detection to speed up extraction.")
1561
+
1562
+ return highlights, transcript_path, interest_points, silence_intervals