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>
442 lines
16 KiB
Python
Executable File
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:]))
|