Files
OSGKeyboard/Scripts/compose_feature_previews.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

442 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""Compose the four Chinese feature preview clips for OSGKeyboard.
Takes raw Simulator screen recordings (1290x2796, iPhone 16 Plus) produced by
`Scripts/record_feature_previews.sh` and turns each into an App Store Connect
compliant preview: title card -> captioned footage -> end card, plus a soft
generated audio bed.
This ffmpeg build ships without libfreetype / libass, so every piece of text is
rendered to RGBA PNG with Pillow and composited with `overlay`.
Usage:
Scripts/compose_feature_previews.py [slug ...]
Output: docs/assets/app-preview/zh/OSGKeyboard-<slug>-6.7-zh.mp4
"""
from __future__ import annotations
import json
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
RAW_DIR = ROOT / ".tmp" / "feature-previews" / "raw"
WORK_ROOT = ROOT / ".tmp" / "feature-previews" / "work"
OUT_DIR = ROOT / "docs" / "assets" / "app-preview" / "zh"
W, H = 1290, 2796 # App Store Connect 6.7" / 6.9" portrait
FPS = 30
ICON = ROOT / "docs" / "assets" / "app-icon.png"
# No PingFang on every machine; Hiragino Sans GB is the cleanest CJK fallback.
FONT_CANDIDATES = [
(Path("/System/Library/Fonts/Hiragino Sans GB.ttc"), 1),
(Path("/System/Library/Fonts/Hiragino Sans GB.ttc"), 0),
(Path("/System/Library/Fonts/STHeiti Medium.ttc"), 0),
(Path("/System/Library/Fonts/Supplemental/Songti.ttc"), 0),
]
BG = (11, 13, 12)
FG = (245, 246, 248)
ACCENT = (52, 168, 96)
# --------------------------------------------------------------------------
# Timeline description
# --------------------------------------------------------------------------
@dataclass
class Caption:
"""A lower-third super burned over the footage."""
start: float
end: float
text: str
# Second line, rendered smaller under `text`.
sub: str | None = None
@dataclass
class Source:
"""One trimmed window of raw footage, with its own captions.
A clip can chain several — the voice clip cuts from the polish flow in the
keyboard to the Styles page that owns the playful personalities.
"""
# Raw recording slug, i.e. `.tmp/feature-previews/raw/<raw>.mov`.
raw: str
trim_start: float
trim_duration: float
captions: list[Caption] = field(default_factory=list)
# `simctl io recordVideo` only emits frames when the screen changes, so a
# static page yields a few seconds of footage no matter how long you wait.
# Set this to grab one frame at that timestamp and hold it for
# `trim_duration` instead, letting `ken_burns` supply the motion.
freeze_at: float | None = None
# Slow push-in over otherwise-static footage (e.g. a settings page that
# does not animate). Expressed as the final zoom factor; 1.0 disables it.
ken_burns: float = 1.0
# Normalised focal point for the push-in — (0.5, 0.5) is dead centre.
ken_burns_focus: tuple[float, float] = (0.5, 0.5)
@dataclass
class Clip:
slug: str
# Title card copy.
title: list[str]
subtitle: str
sources: list[Source]
title_seconds: float = 2.4
end_seconds: float = 2.6
end_title: str = "OSGKeyboard"
end_subtitle: str = "开口即文字"
@property
def body_seconds(self) -> float:
return sum(source.trim_duration for source in self.sources)
@property
def total_seconds(self) -> float:
return self.title_seconds + self.body_seconds + self.end_seconds
def font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for path, index in FONT_CANDIDATES:
if not path.exists():
continue
try:
return ImageFont.truetype(str(path), size=size, index=index)
except OSError:
continue
return ImageFont.load_default()
def text_width(draw: ImageDraw.ImageDraw, text: str, fnt) -> int:
box = draw.textbbox((0, 0), text, font=fnt)
return box[2] - box[0]
def centered(draw: ImageDraw.ImageDraw, y: int, text: str, fnt, fill) -> None:
draw.text(((W - text_width(draw, text, fnt)) // 2, y), text, font=fnt, fill=fill)
# --------------------------------------------------------------------------
# Card + caption rendering
# --------------------------------------------------------------------------
def title_card(lines: list[str], subtitle: str, with_icon: bool = True) -> Image.Image:
im = Image.new("RGB", (W, H), BG)
glow = Image.new("RGB", (W, H), BG)
gdraw = ImageDraw.Draw(glow)
cx, cy = W // 2, int(H * 0.40)
for radius, lift in ((560, 26), (380, 38), (230, 52)):
gdraw.ellipse(
(cx - radius, cy - radius, cx + radius, cy + radius),
fill=(BG[0] + lift // 5, BG[1] + lift // 2, BG[2] + lift // 4),
)
im = Image.blend(im, glow, 0.6)
draw = ImageDraw.Draw(im)
y = int(H * 0.36)
if with_icon and ICON.exists():
icon = Image.open(ICON).convert("RGBA").resize((216, 216), Image.Resampling.LANCZOS)
mask = Image.new("L", (216, 216), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, 215, 215), radius=48, fill=255)
im.paste(icon, ((W - 216) // 2, y - 300), mask)
title_font = font(104 if len(lines) == 1 else 92)
for line in lines:
centered(draw, y, line, title_font, FG)
y += 132
if subtitle:
centered(draw, y + 28, subtitle, font(46), (146, 166, 150))
return im
def caption_overlay(text: str, sub: str | None) -> Image.Image:
"""Full-frame RGBA layer: bottom scrim + caption. Composited via overlay.
The app UI is light, so the scrim has to carry real weight — a gentle
gradient leaves white supers unreadable over a white settings card.
"""
im = Image.new("RGBA", (W, H), (0, 0, 0, 0))
draw = ImageDraw.Draw(im)
scrim_height = 780
for i in range(scrim_height):
# Sub-linear ramp: the supers sit well above the bottom edge, so the
# gradient has to carry real weight by the time it reaches them.
alpha = int(246 * (i / (scrim_height - 1)) ** 0.85)
draw.line([(0, H - scrim_height + i), (W, H - scrim_height + i)], fill=(0, 0, 0, alpha))
main_font = font(66)
baseline = H - 320 if sub else H - 268
centered(draw, baseline, text, main_font, (255, 255, 255, 255))
if sub:
centered(draw, baseline + 104, sub, font(44), (203, 221, 207, 255))
return im
# --------------------------------------------------------------------------
# ffmpeg plumbing
# --------------------------------------------------------------------------
def run(cmd: list[str]) -> None:
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
sys.stderr.write(" ".join(cmd) + "\n")
sys.stderr.write(proc.stderr[-4000:] + "\n")
raise SystemExit(f"ffmpeg failed ({proc.returncode})")
def probe(path: Path) -> dict:
out = subprocess.check_output(
[
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height,r_frame_rate,codec_name",
"-show_entries", "format=duration,size",
"-of", "json",
str(path),
],
text=True,
)
return json.loads(out)
def still_to_segment(image: Image.Image, seconds: float, dest: Path, zoom_in: bool) -> None:
src = dest.with_suffix(".png")
image.save(src, optimize=True)
frames = max(1, int(seconds * FPS))
if zoom_in:
z = "min(zoom+0.00042,1.07)"
else:
z = "if(eq(on,1),1.07,max(zoom-0.00042,1.0))"
vf = (
f"scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},"
f"zoompan=z='{z}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':"
f"d={frames}:s={W}x{H}:fps={FPS},setsar=1,format=yuv420p"
)
run([
"ffmpeg", "-y", "-loop", "1", "-i", str(src),
"-vf", vf, "-t", f"{seconds:.2f}",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-profile:v", "high", "-level", "4.0", "-crf", "18", "-r", str(FPS),
"-colorspace", "bt709", "-color_primaries", "bt709",
"-color_trc", "iec61966-2-1", "-color_range", "tv",
str(dest),
])
def constant_frame_rate(slug: str) -> Path:
"""Return a CFR copy of a raw recording, building it on first use.
`simctl io recordVideo` writes variable-frame-rate footage whose timeline
does not advance at wall-clock rate, so seeking into it lands in the wrong
place and `-t` windows come out the wrong length. Normalising once up
front makes every `trim_start` / `trim_duration` below mean real seconds.
"""
raw = RAW_DIR / f"{slug}.mov"
if not raw.exists():
raise SystemExit(f"missing recording: {raw}")
cfr = WORK_ROOT / "cfr" / f"{slug}.mp4"
if cfr.exists() and cfr.stat().st_mtime >= raw.stat().st_mtime:
return cfr
cfr.parent.mkdir(parents=True, exist_ok=True)
run([
"ffmpeg", "-y", "-i", str(raw),
"-vf", f"fps={FPS},scale={W}:{H}:force_original_aspect_ratio=increase,"
f"crop={W}:{H},setsar=1",
"-vsync", "cfr",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "16",
"-colorspace", "bt709", "-color_primaries", "bt709",
"-color_trc", "iec61966-2-1", "-color_range", "tv",
"-r", str(FPS), str(cfr),
])
return cfr
def footage_segment(source: Source, work: Path, dest: Path, index: int) -> None:
"""Trim one recording, normalise to 1290x2796, burn its captions."""
raw = constant_frame_rate(source.raw)
if source.freeze_at is None:
inputs: list[str] = [
"-ss", f"{source.trim_start:.2f}",
"-t", f"{source.trim_duration:.2f}",
"-i", str(raw),
]
else:
frame = work / f"freeze-{index:02d}.png"
run([
"ffmpeg", "-y", "-ss", f"{source.freeze_at:.2f}", "-i", str(raw),
"-frames:v", "1", str(frame),
])
inputs = [
"-loop", "1", "-t", f"{source.trim_duration:.2f}", "-i", str(frame),
]
for idx, cap in enumerate(source.captions):
png = work / f"cap-{index:02d}-{idx:02d}.png"
caption_overlay(cap.text, cap.sub).save(png)
# A single-frame input has pts 0 forever, which freezes `fade` at its
# t=0 value and leaves the super permanently transparent. Loop it so
# the overlay carries a real timeline for the fades to run against.
inputs += ["-loop", "1", "-t", f"{source.trim_duration:.2f}", "-i", str(png)]
# Normalise the recording first, then stack caption overlays on top.
normalise = (
f"scale={W}:{H}:force_original_aspect_ratio=increase,"
f"crop={W}:{H},fps={FPS}"
)
if source.ken_burns > 1.0:
# Static pages (a settings screen) need the motion to come from the
# camera. Step per frame so the push-in lands exactly at `ken_burns`.
frames = max(1, int(source.trim_duration * FPS))
step = (source.ken_burns - 1.0) / frames
fx, fy = source.ken_burns_focus
normalise += (
f",zoompan=z='min(zoom+{step:.6f},{source.ken_burns:.4f})'"
f":x='(iw-iw/zoom)*{fx:.3f}':y='(ih-ih/zoom)*{fy:.3f}'"
f":d=1:s={W}x{H}:fps={FPS}"
)
chain = [f"[0:v]{normalise},setsar=1[base]"]
current = "base"
for idx, cap in enumerate(source.captions):
nxt = f"ov{idx}"
# Fade the supers in and out so cuts do not pop.
chain.append(
f"[{idx + 1}:v]format=rgba,"
f"fade=t=in:st={cap.start:.2f}:d=0.28:alpha=1,"
f"fade=t=out:st={max(cap.start, cap.end - 0.28):.2f}:d=0.28:alpha=1[c{idx}]"
)
chain.append(
f"[{current}][c{idx}]overlay=0:0:"
f"enable='between(t,{cap.start:.2f},{cap.end:.2f})'[{nxt}]"
)
current = nxt
chain.append(f"[{current}]format=yuv420p[vout]")
run([
"ffmpeg", "-y", *inputs,
"-filter_complex", ";".join(chain),
"-map", "[vout]",
# `simctl io recordVideo` writes variable-frame-rate footage, so an
# input-side `-t` drifts once `fps=` fills the gaps. Clamp the output
# instead or the finished clip overruns the App Store 30s ceiling.
"-t", f"{source.trim_duration:.2f}",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-profile:v", "high", "-level", "4.0", "-crf", "18", "-r", str(FPS),
"-colorspace", "bt709", "-color_primaries", "bt709",
"-color_trc", "iec61966-2-1", "-color_range", "tv",
str(dest),
])
def build(clip: Clip) -> Path:
work = WORK_ROOT / clip.slug
work.mkdir(parents=True, exist_ok=True)
OUT_DIR.mkdir(parents=True, exist_ok=True)
title = work / "00-title.mp4"
still_to_segment(title_card(clip.title, clip.subtitle), clip.title_seconds, title, True)
parts = [title]
for index, source in enumerate(clip.sources):
part = work / f"{index + 1:02d}-body.mp4"
footage_segment(source, work, part, index)
parts.append(part)
end = work / "99-end.mp4"
still_to_segment(
title_card([clip.end_title], clip.end_subtitle), clip.end_seconds, end, False
)
parts.append(end)
concat = work / "concat.txt"
concat.write_text("".join(f"file '{p}'\n" for p in parts), encoding="utf-8")
silent = work / "silent.mp4"
run([
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat),
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18",
"-colorspace", "bt709", "-color_primaries", "bt709",
"-color_trc", "iec61966-2-1", "-color_range", "tv",
"-r", str(FPS), "-movflags", "+faststart", str(silent),
])
# App previews need an audio track; generate a soft royalty-free bed.
total = clip.total_seconds
fade_out = max(0.0, total - 2.2)
final = OUT_DIR / f"OSGKeyboard-{clip.slug}-6.7-zh.mp4"
run([
"ffmpeg", "-y", "-i", str(silent),
"-f", "lavfi", "-i",
f"sine=frequency=196:sample_rate=44100,volume=0.030,"
f"afade=t=in:st=0:d=1.1,afade=t=out:st={fade_out:.2f}:d=2.0",
"-f", "lavfi", "-i",
f"sine=frequency=293.66:sample_rate=44100,volume=0.018,"
f"afade=t=in:st=0:d=1.4,afade=t=out:st={fade_out:.2f}:d=2.0",
"-filter_complex", "[1:a][2:a]amix=inputs=2:duration=first:dropout_transition=2[a]",
"-map", "0:v", "-map", "[a]",
"-c:v", "copy", "-c:a", "aac", "-b:a", "128k",
"-shortest", "-movflags", "+faststart", str(final),
])
return final
CLIPS: dict[str, Clip] = {}
def main(argv: list[str]) -> int:
from feature_preview_clips import CLIPS as configured # noqa: PLC0415
slugs = argv or list(configured)
failures = 0
for slug in slugs:
clip = configured.get(slug)
if clip is None:
sys.stderr.write(f"unknown clip: {slug}\n")
failures += 1
continue
final = build(clip)
info = probe(final)
duration = float(info["format"]["duration"])
stream = info["streams"][0]
size_mb = int(info["format"]["size"]) / 1_000_000
ok = (
stream["width"] == W
and stream["height"] == H
and 15.0 <= duration <= 30.0
and stream["codec_name"] == "h264"
)
flag = "OK " if ok else "!! "
print(
f"{flag}{final.name} {stream['width']}x{stream['height']} "
f"{duration:.1f}s {size_mb:.1f} MB {stream['codec_name']}"
)
if not ok:
failures += 1
if not 15.0 <= duration <= 30.0:
print(f" duration {duration:.1f}s outside the App Store 15-30s window")
return 1 if failures else 0
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parent))
raise SystemExit(main(sys.argv[1:]))