factorstudios commited on
Commit
735a97b
·
verified ·
1 Parent(s): 44ce9ab

Upload 9 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ asset/TR[[:space:]]Impact.TTF filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TrendClip Video Composer Server
2
+ # FastAPI server for scene selection and video composition
3
+
4
+ FROM python:3.13-slim
5
+
6
+ # Set working directory
7
+ WORKDIR /app/composer
8
+
9
+ # Install system dependencies
10
+ RUN apt-get update && apt-get install -y \
11
+ ffmpeg \
12
+ libsm6 \
13
+ libxext6 \
14
+ libxrender-dev \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Copy requirements
18
+ COPY requirements_server.txt .
19
+
20
+ # Install Python dependencies
21
+ RUN pip install --no-cache-dir -r requirements_server.txt
22
+
23
+ # Copy application files
24
+ COPY server.py .
25
+ COPY composer_v2.py .
26
+
27
+ # Copy assets (fonts, etc.)
28
+ COPY asset/ asset/
29
+
30
+ # Create necessary directories
31
+ RUN mkdir -p selected candidates renders upload_candidates
32
+
33
+ # Expose port
34
+ EXPOSE 7860
35
+
36
+ # Environment variables
37
+ ENV PYTHONUNBUFFERED=1
38
+ ENV PYTHONIOENCODING=utf-8
39
+
40
+ # Run server with uvicorn
41
+ CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
asset/TR Impact.TTF ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fd911ac4105fcd6f8d3cdc71ccd1eea6d456c228e5be58a40900a6676c21cc88
3
+ size 119088
compose_scenes.py ADDED
@@ -0,0 +1,923 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Scene Composer — Hardcoded Edit Engine
3
+ Follows the exact structure and logic from sunset_reel_edit_plan.md
4
+
5
+ Global architecture:
6
+ - Resolution: 1080x1920 (9:16 vertical)
7
+ - FPS: 30
8
+ - Colour: global warm grade (+8 temp, -5 tint), vignette 35%
9
+ - Typography: Montserrat Bold (title 100px), medium weight (body 64px)
10
+ - Motion language: Ken Burns default (1.0→1.06), text slide-up 14px @ 18f
11
+ - 7 scenes + end card
12
+ """
13
+
14
+ import os
15
+ import json
16
+ import math
17
+ from pathlib import Path
18
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # GLOBAL CONSTANTS — from edit plan
23
+ # ---------------------------------------------------------------------------
24
+
25
+ RESOLUTION = (1080, 1920)
26
+ FPS = 30
27
+ VIGNETTE_OPACITY = 0.35 # 35% vignette on all scenes
28
+
29
+ # Global warm grade values (applied as multipliers/shifts in _apply_global_grade)
30
+ GLOBAL_TEMP_SHIFT = +8 # warm shift (boosts reds/yellows)
31
+ GLOBAL_TINT_SHIFT = -5 # tint (slight green pull-back)
32
+
33
+ # Typography — sizes map to Montserrat Bold TTF
34
+ FONT_SIZE_TITLE = 100 # Title / hook (SF Pro Display equiv)
35
+ FONT_SIZE_BODY = 64 # Body tip text
36
+ FONT_SIZE_ACCENT = 140 # Number accent (Bebas equiv — still Montserrat here)
37
+ FONT_SIZE_CAPTION = 48 # Hashtag / caption
38
+
39
+ TEXT_COLOR = (255, 255, 255, 255) # White
40
+ TEXT_SHADOW = (0, 0, 0, 80) # 10% drop shadow
41
+ STROKE_COLOR = (0, 0, 0, 200)
42
+ STROKE_WIDTH = 9
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # SCENE CONFIG — hardcoded from edit plan, one dict per scene
47
+ # ---------------------------------------------------------------------------
48
+
49
+ SCENE_CONFIG = [
50
+ # ------------------------------------------------------------------
51
+ # Scene 00 — Chase the Golden Hour
52
+ # Motion: Ken Burns push-up, scale 1.0→1.06
53
+ # Text: Title lower-third, slides up at f8, holds 2.2s, fades f96
54
+ # Grade: Boost oranges/reds +15, reduce highlights -20
55
+ # Trans: Cross dissolve 12f
56
+ # ------------------------------------------------------------------
57
+ {
58
+ "idx": 0,
59
+ "label": "Chase The Golden Hour",
60
+ "duration_s": 3.5,
61
+ "motion": {
62
+ "type": "ken_burns",
63
+ "direction": "up",
64
+ "scale_start": 1.0,
65
+ "scale_end": 1.06,
66
+ },
67
+ "text": {
68
+ "type": "lower_third", # positioned lower third
69
+ "entry": "slide_up",
70
+ "entry_frame": 8,
71
+ "hold_s": 2.2,
72
+ "fade_frame": 96,
73
+ "font_size": FONT_SIZE_TITLE,
74
+ "align": "left",
75
+ },
76
+ "grade": {
77
+ "boost_reds": +15, # orange/red channel boost
78
+ "highlights": -20, # pull back highlights
79
+ "temp": GLOBAL_TEMP_SHIFT,
80
+ "tint": GLOBAL_TINT_SHIFT,
81
+ },
82
+ "transition_out": {
83
+ "type": "cross_dissolve",
84
+ "frames": 12,
85
+ },
86
+ },
87
+
88
+ # ------------------------------------------------------------------
89
+ # Scene 01 — Scout Your Spot
90
+ # Motion: Horizontal slow pan right, +30px offset over 3s
91
+ # Text: Typewriter reveal, 20 chars/s, starts f12
92
+ # Grade: Crush blacks, push purples into shadows
93
+ # Trans: Hard cut (intentional rhythm break)
94
+ # ------------------------------------------------------------------
95
+ {
96
+ "idx": 1,
97
+ "label": "Scout Your Spot First",
98
+ "duration_s": 3.0,
99
+ "motion": {
100
+ "type": "pan",
101
+ "direction": "right",
102
+ "offset_px": 30, # +30px rightward drift
103
+ },
104
+ "text": {
105
+ "type": "typewriter",
106
+ "chars_per_sec": 20,
107
+ "entry_frame": 12,
108
+ "font_size": FONT_SIZE_BODY,
109
+ "align": "left",
110
+ },
111
+ "grade": {
112
+ "crush_blacks": True, # deeper black crush
113
+ "push_purples": True, # purple shadow tint
114
+ "temp": GLOBAL_TEMP_SHIFT,
115
+ "tint": GLOBAL_TINT_SHIFT,
116
+ },
117
+ "transition_out": {
118
+ "type": "hard_cut",
119
+ "frames": 1,
120
+ },
121
+ },
122
+
123
+ # ------------------------------------------------------------------
124
+ # Scene 02 — Balance the Bright Sky
125
+ # Motion: Static + horizon split parallax (-8px sky drift)
126
+ # Text: Two-line stagger, line1 f6, line2 f14, hold 2.5s
127
+ # Grade: Dual-tone — sky cooler +4, ground warmer +12
128
+ # Trans: Zoom-out wipe 16f
129
+ # ------------------------------------------------------------------
130
+ {
131
+ "idx": 2,
132
+ "label": "Balance The Bright Sky",
133
+ "duration_s": 3.5,
134
+ "motion": {
135
+ "type": "horizon_parallax",
136
+ "sky_offset_px": -8, # sky shifts left subtly
137
+ "horizon_ratio": 0.42, # horizon at 42% from top
138
+ },
139
+ "text": {
140
+ "type": "stagger_two_line",
141
+ "line1_frame": 6,
142
+ "line2_frame": 14,
143
+ "hold_s": 2.5,
144
+ "font_size": FONT_SIZE_BODY,
145
+ "align": "left",
146
+ },
147
+ "grade": {
148
+ "sky_temp": +4, # sky cooler
149
+ "ground_temp": +12, # ground warmer
150
+ "temp": GLOBAL_TEMP_SHIFT,
151
+ "tint": GLOBAL_TINT_SHIFT,
152
+ },
153
+ "transition_out": {
154
+ "type": "zoom_out_wipe",
155
+ "frames": 16,
156
+ },
157
+ },
158
+
159
+ # ------------------------------------------------------------------
160
+ # Scene 03 — Rule of Thirds Grid ⚠️ AE Required
161
+ # Motion: Static lock — all motion is overlay animation
162
+ # Text: Grid draw-on f0-f12 → annotations pop f14-f20 → tip f24
163
+ # Grade: Preserve existing grid colour (white ~40% opacity)
164
+ # Trans: Cross dissolve 10f
165
+ # Note: Image already has baked grid — mask it, animate draw-on
166
+ # ------------------------------------------------------------------
167
+ {
168
+ "idx": 3,
169
+ "label": "Rule Of Thirds Grid",
170
+ "duration_s": 4.0,
171
+ "motion": {
172
+ "type": "static",
173
+ },
174
+ "text": {
175
+ "type": "grid_sequence",
176
+ "grid_draw_frames": (0, 12), # draw-on window
177
+ "annotations": [
178
+ # label, pop_frame, (x_ratio, y_ratio)
179
+ ("Silhouettes", 14, (0.68, 0.30)),
180
+ ("Horizon Placement", 15, (0.12, 0.64)),
181
+ ("Leading Lines", 16, (0.60, 0.75)),
182
+ ("S-Curve", 17, (0.65, 0.82)),
183
+ ("Reflections", 18, (0.12, 0.82)),
184
+ ],
185
+ "annotation_scale_punch": (0.8, 1.0),
186
+ "tip_frame": 24,
187
+ "font_size": FONT_SIZE_BODY,
188
+ },
189
+ "grade": {
190
+ "grid_opacity": 0.40, # white grid lines ~40%
191
+ "temp": GLOBAL_TEMP_SHIFT,
192
+ "tint": GLOBAL_TINT_SHIFT,
193
+ },
194
+ "transition_out": {
195
+ "type": "cross_dissolve",
196
+ "frames": 10,
197
+ },
198
+ "ae_required": True,
199
+ "ae_note": "Grid draw-on via trim path. Mask baked grid at comp start.",
200
+ },
201
+
202
+ # ------------------------------------------------------------------
203
+ # Scene 04 — Layer the Foreground Depth ⚠️ AE Required
204
+ # Motion: 3-layer parallax — FG 18px, MG 6px, BG static
205
+ # Text: Right-edge entry at f10, hold 2.8s
206
+ # Grade: Saturate magentas/pinks in FG, preserve sunburst
207
+ # Trans: Ink-bleed / dissolve 14f
208
+ # ------------------------------------------------------------------
209
+ {
210
+ "idx": 4,
211
+ "label": "Layer The Foreground Depth",
212
+ "duration_s": 4.0,
213
+ "motion": {
214
+ "type": "parallax_3layer",
215
+ "layers": {
216
+ "fg": {"drift_px": 18, "direction": "up"}, # wildflowers
217
+ "mg": {"drift_px": 6, "direction": "up"}, # pine trees
218
+ "bg": {"drift_px": 0, "direction": None}, # sky / static
219
+ },
220
+ },
221
+ "text": {
222
+ "type": "right_slide",
223
+ "entry_frame": 10,
224
+ "hold_s": 2.8,
225
+ "font_size": FONT_SIZE_BODY,
226
+ "align": "right",
227
+ },
228
+ "grade": {
229
+ "saturate_magentas": +20, # boost pinks/magentas in FG
230
+ "preserve_highlights": True, # don't clip sunburst
231
+ "temp": GLOBAL_TEMP_SHIFT,
232
+ "tint": GLOBAL_TINT_SHIFT,
233
+ },
234
+ "transition_out": {
235
+ "type": "dissolve",
236
+ "frames": 14,
237
+ },
238
+ "ae_required": True,
239
+ "ae_note": "Roto Brush / manual mask for 3-layer extraction.",
240
+ },
241
+
242
+ # ------------------------------------------------------------------
243
+ # Scene 05 — Add a Silhouette Subject
244
+ # Motion: Push-in toward subjects, scale 1.0→1.04
245
+ # Text: Centered fade-up at f12, single line, minimal
246
+ # Grade: Deep amber, crush blacks to pure silhouette
247
+ # Trans: Fade to black 20f
248
+ # ------------------------------------------------------------------
249
+ {
250
+ "idx": 5,
251
+ "label": "Add A Silhouette Subject",
252
+ "duration_s": 3.5,
253
+ "motion": {
254
+ "type": "ken_burns",
255
+ "direction": "in", # push-in (no directional drift)
256
+ "scale_start": 1.0,
257
+ "scale_end": 1.04,
258
+ },
259
+ "text": {
260
+ "type": "center_fade",
261
+ "entry_frame": 12,
262
+ "font_size": FONT_SIZE_BODY,
263
+ "align": "center",
264
+ "single_line": True, # minimal — image speaks
265
+ },
266
+ "grade": {
267
+ "deep_amber": True, # amber tone throughout
268
+ "crush_blacks": True, # subjects to pure silhouette
269
+ "protect_reflection": True, # water reflection stays bright
270
+ "temp": GLOBAL_TEMP_SHIFT,
271
+ "tint": GLOBAL_TINT_SHIFT,
272
+ },
273
+ "transition_out": {
274
+ "type": "fade_to_black",
275
+ "frames": 20,
276
+ },
277
+ },
278
+
279
+ # ------------------------------------------------------------------
280
+ # Scene 06 — Edit the Warmth Gently ⚠️ AE Required (CTA anchor)
281
+ # Motion: Scroll-up pan, top→bottom of infographic over 3.5s
282
+ # Text: Count-up per param (0.4s each) as it scrolls into frame
283
+ # Grade: Match reel warm grade, don't over-process the infographic
284
+ # Trans: Slide-left 16f to end card
285
+ # ------------------------------------------------------------------
286
+ {
287
+ "idx": 6,
288
+ "label": "Edit The Warmth Gently",
289
+ "duration_s": 4.5,
290
+ "motion": {
291
+ "type": "scroll_pan",
292
+ "direction": "down", # reveal top→bottom
293
+ "pan_duration_s": 3.5,
294
+ },
295
+ "text": {
296
+ "type": "countup_params",
297
+ "countup_duration_s": 0.4,
298
+ "params": [
299
+ ("Exposure", -39),
300
+ ("Highlights", -30),
301
+ ("Shadows", +59),
302
+ ("Contrast", +7),
303
+ ("Brightness", -28),
304
+ ("Saturation", -11),
305
+ ("Vibrance", +63),
306
+ ("Warmth", +60),
307
+ ("Tint", +29),
308
+ ],
309
+ },
310
+ "grade": {
311
+ "match_reel_grade": True, # light warm pass only
312
+ "temp": GLOBAL_TEMP_SHIFT,
313
+ "tint": GLOBAL_TINT_SHIFT,
314
+ },
315
+ "transition_out": {
316
+ "type": "slide_left",
317
+ "frames": 16,
318
+ },
319
+ "ae_required": True,
320
+ "ae_note": "Slider control expressions for count-up. linear(time, startT, endT, 0, targetValue).",
321
+ },
322
+ ]
323
+
324
+
325
+ # ------------------------------------------------------------------
326
+ # End card config
327
+ # ------------------------------------------------------------------
328
+ END_CARD_CONFIG = {
329
+ "duration_s": 2.0,
330
+ "background": (10, 10, 10), # near-black
331
+ "fade_in_frames": 12,
332
+ "lines": [
333
+ ("Stop guessing and start shooting", FONT_SIZE_TITLE),
334
+ ("Which of these do you already do?", FONT_SIZE_BODY),
335
+ ("Drop a 🌅 if you're running out tonight!", FONT_SIZE_BODY),
336
+ ("Save this before you forget", FONT_SIZE_CAPTION),
337
+ ],
338
+ "hashtags": [
339
+ "#SunsetPhotography", "#GoldenHour",
340
+ "#PhotographyTips", "#PhotoHack", "#ContentCreator",
341
+ ],
342
+ }
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # COMPOSER CLASS
347
+ # ---------------------------------------------------------------------------
348
+
349
+ class SceneComposer:
350
+
351
+ def __init__(
352
+ self,
353
+ manifest_path: str,
354
+ selected_dir: str,
355
+ output_dir: str = "output",
356
+ font_path: str = "../trendclip/assets/fonts/Montserrat-Bold.ttf",
357
+ ):
358
+ self.manifest_path = manifest_path
359
+ self.selected_dir = selected_dir
360
+ self.output_dir = output_dir
361
+ self.font_path = font_path
362
+
363
+ Path(self.output_dir).mkdir(parents=True, exist_ok=True)
364
+
365
+ with open(manifest_path, "r") as f:
366
+ self.manifest = json.load(f)
367
+
368
+ print(f"[Composer] Loaded manifest : {len(self.manifest.get('scenes', []))} scenes")
369
+ print(f"[Composer] Selected dir : {selected_dir}")
370
+ print(f"[Composer] Output dir : {output_dir}\n")
371
+
372
+ # ------------------------------------------------------------------
373
+ # Public entry point
374
+ # ------------------------------------------------------------------
375
+
376
+ def compose_all_scenes(self):
377
+ """Compose all 7 scenes + end card using hardcoded edit plan."""
378
+ print(f"[Composer] Processing {len(SCENE_CONFIG)} scenes + end card...\n")
379
+
380
+ for cfg in SCENE_CONFIG:
381
+ self._compose_scene(cfg)
382
+
383
+ self._compose_end_card()
384
+
385
+ print(f"\n[Composer] ✓ All scenes composed.")
386
+ print(f"[Composer] Output → {self.output_dir}/\n")
387
+
388
+ # ------------------------------------------------------------------
389
+ # Scene composition
390
+ # ------------------------------------------------------------------
391
+
392
+ def _compose_scene(self, cfg: dict):
393
+ idx = cfg["idx"]
394
+ label = cfg["label"]
395
+
396
+ src = os.path.join(self.selected_dir, f"scene_{idx:02d}.jpg")
397
+ if not os.path.exists(src):
398
+ print(f"[Scene {idx}] ✗ Missing: {src}")
399
+ return
400
+
401
+ ae_flag = " ⚠️ AE_REQUIRED" if cfg.get("ae_required") else ""
402
+ print(f"[Scene {idx}] {label}{ae_flag}")
403
+ if cfg.get("ae_note"):
404
+ print(f" AE note: {cfg['ae_note']}")
405
+
406
+ try:
407
+ img = self._load_and_resize(src)
408
+ img = self._apply_global_grade(img, cfg["grade"])
409
+ img = self._apply_scene_grade(img, cfg["grade"])
410
+ img = self._apply_motion_preview(img, cfg["motion"], cfg["duration_s"])
411
+ img = self._apply_vignette(img)
412
+ img = self._render_text(img, cfg)
413
+ img = self._apply_transition_overlay(img, cfg["transition_out"])
414
+
415
+ out = os.path.join(self.output_dir, f"scene_{idx:02d}_composed.jpg")
416
+ img.save(out, quality=95)
417
+ print(f" ✓ Saved → {out}\n")
418
+
419
+ except Exception as e:
420
+ print(f" ✗ Error: {e}\n")
421
+ raise
422
+
423
+ # ------------------------------------------------------------------
424
+ # Image helpers
425
+ # ------------------------------------------------------------------
426
+
427
+ def _load_and_resize(self, path: str) -> Image.Image:
428
+ img = Image.open(path).convert("RGB")
429
+ if img.size != RESOLUTION:
430
+ img = img.resize(RESOLUTION, Image.Resampling.LANCZOS)
431
+ return img
432
+
433
+ def _apply_global_grade(self, img: Image.Image, grade: dict) -> Image.Image:
434
+ """
435
+ Global warm grade applied to every scene:
436
+ +8 temperature → boost reds and yellows, pull down blues
437
+ -5 tint → slight pull toward magenta (pull green back)
438
+ Exposure normalise pass (mild)
439
+ """
440
+ r, g, b = img.split()
441
+
442
+ # Temperature: +8 → warm shift
443
+ # Boost R channel, slight boost G, pull B down
444
+ temp = grade.get("temp", GLOBAL_TEMP_SHIFT)
445
+ if temp != 0:
446
+ r = r.point(lambda p: min(255, p + int(temp * 1.2)))
447
+ g = g.point(lambda p: min(255, p + int(temp * 0.4)))
448
+ b = b.point(lambda p: max(0, p - int(temp * 0.8)))
449
+
450
+ # Tint: -5 → pull back green slightly
451
+ tint = grade.get("tint", GLOBAL_TINT_SHIFT)
452
+ if tint != 0:
453
+ g = g.point(lambda p: max(0, min(255, p + int(tint * 0.6))))
454
+
455
+ img = Image.merge("RGB", (r, g, b))
456
+
457
+ # Mild exposure normalise — bring slightly flat images to ~0.5 mean
458
+ enhancer = ImageEnhance.Brightness(img)
459
+ img = enhancer.enhance(1.02)
460
+
461
+ return img
462
+
463
+ def _apply_scene_grade(self, img: Image.Image, grade: dict) -> Image.Image:
464
+ """Per-scene colour grade from edit plan."""
465
+ r, g, b = img.split()
466
+
467
+ # --- Scene 00: boost reds/oranges, reduce highlights
468
+ if grade.get("boost_reds"):
469
+ boost = grade["boost_reds"]
470
+ r = r.point(lambda p: min(255, p + boost))
471
+ g = g.point(lambda p: min(255, p + int(boost * 0.3)))
472
+ if grade.get("highlights") and grade["highlights"] < 0:
473
+ pull = abs(grade["highlights"])
474
+ # Pull back highlights — only affect bright pixels
475
+ r = r.point(lambda p: p - int((p / 255) ** 2 * pull))
476
+ g = g.point(lambda p: p - int((p / 255) ** 2 * pull))
477
+ b = b.point(lambda p: p - int((p / 255) ** 2 * pull))
478
+
479
+ # --- Scene 01 / 05: crush blacks
480
+ if grade.get("crush_blacks"):
481
+ r = r.point(lambda p: max(0, p - 15) if p < 60 else p)
482
+ g = g.point(lambda p: max(0, p - 15) if p < 60 else p)
483
+ b = b.point(lambda p: max(0, p - 15) if p < 60 else p)
484
+
485
+ # --- Scene 01: push purples into shadows
486
+ if grade.get("push_purples"):
487
+ r = r.point(lambda p: min(255, p + 8) if p < 80 else p)
488
+ b = b.point(lambda p: min(255, p + 12) if p < 80 else p)
489
+
490
+ # --- Scene 04: saturate magentas / pinks in FG
491
+ if grade.get("saturate_magentas"):
492
+ boost = grade["saturate_magentas"]
493
+ enhancer = ImageEnhance.Color(Image.merge("RGB", (r, g, b)))
494
+ merged = enhancer.enhance(1.0 + boost / 100.0)
495
+ r, g, b = merged.split()
496
+
497
+ # --- Scene 05: deep amber tone
498
+ if grade.get("deep_amber"):
499
+ r = r.point(lambda p: min(255, p + 10))
500
+ b = b.point(lambda p: max(0, p - 8))
501
+
502
+ # --- Scene 02: dual-tone (crude approximation — full impl needs mask)
503
+ # sky_temp and ground_temp applied as global shift here
504
+ # (full dual-tone requires AE horizon mask)
505
+ sky_temp = grade.get("sky_temp", 0)
506
+ if sky_temp != 0:
507
+ # approximation: slight cool pull on the whole image
508
+ b = b.point(lambda p: min(255, p + int(sky_temp * 0.5)))
509
+
510
+ img = Image.merge("RGB", (r, g, b))
511
+ return img
512
+
513
+ def _apply_motion_preview(
514
+ self, img: Image.Image, motion: dict, duration_s: float
515
+ ) -> Image.Image:
516
+ """
517
+ Simulate the motion at the midpoint frame for the static preview.
518
+ Ken Burns: crop to midpoint scale/position.
519
+ Pan: shift to 50% of offset.
520
+ Parallax / scroll: show mid-scroll state.
521
+ Static: no change.
522
+ """
523
+ w, h = RESOLUTION
524
+ mtype = motion["type"]
525
+
526
+ if mtype == "ken_burns":
527
+ scale_start = motion.get("scale_start", 1.0)
528
+ scale_end = motion.get("scale_end", 1.06)
529
+ mid_scale = (scale_start + scale_end) / 2
530
+ direction = motion.get("direction", "up")
531
+
532
+ new_w = int(w * mid_scale)
533
+ new_h = int(h * mid_scale)
534
+ img_scaled = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
535
+
536
+ # Crop anchor based on direction
537
+ if direction == "up":
538
+ left = (new_w - w) // 2
539
+ top = new_h - h # anchor bottom, reveals top
540
+ elif direction == "in":
541
+ left = (new_w - w) // 2
542
+ top = (new_h - h) // 2
543
+ else:
544
+ left = (new_w - w) // 2
545
+ top = (new_h - h) // 2
546
+
547
+ img = img_scaled.crop((left, top, left + w, top + h))
548
+
549
+ elif mtype == "pan":
550
+ direction = motion.get("direction", "right")
551
+ offset_px = motion.get("offset_px", 30)
552
+ mid_offset = offset_px // 2
553
+
554
+ # Scale slightly wider to allow pan headroom
555
+ img_wide = img.resize((w + offset_px * 2, h), Image.Resampling.LANCZOS)
556
+ if direction == "right":
557
+ left = offset_px - mid_offset
558
+ else:
559
+ left = offset_px + mid_offset
560
+ img = img_wide.crop((left, 0, left + w, h))
561
+
562
+ elif mtype == "scroll_pan":
563
+ # Show mid-scroll: pan downward to reveal bottom half
564
+ pan_s = motion.get("pan_duration_s", 3.5)
565
+ # Scroll ~20% down at midpoint preview
566
+ pan_px = int(h * 0.20)
567
+ img_tall = img.resize((w, h + pan_px * 2), Image.Resampling.LANCZOS)
568
+ top = pan_px # midpoint position
569
+ img = img_tall.crop((0, top, w, top + h))
570
+
571
+ elif mtype == "parallax_3layer":
572
+ # Simulate slight upward drift at midpoint
573
+ drift_px = motion["layers"]["fg"]["drift_px"] // 2
574
+ img_tall = img.resize((w, h + drift_px * 2), Image.Resampling.LANCZOS)
575
+ img = img_tall.crop((0, drift_px, w, drift_px + h))
576
+
577
+ elif mtype == "horizon_parallax":
578
+ # Shift sky layer left by half offset (approximation on flat image)
579
+ sky_offset = abs(motion.get("sky_offset_px", 8)) // 2
580
+ img_wide = img.resize((w + sky_offset * 2, h), Image.Resampling.LANCZOS)
581
+ img = img_wide.crop((sky_offset, 0, sky_offset + w, h))
582
+
583
+ # "static" and "grid_sequence" — no transform
584
+ return img
585
+
586
+ def _apply_vignette(self, img: Image.Image) -> Image.Image:
587
+ """35% opacity vignette — applied to all scenes per global architecture."""
588
+ w, h = RESOLUTION
589
+ vignette = Image.new("L", (w, h), 0)
590
+ draw = ImageDraw.Draw(vignette)
591
+
592
+ # Radial vignette: black ellipse with feathered edge
593
+ for i in range(60, 0, -1):
594
+ alpha = int((60 - i) / 60 * 255 * VIGNETTE_OPACITY)
595
+ margin_x = int(w * 0.01 * i)
596
+ margin_y = int(h * 0.01 * i)
597
+ draw.ellipse(
598
+ [margin_x, margin_y, w - margin_x, h - margin_y],
599
+ fill=alpha,
600
+ )
601
+
602
+ vignette_layer = Image.new("RGB", (w, h), (0, 0, 0))
603
+ img_rgba = img.convert("RGBA")
604
+ vig_rgba = vignette_layer.convert("RGBA")
605
+ vig_rgba.putalpha(vignette)
606
+ img_rgba = Image.alpha_composite(img_rgba, vig_rgba)
607
+ return img_rgba.convert("RGB")
608
+
609
+ def _apply_transition_overlay(self, img: Image.Image, transition: dict) -> Image.Image:
610
+ """
611
+ Apply a visual hint of the transition type as a subtle edge overlay
612
+ on the static preview frame.
613
+ """
614
+ ttype = transition["type"]
615
+ frames = transition.get("frames", 12)
616
+ w, h = RESOLUTION
617
+
618
+ # Represent the transition as a thin coloured edge band
619
+ # (colour-codes the transition type for reference)
620
+ TRANS_COLORS = {
621
+ "cross_dissolve": (255, 255, 255, 30),
622
+ "hard_cut": (255, 80, 80, 40),
623
+ "zoom_out_wipe": (255, 200, 80, 30),
624
+ "dissolve": (200, 200, 255, 25),
625
+ "fade_to_black": ( 0, 0, 0, 60),
626
+ "slide_left": ( 80, 180, 255, 30),
627
+ }
628
+ color = TRANS_COLORS.get(ttype, (255, 255, 255, 20))
629
+
630
+ overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
631
+ draw = ImageDraw.Draw(overlay)
632
+ band_h = max(4, int(frames * 2)) # band height proportional to frame count
633
+ draw.rectangle([(0, h - band_h), (w, h)], fill=color)
634
+
635
+ img_rgba = img.convert("RGBA")
636
+ img_rgba = Image.alpha_composite(img_rgba, overlay)
637
+ return img_rgba.convert("RGB")
638
+
639
+ # ------------------------------------------------------------------
640
+ # Text rendering
641
+ # ------------------------------------------------------------------
642
+
643
+ def _render_text(self, img: Image.Image, cfg: dict) -> Image.Image:
644
+ """
645
+ Render text onto the composed image according to each scene's text config.
646
+ This represents the static/midpoint frame of the animation.
647
+ """
648
+ text_cfg = cfg["text"]
649
+ ttype = text_cfg["type"]
650
+ label = cfg["label"]
651
+
652
+ txt_layer = Image.new("RGBA", RESOLUTION, (0, 0, 0, 0))
653
+ draw = ImageDraw.Draw(txt_layer)
654
+ font = self._load_font(text_cfg.get("font_size", FONT_SIZE_BODY))
655
+
656
+ w, h = RESOLUTION
657
+
658
+ if ttype == "lower_third":
659
+ # Slide up from lower third — position at 75% height
660
+ y_pos = int(h * 0.75)
661
+ x_pos = int(w * 0.08)
662
+ self._draw_text_with_stroke(draw, label, (x_pos, y_pos), font, align="left")
663
+
664
+ elif ttype == "typewriter":
665
+ # Show partially revealed text (mid-animation)
666
+ chars_shown = int(len(label) * 0.6) # 60% revealed at midpoint
667
+ partial = label[:chars_shown] + "▌"
668
+ y_pos = int(h * 0.15)
669
+ x_pos = int(w * 0.08)
670
+ self._draw_text_with_stroke(draw, partial, (x_pos, y_pos), font, align="left")
671
+
672
+ elif ttype == "stagger_two_line":
673
+ # Show both lines visible (line 2 has entered by midframe)
674
+ parts = label.split(" ", 2)
675
+ line1 = parts[0] if len(parts) > 0 else label
676
+ line2 = " ".join(parts[1:]) if len(parts) > 1 else ""
677
+ y_pos = int(h * 0.12)
678
+ x_pos = int(w * 0.08)
679
+ line_h = text_cfg.get("font_size", FONT_SIZE_BODY) + 20
680
+ self._draw_text_with_stroke(draw, line1, (x_pos, y_pos), font, align="left")
681
+ self._draw_text_with_stroke(draw, line2, (x_pos, y_pos + line_h), font, align="left")
682
+
683
+ elif ttype == "grid_sequence":
684
+ # Draw the rule-of-thirds grid overlay + annotation labels
685
+ self._draw_grid_overlay(draw, text_cfg, w, h)
686
+ # Main label at top
687
+ y_pos = int(h * 0.08)
688
+ x_pos = w // 2
689
+ self._draw_text_with_stroke(draw, label, (x_pos, y_pos), font, align="center")
690
+
691
+ elif ttype == "right_slide":
692
+ # Text enters from right — show fully on screen at midpoint
693
+ y_pos = int(h * 0.20)
694
+ x_pos = int(w * 0.92)
695
+ self._draw_text_with_stroke(draw, label, (x_pos, y_pos), font, align="right")
696
+
697
+ elif ttype == "center_fade":
698
+ # Centered text over subjects
699
+ y_pos = int(h * 0.50)
700
+ x_pos = w // 2
701
+ self._draw_text_with_stroke(draw, label, (x_pos, y_pos), font, align="center")
702
+
703
+ elif ttype == "countup_params":
704
+ # Show param labels with their final values (static render of count-up end state)
705
+ self._draw_countup_params(draw, text_cfg, w, h)
706
+
707
+ else:
708
+ # Fallback: centered label
709
+ y_pos = int(h * 0.50)
710
+ x_pos = w // 2
711
+ self._draw_text_with_stroke(draw, label, (x_pos, y_pos), font, align="center")
712
+
713
+ # Composite text layer onto image
714
+ img_rgba = img.convert("RGBA")
715
+ img_rgba = Image.alpha_composite(img_rgba, txt_layer)
716
+ return img_rgba.convert("RGB")
717
+
718
+ def _draw_text_with_stroke(
719
+ self,
720
+ draw: ImageDraw.Draw,
721
+ text: str,
722
+ pos: tuple,
723
+ font: ImageFont.FreeTypeFont,
724
+ align: str = "left",
725
+ ):
726
+ """Draw text with 9px stroke + drop shadow as per typography system."""
727
+ x, y = pos
728
+ w, h = RESOLUTION
729
+
730
+ # Wrap text to 85% of canvas width
731
+ wrapped = self._wrap_text(text, draw, font, w * 0.85)
732
+
733
+ # Resolve x anchor for alignment
734
+ if align == "center":
735
+ bbox = draw.textbbox((0, 0), wrapped, font=font)
736
+ text_w = bbox[2] - bbox[0]
737
+ x = x - text_w // 2
738
+ elif align == "right":
739
+ bbox = draw.textbbox((0, 0), wrapped, font=font)
740
+ text_w = bbox[2] - bbox[0]
741
+ x = x - text_w
742
+
743
+ # Drop shadow (10% opacity black, offset +4,+4)
744
+ draw.text((x + 4, y + 4), wrapped, font=font, fill=(0, 0, 0, 26))
745
+
746
+ # Stroke layers (9px)
747
+ for sw in [9, 7, 5, 3, 1]:
748
+ for ax in range(-sw, sw + 1):
749
+ for ay in range(-sw, sw + 1):
750
+ if ax * ax + ay * ay <= sw * sw:
751
+ draw.text((x + ax, y + ay), wrapped, font=font, fill=STROKE_COLOR)
752
+
753
+ # White text
754
+ draw.text((x, y), wrapped, font=font, fill=TEXT_COLOR)
755
+
756
+ def _draw_grid_overlay(self, draw: ImageDraw.Draw, text_cfg: dict, w: int, h: int):
757
+ """
758
+ Render the rule-of-thirds grid overlay for scene 03.
759
+ Grid lines: white at 40% opacity.
760
+ Annotation labels at their specified positions.
761
+ """
762
+ grid_color = (255, 255, 255, int(255 * 0.40))
763
+
764
+ # Vertical lines at 1/3 and 2/3
765
+ draw.line([(w // 3, 0), (w // 3, h)], fill=grid_color, width=3)
766
+ draw.line([(w * 2 // 3, 0), (w * 2 // 3, h)], fill=grid_color, width=3)
767
+ # Horizontal lines at 1/3 and 2/3
768
+ draw.line([(0, h // 3), (w, h // 3)], fill=grid_color, width=3)
769
+ draw.line([(0, h * 2 // 3), (w, h * 2 // 3)], fill=grid_color, width=3)
770
+
771
+ # Annotation labels
772
+ ann_font = self._load_font(FONT_SIZE_BODY - 20)
773
+ for label, _, (xr, yr) in text_cfg.get("annotations", []):
774
+ ax = int(w * xr)
775
+ ay = int(h * yr)
776
+ self._draw_text_with_stroke(draw, label, (ax, ay), ann_font, align="left")
777
+
778
+ def _draw_countup_params(self, draw: ImageDraw.Draw, text_cfg: dict, w: int, h: int):
779
+ """
780
+ Render the edit params for scene 06 at their final count-up values.
781
+ Laid out as a vertical list centered on screen.
782
+ """
783
+ params = text_cfg.get("params", [])
784
+ font = self._load_font(FONT_SIZE_BODY)
785
+ label_font = self._load_font(FONT_SIZE_BODY - 16)
786
+ num_font = self._load_font(FONT_SIZE_ACCENT)
787
+
788
+ total_params = len(params)
789
+ block_h = int(h * 0.72) # usable vertical space
790
+ step = block_h // total_params
791
+ start_y = int(h * 0.14)
792
+
793
+ for i, (param_name, value) in enumerate(params):
794
+ y = start_y + i * step
795
+ cx = w // 2
796
+
797
+ val_str = (f"+{value}" if value > 0 else str(value))
798
+
799
+ # Param name (left of center)
800
+ self._draw_text_with_stroke(
801
+ draw, param_name, (cx - 20, y), label_font, align="right"
802
+ )
803
+ # Value (right of center, accent size)
804
+ self._draw_text_with_stroke(
805
+ draw, val_str, (cx + 20, y - 20), num_font, align="left"
806
+ )
807
+
808
+ # ------------------------------------------------------------------
809
+ # End card
810
+ # ------------------------------------------------------------------
811
+
812
+ def _compose_end_card(self):
813
+ """Render the end card — black bg, stacked caption text."""
814
+ cfg = END_CARD_CONFIG
815
+ w, h = RESOLUTION
816
+
817
+ img = Image.new("RGB", (w, h), cfg["background"])
818
+ txt = Image.new("RGBA", (w, h), (0, 0, 0, 0))
819
+ draw = ImageDraw.Draw(txt)
820
+
821
+ total_lines = len(cfg["lines"])
822
+ usable_h = int(h * 0.80)
823
+ start_y = int(h * 0.10)
824
+ # Distribute lines evenly
825
+ step = usable_h // total_lines
826
+
827
+ for i, (line_text, font_size) in enumerate(cfg["lines"]):
828
+ y = start_y + i * step
829
+ font = self._load_font(font_size)
830
+ self._draw_text_with_stroke(draw, line_text, (w // 2, y), font, align="center")
831
+
832
+ # Hashtags at bottom
833
+ tag_font = self._load_font(FONT_SIZE_CAPTION - 12)
834
+ tags_text = " ".join(cfg["hashtags"])
835
+ self._draw_text_with_stroke(
836
+ draw, tags_text, (w // 2, int(h * 0.92)), tag_font, align="center"
837
+ )
838
+
839
+ img_rgba = img.convert("RGBA")
840
+ img_rgba = Image.alpha_composite(img_rgba, txt)
841
+ img_out = img_rgba.convert("RGB")
842
+
843
+ out = os.path.join(self.output_dir, "scene_end_card.jpg")
844
+ img_out.save(out, quality=95)
845
+ print(f"[End Card] ✓ Saved → {out}\n")
846
+
847
+ # ------------------------------------------------------------------
848
+ # Utilities
849
+ # ------------------------------------------------------------------
850
+
851
+ def _load_font(self, size: int) -> ImageFont.FreeTypeFont:
852
+ try:
853
+ return ImageFont.truetype(self.font_path, size)
854
+ except Exception:
855
+ # Fallback: try system fonts
856
+ fallbacks = [
857
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
858
+ "/System/Library/Fonts/Helvetica.ttc",
859
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
860
+ ]
861
+ for fb in fallbacks:
862
+ if os.path.exists(fb):
863
+ return ImageFont.truetype(fb, size)
864
+ return ImageFont.load_default()
865
+
866
+ def _wrap_text(
867
+ self,
868
+ text: str,
869
+ draw: ImageDraw.Draw,
870
+ font: ImageFont.FreeTypeFont,
871
+ max_width: float,
872
+ ) -> str:
873
+ words = text.split()
874
+ lines = []
875
+ cur_line = []
876
+
877
+ for word in words:
878
+ test = " ".join(cur_line + [word])
879
+ bbox = draw.textbbox((0, 0), test, font=font)
880
+ if bbox[2] - bbox[0] <= max_width:
881
+ cur_line.append(word)
882
+ else:
883
+ if cur_line:
884
+ lines.append(" ".join(cur_line))
885
+ cur_line = [word]
886
+
887
+ if cur_line:
888
+ lines.append(" ".join(cur_line))
889
+
890
+ return "\n".join(lines)
891
+
892
+ def print_scene_summary(self):
893
+ """Print a full summary of all scene configs — useful for handoff to AE."""
894
+ print("\n" + "=" * 60)
895
+ print("SCENE SUMMARY — Sunset Reel Edit Plan")
896
+ print("=" * 60)
897
+ for cfg in SCENE_CONFIG:
898
+ ae = " [AE REQUIRED]" if cfg.get("ae_required") else ""
899
+ print(f"\nScene {cfg['idx']:02d} — {cfg['label']}{ae}")
900
+ print(f" Duration : {cfg['duration_s']} s ({int(cfg['duration_s'] * FPS)} frames)")
901
+ print(f" Motion : {cfg['motion']['type']}")
902
+ print(f" Text type : {cfg['text']['type']}")
903
+ print(f" Transition : {cfg['transition_out']['type']} ({cfg['transition_out']['frames']}f)")
904
+ if cfg.get("ae_note"):
905
+ print(f" AE note : {cfg['ae_note']}")
906
+ print(f"\nEnd Card : {END_CARD_CONFIG['duration_s']} s — black bg, stacked captions")
907
+ total = sum(c["duration_s"] for c in SCENE_CONFIG) + END_CARD_CONFIG["duration_s"]
908
+ print(f"\nTotal runtime : ~{total:.1f} s ({int(total * FPS)} frames @ {FPS}fps)")
909
+ print("=" * 60 + "\n")
910
+
911
+
912
+ # ---------------------------------------------------------------------------
913
+ # Entry point
914
+ # ---------------------------------------------------------------------------
915
+
916
+ if __name__ == "__main__":
917
+ manifest_file = "../content_gen_server/manifest_response.json"
918
+ selected_dir = "selected"
919
+ output_dir = "output"
920
+
921
+ composer = SceneComposer(manifest_file, selected_dir, output_dir)
922
+ composer.print_scene_summary()
923
+ composer.compose_all_scenes()
composer_dynamic.py ADDED
@@ -0,0 +1,600 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dynamic Video Composer - Generates SCENE_CONFIG from Manifest
3
+ Converts manifest labels to uppercase and creates video without hardcoded config
4
+ """
5
+
6
+ import os
7
+ import numpy as np
8
+ import cv2
9
+ from pathlib import Path
10
+ from PIL import Image, ImageDraw, ImageFont, ImageEnhance, ImageOps
11
+ from typing import Dict, List, Any
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # GLOBALS
15
+ # ---------------------------------------------------------------------------
16
+
17
+ RESOLUTION = (1080, 1920) # W x H
18
+ FPS = 30
19
+ SELECTED_DIR = "selected"
20
+ FONT_PATH = "asset/TR Impact.TTF"
21
+ FONT_PATH_REG = "asset/TR Impact.TTF"
22
+
23
+ GLOBAL_TEMP = +8
24
+ GLOBAL_TINT = -5
25
+
26
+ TEXT_WHITE = (255, 255, 255, 255)
27
+ TEXT_STROKE = (0, 0, 0, 210)
28
+ TEXT_SHADOW = (0, 0, 0, 60)
29
+ STROKE_W = 8
30
+
31
+ # Easing
32
+ def ease_out(t): return 1 - (1 - t) ** 3
33
+ def ease_in_out(t): return t * t * (3 - 2 * t)
34
+ def ease_in(t): return t * t * t
35
+ def lerp(a, b, t): return a + (b - a) * t
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # DYNAMIC SCENE CONFIG GENERATION
39
+ # ---------------------------------------------------------------------------
40
+
41
+ # Default motion, text, grade, transition configs per scene type
42
+ DEFAULT_SCENE_TEMPLATES = {
43
+ "intro": {
44
+ "motion": {"type": "slow_push_in", "scale_start": 1.0, "scale_end": 1.08},
45
+ "text": {"type": "center_stroke_pop", "entry_frame": 2, "font_size": 95},
46
+ "grade": {"crush_blacks": 15, "contrast": 1.15},
47
+ "transition": {"type": "hard_cut", "frames": 1},
48
+ "duration_s": 4.7,
49
+ },
50
+ "default": {
51
+ "motion": {"type": "snap_zoom", "scale_start": 1.0, "scale_end": 1.12},
52
+ "text": {"type": "center_pop", "entry_frame": 0, "font_size": 110},
53
+ "grade": {"warm_tint": True, "lift_mids": 10},
54
+ "transition": {"type": "whip_pan_right", "frames": 4},
55
+ "duration_s": 2.3,
56
+ },
57
+ "final": {
58
+ "motion": {"type": "static"},
59
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "font_size": 110},
60
+ "grade": {"warm_indoor": True, "soft_glow": True, "lift_mids": 12},
61
+ "transition": {"type": "end_fade_black", "frames": 30},
62
+ "duration_s": 2.3,
63
+ }
64
+ }
65
+
66
+ # Grade variations for different scenes
67
+ GRADE_VARIATIONS = [
68
+ {"warm_tint": True, "lift_mids": 10}, # Scene 1: Warm
69
+ {"desaturate": True, "lift_blacks": 5}, # Scene 2: Desaturated
70
+ {"cool_tint": True, "highlights": -15}, # Scene 3: Cool
71
+ {"soft_pink": True, "lift_mids": 15}, # Scene 4: Pink
72
+ {"indoor_warm": True, "lift_shadows": 8}, # Scene 5: Warm indoor
73
+ {"teal_orange": True, "crush_blacks": 10}, # Scene 6: Teal/orange
74
+ {"dark_moody": True, "crush_blacks": 20, "desaturate": 15}, # Scene 7: Moody
75
+ ]
76
+
77
+
78
+ def generate_scene_config(manifest: Dict[str, Any]) -> List[Dict[str, Any]]:
79
+ """
80
+ Generate SCENE_CONFIG dynamically from manifest.
81
+
82
+ Converts labels to uppercase and assigns motion/grade/transition configs.
83
+ """
84
+ scenes = manifest.get("scenes", [])
85
+ config = []
86
+
87
+ for idx, scene_data in enumerate(scenes):
88
+ # Extract label and convert to uppercase
89
+ label = scene_data.get("label", f"SCENE {idx}").upper()
90
+
91
+ # Determine scene type
92
+ if idx == 0:
93
+ template = DEFAULT_SCENE_TEMPLATES["intro"]
94
+ elif idx == len(scenes) - 1:
95
+ template = DEFAULT_SCENE_TEMPLATES["final"]
96
+ else:
97
+ template = DEFAULT_SCENE_TEMPLATES["default"]
98
+
99
+ # Calculate hold_frames based on duration
100
+ duration_s = template.get("duration_s", 2.3)
101
+ total_frames = int(duration_s * FPS)
102
+
103
+ # Build scene config
104
+ scene_cfg = {
105
+ "idx": idx,
106
+ "label": label,
107
+ "duration_s": duration_s,
108
+ "motion": template["motion"].copy(),
109
+ "text": {
110
+ **template["text"],
111
+ "hold_frames": total_frames - template["text"].get("entry_frame", 2) - 6,
112
+ "align": "center"
113
+ },
114
+ "grade": GRADE_VARIATIONS[idx % len(GRADE_VARIATIONS)].copy() if idx > 0 else template["grade"].copy(),
115
+ "transition": template["transition"].copy(),
116
+ }
117
+
118
+ config.append(scene_cfg)
119
+
120
+ return config
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # COLOUR GRADE
125
+ # ---------------------------------------------------------------------------
126
+
127
+ def grade_image(img: Image.Image, grade: dict) -> Image.Image:
128
+ r, g, b = img.split()
129
+
130
+ # Global warm grade
131
+ r = r.point(lambda p: min(255, p + int(GLOBAL_TEMP * 1.2)))
132
+ g = g.point(lambda p: min(255, p + int(GLOBAL_TEMP * 0.35)))
133
+ b = b.point(lambda p: max(0, p - int(GLOBAL_TEMP * 0.8)))
134
+ g = g.point(lambda p: max(0, min(255, p + int(GLOBAL_TINT * 0.5))))
135
+
136
+ # Scene-specific
137
+ if grade.get("boost_reds"):
138
+ v = grade["boost_reds"]
139
+ r = r.point(lambda p: min(255, p + v))
140
+ g = g.point(lambda p: min(255, p + int(v * 0.25)))
141
+
142
+ if grade.get("crush_blacks"):
143
+ v = grade.get("crush_blacks", 10)
144
+ r = r.point(lambda p: max(0, p - v) if p < 55 else p)
145
+ g = g.point(lambda p: max(0, p - v) if p < 55 else p)
146
+ b = b.point(lambda p: max(0, p - v) if p < 55 else p)
147
+
148
+ if grade.get("contrast"):
149
+ v = grade["contrast"]
150
+ r = r.point(lambda p: int((p - 128) * v + 128))
151
+ g = g.point(lambda p: int((p - 128) * v + 128))
152
+ b = b.point(lambda p: int((p - 128) * v + 128))
153
+
154
+ if grade.get("lift_shadows"):
155
+ v = grade.get("lift_shadows", 0)
156
+ r = r.point(lambda p: min(255, p + v))
157
+ g = g.point(lambda p: min(255, p + v))
158
+ b = b.point(lambda p: min(255, p + v))
159
+
160
+ if grade.get("warm_tint"):
161
+ r = r.point(lambda p: min(255, p + 8))
162
+ g = g.point(lambda p: min(255, p + 3))
163
+
164
+ if grade.get("cool_tint"):
165
+ b = b.point(lambda p: min(255, p + 8))
166
+ r = r.point(lambda p: max(0, p - 5))
167
+
168
+ if grade.get("desaturate"):
169
+ v = grade.get("desaturate", 10)
170
+ merged = Image.merge("RGB", (r, g, b))
171
+ merged = ImageEnhance.Color(merged).enhance(max(0, 1.0 - v/100.0))
172
+ r, g, b = merged.split()
173
+
174
+ if grade.get("lift_blacks"):
175
+ v = grade["lift_blacks"]
176
+ r = r.point(lambda p: min(255, p + v))
177
+ g = g.point(lambda p: min(255, p + v))
178
+ b = b.point(lambda p: min(255, p + v))
179
+
180
+ if grade.get("lift_mids"):
181
+ v = grade["lift_mids"]
182
+ r = r.point(lambda p: int(p + v * (1 - abs(p - 128) / 128)))
183
+ g = g.point(lambda p: int(p + v * (1 - abs(p - 128) / 128)))
184
+ b = b.point(lambda p: int(p + v * (1 - abs(p - 128) / 128)))
185
+
186
+ if grade.get("highlights"):
187
+ v = abs(grade["highlights"])
188
+ r = r.point(lambda p: p - int((p / 255) ** 2.2 * v))
189
+ g = g.point(lambda p: p - int((p / 255) ** 2.2 * v))
190
+ b = b.point(lambda p: p - int((p / 255) ** 2.2 * v))
191
+
192
+ if grade.get("teal_orange"):
193
+ r = r.point(lambda p: min(255, p + 5))
194
+ b = b.point(lambda p: max(0, p - 8))
195
+
196
+ if grade.get("soft_pink"):
197
+ r = r.point(lambda p: min(255, p + 10))
198
+ b = b.point(lambda p: min(255, p + 5))
199
+
200
+ if grade.get("indoor_warm"):
201
+ r = r.point(lambda p: min(255, p + 12))
202
+ g = g.point(lambda p: min(255, p + 5))
203
+
204
+ if grade.get("soft_glow"):
205
+ merged = Image.merge("RGB", (r, g, b))
206
+ merged = ImageEnhance.Brightness(merged).enhance(1.05)
207
+ r, g, b = merged.split()
208
+
209
+ if grade.get("dark_moody"):
210
+ r = r.point(lambda p: max(0, p - 15))
211
+ g = g.point(lambda p: max(0, p - 15))
212
+ b = b.point(lambda p: max(0, p - 10))
213
+
214
+ img = Image.merge("RGB", (r, g, b))
215
+ img = ImageEnhance.Brightness(img).enhance(1.02)
216
+ return img
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # IMAGE LOAD + CROP-TO-FILL
220
+ # ---------------------------------------------------------------------------
221
+
222
+ def load_scene_image(idx: int, selected_dir: str) -> Image.Image:
223
+ path = os.path.join(selected_dir, f"scene_{idx:02d}.jpg")
224
+ img = Image.open(path).convert("RGB")
225
+ return crop_to_fill(img, *RESOLUTION)
226
+
227
+ def crop_to_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
228
+ iw, ih = img.size
229
+ scale = max(target_w / iw, target_h / ih)
230
+ new_w = int(iw * scale)
231
+ new_h = int(ih * scale)
232
+ img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
233
+ left = (new_w - target_w) // 2
234
+ top = (new_h - target_h) // 2
235
+ return img.crop((left, top, left + target_w, top + target_h))
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # MOTION
239
+ # ---------------------------------------------------------------------------
240
+
241
+ def get_motion_frame(base: Image.Image, motion: dict, t: float) -> Image.Image:
242
+ mtype = motion["type"]
243
+ w, h = RESOLUTION
244
+
245
+ if mtype == "snap_zoom":
246
+ s_start = motion["scale_start"]
247
+ s_end = motion["scale_end"]
248
+ scale = lerp(s_start, s_end, ease_out(t))
249
+
250
+ nw = int(w * scale)
251
+ nh = int(h * scale)
252
+ scaled = base.resize((nw, nh), Image.Resampling.BILINEAR)
253
+
254
+ left = (nw - w) // 2
255
+ top = (nh - h) // 2
256
+ left = max(0, min(left, nw - w))
257
+ top = max(0, min(top, nh - h))
258
+ return scaled.crop((left, top, left + w, top + h))
259
+
260
+ elif mtype == "slow_push_in":
261
+ s_start = motion.get("scale_start", 1.0)
262
+ s_end = motion.get("scale_end", 1.08)
263
+ scale = lerp(s_start, s_end, ease_in_out(t))
264
+
265
+ nw = int(w * scale)
266
+ nh = int(h * scale)
267
+ scaled = base.resize((nw, nh), Image.Resampling.BILINEAR)
268
+
269
+ left = (nw - w) // 2
270
+ top = (nh - h) // 2
271
+ left = max(0, min(left, nw - w))
272
+ top = max(0, min(top, nh - h))
273
+ return scaled.crop((left, top, left + w, top + h))
274
+
275
+ else: # static or others
276
+ return base
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # FONT CACHE
280
+ # ---------------------------------------------------------------------------
281
+
282
+ _font_cache = {}
283
+
284
+ def get_font(size: int) -> ImageFont.FreeTypeFont:
285
+ if size not in _font_cache:
286
+ try:
287
+ _font_cache[size] = ImageFont.truetype(FONT_PATH, size)
288
+ except Exception:
289
+ try:
290
+ _font_cache[size] = ImageFont.truetype(FONT_PATH_REG, size)
291
+ except Exception:
292
+ _font_cache[size] = ImageFont.load_default()
293
+ return _font_cache[size]
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # TEXT DRAWING
297
+ # ---------------------------------------------------------------------------
298
+
299
+ def draw_text_stroked(draw, text, pos, font, align="left", opacity=1.0):
300
+ """White text with stroke, drop shadow, and opacity."""
301
+ x, y = pos
302
+ w, _ = RESOLUTION
303
+
304
+ lines = text.split("\n")
305
+ line_heights = []
306
+ line_widths = []
307
+ for line in lines:
308
+ bb = draw.textbbox((0, 0), line, font=font)
309
+ line_widths.append(bb[2] - bb[0])
310
+ line_heights.append(bb[3] - bb[1])
311
+
312
+ line_spacing = int(font.size * 1.25)
313
+
314
+ for i, line in enumerate(lines):
315
+ lw = line_widths[i]
316
+ ly = y + i * line_spacing
317
+
318
+ if align == "center":
319
+ lx = x - lw // 2
320
+ elif align == "right":
321
+ lx = x - lw
322
+ else:
323
+ lx = x
324
+
325
+ alpha_stroke = int(TEXT_STROKE[3] * opacity)
326
+ alpha_white = int(TEXT_WHITE[3] * opacity)
327
+ alpha_shadow = int(TEXT_SHADOW[3] * opacity)
328
+
329
+ stroke_col = TEXT_STROKE[:3] + (alpha_stroke,)
330
+ white_col = TEXT_WHITE[:3] + (alpha_white,)
331
+ shadow_col = TEXT_SHADOW[:3] + (alpha_shadow,)
332
+
333
+ # Drop shadow
334
+ draw.text((lx + 4, ly + 4), line, font=font, fill=shadow_col)
335
+
336
+ # Stroke layers
337
+ for sw in [STROKE_W, STROKE_W - 2, STROKE_W - 4, 2]:
338
+ for ax in range(-sw, sw + 1, max(1, sw // 3)):
339
+ for ay in range(-sw, sw + 1, max(1, sw // 3)):
340
+ if ax * ax + ay * ay <= sw * sw:
341
+ draw.text((lx + ax, ly + ay), line, font=font, fill=stroke_col)
342
+
343
+ # White fill
344
+ draw.text((lx, ly), line, font=font, fill=white_col)
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # TEXT ANIMATIONS
348
+ # ---------------------------------------------------------------------------
349
+
350
+ def render_text_frame(cfg: dict, frame: int, total_frames: int) -> Image.Image:
351
+ tcfg = cfg["text"]
352
+ ttype = tcfg["type"]
353
+ label = cfg["label"]
354
+ w, h = RESOLUTION
355
+
356
+ layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
357
+ draw = ImageDraw.Draw(layer)
358
+ font = get_font(tcfg["font_size"])
359
+
360
+ if ttype == "quick_center_pop" or ttype == "center_stroke_pop":
361
+ entry_f = tcfg["entry_frame"]
362
+ hold_f = tcfg["hold_frames"]
363
+ fade_start = entry_f + hold_f
364
+
365
+ if frame < entry_f:
366
+ pass
367
+ elif frame < entry_f + 6: # 0.2s pop-in
368
+ progress = ease_out((frame - entry_f) / 6)
369
+ opacity = min(1.0, progress * 1.5)
370
+ x = w // 2
371
+ y = h // 2
372
+ draw_text_stroked(draw, label, (x, y), font, align="center", opacity=opacity)
373
+ elif frame < fade_start:
374
+ x = w // 2
375
+ y = h // 2
376
+ draw_text_stroked(draw, label, (x, y), font, align="center", opacity=1.0)
377
+ else:
378
+ fade_progress = min(1.0, (frame - fade_start) / 4)
379
+ opacity = 1.0 - fade_progress
380
+ x = w // 2
381
+ y = h // 2
382
+ draw_text_stroked(draw, label, (x, y), font, align="center", opacity=opacity)
383
+
384
+ elif ttype == "center_pop" or ttype == "center_fade_pop":
385
+ entry_f = tcfg["entry_frame"]
386
+ hold_f = tcfg["hold_frames"]
387
+ fade_start = entry_f + hold_f
388
+
389
+ if frame < entry_f:
390
+ pass
391
+ elif frame < entry_f + 6:
392
+ progress = ease_out((frame - entry_f) / 6)
393
+ opacity = min(1.0, progress * 1.5)
394
+ x = w // 2
395
+ y = h // 2
396
+ draw_text_stroked(draw, label, (x, y), font, align="center", opacity=opacity)
397
+ elif frame < fade_start:
398
+ x = w // 2
399
+ y = h // 2
400
+ draw_text_stroked(draw, label, (x, y), font, align="center", opacity=1.0)
401
+ else:
402
+ fade_progress = min(1.0, (frame - fade_start) / 4)
403
+ opacity = 1.0 - fade_progress
404
+ x = w // 2
405
+ y = h // 2
406
+ draw_text_stroked(draw, label, (x, y), font, align="center", opacity=opacity)
407
+
408
+ return layer
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # TRANSITION
412
+ # ---------------------------------------------------------------------------
413
+
414
+ def apply_transition(frame_a: Image.Image, frame_b: Image.Image, ttype: str, progress: float) -> Image.Image:
415
+ """Apply dynamic transition between frames."""
416
+ w, h = RESOLUTION
417
+
418
+ if ttype == "whip_pan_right":
419
+ offset = int(w * (1 - ease_out(progress)))
420
+ result = Image.new("RGB", (w, h))
421
+ result.paste(frame_a, (0, 0))
422
+ result.paste(frame_b, (offset, 0))
423
+ return result
424
+
425
+ elif ttype == "flash":
426
+ if progress < 0.3:
427
+ flash_intensity = (progress / 0.3) * 0.5
428
+ brightened = ImageEnhance.Brightness(frame_a).enhance(1.0 + flash_intensity)
429
+ alpha = min(0.5, progress / 0.3 * 0.5)
430
+ return Image.blend(brightened, frame_b, alpha)
431
+ else:
432
+ blend_t = (progress - 0.3) / 0.7
433
+ return Image.blend(frame_a, frame_b, blend_t)
434
+
435
+ elif ttype == "end_fade_black":
436
+ black = Image.new("RGB", (w, h), (0, 0, 0))
437
+ return Image.blend(frame_a, black, progress)
438
+
439
+ else: # Default to hard cut
440
+ return frame_b if progress >= 0.5 else frame_a
441
+
442
+ # ---------------------------------------------------------------------------
443
+ # MAIN RENDER FROM MANIFEST
444
+ # ---------------------------------------------------------------------------
445
+
446
+ def render_video_from_manifest(manifest_dict: Dict[str, Any], selected_dir: str, output_path: str) -> Dict[str, Any]:
447
+ """
448
+ Render video from manifest without hardcoded SCENE_CONFIG.
449
+
450
+ Args:
451
+ manifest_dict: Dictionary with 'scenes' key containing scene data
452
+ selected_dir: Path to directory with selected scene images
453
+ output_path: Path to output video file
454
+
455
+ Returns:
456
+ Dictionary with success status, duration, and metadata
457
+ """
458
+ w, h = RESOLUTION
459
+ Path(os.path.dirname(output_path) or ".").mkdir(parents=True, exist_ok=True)
460
+
461
+ # Generate dynamic SCENE_CONFIG from manifest
462
+ SCENE_CONFIG = generate_scene_config(manifest_dict)
463
+
464
+ if not SCENE_CONFIG:
465
+ return {
466
+ "success": False,
467
+ "error": "No scenes in manifest",
468
+ "duration_s": 0
469
+ }
470
+
471
+ tmp_path = output_path.replace(".mp4", "_raw.mp4")
472
+ writer = cv2.VideoWriter(
473
+ tmp_path,
474
+ cv2.VideoWriter_fourcc(*"mp4v"),
475
+ FPS,
476
+ (w, h),
477
+ )
478
+
479
+ print(f"\n{'='*55}")
480
+ print(f" Dynamic Video Composition")
481
+ print(f" {len(SCENE_CONFIG)} scenes | {FPS}fps | {w}x{h}")
482
+ print(f"{'='*55}\n")
483
+
484
+ # Preload and grade all base images
485
+ print("[1/3] Loading + grading images...")
486
+ base_images = []
487
+ for cfg in SCENE_CONFIG:
488
+ try:
489
+ raw = load_scene_image(cfg["idx"], selected_dir)
490
+ graded = grade_image(raw, cfg["grade"])
491
+ base_images.append(graded)
492
+ except Exception as e:
493
+ print(f" [ERROR] Failed to load scene {cfg['idx']}: {e}")
494
+ return {"success": False, "error": str(e), "duration_s": 0}
495
+
496
+ print(" [OK] Done\n")
497
+
498
+ # Render scenes
499
+ print("[2/3] Rendering frames...")
500
+ total_scenes = len(SCENE_CONFIG)
501
+ frames_written = 0
502
+
503
+ for scene_i, cfg in enumerate(SCENE_CONFIG):
504
+ total_frames = int(cfg["duration_s"] * FPS)
505
+ trans_cfg = cfg["transition"]
506
+ trans_frames = trans_cfg["frames"]
507
+ base = base_images[scene_i]
508
+
509
+ # Preload next scene base for transitions
510
+ if scene_i + 1 < total_scenes:
511
+ next_cfg = SCENE_CONFIG[scene_i + 1]
512
+ next_base = base_images[scene_i + 1]
513
+ else:
514
+ next_cfg = None
515
+ next_base = None
516
+
517
+ print(f" Scene {cfg['idx']:02d} -- {cfg['label'][:40]} ({total_frames}f, {cfg['duration_s']}s)")
518
+
519
+ for frame in range(total_frames):
520
+ # Motion frame
521
+ t_motion = frame / max(total_frames - 1, 1)
522
+ img = get_motion_frame(base, cfg["motion"], t_motion)
523
+
524
+ # Text layer
525
+ text_layer = render_text_frame(cfg, frame, total_frames)
526
+ img_rgba = img.convert("RGBA")
527
+ img_rgba = Image.alpha_composite(img_rgba, text_layer)
528
+ img = img_rgba.convert("RGB")
529
+
530
+ # Transition blend at end of scene
531
+ frames_into_trans = frame - (total_frames - trans_frames)
532
+ if frames_into_trans >= 0 and next_base is not None:
533
+ trans_t = frames_into_trans / max(trans_frames - 1, 1)
534
+ t_next = 0.0
535
+ next_motion = get_motion_frame(next_base, next_cfg["motion"], t_next)
536
+ img = apply_transition(img, next_motion, trans_cfg["type"], trans_t)
537
+
538
+ # Write frame (cv2 expects BGR)
539
+ arr = np.array(img)
540
+ writer.write(cv2.cvtColor(arr, cv2.COLOR_RGB2BGR))
541
+ frames_written += 1
542
+
543
+ print(f" [OK] {total_frames} frames")
544
+
545
+ # Hard cut to black at end
546
+ print(f"\n Hard cut to black")
547
+ black_frame = np.zeros((h, w, 3), dtype=np.uint8)
548
+ for _ in range(2):
549
+ writer.write(black_frame)
550
+ frames_written += 1
551
+ print(f" [OK] 2 frames")
552
+
553
+ writer.release()
554
+ duration_s = frames_written / FPS
555
+ print(f"\n Total frames written: {frames_written} (~{duration_s:.1f}s)\n")
556
+
557
+ # Re-encode with ffmpeg
558
+ print("[3/3] Encoding H.264 MP4 via ffmpeg...")
559
+ cmd = (
560
+ f"ffmpeg -y -i {tmp_path} "
561
+ f"-vcodec libx264 -crf 20 -preset fast "
562
+ f"-pix_fmt yuv420p "
563
+ f"-movflags +faststart "
564
+ f"{output_path} 2>&1"
565
+ )
566
+ ret = os.system(cmd)
567
+ if ret == 0:
568
+ os.remove(tmp_path)
569
+ size_mb = os.path.getsize(output_path) / (1024 * 1024)
570
+ print(f"\n [OK] Output: {output_path}")
571
+ print(f" [OK] Size : {size_mb:.1f} MB")
572
+ print(f"\n{'='*55}\n")
573
+
574
+ return {
575
+ "success": True,
576
+ "output_path": output_path,
577
+ "duration_s": duration_s,
578
+ "size_mb": size_mb
579
+ }
580
+ else:
581
+ print(f" [ERROR] ffmpeg failed (code {ret}). Raw file kept: {tmp_path}")
582
+ return {
583
+ "success": False,
584
+ "error": f"ffmpeg failed with code {ret}",
585
+ "duration_s": duration_s
586
+ }
587
+
588
+
589
+ if __name__ == "__main__":
590
+ # Example usage
591
+ manifest = {
592
+ "scenes": [
593
+ {"label": "which type of anger do you have?"},
594
+ {"label": "shouting"},
595
+ {"label": "revenge"},
596
+ ]
597
+ }
598
+
599
+ result = render_video_from_manifest(manifest, "selected", "renders/test_dynamic.mp4")
600
+ print(f"Result: {result}")
composer_simple.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sunset Reel — Simplified Frame-by-Frame Renderer
3
+ Renders static frame with text overlay from each scene image (no complex motions)
4
+ Uses PIL only, exports via ffmpeg
5
+ """
6
+
7
+ import os
8
+ import json
9
+ from pathlib import Path
10
+ from PIL import Image, ImageDraw, ImageFont, ImageEnhance
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # GLOBALS
14
+ # ---------------------------------------------------------------------------
15
+
16
+ RESOLUTION = (1080, 1920) # W x H
17
+ FPS = 30
18
+ OUTPUT_PATH = "renders/sunset_reel.mp4"
19
+ SELECTED_DIR = "selected"
20
+ FONT_PATH = "../trendclip/assets/fonts/Montserrat-Bold.ttf"
21
+
22
+ TEXT_WHITE = (255, 255, 255)
23
+ TEXT_STROKE = (0, 0, 0)
24
+ STROKE_W = 8
25
+
26
+ # Easing
27
+ def ease_out(t): return 1 - (1 - t) ** 3
28
+ def ease_in_out(t): return t * t * (3 - 2 * t)
29
+ def ease_in(t): return t * t * t
30
+ def lerp(a, b, t): return a + (b - a) * t
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # SCENE CONFIG
34
+ # ---------------------------------------------------------------------------
35
+
36
+ SCENE_CONFIG = [
37
+ {"idx": 0, "label": "Chase The\nGolden Hour", "duration_s": 3.5, "boost_reds": 15},
38
+ {"idx": 1, "label": "Scout Your\nSpot First", "duration_s": 3.0, "push_purples": True},
39
+ {"idx": 2, "label": "Balance The\nBright Sky", "duration_s": 3.5, "sky_cool": True},
40
+ {"idx": 3, "label": "Rule Of\nThirds Grid", "duration_s": 4.0},
41
+ {"idx": 4, "label": "Layer The\nForeground Depth", "duration_s": 4.0, "saturate": True},
42
+ {"idx": 5, "label": "Add A\nSilhouette Subject", "duration_s": 3.5, "deep_amber": True},
43
+ {"idx": 6, "label": "Edit The\nWarmth Gently", "duration_s": 4.5, "match_reel": True},
44
+ ]
45
+
46
+ END_CARD = {
47
+ "duration_s": 2.5,
48
+ "lines": [
49
+ ("Stop guessing.", 96),
50
+ ("Start shooting.", 96),
51
+ ("Which of these do you already do?", 52),
52
+ ("Save this before you forget", 46),
53
+ ],
54
+ }
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # COLOUR GRADE
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def grade_image(img: Image.Image, grade_cfg: dict) -> Image.Image:
61
+ """Apply colour grading to image"""
62
+ enhancer = ImageEnhance.Brightness(img)
63
+ img = enhancer.enhance(1.02)
64
+
65
+ if grade_cfg.get("boost_reds"):
66
+ enhancer = ImageEnhance.Color(img)
67
+ img = enhancer.enhance(1.1)
68
+
69
+ if grade_cfg.get("saturate"):
70
+ enhancer = ImageEnhance.Color(img)
71
+ img = enhancer.enhance(1.22)
72
+
73
+ if grade_cfg.get("deep_amber"):
74
+ enhancer = ImageEnhance.Color(img)
75
+ img = enhancer.enhance(1.15)
76
+
77
+ return img
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # IMAGE LOAD + CROP
82
+ # ---------------------------------------------------------------------------
83
+
84
+ def load_scene_image(idx: int) -> Image.Image:
85
+ path = os.path.join(SELECTED_DIR, f"scene_{idx:02d}.jpg")
86
+ img = Image.open(path).convert("RGB")
87
+ return crop_to_fill(img, *RESOLUTION)
88
+
89
+
90
+ def crop_to_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
91
+ iw, ih = img.size
92
+ scale = max(target_w / iw, target_h / ih)
93
+ new_w = int(iw * scale)
94
+ new_h = int(ih * scale)
95
+ img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
96
+ left = (new_w - target_w) // 2
97
+ top = (new_h - target_h) // 2
98
+ return img.crop((left, top, left + target_w, top + target_h))
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # TEXT DRAWING
103
+ # ---------------------------------------------------------------------------
104
+
105
+ def get_font(size: int) -> ImageFont.FreeTypeFont:
106
+ try:
107
+ return ImageFont.truetype(FONT_PATH, size)
108
+ except Exception:
109
+ return ImageFont.load_default()
110
+
111
+
112
+ def draw_text_stroked(draw, text, pos, font, align="left"):
113
+ """White text with stroke"""
114
+ x, y = pos
115
+ w, _ = RESOLUTION
116
+
117
+ # Multi-line
118
+ lines = text.split("\n")
119
+ line_spacing = int(font.size * 1.25)
120
+
121
+ for i, line in enumerate(lines):
122
+ bbox = draw.textbbox((0, 0), line, font=font)
123
+ line_w = bbox[2] - bbox[0]
124
+ ly = y + i * line_spacing
125
+
126
+ if align == "center":
127
+ lx = x - line_w // 2
128
+ elif align == "right":
129
+ lx = x - line_w
130
+ else:
131
+ lx = x
132
+
133
+ # Stroke layers
134
+ for sw in [STROKE_W, STROKE_W - 2, STROKE_W - 4, 2]:
135
+ for ax in range(-sw, sw + 1, max(1, sw // 3)):
136
+ for ay in range(-sw, sw + 1, max(1, sw // 3)):
137
+ if ax * ax + ay * ay <= sw * sw:
138
+ draw.text((lx + ax, ly + ay), line, font=font, fill=TEXT_STROKE)
139
+
140
+ # White fill
141
+ draw.text((lx, ly), line, font=font, fill=TEXT_WHITE)
142
+
143
+
144
+ def render_text_frame(cfg: dict, frame: int, total_frames: int) -> Image.Image:
145
+ """Render text layer for frame"""
146
+ w, h = RESOLUTION
147
+ layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
148
+ draw = ImageDraw.Draw(layer)
149
+ font = get_font(90)
150
+
151
+ label = cfg["label"]
152
+
153
+ # Simple fade-in at frame 8
154
+ if frame >= 8:
155
+ progress = min(1.0, (frame - 8) / 18)
156
+ opacity_int = int(255 * progress)
157
+
158
+ # Draw stroked text
159
+ draw_text_stroked(draw, label, (int(w * 0.08), int(h * 0.76)), font, align="left")
160
+
161
+ return layer
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # MAIN RENDER
166
+ # ---------------------------------------------------------------------------
167
+
168
+ def render():
169
+ w, h = RESOLUTION
170
+ Path(os.path.dirname(OUTPUT_PATH)).mkdir(parents=True, exist_ok=True)
171
+
172
+ # Temp frame folder
173
+ frames_dir = "renders/frames_tmp"
174
+ Path(frames_dir).mkdir(parents=True, exist_ok=True)
175
+
176
+ print(f"\n{'='*55}")
177
+ print(f" Sunset Reel Renderer (Simplified)")
178
+ print(f" {len(SCENE_CONFIG)} scenes + end card | {FPS}fps | {w}x{h}")
179
+ print(f"{'='*55}\n")
180
+
181
+ # Render scenes
182
+ print("[1/2] Rendering frames...")
183
+ total_frames_written = 0
184
+
185
+ for scene_i, cfg in enumerate(SCENE_CONFIG):
186
+ total_frames = int(cfg["duration_s"] * FPS)
187
+ base = load_scene_image(cfg["idx"])
188
+ base = grade_image(base, cfg)
189
+
190
+ print(f" Scene {cfg['idx']:02d} — {cfg['label'].replace(chr(10),' ')} ({total_frames}f)")
191
+
192
+ for frame in range(total_frames):
193
+ # Create base image
194
+ img = base.copy()
195
+
196
+ # Add text overlay
197
+ text_layer = render_text_frame(cfg, frame, total_frames)
198
+ img_rgba = img.convert("RGBA")
199
+ img_rgba = Image.alpha_composite(img_rgba, text_layer)
200
+ img = img_rgba.convert("RGB")
201
+
202
+ # Save frame
203
+ frame_path = os.path.join(frames_dir, f"frame_{total_frames_written:06d}.jpg")
204
+ img.save(frame_path, quality=92)
205
+ total_frames_written += 1
206
+
207
+ print(f" ✓ {total_frames} frames")
208
+
209
+ # Render end card
210
+ end_frames = int(END_CARD["duration_s"] * FPS)
211
+ print(f"\n End card ({end_frames}f)")
212
+
213
+ for frame in range(end_frames):
214
+ img = Image.new("RGB", (w, h), (8, 8, 8))
215
+ layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
216
+ draw = ImageDraw.Draw(layer)
217
+
218
+ start_y = int(h * 0.14)
219
+ step = int((h * 0.72) / len(END_CARD["lines"]))
220
+
221
+ for i, (line_text, fsize) in enumerate(END_CARD["lines"]):
222
+ entry_f = 14 + i * 10
223
+ if frame >= entry_f:
224
+ progress = min(1.0, (frame - entry_f) / 18)
225
+ font = get_font(fsize)
226
+ y = start_y + i * step
227
+ slide_y = int(lerp(20, 0, ease_out(progress)))
228
+ draw_text_stroked(draw, line_text, (w // 2, y + slide_y), font, align="center")
229
+
230
+ img_rgba = img.convert("RGBA")
231
+ img_rgba = Image.alpha_composite(img_rgba, layer)
232
+ img = img_rgba.convert("RGB")
233
+
234
+ frame_path = os.path.join(frames_dir, f"frame_{total_frames_written:06d}.jpg")
235
+ img.save(frame_path, quality=92)
236
+ total_frames_written += 1
237
+
238
+ print(f" ✓ {end_frames} frames")
239
+
240
+ print(f"\n Total frames: {total_frames_written} (~{total_frames_written/FPS:.1f}s)\n")
241
+
242
+ # Encode with ffmpeg
243
+ print("[2/2] Encoding MP4 via ffmpeg...")
244
+ cmd = (
245
+ f"ffmpeg -y -framerate {FPS} -i {frames_dir}/frame_%06d.jpg "
246
+ f"-c:v libx264 -crf 20 -preset fast -pix_fmt yuv420p -movflags +faststart "
247
+ f"{OUTPUT_PATH} 2>&1"
248
+ )
249
+ ret = os.system(cmd)
250
+
251
+ if ret == 0:
252
+ import shutil
253
+ shutil.rmtree(frames_dir, ignore_errors=True)
254
+ size_mb = os.path.getsize(OUTPUT_PATH) / (1024 * 1024)
255
+ print(f"\n ✓ Output: {OUTPUT_PATH}")
256
+ print(f" ✓ Size : {size_mb:.1f} MB\n")
257
+ print(f"{'='*55}\n")
258
+ else:
259
+ print(f" ✗ ffmpeg failed (code {ret})")
260
+
261
+
262
+ if __name__ == "__main__":
263
+ render()
composer_v2.py ADDED
@@ -0,0 +1,574 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sunset Reel — Fast-Paced TikTok/Reels Renderer
3
+ 7 scenes × ~1s each = ~7s total video
4
+ Snap zoom, quick center-pop text, hard cuts
5
+ Pure code rendering with PIL + cv2
6
+ """
7
+
8
+ import os
9
+ import numpy as np
10
+ import cv2
11
+ from pathlib import Path
12
+ from PIL import Image, ImageDraw, ImageFont, ImageEnhance, ImageOps
13
+ import random
14
+ import json
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # GLOBALS
18
+ # ---------------------------------------------------------------------------
19
+
20
+ RESOLUTION = (1080, 1920) # W x H
21
+ FPS = 30
22
+ OUTPUT_PATH = "renders/sunset_reel.mp4"
23
+ SELECTED_DIR = "selected"
24
+ FONT_PATH = "asset/TR Impact.TTF"
25
+ FONT_PATH_REG = "asset/TR Impact.TTF"
26
+
27
+ GLOBAL_TEMP = +8
28
+ GLOBAL_TINT = -5
29
+
30
+ TEXT_WHITE = (255, 255, 255, 255)
31
+ TEXT_STROKE = (0, 0, 0, 210)
32
+ TEXT_SHADOW = (0, 0, 0, 60)
33
+ STROKE_W = 8
34
+
35
+ # Easing
36
+ def ease_out(t): return 1 - (1 - t) ** 3
37
+ def ease_in_out(t): return t * t * (3 - 2 * t)
38
+ def ease_in(t): return t * t * t
39
+ def lerp(a, b, t): return a + (b - a) * t
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # FAST-PACED SCENE CONFIG - Types of Anger
43
+ # ---------------------------------------------------------------------------
44
+
45
+ SCENE_CONFIG = [
46
+ # ── INTRO / HOOK ─────────────────────────────────────────────
47
+ {
48
+ "idx": 0, "label": "WHICH TYPE OF\nANGER DO YOU HAVE?",
49
+ "duration_s": 4.7,
50
+ "motion": {"type": "slow_push_in", "scale_start": 1.0, "scale_end": 1.08},
51
+ "text": {"type": "center_stroke_pop", "entry_frame": 2, "hold_frames": 125, "font_size": 95, "align": "center"},
52
+ "grade": {"crush_blacks": 15, "contrast": 1.15},
53
+ "transition": {"type": "hard_cut", "frames": 1},
54
+ },
55
+ # ── TYPE 1: SHOUTING ─────────────────────────────────────────
56
+ {
57
+ "idx": 1, "label": "~SHOUTING",
58
+ "duration_s": 2.3,
59
+ "motion": {"type": "snap_zoom", "scale_start": 1.0, "scale_end": 1.12},
60
+ "text": {"type": "center_pop", "entry_frame": 0, "hold_frames": 69, "font_size": 110, "align": "center"},
61
+ "grade": {"warm_tint": True, "lift_mids": 10},
62
+ "transition": {"type": "whip_pan_right", "frames": 4},
63
+ },
64
+ # ── TYPE 2: REVENGE ──────────────────────────────────────────
65
+ {
66
+ "idx": 2, "label": "~REVENGE",
67
+ "duration_s": 2.3,
68
+ "motion": {"type": "static"},
69
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
70
+ "grade": {"desaturate": True, "lift_blacks": 5},
71
+ "transition": {"type": "whip_pan_right", "frames": 4},
72
+ },
73
+ # ── TYPE 3: IGNORING ─────────────────────────────────────────
74
+ {
75
+ "idx": 3, "label": "~IGNORING",
76
+ "duration_s": 2.3,
77
+ "motion": {"type": "static"},
78
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
79
+ "grade": {"cool_tint": True, "highlights": -15},
80
+ "transition": {"type": "whip_pan_right", "frames": 4},
81
+ },
82
+ # ── TYPE 4: SLAMMING ─────────────────────────────────────────
83
+ {
84
+ "idx": 4, "label": "~SLAMMING",
85
+ "duration_s": 2.3,
86
+ "motion": {"type": "static"},
87
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
88
+ "grade": {"soft_pink": True, "lift_mids": 15},
89
+ "transition": {"type": "whip_pan_right", "frames": 4},
90
+ },
91
+ # ── TYPE 5: CURSING ──────────────────────────────────────────
92
+ {
93
+ "idx": 5, "label": "~CURSING",
94
+ "duration_s": 2.3,
95
+ "motion": {"type": "static"},
96
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
97
+ "grade": {"indoor_warm": True, "lift_shadows": 8},
98
+ "transition": {"type": "whip_pan_right", "frames": 4},
99
+ },
100
+ # ── TYPE 6: WALKING AWAY ─────────────────────────────────────
101
+ {
102
+ "idx": 6, "label": "~WALKING AWAY",
103
+ "duration_s": 2.3,
104
+ "motion": {"type": "static"},
105
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
106
+ "grade": {"teal_orange": True, "crush_blacks": 10},
107
+ "transition": {"type": "whip_pan_right", "frames": 4},
108
+ },
109
+ # ── TYPE 7: FIGHTING ─────────────────────────────────────────
110
+ {
111
+ "idx": 7, "label": "~FIGHTING",
112
+ "duration_s": 2.3,
113
+ "motion": {"type": "static"},
114
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
115
+ "grade": {"dark_moody": True, "crush_blacks": 20, "desaturate": 15},
116
+ "transition": {"type": "whip_pan_right", "frames": 4},
117
+ },
118
+ # ── TYPE 8: CRYING ───────────────────────────────────────────
119
+ {
120
+ "idx": 8, "label": "~CRYING",
121
+ "duration_s": 2.3,
122
+ "motion": {"type": "static"},
123
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
124
+ "grade": {"warm_indoor": True, "soft_glow": True, "lift_mids": 12},
125
+ "transition": {"type": "end_fade_black", "frames": 30},
126
+ },
127
+ ]
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # COLOUR GRADE
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def grade_image(img: Image.Image, grade: dict) -> Image.Image:
135
+ r, g, b = img.split()
136
+
137
+ # Global warm grade
138
+ r = r.point(lambda p: min(255, p + int(GLOBAL_TEMP * 1.2)))
139
+ g = g.point(lambda p: min(255, p + int(GLOBAL_TEMP * 0.35)))
140
+ b = b.point(lambda p: max(0, p - int(GLOBAL_TEMP * 0.8)))
141
+ g = g.point(lambda p: max(0, min(255, p + int(GLOBAL_TINT * 0.5))))
142
+
143
+ # Scene-specific
144
+ if grade.get("boost_reds"):
145
+ v = grade["boost_reds"]
146
+ r = r.point(lambda p: min(255, p + v))
147
+ g = g.point(lambda p: min(255, p + int(v * 0.25)))
148
+
149
+ if grade.get("crush_blacks"):
150
+ v = grade.get("crush_blacks", 10)
151
+ r = r.point(lambda p: max(0, p - v) if p < 55 else p)
152
+ g = g.point(lambda p: max(0, p - v) if p < 55 else p)
153
+ b = b.point(lambda p: max(0, p - v) if p < 55 else p)
154
+
155
+ if grade.get("contrast"):
156
+ v = grade["contrast"]
157
+ r = r.point(lambda p: int((p - 128) * v + 128))
158
+ g = g.point(lambda p: int((p - 128) * v + 128))
159
+ b = b.point(lambda p: int((p - 128) * v + 128))
160
+
161
+ if grade.get("lift_shadows"):
162
+ v = grade.get("lift_shadows", 0)
163
+ r = r.point(lambda p: min(255, p + v))
164
+ g = g.point(lambda p: min(255, p + v))
165
+ b = b.point(lambda p: min(255, p + v))
166
+
167
+ if grade.get("warm_tint"):
168
+ r = r.point(lambda p: min(255, p + 8))
169
+ g = g.point(lambda p: min(255, p + 3))
170
+
171
+ if grade.get("cool_tint"):
172
+ b = b.point(lambda p: min(255, p + 8))
173
+ r = r.point(lambda p: max(0, p - 5))
174
+
175
+ if grade.get("desaturate"):
176
+ v = grade.get("desaturate", 10)
177
+ merged = Image.merge("RGB", (r, g, b))
178
+ merged = ImageEnhance.Color(merged).enhance(max(0, 1.0 - v/100.0))
179
+ r, g, b = merged.split()
180
+
181
+ if grade.get("lift_blacks"):
182
+ v = grade["lift_blacks"]
183
+ r = r.point(lambda p: min(255, p + v))
184
+ g = g.point(lambda p: min(255, p + v))
185
+ b = b.point(lambda p: min(255, p + v))
186
+
187
+ if grade.get("lift_mids"):
188
+ v = grade["lift_mids"]
189
+ r = r.point(lambda p: int(p + v * (1 - abs(p - 128) / 128)))
190
+ g = g.point(lambda p: int(p + v * (1 - abs(p - 128) / 128)))
191
+ b = b.point(lambda p: int(p + v * (1 - abs(p - 128) / 128)))
192
+
193
+ if grade.get("highlights"):
194
+ v = abs(grade["highlights"])
195
+ r = r.point(lambda p: p - int((p / 255) ** 2.2 * v))
196
+ g = g.point(lambda p: p - int((p / 255) ** 2.2 * v))
197
+ b = b.point(lambda p: p - int((p / 255) ** 2.2 * v))
198
+
199
+ if grade.get("teal_orange"):
200
+ r = r.point(lambda p: min(255, p + 5))
201
+ b = b.point(lambda p: max(0, p - 8))
202
+
203
+ if grade.get("soft_pink"):
204
+ r = r.point(lambda p: min(255, p + 10))
205
+ b = b.point(lambda p: min(255, p + 5))
206
+
207
+ if grade.get("indoor_warm"):
208
+ r = r.point(lambda p: min(255, p + 12))
209
+ g = g.point(lambda p: min(255, p + 5))
210
+
211
+ if grade.get("soft_glow"):
212
+ merged = Image.merge("RGB", (r, g, b))
213
+ merged = ImageEnhance.Brightness(merged).enhance(1.05)
214
+ r, g, b = merged.split()
215
+
216
+ if grade.get("dark_moody"):
217
+ r = r.point(lambda p: max(0, p - 15))
218
+ g = g.point(lambda p: max(0, p - 15))
219
+ b = b.point(lambda p: max(0, p - 10))
220
+
221
+ img = Image.merge("RGB", (r, g, b))
222
+ img = ImageEnhance.Brightness(img).enhance(1.02)
223
+ return img
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # IMAGE LOAD + CROP-TO-FILL
227
+ # ---------------------------------------------------------------------------
228
+
229
+ def load_scene_image(idx: int) -> Image.Image:
230
+ path = os.path.join(SELECTED_DIR, f"scene_{idx:02d}.jpg")
231
+ img = Image.open(path).convert("RGB")
232
+ return crop_to_fill(img, *RESOLUTION)
233
+
234
+ def crop_to_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
235
+ iw, ih = img.size
236
+ scale = max(target_w / iw, target_h / ih)
237
+ new_w = int(iw * scale)
238
+ new_h = int(ih * scale)
239
+ img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
240
+ left = (new_w - target_w) // 2
241
+ top = (new_h - target_h) // 2
242
+ return img.crop((left, top, left + target_w, top + target_h))
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # MOTION - snap_zoom (sharp, punchy zoom effect)
246
+ # ---------------------------------------------------------------------------
247
+
248
+ def get_motion_frame(base: Image.Image, motion: dict, t: float) -> Image.Image:
249
+ mtype = motion["type"]
250
+ w, h = RESOLUTION
251
+
252
+ if mtype == "snap_zoom":
253
+ s_start = motion["scale_start"]
254
+ s_end = motion["scale_end"]
255
+ scale = lerp(s_start, s_end, ease_out(t))
256
+
257
+ nw = int(w * scale)
258
+ nh = int(h * scale)
259
+ scaled = base.resize((nw, nh), Image.Resampling.BILINEAR)
260
+
261
+ left = (nw - w) // 2
262
+ top = (nh - h) // 2
263
+ left = max(0, min(left, nw - w))
264
+ top = max(0, min(top, nh - h))
265
+ return scaled.crop((left, top, left + w, top + h))
266
+
267
+ elif mtype == "slow_push_in":
268
+ s_start = motion.get("scale_start", 1.0)
269
+ s_end = motion.get("scale_end", 1.08)
270
+ scale = lerp(s_start, s_end, ease_in_out(t))
271
+
272
+ nw = int(w * scale)
273
+ nh = int(h * scale)
274
+ scaled = base.resize((nw, nh), Image.Resampling.BILINEAR)
275
+
276
+ left = (nw - w) // 2
277
+ top = (nh - h) // 2
278
+ left = max(0, min(left, nw - w))
279
+ top = max(0, min(top, nh - h))
280
+ return scaled.crop((left, top, left + w, top + h))
281
+
282
+ else: # static or others
283
+ return base
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # FONT CACHE
287
+ # ---------------------------------------------------------------------------
288
+
289
+ _font_cache = {}
290
+
291
+ def get_font(size: int) -> ImageFont.FreeTypeFont:
292
+ if size not in _font_cache:
293
+ try:
294
+ _font_cache[size] = ImageFont.truetype(FONT_PATH, size)
295
+ except Exception:
296
+ try:
297
+ _font_cache[size] = ImageFont.truetype(FONT_PATH_REG, size)
298
+ except Exception:
299
+ _font_cache[size] = ImageFont.load_default()
300
+ return _font_cache[size]
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # TEXT DRAWING
304
+ # ---------------------------------------------------------------------------
305
+
306
+ def draw_text_stroked(draw, text, pos, font, align="left", opacity=1.0):
307
+ """White text with stroke, drop shadow, and opacity."""
308
+ x, y = pos
309
+ w, _ = RESOLUTION
310
+
311
+ lines = text.split("\n")
312
+ line_heights = []
313
+ line_widths = []
314
+ for line in lines:
315
+ bb = draw.textbbox((0, 0), line, font=font)
316
+ line_widths.append(bb[2] - bb[0])
317
+ line_heights.append(bb[3] - bb[1])
318
+
319
+ line_spacing = int(font.size * 1.25)
320
+
321
+ for i, line in enumerate(lines):
322
+ lw = line_widths[i]
323
+ ly = y + i * line_spacing
324
+
325
+ if align == "center":
326
+ lx = x - lw // 2
327
+ elif align == "right":
328
+ lx = x - lw
329
+ else:
330
+ lx = x
331
+
332
+ alpha_stroke = int(TEXT_STROKE[3] * opacity)
333
+ alpha_white = int(TEXT_WHITE[3] * opacity)
334
+ alpha_shadow = int(TEXT_SHADOW[3] * opacity)
335
+
336
+ stroke_col = TEXT_STROKE[:3] + (alpha_stroke,)
337
+ white_col = TEXT_WHITE[:3] + (alpha_white,)
338
+ shadow_col = TEXT_SHADOW[:3] + (alpha_shadow,)
339
+
340
+ # Drop shadow
341
+ draw.text((lx + 4, ly + 4), line, font=font, fill=shadow_col)
342
+
343
+ # Stroke layers
344
+ for sw in [STROKE_W, STROKE_W - 2, STROKE_W - 4, 2]:
345
+ for ax in range(-sw, sw + 1, max(1, sw // 3)):
346
+ for ay in range(-sw, sw + 1, max(1, sw // 3)):
347
+ if ax * ax + ay * ay <= sw * sw:
348
+ draw.text((lx + ax, ly + ay), line, font=font, fill=stroke_col)
349
+
350
+ # White fill
351
+ draw.text((lx, ly), line, font=font, fill=white_col)
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # TEXT ANIMATIONS - quick_center_pop
355
+ # ---------------------------------------------------------------------------
356
+
357
+ def render_text_frame(cfg: dict, frame: int, total_frames: int) -> Image.Image:
358
+ tcfg = cfg["text"]
359
+ ttype = tcfg["type"]
360
+ label = cfg["label"]
361
+ w, h = RESOLUTION
362
+
363
+ layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
364
+ draw = ImageDraw.Draw(layer)
365
+ font = get_font(tcfg["font_size"])
366
+
367
+ if ttype == "quick_center_pop" or ttype == "center_stroke_pop":
368
+ entry_f = tcfg["entry_frame"]
369
+ hold_f = tcfg["hold_frames"]
370
+ fade_start = entry_f + hold_f
371
+
372
+ if frame < entry_f:
373
+ pass
374
+ elif frame < entry_f + 6: # 0.2s pop-in
375
+ progress = ease_out((frame - entry_f) / 6)
376
+ opacity = min(1.0, progress * 1.5)
377
+ x = w // 2
378
+ y = h // 2
379
+ draw_text_stroked(draw, label, (x, y), font, align="center", opacity=opacity)
380
+ elif frame < fade_start:
381
+ # Hold full opacity
382
+ x = w // 2
383
+ y = h // 2
384
+ draw_text_stroked(draw, label, (x, y), font, align="center", opacity=1.0)
385
+ else:
386
+ # Fade out quickly
387
+ fade_progress = min(1.0, (frame - fade_start) / 4)
388
+ opacity = 1.0 - fade_progress
389
+ x = w // 2
390
+ y = h // 2
391
+ draw_text_stroked(draw, label, (x, y), font, align="center", opacity=opacity)
392
+
393
+ elif ttype == "center_pop" or ttype == "center_fade_pop":
394
+ entry_f = tcfg["entry_frame"]
395
+ hold_f = tcfg["hold_frames"]
396
+ fade_start = entry_f + hold_f
397
+
398
+ if frame < entry_f:
399
+ pass
400
+ elif frame < entry_f + 6:
401
+ progress = ease_out((frame - entry_f) / 6)
402
+ opacity = min(1.0, progress * 1.5)
403
+ x = w // 2
404
+ y = h // 2
405
+ draw_text_stroked(draw, label, (x, y), font, align="center", opacity=opacity)
406
+ elif frame < fade_start:
407
+ x = w // 2
408
+ y = h // 2
409
+ draw_text_stroked(draw, label, (x, y), font, align="center", opacity=1.0)
410
+ else:
411
+ fade_progress = min(1.0, (frame - fade_start) / 4)
412
+ opacity = 1.0 - fade_progress
413
+ x = w // 2
414
+ y = h // 2
415
+ draw_text_stroked(draw, label, (x, y), font, align="center", opacity=opacity)
416
+
417
+ return layer
418
+
419
+ # ---------------------------------------------------------------------------
420
+ # TRANSITION - hard_cut only
421
+ # ---------------------------------------------------------------------------
422
+
423
+ def apply_transition(frame_a: Image.Image, frame_b: Image.Image, ttype: str, progress: float) -> Image.Image:
424
+ """Apply dynamic transition between frames."""
425
+ w, h = RESOLUTION
426
+
427
+ if ttype == "whip_pan_right":
428
+ # Slide frame_b in from left with motion blur effect
429
+ offset = int(w * (1 - ease_out(progress)))
430
+ result = Image.new("RGB", (w, h))
431
+ result.paste(frame_a, (0, 0))
432
+ result.paste(frame_b, (offset, 0))
433
+ return result
434
+
435
+ elif ttype == "flash":
436
+ if progress < 0.3:
437
+ flash_intensity = (progress / 0.3) * 0.5
438
+ brightened = ImageEnhance.Brightness(frame_a).enhance(1.0 + flash_intensity)
439
+ alpha = min(0.5, progress / 0.3 * 0.5)
440
+ return Image.blend(brightened, frame_b, alpha)
441
+ else:
442
+ blend_t = (progress - 0.3) / 0.7
443
+ return Image.blend(frame_a, frame_b, blend_t)
444
+
445
+ elif ttype == "end_fade_black":
446
+ # Fade to black
447
+ black = Image.new("RGB", (w, h), (0, 0, 0))
448
+ return Image.blend(frame_a, black, progress)
449
+
450
+ else: # Default to hard cut
451
+ return frame_b if progress >= 0.5 else frame_a
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # MAIN RENDER
455
+ # ---------------------------------------------------------------------------
456
+
457
+ def render():
458
+ global SCENE_CONFIG
459
+
460
+ # Load SCENE_CONFIG from JSON if provided via environment variable
461
+ config_path = os.environ.get("COMPOSER_MANIFEST_CONFIG")
462
+ if config_path and os.path.exists(config_path):
463
+ print(f"\n[INFO] Loading config from: {config_path}")
464
+ with open(config_path, "r") as f:
465
+ manifest_data = json.load(f)
466
+ SCENE_CONFIG = manifest_data.get("scenes", SCENE_CONFIG)
467
+ print(f"[INFO] Loaded {len(SCENE_CONFIG)} scenes from manifest")
468
+
469
+ w, h = RESOLUTION
470
+ Path(os.path.dirname(OUTPUT_PATH)).mkdir(parents=True, exist_ok=True)
471
+
472
+ tmp_path = OUTPUT_PATH.replace(".mp4", "_raw.mp4")
473
+ writer = cv2.VideoWriter(
474
+ tmp_path,
475
+ cv2.VideoWriter_fourcc(*"mp4v"),
476
+ FPS,
477
+ (w, h),
478
+ )
479
+
480
+ print(f"\n{'='*55}")
481
+ print(f" Sunset Reel (Fast-Paced)")
482
+ print(f" {len(SCENE_CONFIG)} scenes | {FPS}fps | {w}x{h}")
483
+ print(f"{'='*55}\n")
484
+
485
+ # Preload and grade all base images
486
+ print("[1/3] Loading + grading images...")
487
+ base_images = []
488
+ for cfg in SCENE_CONFIG:
489
+ raw = load_scene_image(cfg["idx"])
490
+ graded = grade_image(raw, cfg["grade"])
491
+ base_images.append(graded)
492
+ print(" [OK] Done\n")
493
+
494
+ # Render scenes
495
+ print("[2/3] Rendering frames...")
496
+ total_scenes = len(SCENE_CONFIG)
497
+ frames_written = 0
498
+
499
+ for scene_i, cfg in enumerate(SCENE_CONFIG):
500
+ total_frames = int(cfg["duration_s"] * FPS)
501
+ trans_cfg = cfg["transition"]
502
+ trans_frames = trans_cfg["frames"]
503
+ base = base_images[scene_i]
504
+
505
+ # Preload next scene base for transitions
506
+ if scene_i + 1 < total_scenes:
507
+ next_cfg = SCENE_CONFIG[scene_i + 1]
508
+ next_base = base_images[scene_i + 1]
509
+ else:
510
+ next_cfg = None
511
+ next_base = None
512
+
513
+ print(f" Scene {cfg['idx']:02d} -- {cfg['label'].replace(chr(10),' ')} ({total_frames}f, {cfg['duration_s']}s)")
514
+
515
+ for frame in range(total_frames):
516
+ # Motion frame
517
+ t_motion = frame / max(total_frames - 1, 1)
518
+ img = get_motion_frame(base, cfg["motion"], t_motion)
519
+
520
+ # Text layer
521
+ text_layer = render_text_frame(cfg, frame, total_frames)
522
+ img_rgba = img.convert("RGBA")
523
+ img_rgba = Image.alpha_composite(img_rgba, text_layer)
524
+ img = img_rgba.convert("RGB")
525
+
526
+ # Transition blend at end of scene
527
+ frames_into_trans = frame - (total_frames - trans_frames)
528
+ if frames_into_trans >= 0 and next_base is not None:
529
+ trans_t = frames_into_trans / max(trans_frames - 1, 1)
530
+ t_next = 0.0
531
+ next_motion = get_motion_frame(next_base, next_cfg["motion"], t_next)
532
+ img = apply_transition(img, next_motion, trans_cfg["type"], trans_t)
533
+
534
+ # Write frame (cv2 expects BGR)
535
+ arr = np.array(img)
536
+ writer.write(cv2.cvtColor(arr, cv2.COLOR_RGB2BGR))
537
+ frames_written += 1
538
+
539
+ print(f" [OK] {total_frames} frames")
540
+
541
+ # Hard cut to black at end
542
+ print(f"\n Hard cut to black")
543
+ black_frame = np.zeros((h, w, 3), dtype=np.uint8)
544
+ for _ in range(2): # 2 frames of black
545
+ writer.write(black_frame)
546
+ frames_written += 1
547
+ print(f" [OK] 2 frames")
548
+
549
+ writer.release()
550
+ print(f"\n Total frames written: {frames_written} (~{frames_written/FPS:.1f}s)\n")
551
+
552
+ # Re-encode with ffmpeg
553
+ print("[3/3] Encoding H.264 MP4 via ffmpeg...")
554
+ cmd = (
555
+ f"ffmpeg -y -i {tmp_path} "
556
+ f"-vcodec libx264 -crf 20 -preset fast "
557
+ f"-pix_fmt yuv420p "
558
+ f"-movflags +faststart "
559
+ f"{OUTPUT_PATH} 2>&1"
560
+ )
561
+ ret = os.system(cmd)
562
+ if ret == 0:
563
+ os.remove(tmp_path)
564
+ size_mb = os.path.getsize(OUTPUT_PATH) / (1024 * 1024)
565
+ print(f"\n [OK] Output: {OUTPUT_PATH}")
566
+ print(f" [OK] Size : {size_mb:.1f} MB")
567
+ else:
568
+ print(f" [ERROR] ffmpeg failed (code {ret}). Raw file kept: {tmp_path}")
569
+
570
+ print(f"\n{'='*55}\n")
571
+
572
+
573
+ if __name__ == "__main__":
574
+ render()
requirements_server.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn==0.24.0
3
+ pydantic==2.5.0
4
+ requests==2.31.0
5
+ python-multipart==0.0.6
6
+ pillow==10.1.0
7
+ opencv-python==4.8.1.78
8
+ numpy==1.24.3
select_scenes.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Scene Image Selector - Randomly selects candidate images for each scene
3
+ Saves selections to a folder before composition
4
+ """
5
+ import os
6
+ import json
7
+ import random
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+
12
+ class SceneSelector:
13
+ def __init__(self, manifest_path, candidates_dir, output_dir="selected"):
14
+ """
15
+ Initialize the scene selector.
16
+
17
+ Args:
18
+ manifest_path: Path to manifest_response.json
19
+ candidates_dir: Path to candidates folder with scene_0-6 subfolders
20
+ output_dir: Where to save selected images
21
+ """
22
+ self.manifest_path = manifest_path
23
+ self.candidates_dir = candidates_dir
24
+ self.output_dir = output_dir
25
+ self.selections = {}
26
+
27
+ # Create output directory
28
+ Path(self.output_dir).mkdir(parents=True, exist_ok=True)
29
+
30
+ # Load manifest
31
+ with open(manifest_path, 'r') as f:
32
+ self.manifest = json.load(f)
33
+
34
+ print(f"[Selector] Initialized with {len(self.manifest.get('scenes', []))} scenes")
35
+ print(f"[Selector] Candidates dir: {candidates_dir}")
36
+ print(f"[Selector] Output dir: {output_dir}\n")
37
+
38
+ def select_all_scenes(self):
39
+ """Randomly select one image per scene."""
40
+ scenes = self.manifest.get("scenes", [])
41
+ print(f"[Selector] Selecting images for {len(scenes)} scenes...\n")
42
+
43
+ for scene_idx, scene in enumerate(scenes):
44
+ self._select_scene(scene_idx, scene)
45
+
46
+ # Save selections metadata
47
+ self._save_selections_metadata()
48
+
49
+ print(f"\n[Selector] ✓ Selected {len(scenes)} images!")
50
+ print(f"[Selector] Check {self.output_dir}/ for selected images")
51
+ print(f"[Selector] Check {self.output_dir}/selections.json for metadata")
52
+
53
+ def _select_scene(self, scene_idx, scene):
54
+ """Randomly select one image for a scene."""
55
+ scene_label = scene.get("label", f"Scene {scene_idx}")
56
+ candidates_folder = os.path.join(self.candidates_dir, f"scene_{scene_idx}")
57
+
58
+ # Get list of candidate images
59
+ if not os.path.exists(candidates_folder):
60
+ print(f"[Scene {scene_idx}] ✗ Folder not found: {candidates_folder}")
61
+ return
62
+
63
+ candidates = [f for f in os.listdir(candidates_folder) if f.endswith(('.jpg', '.png'))]
64
+ if not candidates:
65
+ print(f"[Scene {scene_idx}] ✗ No images found in {candidates_folder}")
66
+ return
67
+
68
+ # Randomly select one image
69
+ selected_image = random.choice(candidates)
70
+ source_path = os.path.join(candidates_folder, selected_image)
71
+
72
+ # Copy to output folder
73
+ output_filename = f"scene_{scene_idx:02d}.jpg"
74
+ dest_path = os.path.join(self.output_dir, output_filename)
75
+ shutil.copy2(source_path, dest_path)
76
+
77
+ # Store selection metadata
78
+ self.selections[scene_idx] = {
79
+ "scene_label": scene_label,
80
+ "original_file": selected_image,
81
+ "source_folder": candidates_folder,
82
+ "total_candidates": len(candidates),
83
+ "output_file": output_filename
84
+ }
85
+
86
+ print(f"[Scene {scene_idx}] {scene_label}")
87
+ print(f" Available: {len(candidates)} candidates")
88
+ print(f" Selected: {selected_image}")
89
+ print(f" Copied to: {output_filename}\n")
90
+
91
+ def _save_selections_metadata(self):
92
+ """Save metadata about which images were selected."""
93
+ metadata_path = os.path.join(self.output_dir, "selections.json")
94
+ with open(metadata_path, 'w') as f:
95
+ json.dump(self.selections, f, indent=2)
96
+ print(f"[Selector] Saved selections metadata to {metadata_path}")
97
+
98
+
99
+ if __name__ == "__main__":
100
+ # Load manifest from content_gen_server
101
+ manifest_file = "../content_gen_server/manifest_response.json"
102
+ candidates_dir = "../workspace/renders/candidates"
103
+ output_dir = "selected"
104
+
105
+ # Create and run selector
106
+ selector = SceneSelector(manifest_file, candidates_dir, output_dir)
107
+ selector.select_all_scenes()
server.py ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI Server for Scene Selection and Video Composition
3
+ Dynamically generates video from manifest without hardcoded labels
4
+ """
5
+
6
+ from fastapi import FastAPI, HTTPException, File, UploadFile, Form
7
+ from fastapi.responses import FileResponse
8
+ from pydantic import BaseModel
9
+ from typing import List, Dict, Any, Optional
10
+ import os
11
+ import json
12
+ import shutil
13
+ from pathlib import Path
14
+ import subprocess
15
+ import sys
16
+
17
+ # ─────────────────────────────────────────────────────────────────────────
18
+ # Pydantic Models
19
+ # ─────────────────────────────────────────────────────────────────────────
20
+
21
+ class SceneRequest(BaseModel):
22
+ """Scene metadata from manifest"""
23
+ label: str
24
+ image_query: str
25
+
26
+
27
+ class ManifestRequest(BaseModel):
28
+ """Manifest JSON format from client"""
29
+ title: str
30
+ scenes: List[SceneRequest]
31
+
32
+
33
+ class VideoResponse(BaseModel):
34
+ """Response after video generation"""
35
+ status: str
36
+ message: str
37
+ output_path: Optional[str] = None
38
+ size_mb: Optional[float] = None
39
+ duration_s: Optional[float] = None
40
+
41
+
42
+ # ─────────────────────────────────────────────────────────────────────────
43
+ # FastAPI App
44
+ # ─────────────────────────────────────────────────────────────────────────
45
+
46
+ app = FastAPI(
47
+ title="TrendClip Video Composer",
48
+ description="Dynamic video composition from manifest without hardcoded configs",
49
+ version="2.0"
50
+ )
51
+
52
+ BASE_DIR = Path(__file__).parent
53
+ CANDIDATES_DIR = BASE_DIR / "candidates"
54
+ SELECTED_DIR = BASE_DIR / "selected"
55
+ RENDERS_DIR = BASE_DIR / "renders"
56
+
57
+ # Ensure directories exist
58
+ CANDIDATES_DIR.mkdir(exist_ok=True)
59
+ SELECTED_DIR.mkdir(exist_ok=True)
60
+ RENDERS_DIR.mkdir(exist_ok=True)
61
+
62
+
63
+ # ─────────────────────────────────────────────────────────────────────────
64
+ # Scene Configuration Generation
65
+ # ─────────────────────────────────────────────────────────────────────────
66
+
67
+ # Intro scene config (4.7s, 95pt font)
68
+ INTRO_CONFIG = {
69
+ "duration_s": 4.7,
70
+ "motion": {"type": "slow_push_in", "scale_start": 1.0, "scale_end": 1.08},
71
+ "text": {"type": "center_stroke_pop", "entry_frame": 2, "hold_frames": 125, "font_size": 95, "align": "center"},
72
+ "grade": {"crush_blacks": 15, "contrast": 1.15},
73
+ "transition": {"type": "hard_cut", "frames": 1},
74
+ }
75
+
76
+ # Scene templates for subsequent scenes (2.3s, 110pt font)
77
+ SCENES_TEMPLATES = [
78
+ {
79
+ "duration_s": 2.3,
80
+ "motion": {"type": "snap_zoom", "scale_start": 1.0, "scale_end": 1.12},
81
+ "text": {"type": "center_pop", "entry_frame": 0, "hold_frames": 69, "font_size": 110, "align": "center"},
82
+ "grade": {"warm_tint": True, "lift_mids": 10},
83
+ "transition": {"type": "whip_pan_right", "frames": 4},
84
+ },
85
+ {
86
+ "duration_s": 2.3,
87
+ "motion": {"type": "static"},
88
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
89
+ "grade": {"desaturate": True, "lift_blacks": 5},
90
+ "transition": {"type": "whip_pan_right", "frames": 4},
91
+ },
92
+ {
93
+ "duration_s": 2.3,
94
+ "motion": {"type": "static"},
95
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
96
+ "grade": {"cool_tint": True, "highlights": -15},
97
+ "transition": {"type": "whip_pan_right", "frames": 4},
98
+ },
99
+ {
100
+ "duration_s": 2.3,
101
+ "motion": {"type": "static"},
102
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
103
+ "grade": {"soft_pink": True, "lift_mids": 15},
104
+ "transition": {"type": "whip_pan_right", "frames": 4},
105
+ },
106
+ {
107
+ "duration_s": 2.3,
108
+ "motion": {"type": "static"},
109
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
110
+ "grade": {"indoor_warm": True, "lift_shadows": 8},
111
+ "transition": {"type": "whip_pan_right", "frames": 4},
112
+ },
113
+ {
114
+ "duration_s": 2.3,
115
+ "motion": {"type": "static"},
116
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
117
+ "grade": {"teal_orange": True, "crush_blacks": 10},
118
+ "transition": {"type": "whip_pan_right", "frames": 4},
119
+ },
120
+ {
121
+ "duration_s": 2.3,
122
+ "motion": {"type": "static"},
123
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
124
+ "grade": {"dark_moody": True, "crush_blacks": 20, "desaturate": 15},
125
+ "transition": {"type": "whip_pan_right", "frames": 4},
126
+ },
127
+ {
128
+ "duration_s": 2.3,
129
+ "motion": {"type": "static"},
130
+ "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
131
+ "grade": {"warm_indoor": True, "soft_glow": True, "lift_mids": 12},
132
+ "transition": {"type": "end_fade_black", "frames": 30},
133
+ },
134
+ ]
135
+
136
+
137
+ def generate_scene_config(manifest: ManifestRequest) -> list:
138
+ """Generate SCENE_CONFIG from manifest, extracting and uppercasing labels."""
139
+ config = []
140
+
141
+ for idx, scene in enumerate(manifest.scenes):
142
+ # Extract label and convert to UPPERCASE for captions
143
+ label = scene.label.upper()
144
+
145
+ scene_cfg = {
146
+ "idx": idx,
147
+ "label": label,
148
+ }
149
+
150
+ if idx == 0:
151
+ # First scene is intro
152
+ scene_cfg.update(INTRO_CONFIG)
153
+ else:
154
+ # Use templated config for subsequent scenes
155
+ template_idx = min(idx - 1, len(SCENES_TEMPLATES) - 1)
156
+ scene_cfg.update(SCENES_TEMPLATES[template_idx])
157
+
158
+ config.append(scene_cfg)
159
+
160
+ return config
161
+ # ─────────────────────────────────────────────────────────────────────────
162
+ # Endpoints
163
+ # ─────────────────────────────────────────────────────────────────────────
164
+
165
+ # ─────────────────────────────────────────────────────────────────────────
166
+ # Endpoints
167
+ # ─────────────────────────────────────────────────────────────────────────
168
+
169
+ @app.post("/generate-video")
170
+ async def generate_video(
171
+ manifest: str = Form(...),
172
+ files: List[UploadFile] = File(...)
173
+ ):
174
+ """
175
+ Full pipeline: Upload candidates + manifest, get MP4 back.
176
+
177
+ Form parameters:
178
+ - manifest: JSON string with title and scenes
179
+ - files: Uploaded image files for each scene
180
+
181
+ Returns: MP4 video file
182
+ """
183
+ try:
184
+ # Parse manifest
185
+ manifest_data = json.loads(manifest)
186
+ manifest_req = ManifestRequest(**manifest_data)
187
+
188
+ # Step 1: Clean and prepare upload directory
189
+ upload_dir = BASE_DIR / "upload_candidates"
190
+ if upload_dir.exists():
191
+ shutil.rmtree(upload_dir)
192
+ upload_dir.mkdir(exist_ok=True)
193
+
194
+ # Step 2: Save uploaded files organized by scene index
195
+ uploaded_count = 0
196
+ for file in files:
197
+ if file.filename:
198
+ # Save to upload_candidates folder
199
+ file_path = upload_dir / file.filename
200
+ with open(file_path, "wb") as f:
201
+ f.write(await file.read())
202
+ uploaded_count += 1
203
+
204
+ if uploaded_count != len(manifest_req.scenes):
205
+ raise Exception(
206
+ f"Expected {len(manifest_req.scenes)} files, received {uploaded_count}"
207
+ )
208
+
209
+ # Step 3: Select scenes (evaluate and copy to selected/)
210
+ select_result = await _select_scenes(manifest_req, upload_dir)
211
+ if select_result["status"] != "success":
212
+ raise Exception(select_result.get("message", "Scene selection failed"))
213
+
214
+ # Step 4: Compose video with manifest data
215
+ compose_result = await _compose(manifest_req)
216
+ if compose_result["status"] != "success":
217
+ raise Exception(compose_result.get("message", "Composition failed"))
218
+
219
+ # Step 5: Return the MP4 file
220
+ output_file = Path(compose_result["output_path"])
221
+ if not output_file.exists():
222
+ raise Exception("Output file not found")
223
+
224
+ return FileResponse(
225
+ path=output_file,
226
+ media_type="video/mp4",
227
+ filename="video.mp4"
228
+ )
229
+
230
+ except json.JSONDecodeError as e:
231
+ raise HTTPException(status_code=400, detail=f"Invalid manifest JSON: {str(e)}")
232
+ except Exception as e:
233
+ raise HTTPException(status_code=500, detail=str(e))
234
+
235
+
236
+ @app.post("/select-scenes")
237
+ async def select_scenes(manifest: ManifestRequest):
238
+ """
239
+ Endpoint for just scene selection (without upload).
240
+ Assumes candidates are already in candidates/ folder.
241
+ """
242
+ try:
243
+ result = await _select_scenes(manifest, CANDIDATES_DIR)
244
+ return result
245
+ except Exception as e:
246
+ raise HTTPException(status_code=500, detail=str(e))
247
+
248
+
249
+ @app.post("/compose")
250
+ async def compose(manifest: ManifestRequest):
251
+ """
252
+ Endpoint for just composition.
253
+ Assumes selected images are already in selected/ folder.
254
+ """
255
+ try:
256
+ result = await _compose(manifest)
257
+ return result
258
+ except Exception as e:
259
+ raise HTTPException(status_code=500, detail=str(e))
260
+
261
+
262
+ async def _select_scenes(manifest: ManifestRequest, source_dir: Path):
263
+ """
264
+ Internal: Select scenes from source directory.
265
+ Copies best image from each scene folder to selected/ folder.
266
+ """
267
+ try:
268
+ # Clean and recreate selected directory
269
+ if SELECTED_DIR.exists():
270
+ shutil.rmtree(SELECTED_DIR)
271
+ SELECTED_DIR.mkdir(exist_ok=True)
272
+
273
+ selected_count = 0
274
+
275
+ # For each scene, find and copy its image
276
+ for i, scene in enumerate(manifest.scenes):
277
+ # Try multiple naming conventions
278
+ scene_folder = None
279
+ for pattern in [f"scene_{i}", f"scene_{i:02d}", f"{i}", f"scene{i}"]:
280
+ potential = source_dir / pattern
281
+ if potential.exists():
282
+ scene_folder = potential
283
+ break
284
+
285
+ # If no folder, look for files named with scene index
286
+ if scene_folder is None:
287
+ images = list(source_dir.glob(f"*scene*{i}*")) + list(
288
+ source_dir.glob(f"{i:02d}*")
289
+ )
290
+ if images:
291
+ dest = SELECTED_DIR / f"scene_{i:02d}.jpg"
292
+ shutil.copy2(images[0], dest)
293
+ selected_count += 1
294
+ continue
295
+
296
+ # If folder found, get best image
297
+ if scene_folder and scene_folder.is_dir():
298
+ images = sorted(
299
+ list(scene_folder.glob("*.jpg")) + list(scene_folder.glob("*.png")),
300
+ key=lambda p: p.stat().st_size,
301
+ reverse=True
302
+ )
303
+ if images:
304
+ dest = SELECTED_DIR / f"scene_{i:02d}.jpg"
305
+ shutil.copy2(images[0], dest)
306
+ selected_count += 1
307
+
308
+ if selected_count == 0:
309
+ raise Exception(
310
+ f"No images found in {source_dir}. "
311
+ "Expected scene_0/, scene_1/, etc. folders or numbered files."
312
+ )
313
+
314
+ return {
315
+ "status": "success",
316
+ "message": f"Selected {selected_count}/{len(manifest.scenes)} scenes",
317
+ "selected_count": selected_count,
318
+ "selected_dir": str(SELECTED_DIR),
319
+ }
320
+ except Exception as e:
321
+ return {
322
+ "status": "error",
323
+ "message": str(e),
324
+ }
325
+
326
+
327
+ async def _compose(manifest: ManifestRequest):
328
+ """
329
+ Internal: Compose video from manifest and selected images.
330
+ """
331
+ try:
332
+ # Verify selected directory has images
333
+ selected_images = sorted(SELECTED_DIR.glob("scene_*.jpg"))
334
+ if len(selected_images) != len(manifest.scenes):
335
+ raise Exception(
336
+ f"Expected {len(manifest.scenes)} selected images, found {len(selected_images)}"
337
+ )
338
+
339
+ # Generate dynamic SCENE_CONFIG from manifest
340
+ scene_config = generate_scene_config(manifest)
341
+
342
+ # Save config as JSON for composer to use
343
+ config_json = {
344
+ "title": manifest.title,
345
+ "scenes": scene_config,
346
+ }
347
+ config_path = BASE_DIR / "manifest_config.json"
348
+ with open(config_path, "w") as f:
349
+ json.dump(config_json, f, indent=2)
350
+
351
+ # Call composer_v2.py with the config
352
+ env = os.environ.copy()
353
+ env["COMPOSER_MANIFEST_CONFIG"] = str(config_path)
354
+ env["PYTHONIOENCODING"] = "utf-8"
355
+
356
+ result = subprocess.run(
357
+ [sys.executable, str(BASE_DIR / "composer_v2.py")],
358
+ cwd=str(BASE_DIR),
359
+ env=env,
360
+ capture_output=True,
361
+ text=True,
362
+ )
363
+
364
+ # Filter out numpy warnings from stderr
365
+ error_msg = result.stderr or result.stdout
366
+ if error_msg:
367
+ lines = error_msg.split("\n")
368
+ # Filter out numpy and importlib warnings
369
+ errors = [
370
+ line for line in lines
371
+ if line.strip() and not any(
372
+ skip in line for skip in [
373
+ "numpy",
374
+ "Numpy",
375
+ "MINGW-W64",
376
+ "CRASHES ARE TO BE EXPECTED",
377
+ "RuntimeWarning",
378
+ "importlib",
379
+ "getlimits",
380
+ "nextafter",
381
+ "ld(",
382
+ "exp2",
383
+ "log10",
384
+ ]
385
+ )
386
+ ]
387
+ if errors and result.returncode != 0:
388
+ error_summary = "\n".join(errors[-5:]) # Last 5 meaningful lines
389
+ raise Exception(f"Composer failed: {error_summary}")
390
+
391
+ # Verify output file exists
392
+ output_file = RENDERS_DIR / "sunset_reel.mp4"
393
+ if not output_file.exists():
394
+ raise Exception("Output video file not created")
395
+
396
+ size_mb = output_file.stat().st_size / (1024 * 1024)
397
+ duration_s = len(scene_config) * 2.3 + 2.4 # Approximate
398
+
399
+ return {
400
+ "status": "success",
401
+ "message": "Video composed successfully",
402
+ "output_path": str(output_file),
403
+ "size_mb": round(size_mb, 1),
404
+ "duration_s": round(duration_s, 1),
405
+ }
406
+ except Exception as e:
407
+ return {
408
+ "status": "error",
409
+ "message": str(e),
410
+ }
411
+
412
+
413
+ @app.get("/health")
414
+ async def health_check():
415
+ """Health check endpoint"""
416
+ return {
417
+ "status": "healthy",
418
+ "service": "TrendClip Video Composer",
419
+ "version": "2.0",
420
+ }
421
+
422
+
423
+ if __name__ == "__main__":
424
+ import uvicorn
425
+ uvicorn.run(app, host="0.0.0.0", port=7860)