composer / server.py
factorstudios's picture
Upload 9 files
735a97b verified
Raw History Blame
16.8 kB
"""
FastAPI Server for Scene Selection and Video Composition
Dynamically generates video from manifest without hardcoded labels
"""
from fastapi import FastAPI, HTTPException, File, UploadFile, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
import os
import json
import shutil
from pathlib import Path
import subprocess
import sys
# ─────────────────────────────────────────────────────────────────────────
# Pydantic Models
# ─────────────────────────────────────────────────────────────────────────
class SceneRequest(BaseModel):
"""Scene metadata from manifest"""
label: str
image_query: str
class ManifestRequest(BaseModel):
"""Manifest JSON format from client"""
title: str
scenes: List[SceneRequest]
class VideoResponse(BaseModel):
"""Response after video generation"""
status: str
message: str
output_path: Optional[str] = None
size_mb: Optional[float] = None
duration_s: Optional[float] = None
# ─────────────────────────────────────────────────────────────────────────
# FastAPI App
# ─────────────────────────────────────────────────────────────────────────
app = FastAPI(
title="TrendClip Video Composer",
description="Dynamic video composition from manifest without hardcoded configs",
version="2.0"
)
BASE_DIR = Path(__file__).parent
CANDIDATES_DIR = BASE_DIR / "candidates"
SELECTED_DIR = BASE_DIR / "selected"
RENDERS_DIR = BASE_DIR / "renders"
# Ensure directories exist
CANDIDATES_DIR.mkdir(exist_ok=True)
SELECTED_DIR.mkdir(exist_ok=True)
RENDERS_DIR.mkdir(exist_ok=True)
# ─────────────────────────────────────────────────────────────────────────
# Scene Configuration Generation
# ─────────────────────────────────────────────────────────────────────────
# Intro scene config (4.7s, 95pt font)
INTRO_CONFIG = {
"duration_s": 4.7,
"motion": {"type": "slow_push_in", "scale_start": 1.0, "scale_end": 1.08},
"text": {"type": "center_stroke_pop", "entry_frame": 2, "hold_frames": 125, "font_size": 95, "align": "center"},
"grade": {"crush_blacks": 15, "contrast": 1.15},
"transition": {"type": "hard_cut", "frames": 1},
}
# Scene templates for subsequent scenes (2.3s, 110pt font)
SCENES_TEMPLATES = [
{
"duration_s": 2.3,
"motion": {"type": "snap_zoom", "scale_start": 1.0, "scale_end": 1.12},
"text": {"type": "center_pop", "entry_frame": 0, "hold_frames": 69, "font_size": 110, "align": "center"},
"grade": {"warm_tint": True, "lift_mids": 10},
"transition": {"type": "whip_pan_right", "frames": 4},
},
{
"duration_s": 2.3,
"motion": {"type": "static"},
"text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
"grade": {"desaturate": True, "lift_blacks": 5},
"transition": {"type": "whip_pan_right", "frames": 4},
},
{
"duration_s": 2.3,
"motion": {"type": "static"},
"text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
"grade": {"cool_tint": True, "highlights": -15},
"transition": {"type": "whip_pan_right", "frames": 4},
},
{
"duration_s": 2.3,
"motion": {"type": "static"},
"text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
"grade": {"soft_pink": True, "lift_mids": 15},
"transition": {"type": "whip_pan_right", "frames": 4},
},
{
"duration_s": 2.3,
"motion": {"type": "static"},
"text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
"grade": {"indoor_warm": True, "lift_shadows": 8},
"transition": {"type": "whip_pan_right", "frames": 4},
},
{
"duration_s": 2.3,
"motion": {"type": "static"},
"text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
"grade": {"teal_orange": True, "crush_blacks": 10},
"transition": {"type": "whip_pan_right", "frames": 4},
},
{
"duration_s": 2.3,
"motion": {"type": "static"},
"text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
"grade": {"dark_moody": True, "crush_blacks": 20, "desaturate": 15},
"transition": {"type": "whip_pan_right", "frames": 4},
},
{
"duration_s": 2.3,
"motion": {"type": "static"},
"text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"},
"grade": {"warm_indoor": True, "soft_glow": True, "lift_mids": 12},
"transition": {"type": "end_fade_black", "frames": 30},
},
]
def generate_scene_config(manifest: ManifestRequest) -> list:
"""Generate SCENE_CONFIG from manifest, extracting and uppercasing labels."""
config = []
for idx, scene in enumerate(manifest.scenes):
# Extract label and convert to UPPERCASE for captions
label = scene.label.upper()
scene_cfg = {
"idx": idx,
"label": label,
}
if idx == 0:
# First scene is intro
scene_cfg.update(INTRO_CONFIG)
else:
# Use templated config for subsequent scenes
template_idx = min(idx - 1, len(SCENES_TEMPLATES) - 1)
scene_cfg.update(SCENES_TEMPLATES[template_idx])
config.append(scene_cfg)
return config
# ─────────────────────────────────────────────────────────────────────────
# Endpoints
# ─────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────
# Endpoints
# ─────────────────────────────────────────────────────────────────────────
@app.post("/generate-video")
async def generate_video(
manifest: str = Form(...),
files: List[UploadFile] = File(...)
):
"""
Full pipeline: Upload candidates + manifest, get MP4 back.
Form parameters:
- manifest: JSON string with title and scenes
- files: Uploaded image files for each scene
Returns: MP4 video file
"""
try:
# Parse manifest
manifest_data = json.loads(manifest)
manifest_req = ManifestRequest(**manifest_data)
# Step 1: Clean and prepare upload directory
upload_dir = BASE_DIR / "upload_candidates"
if upload_dir.exists():
shutil.rmtree(upload_dir)
upload_dir.mkdir(exist_ok=True)
# Step 2: Save uploaded files organized by scene index
uploaded_count = 0
for file in files:
if file.filename:
# Save to upload_candidates folder
file_path = upload_dir / file.filename
with open(file_path, "wb") as f:
f.write(await file.read())
uploaded_count += 1
if uploaded_count != len(manifest_req.scenes):
raise Exception(
f"Expected {len(manifest_req.scenes)} files, received {uploaded_count}"
)
# Step 3: Select scenes (evaluate and copy to selected/)
select_result = await _select_scenes(manifest_req, upload_dir)
if select_result["status"] != "success":
raise Exception(select_result.get("message", "Scene selection failed"))
# Step 4: Compose video with manifest data
compose_result = await _compose(manifest_req)
if compose_result["status"] != "success":
raise Exception(compose_result.get("message", "Composition failed"))
# Step 5: Return the MP4 file
output_file = Path(compose_result["output_path"])
if not output_file.exists():
raise Exception("Output file not found")
return FileResponse(
path=output_file,
media_type="video/mp4",
filename="video.mp4"
)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid manifest JSON: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/select-scenes")
async def select_scenes(manifest: ManifestRequest):
"""
Endpoint for just scene selection (without upload).
Assumes candidates are already in candidates/ folder.
"""
try:
result = await _select_scenes(manifest, CANDIDATES_DIR)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/compose")
async def compose(manifest: ManifestRequest):
"""
Endpoint for just composition.
Assumes selected images are already in selected/ folder.
"""
try:
result = await _compose(manifest)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
async def _select_scenes(manifest: ManifestRequest, source_dir: Path):
"""
Internal: Select scenes from source directory.
Copies best image from each scene folder to selected/ folder.
"""
try:
# Clean and recreate selected directory
if SELECTED_DIR.exists():
shutil.rmtree(SELECTED_DIR)
SELECTED_DIR.mkdir(exist_ok=True)
selected_count = 0
# For each scene, find and copy its image
for i, scene in enumerate(manifest.scenes):
# Try multiple naming conventions
scene_folder = None
for pattern in [f"scene_{i}", f"scene_{i:02d}", f"{i}", f"scene{i}"]:
potential = source_dir / pattern
if potential.exists():
scene_folder = potential
break
# If no folder, look for files named with scene index
if scene_folder is None:
images = list(source_dir.glob(f"*scene*{i}*")) + list(
source_dir.glob(f"{i:02d}*")
)
if images:
dest = SELECTED_DIR / f"scene_{i:02d}.jpg"
shutil.copy2(images[0], dest)
selected_count += 1
continue
# If folder found, get best image
if scene_folder and scene_folder.is_dir():
images = sorted(
list(scene_folder.glob("*.jpg")) + list(scene_folder.glob("*.png")),
key=lambda p: p.stat().st_size,
reverse=True
)
if images:
dest = SELECTED_DIR / f"scene_{i:02d}.jpg"
shutil.copy2(images[0], dest)
selected_count += 1
if selected_count == 0:
raise Exception(
f"No images found in {source_dir}. "
"Expected scene_0/, scene_1/, etc. folders or numbered files."
)
return {
"status": "success",
"message": f"Selected {selected_count}/{len(manifest.scenes)} scenes",
"selected_count": selected_count,
"selected_dir": str(SELECTED_DIR),
}
except Exception as e:
return {
"status": "error",
"message": str(e),
}
async def _compose(manifest: ManifestRequest):
"""
Internal: Compose video from manifest and selected images.
"""
try:
# Verify selected directory has images
selected_images = sorted(SELECTED_DIR.glob("scene_*.jpg"))
if len(selected_images) != len(manifest.scenes):
raise Exception(
f"Expected {len(manifest.scenes)} selected images, found {len(selected_images)}"
)
# Generate dynamic SCENE_CONFIG from manifest
scene_config = generate_scene_config(manifest)
# Save config as JSON for composer to use
config_json = {
"title": manifest.title,
"scenes": scene_config,
}
config_path = BASE_DIR / "manifest_config.json"
with open(config_path, "w") as f:
json.dump(config_json, f, indent=2)
# Call composer_v2.py with the config
env = os.environ.copy()
env["COMPOSER_MANIFEST_CONFIG"] = str(config_path)
env["PYTHONIOENCODING"] = "utf-8"
result = subprocess.run(
[sys.executable, str(BASE_DIR / "composer_v2.py")],
cwd=str(BASE_DIR),
env=env,
capture_output=True,
text=True,
)
# Filter out numpy warnings from stderr
error_msg = result.stderr or result.stdout
if error_msg:
lines = error_msg.split("\n")
# Filter out numpy and importlib warnings
errors = [
line for line in lines
if line.strip() and not any(
skip in line for skip in [
"numpy",
"Numpy",
"MINGW-W64",
"CRASHES ARE TO BE EXPECTED",
"RuntimeWarning",
"importlib",
"getlimits",
"nextafter",
"ld(",
"exp2",
"log10",
]
)
]
if errors and result.returncode != 0:
error_summary = "\n".join(errors[-5:]) # Last 5 meaningful lines
raise Exception(f"Composer failed: {error_summary}")
# Verify output file exists
output_file = RENDERS_DIR / "sunset_reel.mp4"
if not output_file.exists():
raise Exception("Output video file not created")
size_mb = output_file.stat().st_size / (1024 * 1024)
duration_s = len(scene_config) * 2.3 + 2.4 # Approximate
return {
"status": "success",
"message": "Video composed successfully",
"output_path": str(output_file),
"size_mb": round(size_mb, 1),
"duration_s": round(duration_s, 1),
}
except Exception as e:
return {
"status": "error",
"message": str(e),
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"service": "TrendClip Video Composer",
"version": "2.0",
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7860)