Files
ElyOffice/site/video/fx/mock.py
T

92 lines
3.1 KiB
Python

#!/usr/bin/env python3
"""Composites fx layers over a mock letterboxed recording for review."""
import io
import json
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from PIL import Image
HERE = Path(__file__).resolve().parent
OUT = HERE.parent / "out"
PLAN = json.loads((HERE.parent / "edit" / "plan.json").read_text())
FPS = 30
BEAT = 60 / 112
STARTS = {"doc": 32, "table": 48, "show": 64, "pdf": 80, "tree": 96, "mark": 112}
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 source(app, frame):
"""Recording file and time shown at a global frame."""
pieces = next(s for s in PLAN["sections"] if s["app"] == app)["pieces"]
beat = frame / FPS / BEAT - STARTS[app]
piece = pieces[0]
for p in pieces[1:]:
if beat >= p["anchors"][0][0]:
piece = p
return OUT / "rec" / f"{piece['src']}.mp4", lerp(piece["anchors"], beat)
def grab(path, t):
png = subprocess.run(
["ffmpeg", "-v", "error", "-ss", f"{max(t, 0):.3f}", "-i", str(path), "-frames:v", "1",
"-f", "image2pipe", "-vcodec", "png", "-"],
check=True, capture_output=True).stdout
return Image.open(io.BytesIO(png)).convert("RGB")
def layer(name, frame):
path = OUT / "extra" / name / f"{frame:05d}.png"
return Image.open(path).convert("RGBA") if path.exists() else None
def compose(app, frame, rect):
"""One mock frame: recording through the view, cards, then layers."""
win = PLAN["window"]
x, y, w, h = rect
rec = grab(*source(app, frame)).crop((round(x), round(y), round(x + w), round(y + h)))
canvas = Image.new("RGBA", (1920, 1080), "#fbfbfa")
canvas.paste(rec.resize((win["w"], win["h"]), Image.LANCZOS), (win["x"], win["y"]))
for card in (OUT / "edit" / "cards" / "frame.png", OUT / "edit" / "cards" / f"{app}.png"):
canvas.alpha_composite(Image.open(card).convert("RGBA"))
for name in ("fx-grid", f"{app}-fx"):
top = layer(name, frame)
if top is not None:
canvas.alpha_composite(top)
return canvas.convert("RGB")
def main():
app, target = sys.argv[1], Path(sys.argv[2])
picks = [int(f) for f in sys.argv[3:]]
track = json.loads((OUT / "extra" / f"{app}-fx" / "view.json").read_text())
first, rects = track["first"], track["rects"]
frames = picks or list(range(first, first + len(rects)))
target.mkdir(parents=True, exist_ok=True)
def one(frame):
compose(app, frame, rects[frame - first]).save(target / f"{app}-{frame:05d}.png")
with ThreadPoolExecutor(8) as pool:
list(pool.map(one, frames))
if not picks:
subprocess.run(
["ffmpeg", "-v", "error", "-y", "-framerate", str(FPS), "-start_number", str(first),
"-i", str(target / f"{app}-%05d.png"), "-c:v", "libx264", "-crf", "18",
"-pix_fmt", "yuv420p", str(target / f"{app}-mock.mp4")],
check=True)
if __name__ == "__main__":
main()