forked from kamilstanuch/Autocrop-vertical
-
Notifications
You must be signed in to change notification settings - Fork 914
Expand file tree
/
Copy pathmain.py
More file actions
1914 lines (1659 loc) · 86.9 KB
/
Copy pathmain.py
File metadata and controls
1914 lines (1659 loc) · 86.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import time
import cv2
import subprocess
import argparse
import re
import sys
import threading
import unicodedata
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from scenedetect import open_video, SceneManager
from scenedetect.detectors import ContentDetector
from ultralytics import YOLO
import torch
import os
import numpy as np
from tqdm import tqdm
import yt_dlp
import mediapipe as mp
# import whisper (replaced by faster_whisper inside function)
from google import genai
from google.genai import types as genai_types
import gemini_worker
import layout_picker
from clip_selection import (build_transcript_windows, clip_count_targets,
clip_duration_bounds, snap_clip_to_words)
from ffmpeg_utils import (video_encode_args, audio_encode_args, QUALITY,
QUALITY_FAST, METADATA_SCRUB)
from dotenv import load_dotenv
import json
import warnings
warnings.filterwarnings("ignore", category=UserWarning, module='google.protobuf')
# Load environment variables
load_dotenv()
# --- Constants ---
ASPECT_RATIO = 9 / 16
GEMINI_PROMPT_TEMPLATE = """
You are a senior short-form video editor. Read the ENTIRE transcript and word-level timestamps to choose the 3–15 MOST VIRAL moments for TikTok/IG Reels/YouTube Shorts. Each clip must be between 15 and 60 seconds long.
⚠️ FFMPEG TIME CONTRACT — STRICT REQUIREMENTS:
- Return timestamps in ABSOLUTE SECONDS from the start of the video (usable in: ffmpeg -ss <start> -to <end> -i <input> ...).
- Only NUMBERS with decimal point, up to 3 decimals (examples: 0, 1.250, 17.350).
- Ensure 0 ≤ start < end ≤ VIDEO_DURATION_SECONDS.
- Each clip between 15 and 60 s (inclusive).
- Prefer starting 0.2–0.4 s BEFORE the hook and ending 0.2–0.4 s AFTER the payoff.
- Use silence moments for natural cuts; never cut in the middle of a word or phrase.
- STRICTLY FORBIDDEN to use time formats other than absolute seconds.
VIDEO_DURATION_SECONDS: {video_duration}
TRANSCRIPT_TEXT (raw):
{transcript_text}
WORDS_JSON (array of {{w, s, e}} where s/e are seconds):
{words_json}
STRICT EXCLUSIONS:
- No generic intros/outros or purely sponsorship segments unless they contain the hook.
- No clips < 15 s or > 60 s.
OUTPUT — RETURN ONLY VALID JSON (no markdown, no comments). Order clips by predicted performance (best to worst). In the descriptions, ALWAYS include a CTA like "Follow me and comment X and I'll send you the workflow" (especially if discussing an n8n workflow):
{{
"shorts": [
{{
"start": <number in seconds, e.g., 12.340>,
"end": <number in seconds, e.g., 37.900>,
"video_description_for_tiktok": "<description for TikTok oriented to get views>",
"video_description_for_instagram": "<description for Instagram oriented to get views>",
"video_title_for_youtube_short": "<title for YouTube Short oriented to get views 100 chars max>",
"viral_hook_text": "<SHORT punchy text overlay (max 10 words) with 1-2 fitting emojis. MUST BE IN THE SAME LANGUAGE AS THE VIDEO TRANSCRIPT. Examples: 'POV: You realized... 😳', 'Did you know? 🤯', 'Stop doing this! 🚫'>"
}}
]
}}
"""
# Load the YOLO model once (Keep for backup or scene analysis if needed)
# YOLO_MODEL_PATH lets deployments point at a pre-downloaded weights file so a
# volume mounted over the workdir doesn't trigger a re-download at startup.
model = YOLO(os.environ.get("YOLO_MODEL_PATH", "yolov8n.pt"))
# --- MediaPipe Setup ---
# Use standard Face Detection (BlazeFace) for speed
mp_face_detection = mp.solutions.face_detection
face_detection = mp_face_detection.FaceDetection(model_selection=1, min_detection_confidence=0.5)
# Consecutive detections a large target move must survive before the camera
# follows it (see SmoothedCameraman.update_target). Env-overridable so the
# damping can be dialled back without a deploy; 1 restores the old behaviour.
JUMP_CONFIRM_FRAMES = max(int(os.environ.get("JUMP_CONFIRM_FRAMES", "3")), 1)
# Reset the tracker and the cameraman's damping at every scene cut, so the
# first face found in the new shot is framed instantly instead of being treated
# as a suspicious "jump" from the previous shot's subject (see
# SmoothedCameraman.begin_scene). 0 restores the old behaviour.
SCENE_CUT_RESET = os.environ.get("SCENE_CUT_RESET", "1") != "0"
class SmoothedCameraman:
"""
Handles smooth camera movement.
Simplified Logic: "Heavy Tripod"
Only moves if the subject leaves the center safe zone.
Moves slowly and linearly.
"""
def __init__(self, output_width, output_height, video_width, video_height, aspect_ratio=ASPECT_RATIO):
self.output_width = output_width
self.output_height = output_height
self.video_width = video_width
self.video_height = video_height
self.aspect_ratio = aspect_ratio
# Initial State
self.current_center_x = video_width / 2
self.target_center_x = video_width / 2
# Calculate crop dimensions once
self.crop_height = video_height
self.crop_width = int(self.crop_height * aspect_ratio)
if self.crop_width > video_width:
self.crop_width = video_width
self.crop_height = int(self.crop_width / aspect_ratio)
# Safe Zone: 20% of the video width
# As long as the target is within this zone relative to current center, DO NOT MOVE.
self.safe_zone_radius = self.crop_width * 0.25
# A target that teleports further than the safe zone in one detection is
# far more often a detector error — a second face, a false positive, a
# box snapping to a different body part — than a person who actually
# moved that far. Committing to it immediately is what made the camera
# swing: measured on real user footage, 22% of target updates jumped
# more than the entire safe zone. So a big move has to REPEAT this many
# times before the camera follows it; a wrong reading disappears on the
# next detection and never moves the frame.
#
# The cost is latency on a genuinely fast move: at DETECT_STRIDE=4 and
# 30fps, three confirmations is ~0.4s. That reads as an operator being
# unhurried, which is the look we want, and it is far cheaper than the
# whip-panning it replaces.
#
# Measured over 262s of TRACK footage from two real user videos
# (26-jul-2026), confirm=1 -> 3: in-scene reversals 0.41/s -> 0.13/s
# (-69%), camera travel 91px/s -> 60px/s (-34%). Per scene, 54 of 84 get
# calmer and 23 are unchanged — but 7 get BUSIER, up to 59 -> 108px/s,
# because committing later can leave the camera further to travel. Net
# strongly positive, not universally so.
self.jump_confirm_frames = JUMP_CONFIRM_FRAMES
self._pending_target = None
self._pending_count = 0
self._snap_pending = False
def begin_scene(self):
"""Forget the previous shot's subject at a scene cut.
The jump damping above exists to reject detector noise INSIDE a shot.
Across a cut it does the opposite of what is wanted: the new shot's face
is (by construction) far from the old target, so it was held back for
JUMP_CONFIRM_FRAMES detections and the camera then panned towards it at
pan speed. On a real two-camera podcast (24-aug-2026) that showed up as
a headless torso for 1.5s after every cut while the frame slid over to
the speaker. The snap at the scene's first frame did not help: the
target it snapped to was still the previous shot's.
So: drop any pending jump, and cut (rather than pan) to the first target
accepted in the new shot.
"""
self._pending_target = None
self._pending_count = 0
self._snap_pending = True
def update_target(self, face_box):
"""Update the target centre from a detection, ignoring lone big jumps."""
if not face_box:
return
x, y, w, h = face_box
new_center = x + w / 2
if self._snap_pending:
self._snap_pending = False
self._pending_target = None
self._pending_count = 0
self.target_center_x = new_center
self.current_center_x = new_center
return
if abs(new_center - self.target_center_x) > self.safe_zone_radius:
# Same big move as last time? Count it. Otherwise start counting
# afresh — two contradictory outliers must not confirm each other.
if (self._pending_target is not None
and abs(new_center - self._pending_target) <= self.safe_zone_radius):
self._pending_count += 1
else:
self._pending_target = new_center
self._pending_count = 1
if self._pending_count < self.jump_confirm_frames:
return # not convinced yet — hold the frame
self._pending_target = None
self._pending_count = 0
self.target_center_x = new_center
def get_crop_box(self, force_snap=False):
"""
Returns the (x1, y1, x2, y2) for the current frame.
"""
if force_snap:
self.current_center_x = self.target_center_x
else:
diff = self.target_center_x - self.current_center_x
# SIMPLIFIED LOGIC:
# 1. Is the target outside the safe zone?
if abs(diff) > self.safe_zone_radius:
# 2. If yes, move towards it slowly (Linear Speed)
# Determine direction
direction = 1 if diff > 0 else -1
# Speed: 2 pixels per frame (Slow pan)
# If the distance is HUGE (scene change or fast movement), speed up slightly
if abs(diff) > self.crop_width * 0.5:
speed = 15.0 # Fast re-frame
else:
speed = 3.0 # Slow, steady pan
self.current_center_x += direction * speed
# Check if we overshot (prevent oscillation)
new_diff = self.target_center_x - self.current_center_x
if (direction == 1 and new_diff < 0) or (direction == -1 and new_diff > 0):
self.current_center_x = self.target_center_x
# If inside safe zone, DO NOTHING (Stationary Camera)
# Clamp center
half_crop = self.crop_width / 2
if self.current_center_x - half_crop < 0:
self.current_center_x = half_crop
if self.current_center_x + half_crop > self.video_width:
self.current_center_x = self.video_width - half_crop
x1 = int(self.current_center_x - half_crop)
x2 = int(self.current_center_x + half_crop)
x1 = max(0, x1)
x2 = min(self.video_width, x2)
y1 = 0
y2 = self.video_height
return x1, y1, x2, y2
class SpeakerTracker:
"""
Tracks speakers over time to prevent rapid switching and handle temporary obstructions.
"""
def __init__(self, stabilization_frames=15, cooldown_frames=30):
self.active_speaker_id = None
self.speaker_scores = {} # {id: score}
self.last_seen = {} # {id: frame_number}
self.locked_counter = 0 # How long we've been locked on current speaker
# Hyperparameters
self.stabilization_threshold = stabilization_frames # Frames needed to confirm a new speaker
self.switch_cooldown = cooldown_frames # Minimum frames before switching again
self.last_switch_frame = -1000
# ID tracking
self.next_id = 0
self.known_faces = [] # [{'id': 0, 'center': x, 'last_frame': 123}]
def reset(self):
"""Forget every speaker at a scene cut.
Identity, hysteresis and the switch cooldown are all about continuity
within a shot. After a cut none of it applies: the sticky x3 bonus and
the cooldown were holding the previous shot's speaker (returning None)
for up to 30 frames while a new face sat unframed.
"""
self.active_speaker_id = None
self.speaker_scores = {}
self.last_seen = {}
self.locked_counter = 0
self.last_switch_frame = -1000
self.known_faces = []
def get_target(self, face_candidates, frame_number, width):
"""
Decides which face to focus on.
face_candidates: list of {'box': [x,y,w,h], 'score': float}
"""
current_candidates = []
# 1. Match faces to known IDs (simple distance tracking)
for face in face_candidates:
x, y, w, h = face['box']
center_x = x + w / 2
best_match_id = -1
min_dist = width * 0.15 # Reduced matching radius to avoid jumping in groups
# Try to match with known faces seen recently
for kf in self.known_faces:
if frame_number - kf['last_frame'] > 30: # Forgot faces older than 1s (was 2s)
continue
dist = abs(center_x - kf['center'])
if dist < min_dist:
min_dist = dist
best_match_id = kf['id']
# If no match, assign new ID
if best_match_id == -1:
best_match_id = self.next_id
self.next_id += 1
# Update known face
self.known_faces = [kf for kf in self.known_faces if kf['id'] != best_match_id]
self.known_faces.append({'id': best_match_id, 'center': center_x, 'last_frame': frame_number})
current_candidates.append({
'id': best_match_id,
'box': face['box'],
'score': face['score']
})
# 2. Update Scores with decay
for pid in list(self.speaker_scores.keys()):
self.speaker_scores[pid] *= 0.85 # Faster decay (was 0.9)
if self.speaker_scores[pid] < 0.1:
del self.speaker_scores[pid]
# Add new scores
for cand in current_candidates:
pid = cand['id']
# Score is purely based on size (proximity) now that we don't have mouth
raw_score = cand['score'] / (width * width * 0.05)
self.speaker_scores[pid] = self.speaker_scores.get(pid, 0) + raw_score
# 3. Determine Best Speaker
if not current_candidates:
# If no one found, maintain last active speaker if cooldown allows
# to avoid black screen or jump to 0,0
return None
best_candidate = None
max_score = -1
for cand in current_candidates:
pid = cand['id']
total_score = self.speaker_scores.get(pid, 0)
# Hysteresis: HUGE Bonus for current active speaker
if pid == self.active_speaker_id:
total_score *= 3.0 # Sticky factor
if total_score > max_score:
max_score = total_score
best_candidate = cand
# 4. Decide Switch
if best_candidate:
target_id = best_candidate['id']
if target_id == self.active_speaker_id:
self.locked_counter += 1
return best_candidate['box']
# New person. The cooldown must hold whether or not the current
# speaker happens to be detected in THIS frame.
#
# It used to fall through and switch when the active speaker was
# missing from the candidate list — a blink, a head turn or one
# motion-blurred frame was enough. That is precisely when the
# cooldown is needed, so it only ever fired when it wasn't: 3 of 7
# target switches measured on a 12s clip (25-jul-2026) jumped the
# cooldown this way, and every jump drags the camera across frame.
#
# Returning None holds instead: the caller only calls
# update_target() on a truthy box, so the camera keeps its current
# target and finishes whatever move it was making. The hold is
# bounded by the cooldown itself — once it expires, a speaker who
# really did leave the shot is switched away from normally.
if frame_number - self.last_switch_frame < self.switch_cooldown:
old_cand = next((c for c in current_candidates if c['id'] == self.active_speaker_id), None)
return old_cand['box'] if old_cand else None
self.active_speaker_id = target_id
self.last_switch_frame = frame_number
self.locked_counter = 0
return best_candidate['box']
return None
# Detectors never need full-resolution frames: MediaPipe returns relative
# coords and YOLO boxes are scaled back up. Running them on a ≤640px copy cuts
# per-frame preprocessing cost hard, which is what dominates CPU-only renders.
DETECT_MAX_WIDTH = 640
# The global MediaPipe graph and YOLO model are NOT thread-safe; clips render
# in parallel, so every inference goes through this lock. Contention is small
# (a few ms per call) — the ffmpeg renders are where the parallel time goes.
DETECT_LOCK = threading.Lock()
# Detect every Nth frame; SmoothedCameraman interpolates between updates.
DETECT_STRIDE = max(int(os.environ.get("DETECT_STRIDE", "4")), 1)
# YOLO fallback (no face found) is far heavier than MediaPipe — extra throttle.
YOLO_FALLBACK_STRIDE = DETECT_STRIDE * 2
def _detection_frame(frame):
"""Downscaled copy for detectors. Returns (small_frame, scale) with
scale mapping small-frame pixel coords back to the original frame."""
h, w = frame.shape[:2]
if w <= DETECT_MAX_WIDTH:
return frame, 1.0
scale = w / DETECT_MAX_WIDTH
small = cv2.resize(frame, (DETECT_MAX_WIDTH, max(int(h / scale), 2)),
interpolation=cv2.INTER_AREA)
return small, scale
def detect_face_candidates(frame):
"""
Returns list of all detected faces using lightweight FaceDetection.
Boxes are in ORIGINAL frame coordinates (detection runs downscaled;
MediaPipe's relative coords make the mapping exact).
"""
height, width, _ = frame.shape
small, _scale = _detection_frame(frame)
rgb_frame = cv2.cvtColor(small, cv2.COLOR_BGR2RGB)
with DETECT_LOCK:
results = face_detection.process(rgb_frame)
candidates = []
if not results.detections:
return []
for detection in results.detections:
bboxC = detection.location_data.relative_bounding_box
x = int(bboxC.xmin * width)
y = int(bboxC.ymin * height)
w = int(bboxC.width * width)
h = int(bboxC.height * height)
candidates.append({
'box': [x, y, w, h],
'score': w * h # Area as score
})
return candidates
def detect_person_yolo(frame):
"""
Fallback: Detect largest person using YOLO when face detection fails.
Returns [x, y, w, h] of the person's 'upper body' approximation, in
ORIGINAL frame coordinates (inference runs on a downscaled copy).
"""
small, scale = _detection_frame(frame)
# Use the globally loaded model
with DETECT_LOCK:
results = model(small, verbose=False, classes=[0]) # class 0 is person
if not results:
return None
best_box = None
max_area = 0
for result in results:
boxes = result.boxes
for box in boxes:
x1, y1, x2, y2 = [int(i * scale) for i in box.xyxy[0]]
w = x2 - x1
h = y2 - y1
area = w * h
if area > max_area:
max_area = area
# Focus on the top 40% of the person (head/chest) for framing
# This approximates where the face is if we can't detect it directly
face_h = int(h * 0.4)
best_box = [x1, y1, w, face_h]
return best_box
def create_general_frame(frame, output_width, output_height):
"""
Creates a 'General Shot' frame:
- Background: Blurred zoom of original
- Foreground: Original video scaled to fit width, centered vertically.
"""
orig_h, orig_w = frame.shape[:2]
# 1. Background (Fill Height)
# Crop center to aspect ratio
bg_scale = output_height / orig_h
bg_w = int(orig_w * bg_scale)
bg_resized = cv2.resize(frame, (bg_w, output_height), interpolation=cv2.INTER_LINEAR)
# Crop center of background
start_x = (bg_w - output_width) // 2
if start_x < 0: start_x = 0
background = bg_resized[:, start_x:start_x+output_width]
if background.shape[1] != output_width:
background = cv2.resize(background, (output_width, output_height), interpolation=cv2.INTER_LINEAR)
# Blur background: blur at quarter resolution and scale back up — visually
# identical for a defocused backdrop, an order of magnitude cheaper than a
# 51px Gaussian at full size.
small_bg = cv2.resize(background, (max(output_width // 4, 2), max(output_height // 4, 2)),
interpolation=cv2.INTER_AREA)
small_bg = cv2.GaussianBlur(small_bg, (13, 13), 0)
background = cv2.resize(small_bg, (output_width, output_height),
interpolation=cv2.INTER_LINEAR)
# 2. Foreground (Fit Width)
scale = output_width / orig_w
fg_h = int(orig_h * scale)
foreground = cv2.resize(frame, (output_width, fg_h), interpolation=cv2.INTER_LINEAR)
# 3. Overlay
y_offset = (output_height - fg_h) // 2
# Clone background to avoid modifying it
final_frame = background.copy()
final_frame[y_offset:y_offset+fg_h, :] = foreground
return final_frame
# NOTE: a "route text-heavy scenes to GENERAL" rule was tried here and removed
# on 26-jul-2026. The problem it targets is real — a screencast that happens to
# contain one face gets cropped to the face and its headlines come out cut
# mid-word — but edge density is the wrong signal for it. Measured: a
# constructed talking-head-beside-a-chart scored 0.012 while the SAME shot
# without the panels scored 0.029, because a flat panel of text has far fewer
# edges than ordinary scene detail. Canny measures visual busyness, not text.
# A real fix needs an actual text detector (MSER/EAST) validated against clips
# that contain the failure mode; this corpus has almost none.
def analyze_scenes_strategy(video_path, scenes):
"""
Analyzes each scene to determine if it should be TRACK (Single person) or GENERAL (Group/Wide).
Returns list of strategies corresponding to scenes.
"""
cap = cv2.VideoCapture(video_path)
strategies = []
if not cap.isOpened():
return ['TRACK'] * len(scenes)
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
for start, end in tqdm(scenes, desc=" Analyzing Scenes"):
s_f, e_f = start.get_frames(), end.get_frames()
# Sample 5 frames spread across the scene, clamped inside it (the old
# start+5/end-5 samples landed outside scenes shorter than ~10 frames).
margin = min(2, max(0, (e_f - s_f - 1) // 2))
frames_to_check = sorted(set(
int(round(f)) for f in np.linspace(s_f + margin, e_f - 1 - margin, 5)
))
face_counts = []
for f_idx in frames_to_check:
cap.set(cv2.CAP_PROP_POS_FRAMES, f_idx)
ret, frame = cap.read()
if not ret: continue
# Near-black frames (fades, cut-to-black) carry no faces and used
# to drag single-person scenes into GENERAL. Skip them.
if frame.mean() < 16:
continue
# Detect faces
candidates = detect_face_candidates(frame)
face_counts.append(len(candidates))
# Decision Logic
if not face_counts:
avg_faces = 0
else:
avg_faces = sum(face_counts) / len(face_counts)
# Strategy:
# 0 faces -> GENERAL (Landscape/B-roll)
# 1 face -> TRACK
# > 1.2 faces -> GENERAL (Group)
if avg_faces > 1.2 or avg_faces < 0.5:
strategies.append('GENERAL')
else:
strategies.append('TRACK')
cap.release()
# Hysteresis: a short scene whose two neighbors agree on the opposite
# strategy is almost always a sampling miss (profile face, insert shot).
# Each TRACK<->GENERAL flip is a full on-screen layout change, so flapping
# is worse than an occasional wrong-but-stable choice.
max_flip_frames = int(2.0 * fps)
for i in range(1, len(strategies) - 1):
dur = scenes[i][1].get_frames() - scenes[i][0].get_frames()
if (dur < max_flip_frames
and strategies[i - 1] == strategies[i + 1] != strategies[i]):
strategies[i] = strategies[i - 1]
return strategies
def detect_scenes(video_path):
import scene_detection
return scene_detection.detect_scenes(video_path)
def get_video_resolution(video_path):
probe = cv2.VideoCapture(video_path)
try:
if not probe.isOpened():
raise IOError(f"cannot open video: {video_path}")
return (int(probe.get(cv2.CAP_PROP_FRAME_WIDTH)),
int(probe.get(cv2.CAP_PROP_FRAME_HEIGHT)))
finally:
probe.release()
# Byte budget for the sanitized video title used as the stem of every derived
# file. Filesystems cap a name in BYTES (255 on ext4), not characters, and the
# pipeline decorates this stem: "_clip_10.mp4" (12), "subtitled_<ts>_" (21),
# "hooked_<ts>_" (18), "temp_hook_<hex8>_" (19), "autosubs_<ts>_" + ".ass" (24).
# Budgeting 120 bytes leaves room for all of them stacked (worst chain:
# subtitled_<ts>_hooked_<ts>_<stem>_clip_NN.mp4 ≈ 171 bytes) under the limit.
#
# The old cap was 100 CHARACTERS, which is 300 bytes of Bengali or Arabic — over
# the limit before any decoration. It surfaced as OSError 36 killing the hook
# endpoint in prod on 26-jul-2026.
MAX_TITLE_BYTES = 120
def truncate_bytes(text, max_bytes):
"""Trim ``text`` to a byte budget without splitting a multi-byte character."""
encoded = text.encode("utf-8")
if len(encoded) <= max_bytes:
return text
return encoded[:max_bytes].decode("utf-8", "ignore")
def sanitize_filename(filename):
"""Remove invalid characters from filename and bound it for the filesystem."""
# "canción" has two Unicode spellings: a precomposed ó (NFC) or an o plus a
# combining acute (NFD). yt-dlp hands over titles in either, and the name
# becomes the clip file, the R2 key and the URL path. Measured 24-ago-2026:
# a key carrying the combining form is fetchable by a <video> element but a
# fetch() of the same URL comes back 503, which broke the download button on
# every clip with a Spanish title. Normalising here fixes the whole chain at
# its source, and is a no-op for the ASCII names that already worked.
filename = unicodedata.normalize('NFC', filename)
filename = re.sub(r'[<>:"/\\|?*#]', '', filename)
filename = filename.replace(' ', '_')
return truncate_bytes(filename, MAX_TITLE_BYTES)
def plan_download_attempts(direct_first, statics, paid, have_hd):
"""Ordered (label, capped, proxy) download plan — pure, unit-tested.
Cheapest bandwidth first: the server's own IP, then the flat-rate static
ISP proxies (uncapped 1080p, free bytes), then the per-GB paid proxy
(720p cost cap), and last the conservative fallback strategy through the
paid proxy (or a static/direct when no paid proxy is configured).
``capped`` marks attempts whose bytes are billed per GB."""
plan = []
if direct_first:
plan.append(('HD-direct', False, None))
if have_hd:
for i, s in enumerate(statics):
plan.append((f'HD-static{i + 1}', False, s))
plan.append(('HD', bool(paid), paid))
plan.append(('fallback', bool(paid),
paid if paid else (statics[0] if statics else None)))
return plan
def download_youtube_video(url, output_dir="."):
"""
Downloads a YouTube video using yt-dlp.
Returns the path to the downloaded video and the video title.
"""
# SSRF guard: block non-http(s) schemes and private/loopback/metadata hosts
# before handing the URL to yt-dlp.
from security_utils import assert_public_url
assert_public_url(url)
print(f"🔍 Debug: yt-dlp version: {yt_dlp.version.__version__}")
print("📥 Downloading video from YouTube...")
step_start_time = time.time()
cookies_path = '/app/cookies.txt'
cookies_env = os.environ.get("YOUTUBE_COOKIES")
if cookies_env:
print("🍪 Found YOUTUBE_COOKIES env var, creating cookies file inside container...")
try:
with open(cookies_path, 'w') as f:
f.write(cookies_env)
if os.path.exists(cookies_path):
# Never print file CONTENT here: with a headerless cookies
# blob this would leak live YouTube session cookies to logs.
print(f" Debug: Cookies file created. Size: {os.path.getsize(cookies_path)} bytes")
except Exception as e:
print(f"⚠️ Failed to write cookies file: {e}")
cookies_path = None
else:
cookies_path = None
print("⚠️ YOUTUBE_COOKIES env var not found.")
# Optional HTTP proxy. Set PROXY_URL to route downloads through it; unset
# (self-host) goes direct as before.
_proxy = os.environ.get("PROXY_URL", "").strip() or None
if _proxy:
print("🌐 Using proxy for download.")
# Flat-rate static ISP proxies (STATIC_PROXY_URLS, comma-separated), tried
# BEFORE the per-GB proxy: dedicated IPs with unlimited traffic, so their
# bandwidth costs nothing per job and carries no 720p cost cap. Rotated per
# job to spread load (and YouTube's attention) across the pool. PROXY_URL
# stays the paid last resort — with STATIC_PROXY_URLS unset the behavior is
# byte-identical to before.
_statics = [p.strip() for p in
os.environ.get("STATIC_PROXY_URLS", "").split(",") if p.strip()]
if _statics:
import random as _random
k = _random.randrange(len(_statics))
_statics = _statics[k:] + _statics[:k]
print(f"🌐 {len(_statics)} static ISP proxies configured.")
# Two download strategies, tried in order so a break in the HD path degrades
# gracefully instead of failing the whole job: an HD attempt first, then a
# conservative fallback (also the only strategy for self-host).
_bgutil_http = os.environ.get("BGUTIL_BASE_URL", "").strip()
_bgutil_script = os.environ.get("BGUTIL_SCRIPT_PATH", "").strip()
if _bgutil_http:
hd_args = {'youtubepot-bgutilhttp': {'base_url': [_bgutil_http]}}
elif _bgutil_script:
hd_args = {'youtubepot-bgutilscript': {'script_path': [_bgutil_script]}}
else:
hd_args = None
fallback_args = {
'youtube': {
'player_client': ['tv_embed', 'android', 'mweb', 'web'],
'player_skip': ['webpage', 'configs'],
}
}
# Cap at 720p ONLY when the bytes actually go through the PER-GB paid proxy
# — that cap exists to control bandwidth cost, and the direct attempt and
# the flat-rate static proxies have none.
#
# This is per-attempt on purpose. Deciding it once from `_proxy` capped the
# DIRECT attempt too, so with DIRECT_FIRST=1 (which serves most downloads)
# every YouTube source arrived at 720p and, since the reframe inherits the
# source height, 80% of delivered clips came out 406x720 (audited 25-jul-2026).
def _hd_fmt_for(capped):
if capped:
return ('bestvideo[vcodec^=avc1][height<=720][ext=mp4]+bestaudio[ext=m4a]/'
'bestvideo[vcodec^=avc1][height<=720]+bestaudio/'
'best[height<=720][ext=mp4]/best[height<=720]/best')
return ('bestvideo[vcodec^=avc1][height<=1080][ext=mp4]+bestaudio[ext=m4a]/'
'bestvideo[vcodec^=avc1][height<=1080]+bestaudio/'
'best[height<=1080][ext=mp4]/best[ext=mp4]/best')
fallback_fmt = 'best[ext=mp4]/best'
def _base_opts(extractor_args, proxy):
return {
'quiet': False, 'verbose': True, 'no_warnings': False,
'cookiefile': cookies_path if cookies_path else None,
'proxy': proxy, 'socket_timeout': 30, 'retries': 10, 'fragment_retries': 10,
'nocheckcertificate': True, 'cachedir': False,
'extractor_args': extractor_args,
'http_headers': {
'User-Agent': (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
),
},
}
# Wire bytes actually pulled through the (paid) proxy, summed across
# fragments/streams. Reported to app.py via the PROXY_BYTES= line below.
_dl_bytes = {"total": 0}
def _progress_hook(d):
if d.get('status') == 'finished':
_dl_bytes["total"] += int(d.get('total_bytes')
or d.get('total_bytes_estimate')
or d.get('downloaded_bytes') or 0)
def _attempt(extractor_args, fmt, proxy):
_dl_bytes["total"] = 0
with yt_dlp.YoutubeDL(_base_opts(extractor_args, proxy)) as ydl:
info = ydl.extract_info(url, download=False)
sanitized = sanitize_filename(info.get('title', 'youtube_video'))
expected = os.path.join(output_dir, f'{sanitized}.mp4')
if os.path.exists(expected):
os.remove(expected)
dl_opts = {
**_base_opts(extractor_args, proxy),
'format': fmt,
'outtmpl': os.path.join(output_dir, f'{sanitized}.%(ext)s'),
'merge_output_format': 'mp4', 'overwrites': True,
'progress_hooks': [_progress_hook],
}
with yt_dlp.YoutubeDL(dl_opts) as ydl:
ydl.download([url])
return sanitized
# DIRECT_FIRST=1: try the server's own IP before spending proxy bandwidth.
# Needs cookies + a PO-token provider — without both, YouTube flags the
# datacenter IP after the first request (verified in prod, 21-jul-2026).
_direct_first = (os.environ.get("DIRECT_FIRST", "").strip() == "1"
and (_proxy or _statics) and hd_args and cookies_path)
attempts = [
(label,
fallback_args if label == 'fallback' else hd_args,
fallback_fmt if label == 'fallback' else _hd_fmt_for(capped),
proxy)
for label, capped, proxy in plan_download_attempts(
_direct_first, _statics, _proxy, bool(hd_args))
]
sanitized_title = None
last_err = None
used_proxy = False
for label, ea, fmt, proxy in attempts:
# A 403 on the media fetch is usually transient: the googlevideo URL is
# bound to the IP that extracted it, and the residential proxy rotates
# its exit IP between requests. Retrying re-extracts and usually lands
# on a consistent IP (3 of 62 downloads hit this on 22-jul-2026).
for retry in range(2):
try:
print(f"📥 Download attempt: {label}" + (f" (retry {retry})" if retry else ""))
sanitized_title = _attempt(ea, fmt, proxy)
# Only bytes through the PER-GB proxy cost money; direct and
# the flat-rate static proxies are free bandwidth for the
# monthly counter's purposes.
used_proxy = proxy is not None and proxy == _proxy
print(f"✅ Download succeeded ({label}).")
break
except Exception as e:
last_err = e
print(f"⚠️ Download attempt '{label}' failed: {str(e)[:200]}")
retryable = '403' in str(e) or 'Forbidden' in str(e)
if not retryable or retry == 1:
break
time.sleep(3)
if sanitized_title is not None:
break
if sanitized_title is None:
import sys
error_msg = f"""
❌ ================================================================= ❌
❌ FATAL ERROR: YOUTUBE DOWNLOAD FAILED (all strategies)
❌ ================================================================= ❌
REASON: YouTube blocked the request or the download tooling is out of date.
👇 SOLUTION FOR USER: download the video manually and use the 'Upload Video' tab.
Technical Details: {str(last_err)}
"""
print(error_msg, file=sys.stdout)
print(error_msg, file=sys.stderr)
sys.stdout.flush(); sys.stderr.flush()
time.sleep(0.5)
raise last_err
downloaded_file = os.path.join(output_dir, f'{sanitized_title}.mp4')
if not os.path.exists(downloaded_file):
for f in os.listdir(output_dir):
if f.startswith(sanitized_title) and f.endswith('.mp4'):
downloaded_file = os.path.join(output_dir, f)
break
if used_proxy and _dl_bytes["total"]:
# Machine-parseable marker consumed by app.py's log reader for the
# monthly proxy-bandwidth counter. Not shown to clients (log filter).
# Only emitted when the winning attempt actually went through the
# proxy — direct-first successes are free bandwidth.
print(f"PROXY_BYTES={_dl_bytes['total']}")
print(f"✅ Video downloaded in {time.time() - step_start_time:.2f}s: {downloaded_file}")
return downloaded_file, sanitized_title
def finalize_clip_passthrough(input_video, final_output_video):
"""Keep the clip's native framing (for horizontal/16:9 output).
The input is the freshly encoded cut, so a stream-copy remux is enough to
add +faststart — re-encoding here would only cost time and quality.
"""
if os.path.exists(final_output_video):
os.remove(final_output_video)
print(f"🎬 Passthrough (native framing): {input_video}")
cmd = [
'ffmpeg', '-y', '-i', input_video,
'-c', 'copy', *METADATA_SCRUB, '-movflags', '+faststart',
final_output_video,
]
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=1800)
print(f"✅ Clip saved to {final_output_video}")
return True
def auto_caption_clip(clip_path, transcript, clip_start, clip_end, split_ranges=None):
"""Burn the default caption style onto a finished clip.
``split_ranges``: (start, end) stretches, in clip seconds, rendered with
the SPLIT layout; captions there sit on the seam between the two speakers
instead of the bottom. None reads the render's own sidecar next to
``clip_path`` (layout_ranges), which is where recut hands it over.
Captions are mandatory for short-form to land, but they were opt-in behind a
modal and only 9% of delivered clips ever got them (prod audit, 25-jul-2026).
So every clip now ships captioned by default.
The captioned file is written ALONGSIDE the clip as
``subtitled_<ts>_<clip>.mp4`` — the same convention /api/subtitle uses — so
the untouched original stays on disk and re-styling from the modal replaces
the captions instead of burning a second layer over them.
Returns the captioned path, or None when captions were skipped (silent
video, no words in range, AUTO_CAPTIONS=0, or any failure — a caption
problem must never cost the user the clip they already paid for).
"""
if os.environ.get("AUTO_CAPTIONS", "1").strip() == "0":
return None
if not transcript or not transcript.get('segments'):
return None # silent video: nothing to caption
try:
import subtitles as _subs
style = _subs.AUTO_CAPTION_STYLE
output_dir = os.path.dirname(clip_path)
stem = os.path.basename(clip_path)
generation_id = int(time.time())
# The output name MUST stay exactly "subtitled_<ts>_<clip filename>":
# the modal's walk-back and _canonical_clip_file both reconstruct the
# clean original from it, so trimming the stem here would orphan the
# pair. Length is bounded upstream instead, by MAX_TITLE_BYTES at
# download time. A legacy clip whose name predates that budget can still
# overflow — that raises OSError 36, which the except below turns into
# "ship the clip uncaptioned" rather than a broken filename.
# The .ass path is interpolated INTO an ffmpeg filter string
# (-vf ass='...'), where a literal apostrophe closes the quote and
# breaks the filter. Titles carry apostrophes constantly in English
# ("Earth's", "Don't"), so this name must stay free of the clip stem —
# which is exactly why /api/subtitle has always used a neutral
# "subs_<i>_<ts>.ass". Deriving it from the stem silently cost captions
# on every apostrophe title until 29-jul-2026.
#
# The OUTPUT name still carries the stem, and must: the modal's
# walk-back and _canonical_clip_file reconstruct the clean original
# from it. That one is only ever passed as an argv element, never
# inside a filter string, so quoting never applies to it.
# Unique per clip, not just per second: clips render in parallel
# (CLIP_WORKERS), so a bare timestamp would collide and let one clip
# burn another's captions.
ass_path = os.path.join(
output_dir, f"autosubs_{generation_id}_{uuid.uuid4().hex[:8]}.ass")
out_path = os.path.join(output_dir, f"subtitled_{generation_id}_{stem}")
if split_ranges is None:
import layout_ranges as _layouts
split_ranges = _layouts.split_ranges(_layouts.read(clip_path))
if not _subs.generate_ass(
transcript, clip_start, clip_end, ass_path,
split_ranges=split_ranges,
max_chars=style["max_chars"], max_duration=style["max_duration"],
alignment=style["alignment"], fontsize=style["font_size"],
font_name=style["font_name"], font_color=style["font_color"],
border_color=style["border_color"], border_width=style["border_width"],
highlight_color=style["highlight_color"], effect=style["effect"],
base_opacity=style["base_opacity"], uppercase=style["uppercase"]):
print(" ℹ️ No words in range — clip ships without captions.")
return None
_subs.burn_subtitles(
clip_path, ass_path, out_path,
alignment=style["alignment"], fontsize=style["font_size"],
font_name=style["font_name"], font_color=style["font_color"],
border_color=style["border_color"], border_width=style["border_width"])
print(f" 💬 Captions burned: {os.path.basename(out_path)}")
return out_path
except Exception as e:
print(f" ⚠️ Auto-captions failed ({type(e).__name__}: {e}) — "
f"delivering the clip without them.")
return None
def auto_hook_clip(clip_path, clip):
"""Burn the clip's Gemini hook text as a DERIVED file (AUTO_HOOK=1).
Writes ``hooked_<ts>_<clip filename>`` next to the canonical clip, exactly
like captions write ``subtitled_<ts>_...``: the canonical stays clean, so