Spaces:
Sleeping
Sleeping
Upload 9 files
Browse files- .gitattributes +1 -0
- Dockerfile +41 -0
- asset/TR Impact.TTF +3 -0
- compose_scenes.py +923 -0
- composer_dynamic.py +600 -0
- composer_simple.py +263 -0
- composer_v2.py +574 -0
- requirements_server.txt +8 -0
- select_scenes.py +107 -0
- server.py +425 -0
.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)
|