Files
ElyOffice/site/video/edit/edit.py
T

175 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Composites the launch film from scenes, recordings and captions."""
import json
import math
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
OUT = HERE.parent / "out"
SCENES = OUT / "scenes"
REC = OUT / "rec"
WORK = OUT / "edit"
FILM = OUT / "ElyOffice-launch.mp4"
FPS = 30
BEAT = 60 / 112
RGB_IN = "scale=in_color_matrix=bt709:in_range=tv,format=rgb24"
def run(args):
print("+", " ".join(str(a) for a in args[:6]), "...", flush=True)
subprocess.run(["ffmpeg", "-hide_banner", "-v", "error", "-y", *map(str, args)], check=True)
def lerp(points, x):
"""Piecewise linear through points, extended past both ends."""
i = 0
while i < len(points) - 2 and x > points[i + 1][0]:
i += 1
(x0, y0), (x1, y1) = points[i], points[i + 1]
return y0 + (x - x0) * (y1 - y0) / (x1 - x0)
def piecewise(points, var):
"""An ffmpeg expression for lerp over var."""
expr = None
for i in range(len(points) - 2, -1, -1):
(x0, y0), (x1, y1) = points[i], points[i + 1]
seg = f"({y0:.6f}+({var}-{x0:.6f})*{(y1 - y0) / (x1 - x0):.6f})"
expr = seg if expr is None else f"if(lt({var},{x1:.6f}),{seg},{expr})"
return expr
def frames_expr(values):
"""Per-frame value; perspective counts input frames from one."""
return "+".join(f"eq(in,{k + 1})*{v:.3f}" for k, v in enumerate(values))
def piece_chain(label, piece, start, f0, f1, out):
"""Retimes one recording piece into frames f0..f1 of a section."""
anchors = [((start + b) * BEAT, s) for b, s in piece["anchors"]]
inverse = [(s, t) for t, s in anchors]
u0 = max(lerp(anchors, f0 / FPS) - 0.1, 0)
u1 = lerp(anchors, f1 / FPS) + 0.1
pts = piecewise(inverse, f"({u0:.6f}+T)")
n = f1 - f0
return (
f"[{label}:v]trim=start={u0:.4f}:end={u1:.4f},setpts=PTS-STARTPTS,"
f"setpts='({pts}-{f0 / FPS:.6f})/TB',fps={FPS}:start_time=0,"
f"trim=end_frame={n},tpad=stop_mode=clone:stop={n},trim=end_frame={n},setpts=N/{FPS}/TB,"
f"{RGB_IN},setsar=1[{out}]"
)
def section(s, scene, end, plan):
"""Renders one app section to a lossless file."""
app, first, n_plate = s["app"], scene["first"], scene["frames"]
length = end - first
start = scene["bars"][0] * 4 - first / FPS / BEAT
inputs, chains, parts = [], [], []
bounds = []
for i, p in enumerate(s["pieces"]):
a = p["anchors"]
f0 = 0 if i == 0 else math.ceil((start + a[0][0]) * BEAT * FPS - 1e-6)
bounds.append(f0)
bounds.append(length)
for i, p in enumerate(s["pieces"]):
inputs += ["-i", REC / f"{p['src']}.mp4"]
chains.append(piece_chain(i, p, start, bounds[i], bounds[i + 1], f"p{i}"))
parts.append(f"[p{i}]")
k = len(inputs) // 2
inputs += ["-framerate", FPS, "-i", SCENES / scene["name"] / "%05d.png"]
inputs += ["-loop", 1, "-framerate", FPS, "-i", WORK / "cards" / "frame.png"]
inputs += ["-loop", 1, "-framerate", FPS, "-i", WORK / "cards" / f"{app}.png"]
chains.append(f"{''.join(parts)}concat=n={len(parts)}:v=1:a=0[rec]")
chains.append(f"[rec]split[ra][rb]")
chains.append(plate_chain(scene, f"[ra]trim=end_frame={n_plate},setpts=PTS-STARTPTS", k, "plate"))
chains.append(full_chain(s, plan, f"[rb]trim=start_frame={n_plate},setpts=PTS-STARTPTS", length - n_plate, k, "full"))
chains.append("[plate][full]concat=n=2:v=1:a=0,format=rgb24[v]")
target = WORK / f"{app}.mkv"
run([*inputs, "-filter_complex", ";".join(chains), "-map", "[v]", "-frames:v", length,
"-c:v", "libx264rgb", "-qp", 0, "-preset", "ultrafast", target])
return target
def plate_chain(scene, rec, k, out):
"""Pins the recording onto the plate's screen face."""
data = json.loads((SCENES / f"{scene['name']}.json").read_text())["frames"]
landed = next(f["corners"] for f in data if f["corners"])
corners = [f["corners"] or landed for f in data]
pins = ":".join(
f"{axis}{c}='{frames_expr([fr[c][j] for fr in corners])}'"
for c in range(4) for j, axis in enumerate("xy")
)
fades = "".join(
f",colorchannelmixer=aa={f['opacity']:.3f}:enable='eq(n,{f['local']})'"
for f in data if f["opacity"] < 0.999
)
return (
f"{rec},scale=800:450:flags=area,scale=1912:1072:flags=bicubic,format=rgba,"
f"pad=1920:1080:4:4:color=black@0,setsar=1,perspective={pins}:sense=destination:eval=frame:interpolation=cubic"
f"{fades}[pin];[{k}:v]format=rgba[bg];[bg][pin]overlay=format=rgb:eof_action=repeat,format=rgb24,setsar=1[{out}]"
)
def full_chain(s, plan, rec, n, k, out):
"""Letterboxes the recording with a slow push and the caption."""
w = plan["window"]
fx, fy = s["focus"]
z = f"(1+{plan['push'] - 1:.4f}*in/{n - 1})"
cw, ch = f"(1920/{z})", f"(1080/{z})"
left = f"clip({fx}-{cw}/2,0,1920-{cw})"
top = f"clip({fy}-{ch}/2,0,1080-{ch})"
quad = (f"x0='{left}':y0='{top}':x1='{left}+{cw}':y1='{top}':"
f"x2='{left}':y2='{top}+{ch}':x3='{left}+{cw}':y3='{top}+{ch}'")
rise = "round(12*pow(max(0,1-t/0.2),2))"
return (
f"{rec},perspective={quad}:sense=source:eval=frame:interpolation=cubic,"
f"scale={w['w']}:{w['h']}:flags=lanczos,pad=1920:1080:{w['x']}:{w['y']}:color=0xfbfbfa,setsar=1,format=rgba[lb];"
f"[{k + 1}:v]format=rgba[fr];[lb][fr]overlay=format=rgb:shortest=0[lf];"
f"[{k + 2}:v]format=rgba,fade=in:st=0:d=0.2:alpha=1[cap];"
f"[lf][cap]overlay=x=0:y='{rise}':format=rgb:eval=frame,trim=end_frame={n},format=rgb24,setsar=1[{out}]"
)
def scene_input(name):
return ["-framerate", FPS, "-i", SCENES / name / "%05d.png"]
def film(made):
"""Joins every part and the music into the final MP4."""
order = ["opening", "rise", *made, "files", "props", "end"]
inputs = []
for part in order:
inputs += ["-i", part] if isinstance(part, Path) else scene_input(part)
inputs += ["-i", OUT / "music.wav"]
joined = "".join(f"[{i}:v]" for i in range(len(order)))
graph = (f"{joined}concat=n={len(order)}:v=1:a=0,"
"scale=out_color_matrix=bt709:out_range=tv,format=yuv420p[v]")
run([*inputs, "-filter_complex", graph, "-map", "[v]", "-map", f"{len(order)}:a",
"-frames:v", 2700, "-c:v", "libx264", "-profile:v", "high", "-preset", "slow", "-crf", 18,
"-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
"-r", FPS, "-c:a", "aac", "-b:a", "192k", "-t", "90", "-movflags", "+faststart", FILM])
def main():
plan = json.loads((HERE / "plan.json").read_text())
scenes = json.loads((SCENES / "scenes.json").read_text())["scenes"]
WORK.mkdir(parents=True, exist_ok=True)
subprocess.run(["node", HERE / "cards.mjs"], check=True)
only = sys.argv[1:]
made = []
for s in plan["sections"]:
i = next(j for j, sc in enumerate(scenes) if sc["name"] == f"plate-{s['app']}")
target = WORK / f"{s['app']}.mkv"
if not only or s["app"] in only:
target = section(s, scenes[i], scenes[i + 1]["first"], plan)
made.append(target)
if not only:
film(made)
if __name__ == "__main__":
main()