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.
- engine/analysis.py +1562 -0
- engine/beat_sync.py +97 -0
- engine/branding.py +189 -0
- engine/comfy_bridge.py +286 -0
- engine/fillers.py +99 -0
- engine/find_moments.py +135 -0
- engine/kokoro_tts.py +133 -0
- engine/media_gen.py +972 -0
- engine/modes/__init__.py +1 -0
- engine/modes/clip_extract.py +413 -0
- engine/modes/explainer.py +104 -0
- engine/modes/generate.py +1324 -0
- engine/modes/music.py +183 -0
- engine/modes/photo_reel.py +123 -0
- engine/modes/video_filter.py +89 -0
- engine/paths.py +12 -0
- engine/pipeline.py +129 -0
- engine/publish.py +119 -0
- engine/remotion_renderer.py +405 -0
- engine/script_gen.py +2551 -0
- engine/social_gen.py +669 -0
- engine/speaker_focus.py +364 -0
- engine/storage.py +91 -0
- engine/trend_gen.py +50 -0
- engine/utils.py +59 -0
- engine/video_gen.py +3105 -0
- engine/voice_gen.py +418 -0
- shortsflow/__init__.py +2 -0
- shortsflow/__main__.py +4 -0
- shortsflow/_bundle/assets/avatars/cat.mp4 +0 -0
- shortsflow/_bundle/assets/avatars/mafia_cat.mp4 +0 -0
- shortsflow/_bundle/assets/avatars/mafia_cat.png +0 -0
- shortsflow/_bundle/assets/avatars/orange_cat.mp4 +0 -0
- shortsflow/_bundle/assets/avatars/rabbit.png +0 -0
- shortsflow/_bundle/assets/avatars/robot.png +0 -0
- shortsflow/_bundle/assets/avatars/squirrel.png +0 -0
- shortsflow/_bundle/assets/branding/tv_frame.jpg +0 -0
- shortsflow/_bundle/assets/models/face_detection_yunet_2023mar.onnx +0 -0
- shortsflow/_bundle/env.example +48 -0
- shortsflow/_bundle/music/bg_music.mp3 +0 -0
- shortsflow/_bundle/remotion-video/package-lock.json +5721 -0
- shortsflow/_bundle/remotion-video/package.json +35 -0
- shortsflow/_bundle/remotion-video/public/fonts/Anton-Regular.ttf +0 -0
- shortsflow/_bundle/remotion-video/public/fonts/LICENSE-Anton.txt +1 -0
- shortsflow/_bundle/remotion-video/remotion.config.ts +15 -0
- shortsflow/_bundle/remotion-video/src/HelloWorld/Arc.tsx +55 -0
- shortsflow/_bundle/remotion-video/src/HelloWorld/Atom.tsx +36 -0
- shortsflow/_bundle/remotion-video/src/HelloWorld/Logo.tsx +87 -0
- shortsflow/_bundle/remotion-video/src/HelloWorld/Subtitle.tsx +26 -0
- shortsflow/_bundle/remotion-video/src/HelloWorld/Title.tsx +58 -0
- shortsflow/_bundle/remotion-video/src/HelloWorld/constants.ts +5 -0
- shortsflow/_bundle/remotion-video/src/HelloWorld.tsx +76 -0
- shortsflow/_bundle/remotion-video/src/Root.tsx +54 -0
- shortsflow/_bundle/remotion-video/src/ShortFlow.tsx +1695 -0
- shortsflow/_bundle/remotion-video/src/captionEmoji.ts +19 -0
- shortsflow/_bundle/remotion-video/src/fonts.ts +11 -0
- shortsflow/_bundle/remotion-video/src/index.css +1 -0
- shortsflow/_bundle/remotion-video/src/index.ts +8 -0
- shortsflow/_bundle/remotion-video/tsconfig.json +16 -0
- shortsflow/_main.py +281 -0
- shortsflow/api/__init__.py +1 -0
- shortsflow/api/__main__.py +6 -0
- shortsflow/api/app.py +128 -0
- shortsflow/api/jobs.py +108 -0
- shortsflow/cli.py +68 -0
- shortsflow/workspace.py +89 -0
- shortsflow-0.1.0.dist-info/METADATA +308 -0
- shortsflow-0.1.0.dist-info/RECORD +71 -0
- shortsflow-0.1.0.dist-info/WHEEL +4 -0
- shortsflow-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|