factorstudios commited on
Commit
4e7bafb
Β·
verified Β·
1 Parent(s): 3b7c726

Upload 13 files

Browse files
Dockerfile CHANGED
@@ -1,7 +1,7 @@
1
  # TrendClip Video Composer Server
2
  # FastAPI server for scene selection and video composition
3
 
4
- FROM python:3.11-slim
5
 
6
  # Set working directory
7
  WORKDIR /app/composer
 
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
composer_v2.py CHANGED
@@ -303,6 +303,42 @@ def get_font(size: int) -> ImageFont.FreeTypeFont:
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
@@ -363,6 +399,9 @@ def render_text_frame(cfg: dict, frame: int, total_frames: int) -> Image.Image:
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"]
@@ -376,19 +415,19 @@ def render_text_frame(cfg: dict, frame: int, total_frames: int) -> Image.Image:
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"]
@@ -402,17 +441,17 @@ def render_text_frame(cfg: dict, frame: int, total_frames: int) -> Image.Image:
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
 
@@ -459,12 +498,22 @@ def render():
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)
@@ -478,7 +527,7 @@ def render():
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
 
@@ -486,9 +535,12 @@ def render():
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
 
303
  # TEXT DRAWING
304
  # ---------------------------------------------------------------------------
305
 
306
+ def wrap_text(text: str, font, max_width: int = 900) -> str:
307
+ """
308
+ Wrap text to fit within max_width pixels.
309
+ Breaks long lines into multiple lines.
310
+ """
311
+ from PIL import ImageDraw
312
+ temp_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
313
+
314
+ lines = text.split("\n")
315
+ wrapped_lines = []
316
+
317
+ for line in lines:
318
+ if not line.strip():
319
+ wrapped_lines.append("")
320
+ continue
321
+
322
+ words = line.split()
323
+ current_line = ""
324
+
325
+ for word in words:
326
+ test_line = current_line + (" " if current_line else "") + word
327
+ bbox = temp_draw.textbbox((0, 0), test_line, font=font)
328
+ width = bbox[2] - bbox[0]
329
+
330
+ if width <= max_width:
331
+ current_line = test_line
332
+ else:
333
+ if current_line:
334
+ wrapped_lines.append(current_line)
335
+ current_line = word
336
+
337
+ if current_line:
338
+ wrapped_lines.append(current_line)
339
+
340
+ return "\n".join(wrapped_lines)
341
+
342
  def draw_text_stroked(draw, text, pos, font, align="left", opacity=1.0):
343
  """White text with stroke, drop shadow, and opacity."""
344
  x, y = pos
 
399
  layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
400
  draw = ImageDraw.Draw(layer)
401
  font = get_font(tcfg["font_size"])
402
+
403
+ # Wrap text to fit screen width
404
+ wrapped_label = wrap_text(label, font, max_width=900)
405
 
406
  if ttype == "quick_center_pop" or ttype == "center_stroke_pop":
407
  entry_f = tcfg["entry_frame"]
 
415
  opacity = min(1.0, progress * 1.5)
416
  x = w // 2
417
  y = h // 2
418
+ draw_text_stroked(draw, wrapped_label, (x, y), font, align="center", opacity=opacity)
419
  elif frame < fade_start:
420
  # Hold full opacity
421
  x = w // 2
422
  y = h // 2
423
+ draw_text_stroked(draw, wrapped_label, (x, y), font, align="center", opacity=1.0)
424
  else:
425
  # Fade out quickly
426
  fade_progress = min(1.0, (frame - fade_start) / 4)
427
  opacity = 1.0 - fade_progress
428
  x = w // 2
429
  y = h // 2
430
+ draw_text_stroked(draw, wrapped_label, (x, y), font, align="center", opacity=opacity)
431
 
432
  elif ttype == "center_pop" or ttype == "center_fade_pop":
433
  entry_f = tcfg["entry_frame"]
 
441
  opacity = min(1.0, progress * 1.5)
442
  x = w // 2
443
  y = h // 2
444
+ draw_text_stroked(draw, wrapped_label, (x, y), font, align="center", opacity=opacity)
445
  elif frame < fade_start:
446
  x = w // 2
447
  y = h // 2
448
+ draw_text_stroked(draw, wrapped_label, (x, y), font, align="center", opacity=1.0)
449
  else:
450
  fade_progress = min(1.0, (frame - fade_start) / 4)
451
  opacity = 1.0 - fade_progress
452
  x = w // 2
453
  y = h // 2
454
+ draw_text_stroked(draw, wrapped_label, (x, y), font, align="center", opacity=opacity)
455
 
456
  return layer
457
 
 
498
 
499
  # Load SCENE_CONFIG from JSON if provided via environment variable
500
  config_path = os.environ.get("COMPOSER_MANIFEST_CONFIG")
501
+ if config_path:
502
+ # Try as absolute path first, then relative
503
+ if not os.path.isabs(config_path):
504
+ config_path = os.path.abspath(config_path)
505
+
506
+ if os.path.exists(config_path):
507
+ print(f"[INFO] Loading config from: {config_path}")
508
+ try:
509
+ with open(config_path, "r") as f:
510
+ manifest_data = json.load(f)
511
+ SCENE_CONFIG = manifest_data.get("scenes", SCENE_CONFIG)
512
+ print(f"[INFO] Loaded {len(SCENE_CONFIG)} scenes from manifest")
513
+ except Exception as e:
514
+ print(f"[WARN] Failed to load config: {e}")
515
+ else:
516
+ print(f"[WARN] Config file not found: {config_path}")
517
 
518
  w, h = RESOLUTION
519
  Path(os.path.dirname(OUTPUT_PATH)).mkdir(parents=True, exist_ok=True)
 
527
  )
528
 
529
  print(f"\n{'='*55}")
530
+ print(f" Dynamic Video Composer")
531
  print(f" {len(SCENE_CONFIG)} scenes | {FPS}fps | {w}x{h}")
532
  print(f"{'='*55}\n")
533
 
 
535
  print("[1/3] Loading + grading images...")
536
  base_images = []
537
  for cfg in SCENE_CONFIG:
538
+ print(f" Loading scene {cfg['idx']}...", end=" ", flush=True)
539
  raw = load_scene_image(cfg["idx"])
540
+ print(f"grading...", end=" ", flush=True)
541
  graded = grade_image(raw, cfg["grade"])
542
  base_images.append(graded)
543
+ print(f"ok", flush=True)
544
  print(" [OK] Done\n")
545
 
546
  # Render scenes
debug_output.txt ADDED
Binary file (3.71 kB). View file
 
example_manifest.json ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "scenes": [
3
+ {
4
+ "label": "which type of anger do you have?",
5
+ "score": 0.95,
6
+ "duration_s": 4.7
7
+ },
8
+ {
9
+ "label": "shouting",
10
+ "score": 0.92,
11
+ "duration_s": 2.3
12
+ },
13
+ {
14
+ "label": "revenge",
15
+ "score": 0.88,
16
+ "duration_s": 2.3
17
+ },
18
+ {
19
+ "label": "ignoring",
20
+ "score": 0.85,
21
+ "duration_s": 2.3
22
+ },
23
+ {
24
+ "label": "slamming",
25
+ "score": 0.90,
26
+ "duration_s": 2.3
27
+ },
28
+ {
29
+ "label": "cursing",
30
+ "score": 0.87,
31
+ "duration_s": 2.3
32
+ },
33
+ {
34
+ "label": "walking away",
35
+ "score": 0.89,
36
+ "duration_s": 2.3
37
+ },
38
+ {
39
+ "label": "fighting",
40
+ "score": 0.84,
41
+ "duration_s": 2.3
42
+ },
43
+ {
44
+ "label": "crying",
45
+ "score": 0.91,
46
+ "duration_s": 2.3
47
+ }
48
+ ],
49
+ "metadata": {
50
+ "topic": "anger psychology",
51
+ "version": "1.0",
52
+ "created_at": "2026-05-31",
53
+ "content_type": "educational"
54
+ },
55
+ "candidates_path": "workspace/renders/candidates"
56
+ }
full_pipeline.py ADDED
@@ -0,0 +1,545 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Full Pipeline: Prompt β†’ Manifest β†’ Images β†’ Selection β†’ Composition
3
+ Orchestrates the complete workflow from user prompt to final MP4 video
4
+ """
5
+
6
+ import requests
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from PIL import Image
11
+ from io import BytesIO
12
+ import asyncio
13
+ import subprocess
14
+ import sys
15
+ from datetime import datetime
16
+
17
+
18
+ # ─────────────────────────────────────────────────────────────────────────
19
+ # Configuration
20
+ # ─────────────────────────────────────────────────────────────────────────
21
+
22
+ MANIFEST_SERVER = "https://factorstudios-content-gen.hf.space"
23
+ IMAGE_SERVER = "https://factorstudios-pinteresting.hf.space"
24
+ PIPELINE_DIR = Path(__file__).parent
25
+ CANDIDATES_DIR = PIPELINE_DIR / "candidates"
26
+ SELECTED_DIR = PIPELINE_DIR / "selected"
27
+ RENDERS_DIR = PIPELINE_DIR / "renders"
28
+
29
+
30
+ # ─────────────────────────────────────────────────────────────────────────
31
+ # Step 1: Generate Manifest from Prompt
32
+ # ─────────────────────────────────────────────────────────────────────────
33
+
34
+ async def step_generate_manifest(prompt: str, output_dir: Path = PIPELINE_DIR) -> dict:
35
+ """
36
+ Call content-gen server to generate manifest from prompt.
37
+ Saves manifest to manifest_response.json
38
+
39
+ Args:
40
+ prompt (str): User prompt describing video content
41
+ output_dir (Path): Directory to save manifest
42
+
43
+ Returns:
44
+ dict: Manifest with title and scenes
45
+ """
46
+ print("\n" + "="*70)
47
+ print(f"[STEP 1] Generating Manifest from Prompt")
48
+ print("="*70)
49
+ print(f"Prompt: {prompt[:80]}...")
50
+
51
+ try:
52
+ # Call manifest generation server
53
+ payload = {"prompt": prompt}
54
+ print(f"Calling {MANIFEST_SERVER}/generate...")
55
+
56
+ response = requests.post(
57
+ f"{MANIFEST_SERVER}/generate",
58
+ json=payload,
59
+ timeout=60
60
+ )
61
+ response.raise_for_status()
62
+ manifest = response.json()
63
+
64
+ # Save manifest to file
65
+ manifest_path = output_dir / "manifest_response.json"
66
+ with open(manifest_path, "w") as f:
67
+ json.dump(manifest, f, indent=2)
68
+
69
+ scenes = manifest.get("scenes", [])
70
+ print(f"βœ“ Generated manifest with {len(scenes)} scenes")
71
+ print(f"βœ“ Saved to {manifest_path.name}")
72
+
73
+ # Print scene details
74
+ for idx, scene in enumerate(scenes):
75
+ label = scene.get("label", f"Scene {idx}")
76
+ query = scene.get("image_query", "")
77
+ print(f" Scene {idx}: {label} (query: '{query[:30]}...')")
78
+
79
+ return manifest
80
+
81
+ except Exception as e:
82
+ print(f"βœ— Failed to generate manifest: {e}")
83
+ raise
84
+
85
+
86
+ # ─────────────────────────────────────────────────────────────────────────
87
+ # Step 2: Download Images for Each Scene
88
+ # ─────────────────────────────────────────────────────────────────────────
89
+
90
+ async def step_download_images(
91
+ manifest: dict,
92
+ output_dir: Path = CANDIDATES_DIR,
93
+ images_per_scene: int = 5
94
+ ) -> int:
95
+ """
96
+ Download images from pinteresting server for each scene in manifest.
97
+ IMPORTANT: Downloads image for TITLE (scene 0) + all scenes in manifest.scenes
98
+ Follows the pattern from test_api.py
99
+
100
+ Args:
101
+ manifest (dict): Manifest with title and scenes
102
+ output_dir (Path): Base directory to organize images
103
+ images_per_scene (int): Number of images per scene
104
+
105
+ Returns:
106
+ int: Total number of images downloaded
107
+ """
108
+ print("\n" + "="*70)
109
+ print(f"[STEP 2] Downloading Images (Title + Scenes)")
110
+ print("="*70)
111
+
112
+ # Clear and recreate candidates directory
113
+ if output_dir.exists():
114
+ import shutil
115
+ shutil.rmtree(output_dir)
116
+ output_dir.mkdir(parents=True, exist_ok=True)
117
+
118
+ total_downloaded = 0
119
+
120
+ # STEP 2.0: Download image for TITLE (becomes scene_0)
121
+ title = manifest.get("title", "")
122
+ if title:
123
+ scene_dir = output_dir / "scene_0"
124
+ scene_dir.mkdir(parents=True, exist_ok=True)
125
+
126
+ print(f"\n[Scene 0] {title} (TITLE/INTRO)")
127
+ print(f" Query: {title}")
128
+
129
+ try:
130
+ payload = {
131
+ "keyword": title,
132
+ "count": images_per_scene
133
+ }
134
+
135
+ print(f" Calling {IMAGE_SERVER}/scrape...")
136
+ response = requests.post(
137
+ f"{IMAGE_SERVER}/scrape",
138
+ json=payload,
139
+ timeout=60
140
+ )
141
+ response.raise_for_status()
142
+ data = response.json()
143
+ images = data.get("images", [])
144
+
145
+ print(f" Downloaded {len(images)} images")
146
+
147
+ for img_idx, img_url in enumerate(images):
148
+ try:
149
+ img_response = requests.get(img_url, timeout=30)
150
+ if img_response.status_code == 200:
151
+ img_path = scene_dir / f"candidate_{img_idx:02d}.jpg"
152
+ with open(img_path, "wb") as f:
153
+ f.write(img_response.content)
154
+ total_downloaded += 1
155
+ except Exception as e:
156
+ print(f" ⚠ Failed to save image {img_idx}: {e}")
157
+
158
+ except Exception as e:
159
+ print(f" ⚠ Error downloading images for title: {e}")
160
+
161
+ # STEP 2.1: Download images for each content scene (becomes scene_1, scene_2, etc)
162
+ scenes = manifest.get("scenes", [])
163
+
164
+ for scene_idx, scene in enumerate(scenes):
165
+ actual_idx = scene_idx + 1 # scene_1, scene_2, etc (title is scene_0)
166
+ scene_label = scene.get("label", f"Scene {actual_idx}")
167
+ image_query = scene.get("image_query", "")
168
+
169
+ if not image_query:
170
+ print(f"\n[Scene {actual_idx}] ⚠ No image query found, skipping...")
171
+ continue
172
+
173
+ # Create scene-specific folder
174
+ scene_dir = output_dir / f"scene_{actual_idx}"
175
+ scene_dir.mkdir(parents=True, exist_ok=True)
176
+
177
+ print(f"\n[Scene {actual_idx}] {scene_label}")
178
+ print(f" Query: {image_query}")
179
+
180
+ # Fetch images from pinteresting API
181
+ try:
182
+ payload = {
183
+ "keyword": image_query,
184
+ "count": images_per_scene
185
+ }
186
+
187
+ print(f" Calling {IMAGE_SERVER}/scrape...")
188
+ response = requests.post(
189
+ f"{IMAGE_SERVER}/scrape",
190
+ json=payload,
191
+ timeout=60
192
+ )
193
+ response.raise_for_status()
194
+ data = response.json()
195
+
196
+ if data.get("success"):
197
+ images = data.get("images", [])
198
+ print(f" βœ“ Found {len(images)} images")
199
+
200
+ # Download each image
201
+ for img_idx, img_data in enumerate(images):
202
+ img_url = img_data.get("url")
203
+ if not img_url:
204
+ continue
205
+
206
+ try:
207
+ # Download image
208
+ img_response = requests.get(img_url, timeout=15)
209
+ img_response.raise_for_status()
210
+
211
+ # Verify it's a valid image
212
+ img = Image.open(BytesIO(img_response.content))
213
+
214
+ # Save image
215
+ file_name = f"candidate_{img_idx:02d}.jpg"
216
+ file_path = scene_dir / file_name
217
+
218
+ with open(file_path, "wb") as f:
219
+ f.write(img_response.content)
220
+
221
+ size_kb = len(img_response.content) / 1024
222
+ dims = f"{img_data.get('width', '?')}x{img_data.get('height', '?')}"
223
+ print(f" βœ“ {file_name} ({dims}, {size_kb:.0f}KB)")
224
+ total_downloaded += 1
225
+
226
+ except Exception as e:
227
+ print(f" βœ— Image {img_idx} failed: {e}")
228
+ else:
229
+ print(f" βœ— API Error: {data.get('message')}")
230
+
231
+ except Exception as e:
232
+ print(f" βœ— Request failed: {e}")
233
+
234
+ print(f"\nβœ“ Downloaded {total_downloaded} images total")
235
+ return total_downloaded
236
+
237
+
238
+ # ─────────────────────────────────────────────────────────────────────────
239
+ # Step 3: Select Best Image from Each Scene's Candidates
240
+ # ─────────────────────────────────────────────────────────────────��───────
241
+
242
+ async def step_select_scenes(manifest: dict, candidates_dir: Path = CANDIDATES_DIR) -> dict:
243
+ """
244
+ Select best image from each scene's candidate folder.
245
+ IMPORTANT: Selects from TITLE (scene_0) + all scenes in manifest.scenes
246
+ Evaluates by file size (largest = best quality).
247
+
248
+ Args:
249
+ manifest (dict): Manifest with scene count
250
+ candidates_dir (Path): Directory with candidate images
251
+
252
+ Returns:
253
+ dict: Selection results
254
+ """
255
+ print("\n" + "="*70)
256
+ print(f"[STEP 3] Selecting Best Images from Candidates")
257
+ print("="*70)
258
+
259
+ # Ensure selected directory exists
260
+ SELECTED_DIR.mkdir(parents=True, exist_ok=True)
261
+
262
+ scenes = manifest.get("scenes", [])
263
+ selected_count = 0
264
+
265
+ # Select from scene_0 (title) through scene_N (content scenes)
266
+ # Total scenes = len(scenes) + 1 (for title as scene_0)
267
+ total_scene_count = len(scenes) + 1
268
+
269
+ for scene_idx in range(total_scene_count):
270
+ scene_folder = candidates_dir / f"scene_{scene_idx}"
271
+
272
+ if not scene_folder.exists():
273
+ if scene_idx == 0:
274
+ print(f"[Scene {scene_idx}] βœ— No candidates found (TITLE)")
275
+ else:
276
+ print(f"[Scene {scene_idx}] βœ— No candidates found")
277
+ continue
278
+
279
+ # Find largest image (best quality)
280
+ images = list(scene_folder.glob("*.jpg"))
281
+ if not images:
282
+ if scene_idx == 0:
283
+ print(f"[Scene {scene_idx}] βœ— No JPEG images found (TITLE)")
284
+ else:
285
+ print(f"[Scene {scene_idx}] βœ— No JPEG images found")
286
+ continue
287
+
288
+ best_img = max(images, key=lambda p: p.stat().st_size)
289
+ size_kb = best_img.stat().st_size / 1024
290
+
291
+ # Copy to selected folder
292
+ selected_path = SELECTED_DIR / f"scene_{scene_idx:02d}.jpg"
293
+ import shutil
294
+ shutil.copy2(best_img, selected_path)
295
+
296
+ if scene_idx == 0:
297
+ print(f"[Scene {scene_idx}] βœ“ Selected {best_img.name} ({size_kb:.0f}KB) [TITLE]")
298
+ else:
299
+ print(f"[Scene {scene_idx}] βœ“ Selected {best_img.name} ({size_kb:.0f}KB)")
300
+ selected_count += 1
301
+
302
+ print(f"\nβœ“ Selected {selected_count} images ({total_scene_count} total: title + {len(scenes)} scenes)")
303
+
304
+ return {
305
+ "status": "success",
306
+ "selected": selected_count,
307
+ "total": total_scene_count
308
+ }
309
+
310
+
311
+ # ─────────────────────────────────────────────────────────────────────────
312
+ # Step 4: Compose Video with Selected Images and Manifest
313
+ # ─────────────────────────────────────────────────────────────────────────
314
+
315
+ async def step_compose_video(manifest: dict) -> dict:
316
+ """
317
+ Compose final video using selected images and manifest labels.
318
+ Calls the FastAPI /compose endpoint which handles scene config generation.
319
+
320
+ Args:
321
+ manifest (dict): Manifest with title and scenes
322
+
323
+ Returns:
324
+ dict: Composition results with video path and metadata
325
+ """
326
+ print("\n" + "="*70)
327
+ print(f"[STEP 4] Composing Video from Selected Images")
328
+ print("="*70)
329
+
330
+ scenes = manifest.get("scenes", [])
331
+ selected_images = sorted(SELECTED_DIR.glob("scene_*.jpg"))
332
+
333
+ print(f"Manifest title: {manifest.get('title', 'Untitled')}")
334
+ print(f"Selected images: {len(selected_images)}")
335
+ print(f"Required images: {len(scenes) + 1} (title + {len(scenes)} scenes)")
336
+
337
+ # Expected: title + all scenes
338
+ expected_images = len(scenes) + 1
339
+ if len(selected_images) != expected_images:
340
+ raise Exception(
341
+ f"Image count mismatch: expected {expected_images}, "
342
+ f"found {len(selected_images)}"
343
+ )
344
+
345
+ # Call the FastAPI /compose endpoint
346
+ print(f"\nCalling /compose endpoint...")
347
+
348
+ try:
349
+ payload = {
350
+ "title": manifest.get("title", "Untitled"),
351
+ "scenes": [
352
+ {
353
+ "label": s.get("label", f"Scene {idx}"),
354
+ "image_query": s.get("image_query", "")
355
+ }
356
+ for idx, s in enumerate(scenes)
357
+ ]
358
+ }
359
+
360
+ response = requests.post(
361
+ f"http://localhost:7860/compose",
362
+ json=payload,
363
+ timeout=300
364
+ )
365
+
366
+ if response.status_code != 200:
367
+ error_data = response.json() if response.headers.get("content-type") == "application/json" else response.text
368
+ print(f"βœ— Server returned {response.status_code}: {error_data}")
369
+ raise Exception(f"Compose endpoint failed: {error_data}")
370
+
371
+ # Check if response is binary (video file) or JSON
372
+ if response.headers.get("content-type", "").startswith("video"):
373
+ # Save video file
374
+ output_path = PIPELINE_DIR / "output_video.mp4"
375
+ with open(output_path, "wb") as f:
376
+ f.write(response.content)
377
+
378
+ size_mb = output_path.stat().st_size / (1024 * 1024)
379
+ print(f"βœ“ Video saved: {output_path.name} ({size_mb:.2f}MB)")
380
+
381
+ return {
382
+ "status": "success",
383
+ "video_path": str(output_path),
384
+ "size_mb": size_mb,
385
+ "scenes": len(scenes) + 1
386
+ }
387
+ else:
388
+ # Response is JSON (might be error or status)
389
+ data = response.json()
390
+ if data.get("status") == "success":
391
+ print(f"βœ“ Compose completed successfully")
392
+ return data
393
+ else:
394
+ raise Exception(f"Compose failed: {data.get('message', 'Unknown error')}")
395
+
396
+ except Exception as e:
397
+ print(f"βœ— Composition failed: {e}")
398
+ raise
399
+
400
+ # Generate dynamic SCENE_CONFIG from manifest
401
+ print(f"\nGenerating scene configuration...")
402
+
403
+ try:
404
+ payload = {
405
+ "title": manifest.get("title", "Untitled"),
406
+ "scenes": [
407
+ {
408
+ "label": s.get("label", f"Scene {idx}"),
409
+ "image_query": s.get("image_query", "")
410
+ }
411
+ for idx, s in enumerate(scenes)
412
+ ]
413
+ }
414
+
415
+ response = requests.post(
416
+ f"http://localhost:7860/compose",
417
+ json=payload,
418
+ timeout=300
419
+ )
420
+
421
+ if response.status_code != 200:
422
+ error_data = response.json() if response.headers.get("content-type") == "application/json" else response.text
423
+ print(f"βœ— Server returned {response.status_code}: {error_data}")
424
+ raise Exception(f"Compose endpoint failed: {error_data}")
425
+
426
+ # Check if response is binary (video file) or JSON
427
+ if response.headers.get("content-type", "").startswith("video"):
428
+ # Save video file
429
+ output_path = PIPELINE_DIR / "output_video.mp4"
430
+ with open(output_path, "wb") as f:
431
+ f.write(response.content)
432
+
433
+ size_mb = output_path.stat().st_size / (1024 * 1024)
434
+ print(f"βœ“ Video saved: {output_path.name} ({size_mb:.2f}MB)")
435
+
436
+ return {
437
+ "status": "success",
438
+ "video_path": str(output_path),
439
+ "size_mb": size_mb,
440
+ "scenes": len(scenes) + 1
441
+ }
442
+ else:
443
+ # Response is JSON (might be error or status)
444
+ data = response.json()
445
+ if data.get("status") == "success":
446
+ print(f"βœ“ Compose completed successfully")
447
+ return data
448
+ else:
449
+ raise Exception(f"Compose failed: {data.get('message', 'Unknown error')}")
450
+
451
+ except Exception as e:
452
+ print(f"βœ— Composition failed: {e}")
453
+ raise
454
+
455
+
456
+ # ─────────────────────────────────────────────────────────────────────────
457
+ # Main Pipeline Orchestrator
458
+ # ─────────────────────────────────────────────────────────────────────────
459
+
460
+ async def generate_video_from_prompt(prompt: str) -> dict:
461
+ """
462
+ Complete pipeline: Prompt β†’ Manifest β†’ Images β†’ Selection β†’ Video
463
+
464
+ Args:
465
+ prompt (str): User prompt describing video content
466
+
467
+ Returns:
468
+ dict: Final result with video path or error
469
+ """
470
+ try:
471
+ # Step 1: Generate manifest from prompt
472
+ manifest = await step_generate_manifest(prompt)
473
+
474
+ # Step 2: Download images for each scene
475
+ downloaded = await step_download_images(manifest)
476
+ if downloaded == 0:
477
+ raise Exception("No images were downloaded")
478
+
479
+ # Step 3: Select best images from candidates
480
+ selection = await step_select_scenes(manifest)
481
+ if selection["selected"] != selection["total"]:
482
+ raise Exception(
483
+ f"Selection incomplete: {selection['selected']}/{selection['total']}"
484
+ )
485
+
486
+ # Step 4: Compose final video
487
+ composition = await step_compose_video(manifest)
488
+
489
+ # Success!
490
+ print("\n" + "="*70)
491
+ print("[SUCCESS] Pipeline Complete!")
492
+ print("="*70)
493
+ print(f"Title: {manifest.get('title', 'Untitled')}")
494
+ print(f"Scenes: {len(manifest.get('scenes', []))}")
495
+ print(f"Video: {composition['output_path']}")
496
+ print(f"Size: {composition['size_mb']:.1f}MB")
497
+ print("="*70)
498
+
499
+ return {
500
+ "status": "success",
501
+ "message": "Video generated successfully",
502
+ "title": manifest.get("title"),
503
+ "scenes": len(manifest.get("scenes", [])),
504
+ "output_path": composition["output_path"],
505
+ "size_mb": composition["size_mb"],
506
+ }
507
+
508
+ except Exception as e:
509
+ print("\n" + "="*70)
510
+ print(f"[ERROR] Pipeline Failed: {e}")
511
+ print("="*70)
512
+
513
+ return {
514
+ "status": "error",
515
+ "message": str(e),
516
+ "output_path": None,
517
+ }
518
+
519
+
520
+ # ─────────────────────────────────────────────────────────────────────────
521
+ # Local Testing
522
+ # ─────────────────────────────────────────────────────────────────────────
523
+
524
+ if __name__ == "__main__":
525
+ import sys
526
+
527
+ if len(sys.argv) > 1:
528
+ prompt = " ".join(sys.argv[1:])
529
+ else:
530
+ prompt = "A motivational video about personal growth and success"
531
+
532
+ # Ensure directories exist
533
+ PIPELINE_DIR.mkdir(exist_ok=True)
534
+ RENDERS_DIR.mkdir(exist_ok=True)
535
+
536
+ # Run pipeline
537
+ result = asyncio.run(generate_video_from_prompt(prompt))
538
+
539
+ # Print final status
540
+ if result["status"] == "success":
541
+ print(f"\nβœ“ Video saved to: {result['output_path']}")
542
+ sys.exit(0)
543
+ else:
544
+ print(f"\nβœ— Error: {result['message']}")
545
+ sys.exit(1)
manifest_config.json ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "title": "The 6AM Glow Up",
3
+ "scenes": [
4
+ {
5
+ "idx": 0,
6
+ "label": "THE 6AM GLOW UP",
7
+ "duration_s": 4.7,
8
+ "motion": {
9
+ "type": "slow_push_in",
10
+ "scale_start": 1.0,
11
+ "scale_end": 1.08
12
+ },
13
+ "text": {
14
+ "type": "center_stroke_pop",
15
+ "entry_frame": 2,
16
+ "hold_frames": 125,
17
+ "font_size": 95,
18
+ "align": "center"
19
+ },
20
+ "grade": {
21
+ "crush_blacks": 15,
22
+ "contrast": 1.15
23
+ },
24
+ "transition": {
25
+ "type": "hard_cut",
26
+ "frames": 1
27
+ }
28
+ },
29
+ {
30
+ "idx": 1,
31
+ "label": "~SILENT WAKE CALL",
32
+ "duration_s": 2.3,
33
+ "motion": {
34
+ "type": "snap_zoom",
35
+ "scale_start": 1.0,
36
+ "scale_end": 1.12
37
+ },
38
+ "text": {
39
+ "type": "center_pop",
40
+ "entry_frame": 0,
41
+ "hold_frames": 69,
42
+ "font_size": 110,
43
+ "align": "center"
44
+ },
45
+ "grade": {
46
+ "warm_tint": true,
47
+ "lift_mids": 10
48
+ },
49
+ "transition": {
50
+ "type": "whip_pan_right",
51
+ "frames": 4
52
+ }
53
+ },
54
+ {
55
+ "idx": 2,
56
+ "label": "~FIRST SIP CLARITY",
57
+ "duration_s": 2.3,
58
+ "motion": {
59
+ "type": "static"
60
+ },
61
+ "text": {
62
+ "type": "center_fade_pop",
63
+ "entry_frame": 2,
64
+ "hold_frames": 66,
65
+ "font_size": 110,
66
+ "align": "center"
67
+ },
68
+ "grade": {
69
+ "desaturate": true,
70
+ "lift_blacks": 5
71
+ },
72
+ "transition": {
73
+ "type": "whip_pan_right",
74
+ "frames": 4
75
+ }
76
+ },
77
+ {
78
+ "idx": 3,
79
+ "label": "~GENTLE BODY FLOW",
80
+ "duration_s": 2.3,
81
+ "motion": {
82
+ "type": "static"
83
+ },
84
+ "text": {
85
+ "type": "center_fade_pop",
86
+ "entry_frame": 2,
87
+ "hold_frames": 66,
88
+ "font_size": 110,
89
+ "align": "center"
90
+ },
91
+ "grade": {
92
+ "cool_tint": true,
93
+ "highlights": -15
94
+ },
95
+ "transition": {
96
+ "type": "whip_pan_right",
97
+ "frames": 4
98
+ }
99
+ },
100
+ {
101
+ "idx": 4,
102
+ "label": "~PAGES OF PURPOSE",
103
+ "duration_s": 2.3,
104
+ "motion": {
105
+ "type": "static"
106
+ },
107
+ "text": {
108
+ "type": "center_fade_pop",
109
+ "entry_frame": 2,
110
+ "hold_frames": 66,
111
+ "font_size": 110,
112
+ "align": "center"
113
+ },
114
+ "grade": {
115
+ "soft_pink": true,
116
+ "lift_mids": 15
117
+ },
118
+ "transition": {
119
+ "type": "whip_pan_right",
120
+ "frames": 4
121
+ }
122
+ },
123
+ {
124
+ "idx": 5,
125
+ "label": "~SKIN DEEP RITUAL",
126
+ "duration_s": 2.3,
127
+ "motion": {
128
+ "type": "static"
129
+ },
130
+ "text": {
131
+ "type": "center_fade_pop",
132
+ "entry_frame": 2,
133
+ "hold_frames": 66,
134
+ "font_size": 110,
135
+ "align": "center"
136
+ },
137
+ "grade": {
138
+ "indoor_warm": true,
139
+ "lift_shadows": 8
140
+ },
141
+ "transition": {
142
+ "type": "whip_pan_right",
143
+ "frames": 4
144
+ }
145
+ },
146
+ {
147
+ "idx": 6,
148
+ "label": "~INTENTIONAL DAY BLUEPRINT",
149
+ "duration_s": 2.3,
150
+ "motion": {
151
+ "type": "static"
152
+ },
153
+ "text": {
154
+ "type": "center_fade_pop",
155
+ "entry_frame": 2,
156
+ "hold_frames": 66,
157
+ "font_size": 110,
158
+ "align": "center"
159
+ },
160
+ "grade": {
161
+ "teal_orange": true,
162
+ "crush_blacks": 10
163
+ },
164
+ "transition": {
165
+ "type": "whip_pan_right",
166
+ "frames": 4
167
+ }
168
+ },
169
+ {
170
+ "idx": 7,
171
+ "label": "~READY TO CONQUER",
172
+ "duration_s": 2.3,
173
+ "motion": {
174
+ "type": "static"
175
+ },
176
+ "text": {
177
+ "type": "center_fade_pop",
178
+ "entry_frame": 2,
179
+ "hold_frames": 66,
180
+ "font_size": 110,
181
+ "align": "center"
182
+ },
183
+ "grade": {
184
+ "dark_moody": true,
185
+ "crush_blacks": 20,
186
+ "desaturate": 15
187
+ },
188
+ "transition": {
189
+ "type": "whip_pan_right",
190
+ "frames": 4
191
+ }
192
+ }
193
+ ]
194
+ }
requirements_server.txt CHANGED
@@ -1,8 +1,8 @@
1
  fastapi==0.104.1
2
  uvicorn==0.24.0
3
- pydantic==2.10.6
4
  requests==2.31.0
5
  python-multipart==0.0.6
6
  pillow==11.0.0
7
  opencv-python==4.8.1.78
8
- numpy==1.26.4
 
1
  fastapi==0.104.1
2
  uvicorn==0.24.0
3
+ pydantic==2.6.3
4
  requests==2.31.0
5
  python-multipart==0.0.6
6
  pillow==11.0.0
7
  opencv-python==4.8.1.78
8
+ numpy>=2.0.0
server.py CHANGED
@@ -13,6 +13,9 @@ import shutil
13
  from pathlib import Path
14
  import subprocess
15
  import sys
 
 
 
16
 
17
  # ─────────────────────────────────────────────────────────────────────────
18
  # Pydantic Models
@@ -39,6 +42,12 @@ class VideoResponse(BaseModel):
39
  duration_s: Optional[float] = None
40
 
41
 
 
 
 
 
 
 
42
  # ─────────────────────────────────────────────────────────────────────────
43
  # FastAPI App
44
  # ─────────────────────────────────────────────────────────────────────────
@@ -135,10 +144,19 @@ SCENES_TEMPLATES = [
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
 
@@ -147,13 +165,9 @@ def generate_scene_config(manifest: ManifestRequest) -> list:
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
 
@@ -287,6 +301,7 @@ async def _select_scenes(manifest: ManifestRequest, source_dir: Path):
287
  """
288
  Internal: Select scenes from source directory.
289
  Copies best image from each scene folder to selected/ folder.
 
290
  """
291
  try:
292
  # Clean and recreate selected directory
@@ -296,11 +311,37 @@ async def _select_scenes(manifest: ManifestRequest, source_dir: Path):
296
 
297
  selected_count = 0
298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  # For each scene, find and copy its image
300
  for i, scene in enumerate(manifest.scenes):
 
 
301
  # Try multiple naming conventions
302
  scene_folder = None
303
- for pattern in [f"scene_{i}", f"scene_{i:02d}", f"{i}", f"scene{i}"]:
304
  potential = source_dir / pattern
305
  if potential.exists():
306
  scene_folder = potential
@@ -308,13 +349,14 @@ async def _select_scenes(manifest: ManifestRequest, source_dir: Path):
308
 
309
  # If no folder, look for files named with scene index
310
  if scene_folder is None:
311
- images = list(source_dir.glob(f"*scene*{i}*")) + list(
312
- source_dir.glob(f"{i:02d}*")
313
  )
314
  if images:
315
- dest = SELECTED_DIR / f"scene_{i:02d}.jpg"
316
  shutil.copy2(images[0], dest)
317
  selected_count += 1
 
318
  continue
319
 
320
  # If folder found, get best image
@@ -325,9 +367,12 @@ async def _select_scenes(manifest: ManifestRequest, source_dir: Path):
325
  reverse=True
326
  )
327
  if images:
328
- dest = SELECTED_DIR / f"scene_{i:02d}.jpg"
329
  shutil.copy2(images[0], dest)
330
  selected_count += 1
 
 
 
331
 
332
  if selected_count == 0:
333
  raise Exception(
@@ -335,9 +380,14 @@ async def _select_scenes(manifest: ManifestRequest, source_dir: Path):
335
  "Expected scene_0/, scene_1/, etc. folders or numbered files."
336
  )
337
 
 
 
 
 
 
338
  return {
339
  "status": "success",
340
- "message": f"Selected {selected_count}/{len(manifest.scenes)} scenes",
341
  "selected_count": selected_count,
342
  "selected_dir": str(SELECTED_DIR),
343
  }
@@ -351,17 +401,22 @@ async def _select_scenes(manifest: ManifestRequest, source_dir: Path):
351
  async def _compose(manifest: ManifestRequest):
352
  """
353
  Internal: Compose video from manifest and selected images.
 
 
354
  """
355
  try:
356
  # Verify selected directory has images
357
  selected_images = sorted(SELECTED_DIR.glob("scene_*.jpg"))
358
- if len(selected_images) != len(manifest.scenes):
359
- raise Exception(
360
- f"Expected {len(manifest.scenes)} selected images, found {len(selected_images)}"
361
- )
362
 
363
- # Generate dynamic SCENE_CONFIG from manifest
364
  scene_config = generate_scene_config(manifest)
 
 
 
 
 
 
 
365
 
366
  # Save config as JSON for composer to use
367
  config_json = {
@@ -417,6 +472,227 @@ async def _compose(manifest: ManifestRequest):
417
  }
418
 
419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  @app.get("/health")
421
  async def health_check():
422
  """Health check endpoint"""
 
13
  from pathlib import Path
14
  import subprocess
15
  import sys
16
+ import requests
17
+ from PIL import Image
18
+ from io import BytesIO
19
 
20
  # ─────────────────────────────────────────────────────────────────────────
21
  # Pydantic Models
 
42
  duration_s: Optional[float] = None
43
 
44
 
45
+ class PromptRequest(BaseModel):
46
+ """Request with user prompt to generate video from scratch"""
47
+ prompt: str
48
+ title: Optional[str] = None
49
+
50
+
51
  # ─────────────────────────────────────────────────────────────────────────
52
  # FastAPI App
53
  # ─────────────────────────────────────────────────────────────────────────
 
144
 
145
 
146
  def generate_scene_config(manifest: ManifestRequest) -> list:
147
+ """Generate SCENE_CONFIG from manifest with title as intro slide."""
148
  config = []
149
 
150
+ # Scene 0: Title as intro (4.7s, 95pt)
151
+ title_cfg = {
152
+ "idx": 0,
153
+ "label": manifest.title.upper(),
154
+ }
155
+ title_cfg.update(INTRO_CONFIG)
156
+ config.append(title_cfg)
157
+
158
+ # Scenes 1+: Manifest scenes with templates
159
+ for idx, scene in enumerate(manifest.scenes, start=1):
160
  # Extract label and convert to UPPERCASE for captions
161
  label = scene.label.upper()
162
 
 
165
  "label": label,
166
  }
167
 
168
+ # Use templated config for subsequent scenes (cycle through templates)
169
+ template_idx = min(idx - 1, len(SCENES_TEMPLATES) - 1)
170
+ scene_cfg.update(SCENES_TEMPLATES[template_idx])
 
 
 
 
171
 
172
  config.append(scene_cfg)
173
 
 
301
  """
302
  Internal: Select scenes from source directory.
303
  Copies best image from each scene folder to selected/ folder.
304
+ Includes title image from scene_0 + content scenes from scene_1, scene_2, etc.
305
  """
306
  try:
307
  # Clean and recreate selected directory
 
311
 
312
  selected_count = 0
313
 
314
+ print(f" [DEBUG] SELECTED_DIR = {SELECTED_DIR}")
315
+ print(f" [DEBUG] source_dir = {source_dir}")
316
+
317
+ # ─────────────────────────────────────────────────────────────────
318
+ # SELECT FROM TITLE (scene_0)
319
+ # ─────────────────────────────────────────────────────────────────
320
+ title_folder = source_dir / "scene_0"
321
+ if title_folder.exists() and title_folder.is_dir():
322
+ images = sorted(
323
+ list(title_folder.glob("*.jpg")) + list(title_folder.glob("*.png")),
324
+ key=lambda p: p.stat().st_size,
325
+ reverse=True
326
+ )
327
+ if images:
328
+ dest = SELECTED_DIR / "scene_00.jpg" # Use 00 for scene_0
329
+ shutil.copy2(images[0], dest)
330
+ selected_count += 1
331
+ print(f" [TITLE] Selected: {images[0].name} β†’ {dest}")
332
+ if not dest.exists():
333
+ print(f" [ERROR] File was not saved! {dest}")
334
+
335
+ # ─────────────────────────────────────────────────────────────────
336
+ # SELECT FROM CONTENT SCENES (scene_1, scene_2, etc)
337
+ # ─────────────────────────────────────────────────────────────────
338
  # For each scene, find and copy its image
339
  for i, scene in enumerate(manifest.scenes):
340
+ actual_i = i + 1 # scene_1, scene_2, etc
341
+
342
  # Try multiple naming conventions
343
  scene_folder = None
344
+ for pattern in [f"scene_{actual_i}", f"scene_{actual_i:02d}", f"{actual_i}", f"scene{actual_i}"]:
345
  potential = source_dir / pattern
346
  if potential.exists():
347
  scene_folder = potential
 
349
 
350
  # If no folder, look for files named with scene index
351
  if scene_folder is None:
352
+ images = list(source_dir.glob(f"*scene*{actual_i}*")) + list(
353
+ source_dir.glob(f"{actual_i:02d}*")
354
  )
355
  if images:
356
+ dest = SELECTED_DIR / f"scene_{actual_i:02d}.jpg"
357
  shutil.copy2(images[0], dest)
358
  selected_count += 1
359
+ print(f" [Scene {actual_i}] Selected: {images[0].name} β†’ {dest}")
360
  continue
361
 
362
  # If folder found, get best image
 
367
  reverse=True
368
  )
369
  if images:
370
+ dest = SELECTED_DIR / f"scene_{actual_i:02d}.jpg"
371
  shutil.copy2(images[0], dest)
372
  selected_count += 1
373
+ print(f" [Scene {actual_i}] Selected: {images[0].name} β†’ {dest}")
374
+
375
+ total_expected = len(manifest.scenes) + 1 # title + content scenes
376
 
377
  if selected_count == 0:
378
  raise Exception(
 
380
  "Expected scene_0/, scene_1/, etc. folders or numbered files."
381
  )
382
 
383
+ if selected_count != total_expected:
384
+ raise Exception(
385
+ f"Expected {total_expected} selected images (title + {len(manifest.scenes)} scenes), found {selected_count}"
386
+ )
387
+
388
  return {
389
  "status": "success",
390
+ "message": f"Selected {selected_count}/{total_expected} scenes",
391
  "selected_count": selected_count,
392
  "selected_dir": str(SELECTED_DIR),
393
  }
 
401
  async def _compose(manifest: ManifestRequest):
402
  """
403
  Internal: Compose video from manifest and selected images.
404
+ Note: generate_scene_config adds title as scene 0, so we expect:
405
+ selected_images_count = manifest_scenes + 1
406
  """
407
  try:
408
  # Verify selected directory has images
409
  selected_images = sorted(SELECTED_DIR.glob("scene_*.jpg"))
 
 
 
 
410
 
411
+ # Generate dynamic SCENE_CONFIG from manifest (adds title as scene 0)
412
  scene_config = generate_scene_config(manifest)
413
+ expected_images = len(scene_config) # includes title as scene 0
414
+
415
+ if len(selected_images) != expected_images:
416
+ raise Exception(
417
+ f"Expected {expected_images} selected images (title + {len(manifest.scenes)} scenes), "
418
+ f"found {len(selected_images)}"
419
+ )
420
 
421
  # Save config as JSON for composer to use
422
  config_json = {
 
472
  }
473
 
474
 
475
+ @app.post("/generate-from-prompt")
476
+ async def generate_from_prompt(request: PromptRequest):
477
+ """
478
+ Full End-to-End Pipeline: Prompt β†’ Manifest β†’ Images β†’ Selection β†’ Video
479
+
480
+ Workflow:
481
+ 1. Call content-gen server to generate manifest from prompt
482
+ 2. Call pinteresting server to download images for each scene
483
+ 3. Select best images from candidates
484
+ 4. Compose video with manifest labels
485
+ 5. Return MP4 file
486
+
487
+ Args:
488
+ request.prompt: User description (e.g., "A motivational video about success")
489
+ request.title: Optional override for video title
490
+
491
+ Returns: MP4 video file (video/mp4)
492
+ """
493
+ try:
494
+ print(f"\n[PROMPT] {request.prompt[:80]}...")
495
+
496
+ # ─────────────────────────────────────────────────────────────────
497
+ # Step 1: Generate Manifest from Prompt
498
+ # ─────────────────────────────────────────────────────────────────
499
+ print("[STEP 1] Generating manifest from prompt...")
500
+
501
+ manifest_server = "https://factorstudios-content-gen.hf.space"
502
+ manifest_payload = {"topic": request.prompt}
503
+
504
+ manifest_response = requests.post(
505
+ f"{manifest_server}/generate",
506
+ json=manifest_payload,
507
+ timeout=120
508
+ )
509
+ manifest_response.raise_for_status()
510
+ manifest_data = manifest_response.json()
511
+
512
+ # Override title if provided
513
+ if request.title:
514
+ manifest_data["title"] = request.title
515
+
516
+ # Save manifest
517
+ manifest_path = BASE_DIR / "manifest_from_prompt.json"
518
+ with open(manifest_path, "w") as f:
519
+ json.dump(manifest_data, f, indent=2)
520
+
521
+ scenes = manifest_data.get("scenes", [])
522
+ print(f"[OK] Generated manifest with {len(scenes)} scenes")
523
+
524
+ # ─────────────────────────────────────────────────────────────────
525
+ # Step 2: Download Images from Pinteresting Server
526
+ # ─────────────────────────────────────────────────────────────────
527
+ print("[STEP 2] Downloading images for each scene...")
528
+
529
+ # Clear candidates directory
530
+ if CANDIDATES_DIR.exists():
531
+ shutil.rmtree(CANDIDATES_DIR)
532
+ CANDIDATES_DIR.mkdir(parents=True, exist_ok=True)
533
+
534
+ image_server = "https://factorstudios-pinteresting.hf.space"
535
+ total_downloaded = 0
536
+ images_per_scene = 5
537
+
538
+ # ─────────────────────────────────────────────────────────────────
539
+ # STEP 2.0: Download images for TITLE (as scene_0)
540
+ # ─────────────────────────────────────────────────────────────────
541
+ title = manifest_data.get("title", "")
542
+ if title:
543
+ scene_dir = CANDIDATES_DIR / "scene_0"
544
+ scene_dir.mkdir(parents=True, exist_ok=True)
545
+
546
+ try:
547
+ payload = {"keyword": title, "count": images_per_scene}
548
+
549
+ img_response = requests.post(
550
+ f"{image_server}/scrape",
551
+ json=payload,
552
+ timeout=120
553
+ )
554
+ img_response.raise_for_status()
555
+ img_data = img_response.json()
556
+
557
+ if img_data.get("success"):
558
+ images = img_data.get("images", [])
559
+
560
+ for img_idx, img_info in enumerate(images):
561
+ img_url = img_info.get("url")
562
+ if not img_url:
563
+ continue
564
+
565
+ try:
566
+ dl_response = requests.get(img_url, timeout=15)
567
+ dl_response.raise_for_status()
568
+ Image.open(BytesIO(dl_response.content))
569
+
570
+ file_path = scene_dir / f"candidate_{img_idx:02d}.jpg"
571
+ with open(file_path, "wb") as f:
572
+ f.write(dl_response.content)
573
+
574
+ total_downloaded += 1
575
+ except Exception as e:
576
+ print(f" [TITLE] Image {img_idx} failed: {e}")
577
+
578
+ print(f" [TITLE] Downloaded {len(images)} images")
579
+ else:
580
+ print(f" [TITLE] API error: {img_data.get('message')}")
581
+ except Exception as e:
582
+ print(f" [TITLE] Request failed: {e}")
583
+
584
+ # ─────────────────────────────────────────────────────────────────
585
+ # STEP 2.1: Download images for each CONTENT SCENE (as scene_1+)
586
+ # ─────────────────────────────────────────────────────────────────
587
+ for scene_idx, scene in enumerate(scenes):
588
+ actual_scene_idx = scene_idx + 1 # scene_1, scene_2, etc
589
+ scene_label = scene.get("label", f"Scene {actual_scene_idx}")
590
+ image_query = scene.get("image_query", "")
591
+
592
+ if not image_query:
593
+ print(f" [Scene {actual_scene_idx}] No query found")
594
+ continue
595
+
596
+ # Create scene folder
597
+ scene_dir = CANDIDATES_DIR / f"scene_{actual_scene_idx}"
598
+ scene_dir.mkdir(parents=True, exist_ok=True)
599
+
600
+ try:
601
+ # Fetch from pinteresting
602
+ payload = {"keyword": image_query, "count": images_per_scene}
603
+
604
+ img_response = requests.post(
605
+ f"{image_server}/scrape",
606
+ json=payload,
607
+ timeout=120
608
+ )
609
+ img_response.raise_for_status()
610
+ img_data = img_response.json()
611
+
612
+ if img_data.get("success"):
613
+ images = img_data.get("images", [])
614
+
615
+ # Download each image
616
+ for img_idx, img_info in enumerate(images):
617
+ img_url = img_info.get("url")
618
+ if not img_url:
619
+ continue
620
+
621
+ try:
622
+ # Download and verify
623
+ dl_response = requests.get(img_url, timeout=15)
624
+ dl_response.raise_for_status()
625
+
626
+ # Verify it's valid image
627
+ Image.open(BytesIO(dl_response.content))
628
+
629
+ # Save
630
+ file_path = scene_dir / f"candidate_{img_idx:02d}.jpg"
631
+ with open(file_path, "wb") as f:
632
+ f.write(dl_response.content)
633
+
634
+ total_downloaded += 1
635
+
636
+ except Exception as e:
637
+ print(f" [Scene {actual_scene_idx}] Image {img_idx} failed: {e}")
638
+
639
+ print(f" [Scene {actual_scene_idx}] Downloaded {len(images)} images")
640
+ else:
641
+ print(f" [Scene {actual_scene_idx}] API error: {img_data.get('message')}")
642
+
643
+ except Exception as e:
644
+ print(f" [Scene {actual_scene_idx}] Request failed: {e}")
645
+
646
+ if total_downloaded == 0:
647
+ raise Exception(f"No images were downloaded from {image_server}")
648
+
649
+ print(f"[OK] Downloaded {total_downloaded} images total")
650
+
651
+ # ─────────────────────────────────────────────────────────────────
652
+ # Step 3: Select Best Images from Candidates
653
+ # ─────────────────────────────────────────────────────────────────
654
+ print("[STEP 3] Selecting best images from candidates...")
655
+
656
+ manifest_req = ManifestRequest(**manifest_data)
657
+ select_result = await _select_scenes(manifest_req, CANDIDATES_DIR)
658
+
659
+ if select_result["status"] != "success":
660
+ raise Exception(select_result.get("message", "Scene selection failed"))
661
+
662
+ print(f"[OK] Selected {select_result['selected_count']} images")
663
+
664
+ # ─────────────────────────────────────────────────────────────────
665
+ # Step 4: Compose Video
666
+ # ─────────────────────────────────────────────────────────────────
667
+ print("[STEP 4] Composing video...")
668
+
669
+ compose_result = await _compose(manifest_req)
670
+
671
+ if compose_result["status"] != "success":
672
+ raise Exception(compose_result.get("message", "Composition failed"))
673
+
674
+ print(f"[OK] Video composed ({compose_result['size_mb']:.1f}MB)")
675
+
676
+ # ─────────────────────────────────────────────────────────────────
677
+ # Step 5: Return Video File
678
+ # ─────────────────────────────────────────────────────────────────
679
+ output_file = Path(compose_result["output_path"])
680
+ if not output_file.exists():
681
+ raise Exception("Output video file not found")
682
+
683
+ print(f"[SUCCESS] Video ready: {output_file.name}")
684
+
685
+ return FileResponse(
686
+ path=output_file,
687
+ media_type="video/mp4",
688
+ filename="video.mp4"
689
+ )
690
+
691
+ except Exception as e:
692
+ print(f"[ERROR] {str(e)}")
693
+ raise HTTPException(status_code=500, detail=str(e))
694
+
695
+
696
  @app.get("/health")
697
  async def health_check():
698
  """Health check endpoint"""