← Files FountainARCHIVED FILE

skills/fountain-clip-producer/modules/overlays/scripts/build-overlays.py

34.4 KB · Oct 10, 2026 · 12:04 UTC

↓ Download file

#!/usr/bin/env python3
"""Compile an overlay spec into a complete ffmpeg command.

The overlay spec is the overlays counterpart of the caption style spec: a JSON
document listing layers (logo images, hook title cards, lower thirds, progress
bars, audiograms, blurred background fill), each with nine-point positioning,
timing windows, and fades. This script validates the spec, resolves the show's
brand kit standing layers, and prints (or runs) the single ffmpeg command that
composites everything in one pass — so overlay work is reproducible from the
recorded plan instead of improvised filtergraphs.

Precedence mirrors captions: brand kit standing layers render first (underneath),
then the spec's layers in order; --override dot paths (list indices allowed,
e.g. layers.0.opacity=0.6) apply to the spec last.

Omit --run to print the composed ffmpeg command instead of executing it, which
is the way to inspect or debug a single layer's filtergraph.
"""

import argparse
import copy
import json
import shlex
import shutil
import subprocess
import sys
from pathlib import Path

LAYER_DEFAULTS = {
    "image": {
        # asset may be a still (PNG/JPG) or a video/GIF (B-roll insert, animated
        # CTA sticker — alpha-channel .mov/.webm composites transparently)
        "type": "image",
        "asset": None,
        "width": 180,
        "opacity": 1.0,
        "position": "top-right",
        "marginH": 40,
        "marginV": 40,
        "start": 0.0,
        "end": None,
        "fadeInMs": 0,
        "fadeOutMs": 0,
        "loop": False,  # video/GIF assets only; stills always persist
        "cornerRadius": 0,  # rounds the asset, the way every artwork card on a podcast clip is
    },
    "title": {
        "type": "title",
        "text": None,
        "fontFile": None,
        "size": 64,
        "color": "#FFFFFF",
        "borderW": 4,
        "borderColor": "#000000",
        "boxColor": None,  # solid bar behind the text (headline-bar style)
        "boxPad": 12,
        "maxLines": 2,  # a title that wraps past this is set smaller until it fits
        # what the boxColor paints: the text itself, a band across the frame, or a rounded card
        "boxShape": "text",  # text | band | card
        "cardRadius": 28,
        "boxMargin": 0,  # how close a band or a card may come to the edge of the frame
        "position": "top-center",
        "marginH": 60,
        "marginV": 300,
        "start": 0.0,
        "end": 2.5,
        "fadeInMs": 200,
        "fadeOutMs": 300,
    },
    "lowerThird": {
        "type": "lowerThird",
        "lines": None,
        "fontFile": None,
        "size": 44,
        "secondarySize": 30,
        "color": "#FFFFFF",
        "borderW": 3,
        "borderColor": "#000000",
        "boxColor": None,
        "boxPad": 10,
        "position": "bottom-left",
        "marginH": 70,
        "marginV": 560,
        "start": 2.0,
        "end": 7.0,
        "fadeInMs": 400,
        "fadeOutMs": 400,
    },
    "watermark": {
        # persistent small text (@handle) for the whole clip
        "type": "watermark",
        "text": None,
        "fontFile": None,
        "size": 30,
        "color": "#FFFFFFB3",
        "borderW": 2,
        "borderColor": "#000000",
        "position": "top-left",
        "marginH": 40,
        "marginV": 40,
    },
    "scrim": {
        # a soft dark band so bright footage cannot swallow the caption under it
        "type": "scrim",
        "height": 620,
        "color": "#000000",
        "opacity": 0.85,
        "position": "bottom",  # bottom | top
        "curve": 1.6,  # >1 keeps the middle clearer and darkens only near the edge
    },
    "progressBar": {
        "type": "progressBar",
        "height": 12,
        "color": "#FFFFFFD9",
        "position": "bottom",
    },
    "audiogram": {
        "type": "audiogram",
        "width": 900,
        "height": 140,
        "color": "#FFFFFFE6",
        "position": "bottom-center",
        "marginV": 560,
        "waveMode": "bars",  # bars | filled | line
        "bars": 40,  # how many bars across the width; fewer and fatter reads better than many
        "barGap": 0.33,  # share of each bar's slot left empty, which is what makes them bars
        "gain": 3.0,  # speech fills a fraction of the meter, so the drawn band is stretched
        "mirror": False,  # grow the bars from a centre line instead of from the floor
    },
    "blurFill": {
        # base composition: foreground scaled onto a background. scale=1.0 +
        # background=blur is the classic blurred fill; scale<1 + a solid color
        # + cornerRadius is the picture-in-picture card look. asset builds the
        # whole base from a still instead of from the clip, which is the only
        # way a source with no video gets a picture at all.
        "type": "blurFill",
        "sigma": 30,
        "scale": 1.0,
        "background": "blur",  # "blur" or a hex color
        "cornerRadius": 0,
        "backgroundDim": 0.0,  # how far the background is taken toward black, so the card sits forward
        "asset": None,  # a still to build the base from; defaults to the clip's own picture
        "marginV": None,  # how far down the foreground sits; centred when absent
        "borderW": 0,  # a rim around the card, which is what separates a dark cover from a dark ground
        "borderColor": "#FFFFFF26",
    },
}

BUNDLED_FONTS = Path(__file__).resolve().parents[3] / "assets" / "fonts"
DEFAULT_FONT = BUNDLED_FONTS / "montserrat-bold.ttf"

VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv", ".gif"}
WAVE_MODES = {"filled": ("showwaves", "cline"), "line": ("showwaves", "p2p"), "bars": ("showfreqs", "bar")}

HORIZONTAL = {"left", "center", "right"}
VERTICAL = {"top", "middle", "bottom"}


def is_remote(value):
    """A URL ffmpeg opens itself, rather than a file on this machine."""
    return str(value).startswith(("http://", "https://"))


def asset_suffix(value):
    """The file extension of an asset, ignoring any query a URL carries."""
    return Path(str(value).split("?", 1)[0].split("#", 1)[0]).suffix.lower()


def fail(msg):
    print(f"error: {msg}", file=sys.stderr)
    sys.exit(1)


def ff_color(value, context):
    """#RRGGBB or #RRGGBBAA -> ffmpeg color spec (0xRRGGBB@alpha)."""
    raw = str(value).lstrip("#")
    if len(raw) == 6:
        return f"0x{raw}"
    if len(raw) == 8:
        alpha = int(raw[6:8], 16) / 255
        return f"0x{raw[:6]}@{alpha:.3f}"
    fail(f"{context}: expected #RRGGBB or #RRGGBBAA, got '{value}'")


# showfreqs spreads the whole spectrum across the width, and speech lives in the
# bottom few kHz, so most of a full-band meter never moves. Resampling to 8 kHz
# puts voice across the whole width. The meter is then drawn one pixel per bar
# and blown up with nearest-neighbour, which is what makes a bar a bar rather
# than one of several hundred hairlines.
BARS_SOURCE_HEIGHT = 60
BARS_RATE = "aresample=8000"
BARS_LEVEL = "dynaudnorm=f=200:g=5"  # a quiet clip and a loud one draw the same meter


def bars_chain(layer, color):
    bars, width, height = layer["bars"], layer["width"], layer["height"]
    half = height // 2 if layer["mirror"] else height
    # Speech uses a fraction of the meter, so only the drawn part is kept and stretched.
    kept = max(4, round(BARS_SOURCE_HEIGHT / layer["gain"]))
    steps = [
        BARS_RATE,
        BARS_LEVEL,
        f"showfreqs=s={bars}x{BARS_SOURCE_HEIGHT}:mode=bar:ascale=cbrt:colors={color}",
        f"crop={bars}:{kept}:0:{BARS_SOURCE_HEIGHT - kept}",
        f"scale={width}:{half}:flags=neighbor",
        # showfreqs paints its own black background, which draws a black box over
        # the artwork unless it is keyed away first.
        "colorkey=0x000000:0.10:0.0",
    ]
    if layer["mirror"]:
        chain = ",".join(steps)
        return f"{chain},split[up][down];[down]vflip[flip];[up][flip]vstack,{gap_mask(layer)}"
    steps.append(gap_mask(layer))
    return ",".join(steps)


def gap_mask(layer):
    """Cut a gap out of every bar's slot, because a meter with no gaps is a lump."""
    pitch = layer["width"] / layer["bars"]
    solid = pitch * (1 - layer["barGap"])
    alpha = 255
    raw = str(layer["color"]).lstrip("#")
    if len(raw) == 8:
        alpha = int(raw[6:8], 16)
    # alpha(X,Y) keeps what the colour key already cut away.
    return (
        "format=rgba,"
        rf"geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':"
        rf"a='alpha(X,Y)*{alpha / 255:.3f}*lt(mod(X\,{pitch:.3f})\,{solid:.3f})'"
    )


def rounded_alpha(radius):
    """An alpha expression that cuts the four corners off whatever it is applied to."""
    corners = "+".join(
        f"({xc}*{yc}*gt((X-{cx})^2+(Y-{cy})^2,{radius * radius}))"
        for xc, yc, cx, cy in (
            (f"lt(X,{radius})", f"lt(Y,{radius})", radius, radius),
            (f"gt(X,W-{radius})", f"lt(Y,{radius})", f"(W-{radius})", radius),
            (f"lt(X,{radius})", f"gt(Y,H-{radius})", radius, f"(H-{radius})"),
            (f"gt(X,W-{radius})", f"gt(Y,H-{radius})", f"(W-{radius})", f"(H-{radius})"),
        )
    )
    # alpha(X,Y) rather than 255: overwriting it would make a translucent rim solid.
    return f"alpha(X,Y)*not({corners})"


def rounded_steps(radius):
    return ["format=rgba", f"geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='{rounded_alpha(radius)}'"]


def text_box(text, font_file, size, magick):
    """The inked width and height of a line, measured in the font that will draw it.

    Trimmed, because the typeset box carries leading that the drawn glyphs do not,
    and a backing sized to that box sits visibly high on the text inside it.
    """
    if not (font_file and magick):
        return None, None
    proc = subprocess.run(
        [
            magick,
            "-background",
            "none",
            "-font",
            str(font_file),
            "-pointsize",
            str(size),
            f"label:{text}",
            "-trim",
            "+repage",
            "-format",
            "%w %h",
            "info:",
        ],
        check=False,
        text=True,
        capture_output=True,
    )
    try:
        width, height = proc.stdout.strip().split()
        return int(width), int(height)
    except ValueError:
        return None, None


def text_width(text, font_file, size, magick):
    return text_box(text, font_file, size, magick)[0]


def wrap_text(text, font_file, size, budget, magick):
    """Break a line of text into lines that fit the frame.

    Measured when ImageMagick answers, and estimated from the point size when it
    does not. A title that runs off both edges is the commonest way an overlay
    spoils a clip, and drawtext will not wrap on its own.
    """
    words, lines, current = str(text).split(), [], ""
    estimate = 0.62  # average advance of a bold sans, as a share of the point size

    def fits(candidate):
        measured = text_width(candidate, font_file, size, magick)
        if measured is None:
            measured = len(candidate) * size * estimate
        return measured <= budget

    for word in words:
        candidate = f"{current} {word}".strip()
        if current and not fits(candidate):
            lines.append(current)
            current = word
        else:
            current = candidate
    if current:
        lines.append(current)
    return lines or [str(text)]


def fit_lines(layer, font_file, budget, magick):
    """Wrap a title, and shrink it until the wrap fits the lines it is allowed.

    A title is written by somebody who cannot see the frame, so its length is
    never known in advance. Shrinking keeps the whole hook rather than cutting
    the end off it, and the floor stops it shrinking into something unreadable.
    """
    size = layer["size"]
    floor = max(28, round(layer["size"] * 0.6))
    while True:
        lines = wrap_text(layer["text"], font_file, size, budget, magick)
        if len(lines) <= layer["maxLines"] or size <= floor:
            return lines[: layer["maxLines"]], size
        size -= 4


def escape_drawtext(text):
    out = []
    for ch in str(text):
        if ch in "\\':,%":
            out.append("\\" + ch)
        else:
            out.append(ch)
    return "".join(out)


def split_position(pos, context, allowed=None):
    try:
        v, h = pos.split("-")
    except ValueError:
        fail(f"{context}: position '{pos}' is not vertical-horizontal (e.g. top-right)")
    if v not in VERTICAL or h not in HORIZONTAL:
        fail(f"{context}: position '{pos}' is not one of {sorted(VERTICAL)} x {sorted(HORIZONTAL)}")
    return v, h


def apply_override(spec, raw):
    if "=" not in raw:
        fail(f"--override expects dot.path=value, got '{raw}'")
    dotted, value = raw.split("=", 1)
    try:
        parsed = json.loads(value)
    except json.JSONDecodeError:
        parsed = value
    node = spec
    keys = dotted.split(".")
    for i, key in enumerate(keys):
        last = i == len(keys) - 1
        if isinstance(node, list):
            try:
                idx = int(key)
                node[idx]  # noqa: B018 — bounds check
            except (ValueError, IndexError):
                fail(f"override '{dotted}': '{key}' is not a valid index into a {len(node)}-item list")
            if last:
                node[idx] = parsed
            else:
                node = node[idx]
        elif isinstance(node, dict):
            if key not in node:
                fail(f"override '{dotted}': unknown field '{key}'")
            if last:
                node[key] = parsed
            else:
                node = node[key]
        else:
            fail(f"override '{dotted}': '{key}' does not address into a value")


def resolve_layers(spec, kit, kit_dir):
    layers = []
    if kit:
        kit_overlays = kit.get("overlays") or {}
        kit_layers = kit_overlays.get("layers", [])
        for layer in kit_layers:
            layer = dict(layer)
            # kit asset/font paths resolve relative to the kit directory
            for field in ("asset", "fontFile"):
                if layer.get(field) and not is_remote(layer[field]) and not Path(layer[field]).is_absolute():
                    layer[field] = str(kit_dir / layer[field])
            layers.append(layer)
    layers.extend(spec.get("layers", []))
    resolved = []
    for i, layer in enumerate(layers):
        kind = layer.get("type")
        if kind not in LAYER_DEFAULTS:
            fail(f"layers[{i}]: unknown type '{kind}' (expected one of {sorted(LAYER_DEFAULTS)})")
        merged = copy.deepcopy(LAYER_DEFAULTS[kind])
        for key, value in layer.items():
            if key not in merged and key != "name":
                fail(f"layers[{i}] ({kind}): unknown field '{key}'")
            merged[key] = value
        resolved.append(merged)
    return resolved


def validate(layers, duration):
    warnings = []
    blur_count = sum(1 for layer in layers if layer["type"] == "blurFill")
    if blur_count > 1:
        fail("only one blurFill layer is allowed")
    if blur_count and layers[0]["type"] != "blurFill":
        fail("blurFill must be the first layer — it builds the base the others sit on")
    needs_duration = any(layer["type"] in {"image", "progressBar"} for layer in layers)
    if needs_duration and duration is None:
        fail(
            "--duration is required when the spec has image or progressBar layers "
            "(looping images and progress math need the clip length)"
        )
    for i, layer in enumerate(layers):
        kind = layer["type"]
        ctx = f"layers[{i}] ({kind})"
        if kind == "image":
            if not layer["asset"]:
                fail(f"{ctx}: 'asset' is required")
            if not is_remote(layer["asset"]) and not Path(layer["asset"]).exists():
                fail(f"{ctx}: asset not found: {layer['asset']}")
            if not 0 < layer["opacity"] <= 1:
                fail(f"{ctx}: opacity {layer['opacity']} outside (0, 1]")
        if kind in {"title", "watermark"} and not layer["text"]:
            fail(f"{ctx}: 'text' is required")
        if kind == "lowerThird" and not layer["lines"]:
            fail(f"{ctx}: 'lines' is required (one or two strings)")
        if kind in {"title", "lowerThird", "watermark"} and not layer["fontFile"]:
            warnings.append(
                f"{ctx}: no fontFile — drawtext falls back to the libass/fontconfig default; "
                "point it at the brand kit's font file for a branded result"
            )
        if kind == "audiogram" and layer["waveMode"] not in WAVE_MODES:
            fail(f"{ctx}: waveMode '{layer['waveMode']}' is not one of {sorted(WAVE_MODES)}")
        if kind == "scrim":
            if layer["position"] not in {"bottom", "top"}:
                fail(f"layers[{i}] (scrim): position '{layer['position']}' is not bottom or top")
            if not 0 < layer["opacity"] <= 1:
                fail(f"layers[{i}] (scrim): opacity {layer['opacity']} is not between 0 and 1")
        if kind == "blurFill":
            if not 0.3 <= layer["scale"] <= 1.0:
                fail(f"{ctx}: scale {layer['scale']} outside [0.3, 1.0]")
            if layer["cornerRadius"] < 0:
                fail(f"{ctx}: cornerRadius must be >= 0")
        if kind in {"title", "lowerThird", "image", "audiogram", "watermark"}:
            pos = layer.get("position")
            if pos:
                v, h = split_position(pos, ctx)
                if v == "bottom" and layer.get("marginV", 0) < 400:
                    warnings.append(
                        f"{ctx}: bottom position with marginV {layer.get('marginV')} may sit in "
                        "the caption zone — check against the caption spec's marginV"
                    )
                if h == "right" and layer.get("marginH", 0) < 100 and v == "middle":
                    warnings.append(
                        f"{ctx}: middle-right with marginH {layer.get('marginH')} sits in the "
                        "platform engagement rail (right ~10% of frame)"
                    )
        start, end = layer.get("start"), layer.get("end")
        if start is not None and end is not None and end <= start:
            fail(f"{ctx}: end {end} <= start {start}")
        if duration is not None and start is not None and start >= duration:
            fail(f"{ctx}: start {start} is beyond the clip duration {duration}")
    return warnings


def xy_exprs(layer, w_expr, h_expr):
    v, h = split_position(layer["position"], f"{layer['type']} position")
    mh, mv = layer.get("marginH", 0), layer.get("marginV", 0)
    x = {"left": str(mh), "center": f"(W-{w_expr})/2", "right": f"W-{w_expr}-{mh}"}[h]
    y = {"top": str(mv), "middle": f"(H-{h_expr})/2", "bottom": f"H-{h_expr}-{mv}"}[v]
    return x, y


def drawtext_xy(layer, y_offset=0):
    v, h = split_position(layer["position"], f"{layer['type']} position")
    mh, mv = layer["marginH"], layer["marginV"]
    x = {"left": str(mh), "center": "(w-tw)/2", "right": f"w-tw-{mh}"}[h]
    base_y = {"top": str(mv), "middle": "(h-th)/2", "bottom": f"h-th-{mv}"}[v]
    return x, f"{base_y}+{y_offset}" if y_offset else base_y


def fade_alpha(layer):
    start, end = layer["start"], layer["end"]
    fi, fo = layer["fadeInMs"] / 1000, layer["fadeOutMs"] / 1000
    parts = []
    if fi > 0:
        parts.append(f"min(1\\,max(0\\,(t-{start})/{fi}))")
    if fo > 0 and end is not None:
        parts.append(f"min(1\\,max(0\\,({end}-t)/{fo}))")
    return "*".join(parts) if parts else None


def drawtext_filter(layer, text, size, y_offset, duration, timed=True):
    x, y = drawtext_xy(layer, y_offset)
    opts = [
        f"text='{escape_drawtext(text)}'",
        f"fontsize={size}",
        f"fontcolor={ff_color(layer['color'], 'text color')}",
        f"borderw={layer['borderW']}",
        f"bordercolor={ff_color(layer['borderColor'], 'border color')}",
        f"x={x}",
        f"y={y}",
    ]
    if layer["fontFile"]:
        opts.insert(0, f"fontfile={layer['fontFile']}")
    if layer.get("boxColor"):
        opts.append("box=1")
        opts.append(f"boxcolor={ff_color(layer['boxColor'], 'box color')}")
        opts.append(f"boxborderw={layer['boxPad']}")
    if timed:
        end = layer["end"] if layer["end"] is not None else (duration if duration is not None else 1e9)
        opts.append(f"enable='between(t,{layer['start']},{end})'")
        alpha = fade_alpha({**layer, "end": end})
        if alpha:
            opts.append(f"alpha='{alpha}'")
    return "drawtext=" + ":".join(opts)


def backing_box(layer, lines, size, step, args):
    """Where the thing behind a title sits, and how big, measured from the drawn glyphs.

    drawtext can only box what it draws, which gives every line its own width and
    leaves a ragged edge. A band and a card are placed before a glyph exists, so
    both keep one clean edge whatever the text turns out to be.

    Both hug the text rather than the frame: a backing sized to the frame is far
    wider than a short hook needs, and one sized to the typeset box rather than
    to the ink sits visibly high on the words inside it.
    """
    pad = layer["boxPad"]
    widest, last_ink = 0, None
    for line in lines:
        width, height = text_box(line, layer["fontFile"], size, args.magick)
        widest = max(widest, width if width is not None else len(line) * size * 0.62)
        last_ink = height if height is not None else round(size * 0.75)
    ceiling = args.width - 2 * layer["boxMargin"]
    box_w = min(round(widest) + 2 * pad, ceiling)
    box_h = step * (len(lines) - 1) + last_ink + 2 * pad

    # drawtext puts the top of the first line's ink at its own y, so the backing
    # starts one pad above that and ends one pad below the last line's ink.
    vertical = layer["position"].split("-")[0]
    ink_top = {
        "top": layer["marginV"],
        "middle": round((args.height - (box_h - 2 * pad)) / 2),
        "bottom": args.height - layer["marginV"] - (box_h - 2 * pad),
    }[vertical]
    return max(0, ink_top - pad), box_w, box_h, ink_top


def build_command(layers, args):
    duration = args.duration
    for layer in layers:
        if layer["type"] in {"title", "lowerThird", "watermark"} and not layer["fontFile"]:
            layer["fontFile"] = args.font
    inputs = [("", args.input)]
    graph = []
    chain = "[0:v]"
    label_n = 0

    def next_label():
        nonlocal label_n
        label_n += 1
        return f"[v{label_n}]"

    if layers and layers[0]["type"] == "blurFill":
        blur = layers[0]
        out = next_label()
        fg_w = round(args.width * blur["scale"] / 2) * 2
        fg_steps = [f"scale={fg_w}:-2"]
        if blur["borderW"] > 0:
            rim = blur["borderW"]
            fg_steps.append(
                f"pad=iw+{2 * rim}:ih+{2 * rim}:{rim}:{rim}:"
                f"color={ff_color(blur['borderColor'], 'blurFill borderColor')}"
            )
        if blur["cornerRadius"] > 0:
            fg_steps += rounded_steps(blur["cornerRadius"])
        # An input stream can feed two filters, and ffmpeg splits it for itself.
        if blur["asset"]:
            inputs.append(("-loop 1", blur["asset"]))
            source = f"[{len(inputs) - 1}:v]"
        else:
            source = "[0:v]"
        if blur["background"] == "blur":
            # colorlevels scales the output range, which darkens without washing the colour out.
            keep = round(1 - blur["backgroundDim"], 3)
            dim = f",colorlevels=romax={keep}:gomax={keep}:bomax={keep}" if blur["backgroundDim"] else ""
            graph.append(
                f"{source}scale={args.width}:{args.height}:force_original_aspect_ratio=increase,"
                f"crop={args.width}:{args.height},gblur=sigma={blur['sigma']}{dim}[bg]"
            )
        else:
            graph.append(
                f"color=c={ff_color(blur['background'], 'blurFill background')}:"
                f"s={args.width}x{args.height}:d={duration}[bg]"
            )
        y = "(H-h)/2" if blur["marginV"] is None else str(blur["marginV"])
        graph.append(f"{source}{','.join(fg_steps)}[fg]")
        graph.append(f"[bg][fg]overlay=(W-w)/2:{y}:shortest=1{out}")
        chain = out
        layers = layers[1:]

    for layer in layers:
        kind = layer["type"]
        if kind == "image":
            is_video = asset_suffix(layer["asset"]) in VIDEO_EXTS
            if not is_video:
                in_flags = "-loop 1"
            elif layer["loop"]:
                in_flags = "-ignore_loop 0" if asset_suffix(layer["asset"]) == ".gif" else "-stream_loop -1"
            else:
                in_flags = ""
            inputs.append((in_flags, layer["asset"]))
            idx = len(inputs) - 1
            steps = [f"format=rgba,scale={layer['width']}:-2"]
            if layer["cornerRadius"] > 0:
                steps += rounded_steps(layer["cornerRadius"])
            if is_video:
                # shift the asset's own timeline to the layer's start
                steps.insert(0, f"setpts=PTS-STARTPTS+{layer['start']}/TB")
            end = layer["end"] if layer["end"] is not None else duration
            if layer["fadeInMs"]:
                steps.append(f"fade=t=in:st={layer['start']}:d={layer['fadeInMs'] / 1000}:alpha=1")
            if layer["fadeOutMs"] and end is not None:
                steps.append(f"fade=t=out:st={end - layer['fadeOutMs'] / 1000}:d={layer['fadeOutMs'] / 1000}:alpha=1")
            if layer["opacity"] < 1:
                steps.append(f"colorchannelmixer=aa={layer['opacity']}")
            lyr = f"[l{idx}]"
            graph.append(f"[{idx}:v]{','.join(steps)}{lyr}")
            x, y = xy_exprs(layer, "w", "h")
            out = next_label()
            enable = f":enable='between(t,{layer['start']},{end})'" if (layer["start"] or end is not None) else ""
            eof = ":eof_action=pass" if is_video and not layer["loop"] else ""
            graph.append(f"{chain}{lyr}overlay=x={x}:y={y}{enable}{eof}{out}")
            chain = out
        elif kind == "title":
            out = next_label()
            budget = args.width - 2 * layer["marginH"]
            shape = layer["boxShape"] if layer["boxColor"] else "text"
            if shape in {"band", "card"}:
                # The words have to fit inside the backing, not merely inside the frame.
                budget = min(budget, args.width - 2 * layer["boxMargin"] - 2 * layer["boxPad"])
            lines_of, size = fit_lines(layer, layer["fontFile"], budget, args.magick)
            step = round(size * 1.25)
            end_t = layer["end"] if layer["end"] is not None else (duration if duration is not None else 1e9)
            if shape in {"band", "card"}:
                top, box_w, box_h, ink_top = backing_box(layer, lines_of, size, step, args)
                radius = layer["cardRadius"] if shape == "card" else 0
                steps = [f"color=c={ff_color(layer['boxColor'], 'box color')}:s={box_w}x{box_h}:d={duration}"]
                if radius > 0:
                    steps += rounded_steps(radius)
                backing = f"[box{label_n}]"
                graph.append(f"{','.join(steps)}{backing}")
                mid = next_label()
                graph.append(
                    f"{chain}{backing}overlay=x=(W-w)/2:y={top}:enable='between(t,{layer['start']},{end_t})'{mid}"
                )
                chain = mid
                # The text is re-anchored to the ink line the backing was built around.
                layer = {**layer, "boxColor": None, "marginV": ink_top, "position": "top-center"}
            filters = [drawtext_filter(layer, line, size, i * step, duration) for i, line in enumerate(lines_of)]
            graph.append(f"{chain}{','.join(filters)}{out}")
            chain = out
        elif kind == "watermark":
            out = next_label()
            graph.append(
                f"{chain}{drawtext_filter(layer, layer['text'], layer['size'], 0, duration, timed=False)}{out}"
            )
            chain = out
        elif kind == "lowerThird":
            lines = layer["lines"] if isinstance(layer["lines"], list) else [layer["lines"]]
            out = next_label()
            filters = [drawtext_filter(layer, lines[0], layer["size"], 0, duration)]
            if len(lines) > 1:
                filters.append(
                    drawtext_filter(layer, lines[1], layer["secondarySize"], round(layer["size"] * 1.3), duration)
                )
            graph.append(f"{chain}{','.join(filters)}{out}")
            chain = out
        elif kind == "progressBar":
            out = next_label()
            graph.append(
                f"{chain}drawbox=x=0:y=ih-{layer['height']}:w='iw*t/{duration}':"
                f"h={layer['height']}:color={ff_color(layer['color'], 'progress color')}:t=fill{out}"
            )
            chain = out
        elif kind == "scrim":
            band, out = f"[scrim{label_n}]", next_label()
            alpha = round(layer["opacity"] * 255)
            raw = str(layer["color"]).lstrip("#")
            r, g, b = (int(raw[j : j + 2], 16) for j in (0, 2, 4))
            # geq paints the alpha ramp itself; the gradients source only gives a hard band.
            ramp = "Y/H" if layer["position"] == "bottom" else "(1-Y/H)"
            graph.append(
                f"color=c=black:s={args.width}x{layer['height']}:d={duration},format=rgba,"
                rf"geq=r={r}:g={g}:b={b}:a='{alpha}*pow({ramp}\,{layer['curve']})'{band}"
            )
            y = "H-h" if layer["position"] == "bottom" else "0"
            graph.append(f"{chain}{band}overlay=0:{y}{out}")
            chain = out
        elif kind == "audiogram":
            wave = f"[wave{label_n}]"
            out = next_label()
            x, y = xy_exprs(layer, "w", "h")
            filt, mode = WAVE_MODES[layer["waveMode"]]
            color = ff_color(layer["color"], "audiogram color")
            if filt == "showwaves":
                graph.append(
                    # draw=full paints a solid wave; the default trace is a hairline that
                    # disappears over footage. sqrt lifts speech, which sits low on a
                    # linear scale and otherwise reads as a flat line.
                    f"[0:a]showwaves=s={layer['width']}x{layer['height']}:mode={mode}"
                    f":draw=full:scale=sqrt:colors={color}:rate=30{wave}"
                )
            else:
                graph.append(f"[0:a]{bars_chain(layer, color)}{wave}")
            graph.append(f"{chain}{wave}overlay=x={x}:y={y}{out}")
            chain = out

    cmd = [args.ffmpeg, "-hide_banner", "-y"]
    for flags, path in inputs:
        if flags:
            cmd.extend(flags.split())
        cmd.extend(["-i", path])
    cmd.extend(["-filter_complex", ";".join(graph), "-map", chain.strip("[]").join(["[", "]"]), "-map", "0:a?"])
    if duration is not None:
        cmd.extend(["-t", str(duration)])
    cmd.extend(
        ["-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-c:a", "copy", "-movflags", "+faststart", args.output]
    )
    return cmd


def resolve_spec_path(spec_arg):
    path = Path(spec_arg)
    if not path.exists():
        candidate = Path(__file__).resolve().parents[1] / "assets" / f"{spec_arg}.json"
        if candidate.exists():
            path = candidate
        else:
            fail(f"spec '{spec_arg}' is neither a file nor a preset in {candidate.parent}")
    return path


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument(
        "--spec",
        help="Overlay preset name (from modules/overlays/assets/) or path to an overlay spec JSON. "
        "Optional when --brand-kit names a default via overlays.preset.",
    )
    parser.add_argument("--input", required=True, help="The clip to composite onto (usually the vertical export).")
    parser.add_argument("--output", required=True)
    parser.add_argument(
        "--duration", type=float, help="Clip duration in seconds. Required for image/progressBar layers."
    )
    parser.add_argument(
        "--brand-kit",
        help="kit.json whose overlays.layers render underneath the spec's "
        "layers; kit asset paths resolve relative to the kit.",
    )
    parser.add_argument(
        "--override",
        action="append",
        default=[],
        metavar="dot.path=value",
        help="Spec override; list indices allowed, e.g. layers.0.text='new hook'. Repeatable.",
    )
    parser.add_argument(
        "--font",
        default=str(DEFAULT_FONT),
        help="Font for any text layer that names none. Defaults to the bundled Montserrat Bold, so a "
        "clip never falls back to whatever the machine happens to carry.",
    )
    parser.add_argument("--magick", default=shutil.which("magick") or "magick")
    parser.add_argument("--width", type=int, default=1080)
    parser.add_argument("--height", type=int, default=1920)
    parser.add_argument("--emit-plan", help="Write the fully resolved layer list (record this in the clip manifest).")
    parser.add_argument(
        "--ffmpeg",
        default="ffmpeg",
        help="ffmpeg binary to use. A stock build often lacks drawtext, so pass the one module preflight names.",
    )
    parser.add_argument("--run", action="store_true", help="Execute the ffmpeg command instead of printing it.")
    args = parser.parse_args()

    kit, kit_dir = None, None
    if args.brand_kit:
        kit_path = Path(args.brand_kit)
        kit = json.loads(kit_path.read_text())
        kit_dir = kit_path.parent

    spec_arg = args.spec or (kit or {}).get("overlays", {}).get("preset")
    if not spec_arg:
        fail("no overlay spec: pass --spec, or set overlays.preset in the brand kit")
    spec_path = resolve_spec_path(spec_arg)
    spec = json.loads(spec_path.read_text())
    for raw in args.override:
        apply_override(spec, raw)
    # A preset that ships its own asset names it beside itself, so resolve a
    # relative path against the preset rather than against the caller's cwd.
    # An override always wins, because it is applied above.
    for layer in spec.get("layers", []):
        for field in ("asset", "fontFile"):
            value = layer.get(field)
            if value and not is_remote(value) and not Path(value).is_absolute() and not Path(value).exists():
                beside = spec_path.parent / value
                if beside.is_file():
                    layer[field] = str(beside)

    layers = resolve_layers(spec, kit, kit_dir)
    if not layers:
        fail("no layers to composite (spec and kit are both empty)")
    for message in validate(layers, args.duration):
        print(f"warning: {message}", file=sys.stderr)

    if args.emit_plan:
        Path(args.emit_plan).write_text(json.dumps({"spec": str(spec_path), "layers": layers}, indent=2) + "\n")

    cmd = build_command(layers, args)
    if args.run:
        proc = subprocess.run(cmd)
        return proc.returncode
    print(" ".join(shlex.quote(c) for c in cmd))
    return 0


if __name__ == "__main__":
    sys.exit(main())

SHA-256: 652e1266cb64eeb17616617518463da50846a9ad7e232d41d920d27bde4f902c