import json
import os
import subprocess
import sys
import tempfile
from PIL import Image, ImageDraw, ImageFont
WEBM, TL, OUT_MP4, OUT_WEBP = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
tl = json.load(open(TL))
W, H = tl["size"]
KF = tl["keyframes"]
FPS = 30
OUT_W = 900
OUT_H = round(OUT_W * H / W) PAD = 64
ZMAX = 1.85
MOVE = 600 BAR_H = 44
BAR_BG = (24, 23, 28)
TXT = (236, 236, 240)
ACCENT = (147, 167, 239)
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 19)
def cam_for(focus):
if not focus:
return (0.5, 0.5, 1.0)
x, y, w, h = focus
h = min(h, H * 0.5)
z = max(1.0, min(ZMAX, min(W / (w + 2 * PAD), H / (h + 2 * PAD))))
return ((x + w / 2) / W, (y + h / 2) / H, z)
CAMS = [cam_for(k["focus"]) for k in KF]
ATS = [k["at"] for k in KF]
CAPS = [k.get("caption", "") for k in KF]
def ease(t): return 4 * t * t * t if t < 0.5 else 1 - (-2 * t + 2) ** 3 / 2
def camera_at(t_ms):
if t_ms <= ATS[0]:
return CAMS[0]
for i in range(len(ATS) - 1):
if ATS[i] <= t_ms < ATS[i + 1]:
move_start = ATS[i + 1] - MOVE
if t_ms < move_start:
return CAMS[i]
e = ease((t_ms - move_start) / MOVE)
return tuple(CAMS[i][d] + (CAMS[i + 1][d] - CAMS[i][d]) * e for d in range(3))
return CAMS[-1]
def caption_at(t_ms):
cap = CAPS[0]
for i in range(1, len(ATS)):
if t_ms >= ATS[i] - MOVE:
cap = CAPS[i]
return cap
def crop(img, cam):
cxf, cyf, z = cam
cw, ch = W / z, H / z
x0 = min(max(cxf * W - cw / 2, 0), W - cw)
y0 = min(max(cyf * H - ch / 2, 0), H - ch)
region = img.crop((round(x0), round(y0), round(x0 + cw), round(y0 + ch)))
return region.resize((OUT_W, OUT_H), Image.LANCZOS)
def caption_bar(text):
bar = Image.new("RGB", (OUT_W, BAR_H), BAR_BG)
d = ImageDraw.Draw(bar)
tw = d.textlength(text, font=font)
x = (OUT_W - tw) / 2
d.text((x, (BAR_H - 22) // 2), text, font=font, fill=TXT)
d.ellipse([x - 17, BAR_H / 2 - 3, x - 11, BAR_H / 2 + 3], fill=ACCENT)
return bar
def main():
work = tempfile.mkdtemp(prefix="sw-zoom-")
frames = os.path.join(work, "in")
out = os.path.join(work, "out")
os.makedirs(frames)
os.makedirs(out)
subprocess.run(
["ffmpeg", "-nostdin", "-loglevel", "error", "-i", WEBM,
"-vf", f"fps={FPS}", "-start_number", "0", os.path.join(frames, "%05d.png")],
check=True,
)
src = sorted(os.listdir(frames))
bars = {} for idx, name in enumerate(src):
t = idx / FPS * 1000
img = Image.open(os.path.join(frames, name)).convert("RGB")
frame = crop(img, camera_at(t))
cap = caption_at(t)
if cap not in bars:
bars[cap] = caption_bar(cap)
canvas = Image.new("RGB", (OUT_W, OUT_H + BAR_H), BAR_BG)
canvas.paste(frame, (0, 0))
canvas.paste(bars[cap], (0, OUT_H))
canvas.save(os.path.join(out, f"{idx:05d}.png"))
subprocess.run(
["ffmpeg", "-nostdin", "-loglevel", "error", "-framerate", str(FPS),
"-i", os.path.join(out, "%05d.png"),
"-c:v", "libx264", "-crf", "23", "-preset", "slow", "-pix_fmt", "yuv420p",
"-movflags", "+faststart", OUT_MP4, "-y"],
check=True,
)
subprocess.run(
["ffmpeg", "-nostdin", "-loglevel", "error", "-i", OUT_MP4,
"-vf", "fps=12,scale=720:-2:flags=lanczos", "-vcodec", "libwebp",
"-lossless", "0", "-q:v", "50", "-loop", "0", OUT_WEBP, "-y"],
check=True,
)
subprocess.run(["rm", "-rf", work])
for f in (OUT_MP4, OUT_WEBP):
print(f"{f} {os.path.getsize(f)/1024:.0f} KB")
if __name__ == "__main__":
main()