← Files AgentBrokoARCHIVED FILE

src/video_forge/spec.py

2.7 KB · Sep 30, 2026 · 23:15 UTC

↓ Download file

"""Spec loading, defaults, act alternation, and light validation.

A spec is plain JSON (see schema/spec.schema.json and ads/_template/spec.json).
This module fills defaults, auto-alternates light/dark acts where a scene omits
`act`, and estimates fallback scene durations for silent renders (when no VO
timing is available).
"""

import json

from .scenes import REGISTRY

DEFAULT_VIDEO = {"width": 1920, "height": 1080, "fps": 30,
                 "target_seconds": 60, "supersample": 1.5}
DEFAULT_VOICE = {"backend": "windows", "voice": "en-US", "rate": 0}
DEFAULT_MUSIC = {"file": None, "gain_db": -19.0, "duck_db": -11.0}
DEFAULT_BRAND = {"name": "Brand", "wordmark": None, "url": None,
                 "accent": "#3B5BFF", "font": None}

# Per-type base seconds + per-unit add-on, used only when VO timing is absent.
_DUR = {
    "cold_open":    (2.4, 0.0),
    "statement":    (3.4, 0.0),
    "pill_list":    (3.0, 0.55),
    "message":      (4.2, 0.0),
    "node_stack":   (4.6, 0.0),
    "orbit":        (4.4, 0.4),
    "waveform":     (3.6, 0.0),
    "feature_grid": (3.8, 0.35),
    "stat":         (3.2, 0.0),
    "split_compare": (4.0, 0.0),
    "cta":          (3.6, 0.0),
    "logo_reveal":  (3.4, 0.0),
    "screenshot":   (4.0, 0.0),
}


def _merge(base, override):
    out = dict(base)
    if override:
        for k, v in override.items():
            if v is not None:
                out[k] = v
    return out


def default_duration(scene):
    base, per = _DUR.get(scene.get("type"), (3.6, 0.0))
    n = len(scene.get("items", []) or [])
    return round(base + per * n, 3)


def normalize(spec):
    """Return a spec dict with defaults applied and scenes normalized in place."""
    spec = dict(spec)
    spec["video"] = _merge(DEFAULT_VIDEO, spec.get("video"))
    spec["voice"] = _merge(DEFAULT_VOICE, spec.get("voice"))
    spec["music"] = _merge(DEFAULT_MUSIC, spec.get("music"))
    spec["brand"] = _merge(DEFAULT_BRAND, spec.get("brand"))

    scenes = spec.get("scenes") or []
    if not scenes:
        raise ValueError("spec has no scenes")

    prev_act = None
    for i, sc in enumerate(scenes):
        t = sc.get("type")
        if t not in REGISTRY:
            raise ValueError(
                f"scene {i}: unknown type {t!r}. Known: {sorted(REGISTRY)}")
        act = sc.get("act")
        if act not in ("light", "dark"):
            # alternate; first scene defaults to light
            act = "light" if prev_act is None else (
                "dark" if prev_act == "light" else "light")
        sc["act"] = act
        prev_act = act
        sc.setdefault("vo", "")
    spec["scenes"] = scenes
    return spec


def load_spec(path):
    with open(path, "r", encoding="utf-8") as fh:
        return normalize(json.load(fh))

SHA-256: 06ad0e24f0bb07a95312a211626b6407db78b1052e5e8a8b5b9bc2b9516dadd1