Files

323 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:]))