🗝 KeyzHub
19Keys · community archive
8179 bytes raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
#!/usr/bin/env python3
"""hyperframes_render.py — render a clip through a HyperFrames motion composition.

Mirror of motion_render.py (Remotion) with the same render() contract, backed by the
HyperFrames engine (HTML + one paused GSAP timeline, npx published CLI). First
capability: KineticCaptions (word-punch karaoke captions + animated hook + handle)
over the live footage, 9:16 cover-crop at SOURCE-proportional resolution.

Pipeline per render:
  1. ffprobe the source (w/h/fps)
  2. ffmpeg pre-cut the segment, cover-cropped to a source-proportional 9:16 canvas
     (crop H = min(srcH, srcW*16/9); never downscaled to 1080; canvas height capped 2160)
  3. copy compositions/hyperframes/<comp>/ into compositions/hyperframes/.builds/<id>/,
     drop the cut clip at assets/clip.mp4, rewrite canvas data-attributes
  4. write vars json {clip, words, hook, handle, accent, fps, durationSec, width, height}
  5. `npx hyperframes render --variables-file … --fps … --quality standard`
  6. verify the output, clean the build dir on success

CLI: python3 hyperframes_render.py --source V --start S --end E --out O
     [--words W.json] [--hook ".."] [--handle "@19keys"] [--accent "#D4AF37"] [--comp kinetic]
"""
import argparse
import json
import os
import re
import shutil
import subprocess
from pathlib import Path

HERE = Path(__file__).resolve().parent          # tools/
STUDIO = HERE.parent
COMPS = STUDIO / "compositions" / "hyperframes"
BUILDS = COMPS / ".builds"
FFMPEG_DIR = "/opt/homebrew/opt/ffmpeg-full/bin"
FFMPEG = f"{FFMPEG_DIR}/ffmpeg"
FFPROBE = f"{FFMPEG_DIR}/ffprobe"
# Pinned published CLI (repo vendors 0.6.110 source; published package needs no build)
HYPERFRAMES_SPEC = os.environ.get("HYPERFRAMES_SPEC", "hyperframes@0.7.53")
RENDER_FPS_CHOICES = (24, 30, 60)               # values the CLI --fps flag accepts


def _env():
    env = dict(os.environ)
    env["PATH"] = f"{FFMPEG_DIR}:/opt/homebrew/bin:" + env.get("PATH", "")
    return env


def probe(source):
    """Return (width, height, fps) of the source's first video stream."""
    r = subprocess.run(
        [FFPROBE, "-v", "error", "-select_streams", "v:0",
         "-show_entries", "stream=width,height,r_frame_rate", "-of", "json", str(source)],
        capture_output=True, text=True)
    try:
        s = json.loads(r.stdout)["streams"][0]
        num, den = s["r_frame_rate"].split("/")
        fps = float(num) / float(den or 1)
        return int(s["width"]), int(s["height"]), fps
    except (KeyError, IndexError, ValueError, json.JSONDecodeError):
        raise RuntimeError(f"ffprobe failed on {source}: {r.stderr[-400:]}")


def canvas_for(src_w, src_h):
    """Source-proportional 9:16 canvas: crop H = min(srcH, srcW*16/9), capped at 2160."""
    crop_h = min(src_h, int(src_w * 16 / 9))
    h = min(crop_h, 2160)
    h -= h % 2
    w = round(h * 9 / 16 / 2) * 2
    return w, h


def render_fps_for(src_fps):
    """Nearest CLI-accepted fps (24/30/60) to the source fps."""
    return min(RENDER_FPS_CHOICES, key=lambda c: abs(c - src_fps))


def clip_words(words_path, start, end):
    """words.json ({"words":[{start,end,text,type}]} absolute source seconds) →
    clip-relative [{start,end,text}] within [start, end]."""
    try:
        words = json.loads(Path(words_path).read_text()).get("words", [])
    except (OSError, json.JSONDecodeError):
        return []
    out = []
    for w in words:
        if w.get("type", "word") != "word":
            continue
        s = w.get("start", 0)
        if start - 0.05 <= s <= end + 0.05:
            out.append({"start": round(s - start, 3),
                        "end": round(w.get("end", s) - start, 3),
                        "text": w.get("text", "")})
    return out


def _fmt(n):
    s = f"{n:.3f}".rstrip("0").rstrip(".")
    return s or "0"


def _rewrite(html, width, height, dur):
    """Rewrite the template's canvas + duration tokens for this render."""
    subs = [
        ('data-width="1080"', f'data-width="{width}"', 1),
        ('data-height="1920"', f'data-height="{height}"', 1),
        ('width: 1080px', f'width: {width}px', 1),
        ('height: 1920px', f'height: {height}px', 1),
        ('content="width=1080, height=1920"', f'content="width={width}, height={height}"', 1),
        ('data-duration="8"', f'data-duration="{_fmt(dur)}"', None),  # every clip spans full duration
    ]
    for old, new, expect in subs:
        n = html.count(old)
        if expect is not None and n != expect:
            raise RuntimeError(f"template drift: expected {expect}x {old!r}, found {n}")
        if expect is None and n < 1:
            raise RuntimeError(f"template drift: {old!r} not found")
        html = html.replace(old, new)
    return html


def render(source, start, end, out, words_path=None, hook="", handle="@19keys",
           accent="#D4AF37", comp="kinetic", font="", extra=None):
    start, end = float(start), float(end)
    dur = end - start
    if dur <= 0:
        raise ValueError("end must be after start")
    comp_dir = COMPS / comp
    if not (comp_dir / "index.html").exists():
        raise RuntimeError(f"unknown comp {comp!r}: {comp_dir}/index.html missing")

    src_w, src_h, src_fps = probe(source)
    width, height = canvas_for(src_w, src_h)
    fps = render_fps_for(src_fps)

    cid = f"{comp}_{int(start * 1000)}_{int(end * 1000)}"
    build = BUILDS / cid
    if build.exists():
        shutil.rmtree(build)
    build.parent.mkdir(parents=True, exist_ok=True)
    shutil.copytree(comp_dir, build)

    # Pre-cut the segment, cover-cropped onto the canvas, straight into the build assets
    clip_path = build / "assets" / "clip.mp4"
    r = subprocess.run(
        [FFMPEG, "-y", "-ss", str(start), "-to", str(end), "-i", str(source),
         "-vf", f"scale={width}:{height}:force_original_aspect_ratio=increase,"
                f"crop={width}:{height},setsar=1",
         "-r", str(fps), "-c:v", "libx264", "-preset", "fast", "-crf", "17",
         "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", str(clip_path)],
        capture_output=True, text=True)
    if r.returncode != 0 or not clip_path.exists():
        raise RuntimeError("pre-cut failed: " + (r.stderr or "")[-500:])

    index = build / "index.html"
    index.write_text(_rewrite(index.read_text(), width, height, dur))

    variables = {"clip": "clip.mp4",
                 "words": json.dumps(clip_words(words_path, start, end) if words_path else []),
                 "hook": hook, "handle": handle, "accent": accent, "font": font or "",
                 "fps": fps, "durationSec": round(dur, 3),
                 "width": width, "height": height}
    if extra:
        variables.update(extra)
    vars_file = build / "vars.json"
    vars_file.write_text(json.dumps(variables))

    out = Path(out).expanduser()
    out.parent.mkdir(parents=True, exist_ok=True)
    r = subprocess.run(
        ["npx", "-y", HYPERFRAMES_SPEC, "render", str(build),
         "--output", str(out), "--variables-file", str(vars_file),
         "--fps", str(fps), "--quality", "standard"],
        cwd=str(build), env=_env(), capture_output=True, text=True, timeout=1800)
    if r.returncode != 0 or not out.exists() or out.stat().st_size == 0:
        raise RuntimeError(f"hyperframes render failed (build kept at {build}): "
                           + ((r.stderr or "") + (r.stdout or ""))[-700:])
    shutil.rmtree(build, ignore_errors=True)
    return str(out)


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--source", required=True)
    ap.add_argument("--start", type=float, required=True)
    ap.add_argument("--end", type=float, required=True)
    ap.add_argument("--out", required=True)
    ap.add_argument("--words", default=None)
    ap.add_argument("--hook", default="")
    ap.add_argument("--handle", default="@19keys")
    ap.add_argument("--accent", default="#D4AF37")
    ap.add_argument("--comp", default="kinetic")
    ap.add_argument("--font", default="")
    a = ap.parse_args()
    print(render(a.source, a.start, a.end, a.out, words_path=a.words, hook=a.hook,
                 handle=a.handle, accent=a.accent, comp=a.comp, font=a.font))