chore(previews): record English feature previews and add App Store creative asset scripts
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the App Store creative assets (product page header + search results).
|
||||
|
||||
Specs (App Store Connect › Creative assets specifications):
|
||||
* Header video 21:9 3840×1646 30 fps, 5–30 s, mp4
|
||||
* Search video 3:2 1920×1280…3840×2560
|
||||
* Universal still 16:9 5244×2950 PNG (no video allowed at this size)
|
||||
Safe areas are taken from Apple's creative-asset Sketch template.
|
||||
|
||||
Scenes are deterministic canvas pages (header.html / search.html) rendered by
|
||||
render.cjs; search results composite the real simulator recordings from
|
||||
`Scripts/record_feature_previews.sh` into the device screens.
|
||||
|
||||
Usage:
|
||||
NODE_PATH=.tmp/appstore-assets/node_modules \\
|
||||
python3 Scripts/appstore-creative/compose.py [header|search|stills ...] [--lang zh,en]
|
||||
|
||||
Output: .tmp/appstore-creative/out/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
WORK = ROOT / ".tmp" / "appstore-creative" / "work"
|
||||
OUT = ROOT / ".tmp" / "appstore-creative" / "out"
|
||||
RAW = {
|
||||
"zh": ROOT / ".tmp" / "feature-previews" / "raw",
|
||||
"en": ROOT / ".tmp" / "feature-previews" / "raw-en",
|
||||
}
|
||||
|
||||
FPS = 30
|
||||
DURATION = 10.0 # every scene loops on this period
|
||||
LOOP_BLEND = 0.6 # tail-over-head crossfade that closes each screen loop
|
||||
POSTER_T = 7.4 # header: full line written, sheen finished
|
||||
|
||||
HEADER_SIZE = (3840, 1646)
|
||||
HEADER_SAFE = "1097,493,1646,661"
|
||||
UNIVERSAL_SIZE = (5244, 2950)
|
||||
UNIVERSAL_SAFE = "1921,660,1402,962"
|
||||
SEARCH_SIZE = (3840, 2560)
|
||||
# H.264 above Level 5.1 is not universally decodable; ship a 5.1 copy too.
|
||||
SEARCH_COMPAT = (3072, 2048)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Window:
|
||||
"""Segments of a raw recording, in real (CFR) seconds, played back to back.
|
||||
|
||||
Cuts only land inside static holds (processing, generating, answer
|
||||
review), where neighbouring frames are identical, so they read as one
|
||||
continuous take. Segments must add up to DURATION + LOOP_BLEND.
|
||||
"""
|
||||
|
||||
raw: str
|
||||
segments: tuple[tuple[float, float], ...]
|
||||
|
||||
|
||||
# Read off 0.5 s contact sheets of the CFR footage. Re-time after re-recording.
|
||||
WINDOWS: dict[str, dict[str, Window]] = {
|
||||
"zh": {
|
||||
# 复制的消息 → 技能行 → 思考 → 答案 → 回复气泡落进对话
|
||||
"clipboard": Window("clipboard-agent", ((17.0, 27.6),)),
|
||||
# 聆听 → 处理 → 编辑后结果 → 应用到备忘录
|
||||
"voice": Window("voice-polish", ((6.3, 9.6), (10.9, 14.6), (16.9, 20.5))),
|
||||
# 长按提问 → 生成 → 答案 → 插入对话
|
||||
"ask": Window("ask-ai", ((12.8, 16.6), (20.2, 23.6), (25.9, 29.3))),
|
||||
},
|
||||
"en": {
|
||||
"clipboard": Window("clipboard-agent", ((17.5, 28.1),)),
|
||||
"voice": Window("voice-polish", ((6.3, 9.6), (11.0, 14.7), (17.6, 21.2))),
|
||||
"ask": Window("ask-ai", ((12.2, 15.9), (21.0, 24.6), (29.2, 32.5))),
|
||||
},
|
||||
}
|
||||
|
||||
# sRGB transfer matches the simulator footage and the PNG stills, so players
|
||||
# do not shift the greens between the video and its poster.
|
||||
COLOR_TAGS = [
|
||||
"-colorspace", "bt709", "-color_primaries", "bt709",
|
||||
"-color_trc", "iec61966-2-1", "-color_range", "tv",
|
||||
]
|
||||
# Frame side data wins over the CLI tags after `scale`; pin it explicitly or
|
||||
# the stream ships with unknown primaries / transfer.
|
||||
SET_COLOR = "setparams=color_primaries=bt709:color_trc=iec61966-2-1:colorspace=bt709:range=tv"
|
||||
|
||||
|
||||
def run(cmd: list[str]) -> None:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
sys.stderr.write(" ".join(map(str, cmd)) + "\n" + proc.stderr[-4000:] + "\n")
|
||||
raise SystemExit(f"command failed ({proc.returncode})")
|
||||
|
||||
|
||||
def render(page: str, query: str, out: Path, *, still: float | None = None,
|
||||
layout: Path | None = None) -> None:
|
||||
cmd = ["node", str(HERE / "render.cjs"), "--page", page, "--query", query, "--out", str(out)]
|
||||
if still is None:
|
||||
cmd += ["--video", "--fps", str(FPS)]
|
||||
else:
|
||||
cmd += ["--still", f"{still}"]
|
||||
if layout:
|
||||
cmd += ["--layout", str(layout)]
|
||||
print(f" render {page} {query} -> {out.name}", flush=True)
|
||||
subprocess.run(cmd, check=True, env={**os.environ})
|
||||
|
||||
|
||||
def flatten_png(path: Path) -> None:
|
||||
"""App Store Connect rejects images with an alpha channel."""
|
||||
Image.open(path).convert("RGB").save(path, optimize=True)
|
||||
|
||||
|
||||
def deliver(src: Path, dest: Path, size: tuple[int, int], level: str) -> None:
|
||||
w, h = size
|
||||
run([
|
||||
"ffmpeg", "-y", "-i", str(src),
|
||||
"-vf", f"scale={w}:{h}:flags=lanczos:out_color_matrix=bt709:out_range=tv,"
|
||||
f"format=yuv420p,{SET_COLOR}",
|
||||
"-an", "-r", str(FPS), "-t", f"{DURATION:.3f}",
|
||||
"-c:v", "libx264", "-preset", "slow", "-profile:v", "high", "-level", level,
|
||||
"-crf", "14", "-maxrate", "60M", "-bufsize", "120M",
|
||||
*COLOR_TAGS, "-movflags", "+faststart", str(dest),
|
||||
])
|
||||
|
||||
|
||||
def probe(path: Path) -> str:
|
||||
info = json.loads(subprocess.check_output([
|
||||
"ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=codec_name,profile,level,width,height,r_frame_rate,nb_frames",
|
||||
"-show_entries", "format=duration,size,nb_streams", "-of", "json", str(path),
|
||||
], text=True))
|
||||
s, f = info["streams"][0], info["format"]
|
||||
return (f"{path.name}: {s['width']}x{s['height']} {s['codec_name']} {s.get('profile')} "
|
||||
f"L{s.get('level')} {s['r_frame_rate']} {float(f['duration']):.2f}s "
|
||||
f"{int(f['size']) / 1e6:.1f} MB streams={f['nb_streams']}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Header
|
||||
|
||||
|
||||
def build_header(lang: str) -> None:
|
||||
w, h = HEADER_SIZE
|
||||
q = f"w={w}&h={h}&safe={HEADER_SAFE}&lang={lang}"
|
||||
master = WORK / f"header-{lang}.mov"
|
||||
render("header.html", q, master)
|
||||
dest = OUT / f"OSG-Header-{w}x{h}-{lang}.mp4"
|
||||
deliver(master, dest, HEADER_SIZE, "5.1")
|
||||
poster = OUT / f"OSG-Header-{w}x{h}-{lang}-poster.png"
|
||||
render("header.html", q, poster, still=POSTER_T)
|
||||
flatten_png(poster)
|
||||
print(" " + probe(dest))
|
||||
|
||||
|
||||
def build_universal(lang: str) -> None:
|
||||
w, h = UNIVERSAL_SIZE
|
||||
dest = OUT / f"OSG-Universal-{w}x{h}-{lang}.png"
|
||||
render("header.html", f"w={w}&h={h}&safe={UNIVERSAL_SAFE}&lang={lang}&hero=1",
|
||||
dest, still=POSTER_T)
|
||||
flatten_png(dest)
|
||||
# Static 21:9 header in the same hero state, for an image-only upload.
|
||||
hw, hh = HEADER_SIZE
|
||||
still = OUT / f"OSG-Header-{hw}x{hh}-{lang}-still.png"
|
||||
render("header.html", f"w={hw}&h={hh}&safe={HEADER_SAFE}&lang={lang}&hero=1",
|
||||
still, still=POSTER_T)
|
||||
flatten_png(still)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Search results
|
||||
|
||||
|
||||
def cfr(lang: str, raw: str) -> Path:
|
||||
src = RAW[lang] / f"{raw}.mov"
|
||||
if not src.exists():
|
||||
raise SystemExit(f"missing recording: {src}")
|
||||
dest = WORK / "cfr" / lang / f"{raw}.mp4"
|
||||
if dest.exists() and dest.stat().st_mtime >= src.stat().st_mtime:
|
||||
return dest
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
# simctl footage is VFR; normalise once so window times are real seconds.
|
||||
run([
|
||||
"ffmpeg", "-y", "-i", str(src), "-vf", f"fps={FPS},setsar=1", "-vsync", "cfr",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "12", "-preset", "medium",
|
||||
*COLOR_TAGS, str(dest),
|
||||
])
|
||||
return dest
|
||||
|
||||
|
||||
def screen_mask(w: int, h: int, r: int, dest: Path) -> Path:
|
||||
"""Anti-aliased rounded-rect alpha mask (4× supersampled)."""
|
||||
ss = 4
|
||||
big = Image.new("L", (w * ss, h * ss), 0)
|
||||
ImageDraw.Draw(big).rounded_rectangle((0, 0, w * ss - 1, h * ss - 1), radius=r * ss, fill=255)
|
||||
big.resize((w, h), Image.LANCZOS).save(dest)
|
||||
return dest
|
||||
|
||||
|
||||
def screen_loop(lang: str, screen: dict) -> Path:
|
||||
"""Trim → scale → close the loop → round the corners, as ProRes 4444."""
|
||||
win = WINDOWS[lang][screen["id"]]
|
||||
src = cfr(lang, win.raw)
|
||||
w, h, r = screen["w"], screen["h"], screen["r"]
|
||||
mask = screen_mask(w, h, r, WORK / f"mask-{w}x{h}.png")
|
||||
dest = WORK / f"screen-{lang}-{screen['id']}.mov"
|
||||
body = DURATION
|
||||
total = DURATION + LOOP_BLEND
|
||||
span = sum(end - start for start, end in win.segments)
|
||||
if abs(span - total) > 0.01:
|
||||
raise SystemExit(f"{lang}/{screen['id']}: segments sum to {span:.2f}s, need {total:.2f}s")
|
||||
|
||||
n = len(win.segments)
|
||||
chain = f"[0:v]split={n}" + "".join(f"[s{i}]" for i in range(n)) + ";"
|
||||
for i, (start, end) in enumerate(win.segments):
|
||||
chain += f"[s{i}]trim=start={start:.3f}:end={end:.3f},setpts=PTS-STARTPTS[g{i}];"
|
||||
chain += "".join(f"[g{i}]" for i in range(n)) + f"concat=n={n}:v=1:a=0,"
|
||||
chain += (
|
||||
f"scale={w}:{h}:flags=lanczos,split=2[a][b];"
|
||||
f"[a]trim=start=0:end={body:.3f},setpts=PTS-STARTPTS[head];"
|
||||
f"[b]trim=start={body:.3f}:end={total:.3f},setpts=PTS-STARTPTS,"
|
||||
f"format=yuva444p,fade=t=out:st=0:d={LOOP_BLEND:.3f}:alpha=1[tail];"
|
||||
f"[head][tail]overlay=0:0:eof_action=pass,format=yuva444p[looped];"
|
||||
f"[1:v]format=gray,loop=-1:size=1,trim=duration={body:.3f}[m];"
|
||||
f"[looped][m]alphamerge[v]"
|
||||
)
|
||||
run([
|
||||
"ffmpeg", "-y", "-i", str(src), "-i", str(mask), "-filter_complex", chain,
|
||||
"-map", "[v]", "-t", f"{body:.3f}", "-r", str(FPS),
|
||||
"-c:v", "prores_ks", "-profile:v", "4", "-pix_fmt", "yuva444p10le", str(dest),
|
||||
])
|
||||
return dest
|
||||
|
||||
|
||||
def build_search(lang: str) -> None:
|
||||
w, h = SEARCH_SIZE
|
||||
base_q = f"w={w}&h={h}&lang={lang}"
|
||||
layout_path = WORK / f"search-layout-{lang}.json"
|
||||
under = WORK / f"search-under-{lang}.mov"
|
||||
over = WORK / f"search-over-{lang}.mov"
|
||||
scene_mtime = (HERE / "search.html").stat().st_mtime
|
||||
fresh = all(p.exists() and p.stat().st_mtime > scene_mtime for p in (under, over, layout_path))
|
||||
if not fresh:
|
||||
render("search.html", base_q + "&layer=under", under, layout=layout_path)
|
||||
render("search.html", base_q + "&layer=over", over)
|
||||
layout = json.loads(layout_path.read_text())
|
||||
|
||||
screens = [screen_loop(lang, s) for s in layout["screens"]]
|
||||
|
||||
inputs = ["-i", str(under)]
|
||||
for s in screens:
|
||||
inputs += ["-i", str(s)]
|
||||
inputs += ["-i", str(over)]
|
||||
chain = "[0:v]format=yuva444p10le[base0];"
|
||||
for i, s in enumerate(layout["screens"], start=1):
|
||||
chain += f"[base{i - 1}][{i}:v]overlay={s['x']}:{s['y']}:format=auto[base{i}];"
|
||||
n = len(screens)
|
||||
chain += f"[base{n}][{n + 1}:v]overlay=0:0:format=auto,format=yuv444p10le,{SET_COLOR}[v]"
|
||||
|
||||
master = WORK / f"search-{lang}.mov"
|
||||
run([
|
||||
"ffmpeg", "-y", *inputs, "-filter_complex", chain, "-map", "[v]",
|
||||
"-t", f"{DURATION:.3f}", "-r", str(FPS),
|
||||
"-c:v", "prores_ks", "-profile:v", "4", "-pix_fmt", "yuv444p10le",
|
||||
*COLOR_TAGS, str(master),
|
||||
])
|
||||
|
||||
full = OUT / f"OSG-Search-{w}x{h}-{lang}.mp4"
|
||||
deliver(master, full, SEARCH_SIZE, "6.0")
|
||||
cw, ch = SEARCH_COMPAT
|
||||
compat = OUT / f"OSG-Search-{cw}x{ch}-{lang}.mp4"
|
||||
deliver(master, compat, SEARCH_COMPAT, "5.1")
|
||||
|
||||
poster = OUT / f"OSG-Search-{w}x{h}-{lang}-poster.png"
|
||||
run(["ffmpeg", "-y", "-ss", "4.0", "-i", str(master), "-frames:v", "1",
|
||||
"-vf", "scale=out_color_matrix=bt709:in_range=tv:out_range=pc,format=rgb24", str(poster)])
|
||||
flatten_png(poster)
|
||||
print(" " + probe(full))
|
||||
print(" " + probe(compat))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
langs = ["zh", "en"]
|
||||
if "--lang" in argv:
|
||||
i = argv.index("--lang")
|
||||
langs = argv[i + 1].split(",")
|
||||
del argv[i:i + 2]
|
||||
jobs = argv or ["header", "stills", "search"]
|
||||
if "redeliver-header" in jobs:
|
||||
# Re-encode the delivery mp4s from existing masters (no re-render).
|
||||
for lang in langs:
|
||||
w, h = HEADER_SIZE
|
||||
dest = OUT / f"OSG-Header-{w}x{h}-{lang}.mp4"
|
||||
deliver(WORK / f"header-{lang}.mov", dest, HEADER_SIZE, "5.1")
|
||||
print(" " + probe(dest))
|
||||
WORK.mkdir(parents=True, exist_ok=True)
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for lang in langs:
|
||||
if "header" in jobs:
|
||||
print(f"[header {lang}]", flush=True)
|
||||
build_header(lang)
|
||||
if "stills" in jobs:
|
||||
print(f"[universal {lang}]", flush=True)
|
||||
build_universal(lang)
|
||||
if "search" in jobs:
|
||||
print(f"[search {lang}]", flush=True)
|
||||
build_search(lang)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user