Files
OSGKeyboard/Scripts/compose_feature_cards.py
rockyandClaude Opus 5 5d51c5bf77 chore: snapshot uncommitted local work before rebasing onto cloud-agent
Safety commit so the 94 uncommitted files in the working tree (in-flight
Account/Mac/Tip work plus the feature-preview recording tooling) survive the
move onto cursor/cloud-agent-1787824905651-vxuwc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:28:07 -07:00

207 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""Compose short, silent, seamlessly looping feature clips for in-app cards.
Different deliverable from `compose_feature_previews.py`: no captions, no
title / end cards, no audio track, and cropped to the keyboard chrome so the
card shows the real keyboard rather than a stand-in host document.
Each clip is built to loop — the last frame matches the first, so a card can
autoplay it forever without a visible cut.
Reuses the raw recordings from `Scripts/record_feature_previews.sh` and the
CFR normalisation from `compose_feature_previews.py`.
Usage:
Scripts/compose_feature_cards.py [slug ...]
Output: docs/assets/feature-cards/<slug>.mp4 (+ <slug>-poster.png)
"""
from __future__ import annotations
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from compose_feature_previews import RAW_DIR, constant_frame_rate, run # noqa: F401
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "docs" / "assets" / "feature-cards"
WORK = ROOT / ".tmp" / "feature-previews" / "cards"
FPS = 30
# Source-frame crop covering the keyboard chrome across every scenario. The
# clipboard panel is the tallest thing that appears there (top edge y=1779),
# and the bottom 72px of the screen is empty padding below the key row.
CROP_X, CROP_W = 0, 1290
CROP_Y, CROP_H = 1780, 944
# Overlap used to make the cut back to frame 0 invisible.
LOOP_BLEND = 0.5
@dataclass
class Card:
slug: str
# Raw recording slug under `.tmp/feature-previews/raw/`.
raw: str
# Window into the CFR recording, in real seconds.
start: float
duration: float
# Static pages have nothing to animate; zoom in then back out so the clip
# still moves and still loops perfectly.
ping_pong_zoom: float = 1.0
# Crop override for clips that are not the keyboard (e.g. a settings page).
crop_y: int = CROP_Y
crop_h: int = CROP_H
def probe_duration(path: Path) -> float:
out = subprocess.check_output(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "json", str(path)],
text=True,
)
return float(json.loads(out)["format"]["duration"])
def encode_common(dest: Path) -> list[str]:
return [
"-an", # cards are silent
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-profile:v", "high", "-level", "4.0", "-crf", "20",
"-colorspace", "bt709", "-color_primaries", "bt709",
"-color_trc", "iec61966-2-1", "-color_range", "tv",
"-r", str(FPS), "-movflags", "+faststart",
str(dest),
]
def build_motion(card: Card, work: Path) -> Path:
"""Trim + crop the window, or build a ping-pong zoom over a frozen frame."""
src = constant_frame_rate(card.raw)
dest = work / f"{card.slug}-motion.mp4"
crop = f"crop={CROP_W}:{card.crop_h}:{CROP_X}:{card.crop_y}"
if card.ping_pong_zoom > 1.0:
frame = work / f"{card.slug}-still.png"
run(["ffmpeg", "-y", "-ss", f"{card.start:.2f}", "-i", str(src),
"-frames:v", "1", "-vf", crop, str(frame)])
# Build only the zoom-in half and mirror it. Driving both directions
# from a single zoompan expression relies on its output frame count
# matching `duration * FPS`, which it does not — the clip then stops
# part-way back out and the loop visibly jumps.
half = card.duration / 2
frames = max(2, int(half * FPS))
step = (card.ping_pong_zoom - 1.0) / frames
vf = (
f"zoompan=z='min(1+{step:.6f}*on,{card.ping_pong_zoom:.4f})'"
f":x='(iw-iw/zoom)/2':y='(ih-ih/zoom)/2'"
f":d=1:s={CROP_W}x{card.crop_h}:fps={FPS},setsar=1"
)
forward = work / f"{card.slug}-zoomin.mp4"
run([
# A looped PNG defaults to 25 fps; without this the 30 fps zoompan
# emits 25/30 of the frames asked for and the clip comes up short.
"ffmpeg", "-y", "-loop", "1", "-framerate", str(FPS),
"-t", f"{half:.2f}",
"-i", str(frame), "-vf", vf, "-t", f"{half:.2f}",
*encode_common(forward),
])
run([
"ffmpeg", "-y", "-i", str(forward), "-i", str(forward),
"-filter_complex",
"[1:v]reverse[r];[0:v][r]concat=n=2:v=1:a=0,format=yuv420p[v]",
"-map", "[v]", *encode_common(dest),
])
return dest
run([
"ffmpeg", "-y", "-ss", f"{card.start:.2f}", "-i", str(src),
"-vf", f"{crop},fps={FPS},setsar=1",
# The CFR pass already fixed timing, but clamp on the output side so
# the window length is exact regardless of keyframe placement.
"-t", f"{card.duration:.2f}",
*encode_common(dest),
])
return dest
def close_loop(source: Path, dest: Path, blend: float) -> None:
"""Fade the tail back over the head so frame 0 and the last frame match."""
total = probe_duration(source)
body = total - blend
chain = (
f"[0:v]split=2[head][tail];"
f"[head]trim=start=0:end={body:.3f},setpts=PTS-STARTPTS[h];"
f"[tail]trim=start={body:.3f}:end={total:.3f},setpts=PTS-STARTPTS,"
f"format=yuva420p,fade=t=out:st=0:d={blend:.3f}:alpha=1[t];"
f"[h][t]overlay=0:0:eof_action=pass,format=yuv420p[v]"
)
run([
"ffmpeg", "-y", "-i", str(source),
"-filter_complex", chain, "-map", "[v]",
*encode_common(dest),
])
CARDS: dict[str, Card] = {}
def build(card: Card) -> Path:
WORK.mkdir(parents=True, exist_ok=True)
OUT_DIR.mkdir(parents=True, exist_ok=True)
motion = build_motion(card, WORK)
final = OUT_DIR / f"{card.slug}.mp4"
if card.ping_pong_zoom > 1.0:
# A ping-pong already ends where it started; blending would only
# duplicate frames.
motion.replace(final)
else:
close_loop(motion, final, LOOP_BLEND)
poster = OUT_DIR / f"{card.slug}-poster.png"
run(["ffmpeg", "-y", "-i", str(final), "-frames:v", "1", str(poster)])
return final
def main(argv: list[str]) -> int:
from feature_card_specs import CARDS as configured # noqa: PLC0415
slugs = argv or list(configured)
failures = 0
for slug in slugs:
card = configured.get(slug)
if card is None:
sys.stderr.write(f"unknown card: {slug}\n")
failures += 1
continue
final = build(card)
info = json.loads(subprocess.check_output(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,nb_frames",
"-show_entries", "format=duration,size,nb_streams",
"-of", "json", str(final)],
text=True,
))
stream, fmt = info["streams"][0], info["format"]
duration = float(fmt["duration"])
silent = int(fmt["nb_streams"]) == 1
ok = 5.0 <= duration <= 11.0 and silent
print(
f"{'OK ' if ok else '!! '}{final.name} "
f"{stream['width']}x{stream['height']} {duration:.1f}s "
f"{int(fmt['size']) / 1000:.0f} KB {'silent' if silent else 'HAS AUDIO'}"
)
if not ok:
failures += 1
return 1 if failures else 0
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parent))
raise SystemExit(main(sys.argv[1:]))