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>
207 lines
7.2 KiB
Python
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:]))
|