chore(previews): record English feature previews and add App Store creative asset scripts

This commit is contained in:
2026-10-20 20:06:37 +02:00
parent ad79c2ea13
commit a4c6102f5c
9 changed files with 1415 additions and 23 deletions
+322
View File
@@ -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:]))
+506
View File
@@ -0,0 +1,506 @@
<!doctype html>
<!--
OSGKeyboard · App Store creative · Header scene「开口即文字 / Speak it. It's typed.」
Deterministic: `window.render(t)` paints frame t ∈ [0, DURATION) and every
periodic motion completes whole cycles in DURATION, so frame 0 == frame N.
Query: ?w=3840&h=1646&safe=x,y,w,h&lang=zh|en
Safe areas come from Apple's creative-asset Sketch template.
-->
<html>
<head>
<meta charset="utf-8">
<style>
html, body { margin: 0; background: #F4F3EF; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
(() => {
const q = new URLSearchParams(location.search);
const W = +q.get('w') || 3840;
const H = +q.get('h') || 1646;
const [SX, SY, SW, SH] = (q.get('safe') || '1097,493,1646,661').split(',').map(Number);
const LANG = q.get('lang') === 'en' ? 'en' : 'zh';
// Stills only: the voice wave at full energy beside the finished line.
const HERO = q.get('hero') === '1';
const HERO_WAVE_T = 1.74; // peak of speechLevel()
const HERO_TEXT_T = 7.4;
const DURATION = 10;
const TAU = Math.PI * 2;
// Brand palette (OSGKeyboardShared/DesignSystem/ColorLibrary.swift).
const C = {
ink: '#111118',
inkSoft: '#64646F',
green: '#3AA05A',
greenHi: '#4CC27A',
teal: '#2BAFA4',
paper: '#F4F3EF',
};
const COPY = {
zh: { lead: '开口即', accent: '文字。', typed: '明天下午三点,3 楼会议室见。' },
en: { lead: 'Speak it. ', accent: "It's typed.", typed: 'See you tomorrow at 3 PM in Room 3.' },
}[LANG];
const FONT_CJK = '"PingFang SC", "SF Pro Display", system-ui, sans-serif';
const FONT_LATIN = '"SF Pro Display", system-ui, "PingFang SC", sans-serif';
const FONT = LANG === 'en' ? FONT_LATIN : FONT_CJK;
const canvas = document.getElementById('c');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
// Offscreen layers: background + wave, reused for the frosted-glass field.
const scene = new OffscreenCanvas(W, H);
const sctx = scene.getContext('2d');
const glow = new OffscreenCanvas(W, H);
const gctx = glow.getContext('2d');
// ---------- Layout (design units = header safe area 1646×661) ----------
const S = Math.min(SW / 1646, SH / 661);
const cx = SX + SW / 2;
const markH = 100 * S;
const headlineSize = (LANG === 'en' ? 150 : 172) * S;
const fieldH = 150 * S;
const fieldW = 1380 * S;
const gap1 = 60 * S;
const gap2 = 70 * S;
const blockH = markH + gap1 + headlineSize * 1.08 + gap2 + fieldH;
const top = SY + (SH - blockH) / 2;
const markY = top;
const headlineBaseline = markY + markH + gap1 + headlineSize * 0.9;
const fieldY = markY + markH + gap1 + headlineSize * 1.08 + gap2;
const fieldX = cx - fieldW / 2;
const waveY = fieldY + fieldH / 2;
// ---------- Helpers ----------
const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
const smooth = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0)); return t * t * (3 - 2 * t); };
const easeOut = (x) => 1 - Math.pow(1 - clamp(x), 3);
const window01 = (t, a, b, c, d) => smooth(a, b, t) * (1 - smooth(c, d, t));
function roundRect(c, x, y, w, h, r) {
c.beginPath();
c.roundRect(x, y, w, h, r);
}
// Film grain tile: breaks up 8-bit banding in the soft gradients once encoded.
const grain = (() => {
const g = new OffscreenCanvas(256, 256);
const gc = g.getContext('2d');
const img = gc.createImageData(256, 256);
let seed = 1337;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
for (let i = 0; i < img.data.length; i += 4) {
const v = 128 + (rnd() - 0.5) * 255;
img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
img.data[i + 3] = 10;
}
gc.putImageData(img, 0, 0);
return g;
})();
// ---------- Timeline ----------
// 0–1.1 idle · 1.1 speak · 1.6–5.4 text crystallises · 6.2 settle
// 8.0–9.0 clear · 9.0–10 idle (== frame 0)
const TYPE_START = 1.6;
const TYPE_END = 5.4;
const CLEAR_START = 8.0;
const CLEAR_END = 8.9;
function speechLevel(t) {
const on = window01(t, 0.9, 1.6, 5.2, 6.3);
// Syllable-like flutter; integer frequencies keep it periodic over DURATION.
const flutter = 0.62
+ 0.22 * Math.sin(TAU * 13 * t / DURATION)
+ 0.12 * Math.sin(TAU * 29 * t / DURATION + 1.3)
+ 0.06 * Math.sin(TAU * 47 * t / DURATION + 0.4);
return 0.10 + 0.90 * on * flutter;
}
// ---------- Background ----------
function drawBackground(c, t) {
const base = c.createLinearGradient(0, 0, 0, H);
base.addColorStop(0, '#FAF9F6');
base.addColorStop(1, '#F1F4EE');
c.fillStyle = base;
c.fillRect(0, 0, W, H);
// Slow aurora blobs. Each drifts on a whole-number cycle per loop.
const blobs = [
{ x: 0.20, y: 0.18, r: 0.36, col: [168, 226, 186], a: 0.60, k: 1, ph: 0.0 },
{ x: 0.82, y: 0.22, r: 0.34, col: [176, 230, 220], a: 0.55, k: 1, ph: 2.1 },
{ x: 0.62, y: 0.95, r: 0.40, col: [200, 236, 186], a: 0.50, k: 1, ph: 4.0 },
{ x: 0.06, y: 0.86, r: 0.26, col: [250, 236, 210], a: 0.55, k: 2, ph: 1.0 },
{ x: 0.97, y: 0.84, r: 0.26, col: [250, 240, 222], a: 0.55, k: 1, ph: 3.2 },
];
const R = Math.max(W, H);
for (const b of blobs) {
const a = TAU * b.k * t / DURATION + b.ph;
const x = (b.x + 0.035 * Math.cos(a)) * W;
const y = (b.y + 0.05 * Math.sin(a)) * H;
const g = c.createRadialGradient(x, y, 0, x, y, b.r * R);
const [r, gg, bb] = b.col;
g.addColorStop(0, `rgba(${r},${gg},${bb},${b.a})`);
g.addColorStop(0.55, `rgba(${r},${gg},${bb},${b.a * 0.35})`);
g.addColorStop(1, `rgba(${r},${gg},${bb},0)`);
c.fillStyle = g;
c.fillRect(0, 0, W, H);
}
// Soft white halo behind the copy keeps the headline crisp on any blob.
const hy = (markY + fieldY + fieldH) / 2;
const halo = c.createRadialGradient(cx, hy, 0, cx, hy, SW * 0.75);
halo.addColorStop(0, 'rgba(255,255,255,0.85)');
halo.addColorStop(0.6, 'rgba(255,255,255,0.35)');
halo.addColorStop(1, 'rgba(255,255,255,0)');
c.save();
c.translate(cx, hy);
c.scale(1, SH / SW * 1.25);
c.translate(-cx, -hy);
c.fillStyle = halo;
c.fillRect(-W, -H * 10, W * 3, H * 20);
c.restore();
// Fine dot grid, strongest away from the copy so the centre stays calm.
const step = 46 * S;
const dot = 2.2 * S;
c.save();
for (let y = step / 2; y < H; y += step) {
for (let x = step / 2; x < W; x += step) {
const dx = (x - cx) / (W * 0.5);
const dy = (y - waveY) / (H * 0.6);
const d = Math.sqrt(dx * dx + dy * dy);
const a = 0.075 * smooth(0.28, 0.85, d);
if (a < 0.004) continue;
c.fillStyle = `rgba(17,17,24,${a})`;
c.fillRect(x - dot / 2, y - dot / 2, dot, dot);
}
}
c.restore();
}
// ---------- Voice wave ----------
const STRANDS = [
{ f: 3.0, k: 4, ph: 0.0, a: 1.00, w: 6.5, col: [58, 160, 90] },
{ f: 4.2, k: 5, ph: 1.7, a: 0.70, w: 4.5, col: [43, 175, 164] },
{ f: 2.2, k: 3, ph: 3.1, a: 0.82, w: 4.0, col: [110, 200, 120] },
{ f: 5.4, k: 6, ph: 0.6, a: 0.45, w: 2.6, col: [58, 160, 90] },
];
// Energy peaks just outside the field and travels inward from both sides,
// so the voice visibly pours into the line being written.
const fieldHalfU = fieldW / W;
function strandY(s, x, t, level) {
const u = (x - cx) / (W * 0.5);
const au = Math.abs(u);
const env = 0.18 * Math.exp(-Math.pow(u / 0.3, 2))
+ Math.exp(-Math.pow((au - (fieldHalfU + 0.13)) / 0.2, 2)) * smooth(1.0, 0.7, au);
const amp = 210 * S * level * s.a * env;
return waveY + amp * Math.sin(TAU * s.f * au + TAU * s.k * t / DURATION + s.ph)
* (0.78 + 0.22 * Math.sin(TAU * 0.7 * au - TAU * s.k * t / DURATION));
}
function strandGradient(c, col, alpha) {
const g = c.createLinearGradient(0, 0, W, 0);
const [r, gg, b] = col;
g.addColorStop(0.00, `rgba(${r},${gg},${b},0)`);
g.addColorStop(0.22, `rgba(${r},${gg},${b},${alpha * 0.35})`);
g.addColorStop(0.50, `rgba(${r},${gg},${b},${alpha})`);
g.addColorStop(0.78, `rgba(${r},${gg},${b},${alpha * 0.35})`);
g.addColorStop(1.00, `rgba(${r},${gg},${b},0)`);
return g;
}
function drawWave(c, t, glowPass) {
const level = speechLevel(t);
const stepX = 6;
// Translucent lens fills (Siri-like body) behind the strands.
for (let i = 0; i < 2; i++) {
const s = STRANDS[i];
c.beginPath();
for (let x = 0; x <= W; x += stepX) {
const y = strandY(s, x, t, level);
x === 0 ? c.moveTo(x, y) : c.lineTo(x, y);
}
for (let x = W; x >= 0; x -= stepX) {
c.lineTo(x, 2 * waveY - strandY(s, x, t, level));
}
c.closePath();
c.fillStyle = strandGradient(c, s.col, glowPass ? 0.22 : 0.10);
c.fill();
}
for (const s of STRANDS) {
c.beginPath();
for (let x = 0; x <= W; x += stepX) {
const y = strandY(s, x, t, level);
x === 0 ? c.moveTo(x, y) : c.lineTo(x, y);
}
c.lineWidth = s.w * S * (glowPass ? 3.2 : 1);
c.lineCap = 'round';
c.lineJoin = 'round';
c.strokeStyle = strandGradient(c, s.col, glowPass ? 0.45 : 0.9);
c.stroke();
}
}
// ---------- Brand mark (OSGLogo.svg paths, 1024 viewBox) ----------
const MARK = [
'M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z',
'M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z',
'M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z',
'M512.741 483.398V473.561H625.864V483.398H512.741Z',
'M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z',
].map((d) => new Path2D(d));
function drawMark(c, t) {
const scale = markH / 251;
const w = 912 * scale;
c.save();
c.translate(cx - w / 2 - 56 * scale, markY - 387 * scale);
c.scale(scale, scale);
c.fillStyle = C.green;
for (const p of MARK) c.fill(p);
c.restore();
}
// ---------- Headline ----------
function drawHeadline(c) {
c.save();
c.font = `600 ${headlineSize}px ${FONT}`;
c.textBaseline = 'alphabetic';
const tracking = LANG === 'en' ? -0.02 : 0.02;
c.letterSpacing = `${tracking * headlineSize}px`;
const wLead = c.measureText(COPY.lead).width;
const wAcc = c.measureText(COPY.accent).width;
const x0 = cx - (wLead + wAcc) / 2;
c.fillStyle = C.ink;
c.fillText(COPY.lead, x0, headlineBaseline);
const g = c.createLinearGradient(x0 + wLead, 0, x0 + wLead + wAcc, 0);
g.addColorStop(0, '#2F9A50');
g.addColorStop(1, '#3FBF8A');
c.fillStyle = g;
c.fillText(COPY.accent, x0 + wLead, headlineBaseline);
c.restore();
}
// ---------- Glass dictation field ----------
const textSize = 58 * S;
const micR = 50 * S;
const pad = 24 * S;
const micCX = fieldX + pad + micR;
const textX = micCX + micR + 34 * S;
function drawMic(c, x, y, r, level, t) {
// Pulse rings while listening.
const live = clamp((level - 0.12) / 0.6);
for (let i = 0; i < 2; i++) {
const p = ((t * 1.0 + i * 0.5) % 1);
const rr = r * (1 + 0.55 * p);
c.beginPath();
c.arc(x, y, rr, 0, TAU);
c.fillStyle = `rgba(58,160,90,${0.22 * (1 - p) * live})`;
c.fill();
}
const g = c.createLinearGradient(x, y - r, x, y + r);
g.addColorStop(0, '#4CBB6E');
g.addColorStop(1, '#2F9450');
c.beginPath();
c.arc(x, y, r * (1 + 0.04 * live * Math.sin(TAU * 7 * t / DURATION)), 0, TAU);
c.fillStyle = g;
c.shadowColor = 'rgba(47,148,80,0.35)';
c.shadowBlur = 24 * S;
c.shadowOffsetY = 8 * S;
c.fill();
c.shadowColor = 'transparent';
// Mic glyph.
const u = r / 50;
c.fillStyle = '#fff';
c.strokeStyle = '#fff';
roundRect(c, x - 9 * u, y - 25 * u, 18 * u, 32 * u, 9 * u);
c.fill();
c.lineWidth = 4.2 * u;
c.lineCap = 'round';
c.beginPath();
c.arc(x, y - 4 * u, 16 * u, 0.15 * Math.PI, 0.85 * Math.PI);
c.stroke();
c.beginPath();
c.moveTo(x, y + 12 * u);
c.lineTo(x, y + 21 * u);
c.stroke();
}
// Per-character x offsets, measured once with the final font.
const chars = Array.from(COPY.typed);
ctx.font = `500 ${textSize}px ${FONT}`;
const offsets = chars.map((_, i) => ctx.measureText(chars.slice(0, i).join('')).width);
const typedWidth = ctx.measureText(COPY.typed).width;
function charAppear(i) {
const n = chars.length;
return TYPE_START + (TYPE_END - TYPE_START) * (i / Math.max(1, n - 1));
}
function drawField(c, t) {
const r = fieldH / 2;
// Shadow.
c.save();
roundRect(c, fieldX, fieldY, fieldW, fieldH, r);
c.shadowColor = 'rgba(28,70,44,0.16)';
c.shadowBlur = 90 * S;
c.shadowOffsetY = 30 * S;
c.fillStyle = 'rgba(255,255,255,0.5)';
c.fill();
c.restore();
// Frosted glass: blurred copy of what is behind, washed with white.
c.save();
roundRect(c, fieldX, fieldY, fieldW, fieldH, r);
c.clip();
c.filter = `blur(${60 * S}px) saturate(1.3)`;
c.drawImage(scene, 0, 0);
c.filter = 'none';
const wash = c.createLinearGradient(0, fieldY, 0, fieldY + fieldH);
wash.addColorStop(0, 'rgba(255,255,255,0.86)');
wash.addColorStop(1, 'rgba(255,255,255,0.70)');
c.fillStyle = wash;
c.fillRect(fieldX, fieldY, fieldW, fieldH);
c.restore();
// Specular rim.
c.save();
roundRect(c, fieldX + 1, fieldY + 1, fieldW - 2, fieldH - 2, r);
const rim = c.createLinearGradient(0, fieldY, 0, fieldY + fieldH);
rim.addColorStop(0, 'rgba(255,255,255,0.95)');
rim.addColorStop(1, 'rgba(255,255,255,0.35)');
c.lineWidth = 2.5 * S;
c.strokeStyle = rim;
c.stroke();
c.restore();
const level = speechLevel(HERO ? HERO_WAVE_T : t);
drawMic(c, micCX, fieldY + fieldH / 2, micR, level, t);
// Typed text: each glyph rises, sharpens and cools from green to ink.
const clearK = smooth(CLEAR_START, CLEAR_END, t);
const baseY = fieldY + fieldH / 2 + textSize * 0.36;
c.save();
c.font = `500 ${textSize}px ${FONT}`;
c.textBaseline = 'alphabetic';
let caretX = textX;
for (let i = 0; i < chars.length; i++) {
const k = (t - charAppear(i)) / 0.42;
if (k <= 0) continue;
const e = easeOut(k);
const alpha = e * (1 - clearK);
if (alpha <= 0.002) continue;
const x = textX + offsets[i];
const y = baseY + (1 - e) * 22 * S - clearK * 10 * S;
const blur = (1 - e) * 10 * S + clearK * 8 * S;
const cool = smooth(0.2, 1.6, k);
const rr = Math.round(58 + (17 - 58) * cool);
const gg = Math.round(160 + (17 - 160) * cool);
const bb = Math.round(90 + (24 - 90) * cool);
c.filter = blur > 0.3 ? `blur(${blur}px)` : 'none';
c.fillStyle = `rgba(${rr},${gg},${bb},${alpha})`;
c.fillText(chars[i], x, y);
caretX = textX + offsets[i] + c.measureText(chars[i]).width * e;
}
c.filter = 'none';
// Polished sheen sweeping the finished line.
const sweep = smooth(5.9, 7.1, t);
if (sweep > 0 && sweep < 1 && clearK < 1) {
const sx = textX - 200 * S + (typedWidth + 400 * S) * sweep;
const g = c.createLinearGradient(sx - 160 * S, 0, sx + 160 * S, 0);
g.addColorStop(0, 'rgba(255,255,255,0)');
g.addColorStop(0.5, 'rgba(255,255,255,0.75)');
g.addColorStop(1, 'rgba(255,255,255,0)');
c.globalCompositeOperation = 'source-atop';
c.fillStyle = g;
c.fillRect(textX, fieldY, typedWidth + 20 * S, fieldH);
c.globalCompositeOperation = 'source-over';
}
c.restore();
// Caret: steady while text lands, blinking (1 Hz, loop-safe) at rest.
const typing = t > TYPE_START - 0.2 && t < TYPE_END + 0.6;
const hasText = t >= TYPE_START && t < CLEAR_END;
const cx0 = hasText ? caretX + 6 * S : textX;
const blink = typing ? 1 : (Math.cos(TAU * t) > -0.2 ? 1 : 0);
const caretAlpha = blink * (1 - 0.0);
if (caretAlpha > 0) {
c.fillStyle = `rgba(58,160,90,${caretAlpha})`;
roundRect(c, cx0, fieldY + fieldH / 2 - textSize * 0.55, 5 * S, textSize * 1.1, 2.5 * S);
c.fill();
}
// Done badge at the far end once the line is in.
const done = window01(t, 5.9, 6.4, CLEAR_START, CLEAR_END - 0.3);
if (done > 0) {
const bx = fieldX + fieldW - pad - 34 * S;
const by = fieldY + fieldH / 2;
const br = 30 * S * (0.7 + 0.3 * easeOut(done));
c.save();
c.globalAlpha = done;
c.beginPath();
c.arc(bx, by, br, 0, TAU);
c.fillStyle = 'rgba(58,160,90,0.14)';
c.fill();
c.lineWidth = 5 * S;
c.lineCap = 'round';
c.lineJoin = 'round';
c.strokeStyle = C.green;
c.beginPath();
c.moveTo(bx - 12 * S, by + 1 * S);
c.lineTo(bx - 3 * S, by + 10 * S);
c.lineTo(bx + 13 * S, by - 9 * S);
c.stroke();
c.restore();
}
}
// ---------- Frame ----------
window.render = (t) => {
t = ((t % DURATION) + DURATION) % DURATION;
const waveT = HERO ? HERO_WAVE_T : t;
const textT = HERO ? HERO_TEXT_T : t;
drawBackground(sctx, t);
gctx.clearRect(0, 0, W, H);
drawWave(gctx, waveT, true);
sctx.save();
sctx.filter = `blur(${26 * S}px)`;
sctx.drawImage(glow, 0, 0);
sctx.filter = 'none';
sctx.restore();
drawWave(sctx, waveT, false);
ctx.drawImage(scene, 0, 0);
drawMark(ctx, t);
drawHeadline(ctx);
drawField(ctx, textT);
ctx.fillStyle = ctx.createPattern(grain, 'repeat');
ctx.fillRect(0, 0, W, H);
return true;
};
window.SAFE_DEBUG = () => {
ctx.save();
ctx.strokeStyle = 'rgba(255,0,80,0.9)';
ctx.lineWidth = 4;
ctx.setLineDash([24, 16]);
ctx.strokeRect(SX, SY, SW, SH);
ctx.restore();
};
window.DURATION = DURATION;
window.READY = document.fonts.ready.then(() => true);
})();
</script>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env node
// Renders a deterministic canvas scene (window.render(t)) to PNG stills or
// a looping video piped straight into ffmpeg.
//
// NODE_PATH=<dir with playwright-core> node render.cjs \
// --page header.html --query "w=3840&h=1646&lang=zh" \
// --still 7.0 --out poster.png [--debug-safe]
// ... --video --fps 30 --out frames.mov (ProRes 4444 intermediate)
//
// Chromium comes from the Playwright cache; override with CHROMIUM_PATH.
const { chromium } = require('playwright-core');
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
function arg(name, fallback) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const v = process.argv[i + 1];
return v === undefined || v.startsWith('--') ? true : v;
}
function chromiumPath() {
if (process.env.CHROMIUM_PATH) return process.env.CHROMIUM_PATH;
const cache = path.join(os.homedir(), 'Library/Caches/ms-playwright');
const dirs = fs.readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort().reverse();
for (const d of dirs) {
const p = path.join(cache, d,
'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing');
if (fs.existsSync(p)) return p;
}
throw new Error('No Playwright Chromium found; set CHROMIUM_PATH');
}
(async () => {
const page = path.resolve(__dirname, arg('page', 'header.html'));
const query = arg('query', '');
const out = path.resolve(arg('out'));
const params = new URLSearchParams(query);
const W = +params.get('w');
const H = +params.get('h');
const browser = await chromium.launch({
executablePath: chromiumPath(),
args: ['--force-color-profile=srgb', '--disable-gpu-vsync'],
});
const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
const tab = await ctx.newPage();
tab.on('console', (m) => console.log('[page]', m.text()));
tab.on('pageerror', (e) => { console.error('[page error]', e); process.exit(1); });
await tab.goto(`file://${page}?${query}`);
await tab.evaluate(() => window.READY);
const grab = async (t, debugSafe) => {
const b64 = await tab.evaluate(async ({ t, debugSafe }) => {
await window.render(t);
if (debugSafe) window.SAFE_DEBUG();
const c = document.getElementById('c');
return c.toDataURL('image/png').split(',')[1];
}, { t, debugSafe });
return Buffer.from(b64, 'base64');
};
if (arg('layout')) {
const layout = await tab.evaluate(() => window.LAYOUT);
fs.writeFileSync(path.resolve(arg('layout')), JSON.stringify(layout, null, 2));
}
if (arg('still') !== undefined) {
const t = parseFloat(arg('still'));
fs.writeFileSync(out, await grab(t, !!arg('debug-safe')));
console.log(`wrote ${out}`);
} else if (arg('video')) {
const fps = +arg('fps', 30);
const duration = await tab.evaluate(() => window.DURATION);
const frames = Math.round(duration * fps);
// Lossless-ish intermediate; delivery encodes happen in a second pass.
const ff = spawn('ffmpeg', [
'-y', '-hide_banner', '-loglevel', 'error',
'-f', 'image2pipe', '-framerate', String(fps), '-c:v', 'png', '-i', '-',
// Same matrix + tags as the simulator footage it gets composited with.
'-vf', 'scale=out_color_matrix=bt709:out_range=tv',
'-c:v', 'prores_ks', '-profile:v', '4', '-pix_fmt', 'yuva444p10le',
'-colorspace', 'bt709', '-color_primaries', 'bt709',
'-color_trc', 'iec61966-2-1', '-color_range', 'tv',
'-vendor', 'apl0', out,
], { stdio: ['pipe', 'inherit', 'inherit'] });
const started = Date.now();
for (let i = 0; i < frames; i++) {
const buf = await grab(i / fps, false);
if (!ff.stdin.write(buf)) await new Promise((r) => ff.stdin.once('drain', r));
if (i % 30 === 0) {
const s = ((Date.now() - started) / 1000).toFixed(0);
console.log(`frame ${i}/${frames} (${s}s)`);
}
}
ff.stdin.end();
await new Promise((r, j) => ff.on('close', (c) => (c === 0 ? r() : j(new Error(`ffmpeg ${c}`)))));
console.log(`wrote ${out}`);
}
await browser.close();
})().catch((e) => { console.error(e); process.exit(1); });
+365
View File
@@ -0,0 +1,365 @@
<!doctype html>
<!--
OSGKeyboard · App Store creative · Search Results scene (3:2).
Two layers, composited with the real screen recordings by compose_search.py:
?layer=under opaque — background, waveform, device bodies, shadows
?layer=over alpha — glass sheen, floating label chips
`window.LAYOUT` exposes the screen rects so ffmpeg can place the footage.
Query: ?w=3840&h=2560&lang=zh|en&layer=under|over
Safe area (Apple template, 3840×2560): x=836 y=765 w=2168 h=1030.
-->
<html>
<head>
<meta charset="utf-8">
<style>
html, body { margin: 0; background: transparent; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
(() => {
const q = new URLSearchParams(location.search);
const W = +q.get('w') || 3840;
const H = +q.get('h') || 2560;
const LANG = q.get('lang') === 'en' ? 'en' : 'zh';
const LAYER = q.get('layer') === 'over' ? 'over' : 'under';
const DURATION = 10;
const TAU = Math.PI * 2;
const K = W / 3840; // everything below is authored at 3840 wide
const SAFE = { x: 836 * K, y: 765 * K, w: 2168 * K, h: 1030 * K };
const FONT = LANG === 'en'
? '"SF Pro Display", system-ui, "PingFang SC", sans-serif'
: '"PingFang SC", "SF Pro Display", system-ui, sans-serif';
// Left → right reads as the product story: reply · speak · ask.
// [accent, rest] — the verb carries the brand green.
const LABELS = {
zh: { clipboard: ['复制', '即回复'], voice: ['说完', '就写好'], ask: ['长按', '问 AI'] },
en: { clipboard: ['Copy', ' to reply'], voice: ['Talk', ' to type'], ask: ['Ask', ' AI'] },
}[LANG];
const canvas = document.getElementById('c');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
const smooth = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0)); return t * t * (3 - 2 * t); };
// ---------- Device geometry ----------
// Footage is 1290×2796 (iPhone 16 Plus); the body is drawn to match it.
//
// Vertical budget is the 1030 px safe band. Label chips straddle the top of
// each device (over the status bar / host title only), and the screen is
// sized so the keyboard down to its main key row (~88 % of the screen)
// still lands inside the band; only the home-indicator strip may crop.
const SCREEN_ASPECT = 1290 / 2796;
const screenH = 1100 * K;
const screenTop = 802 * K;
const bezel = 15 * K;
const frame = 7 * K;
const bodyW = screenH * SCREEN_ASPECT + 2 * (bezel + frame);
const cy = screenTop + screenH / 2;
// Label chips: measured first, because they — not the devices — decide how
// far apart the devices can sit while every chip stays inside the safe area.
const CHIP_GAP = 40 * K;
let chipSize = 96 * K;
const chipWidth = ([a, b], size) => {
ctx.font = `600 ${size}px ${FONT}`;
return ctx.measureText(a + b).width + size * 1.24;
};
let pitch;
for (;;) {
const [wl, wm, wr] = [LABELS.clipboard, LABELS.voice, LABELS.ask].map((l) => chipWidth(l, chipSize));
const fit = Math.min(
(SAFE.w - bodyW) / 2,
W / 2 - SAFE.x - Math.max(wl, bodyW) / 2,
SAFE.x + SAFE.w - W / 2 - Math.max(wr, bodyW) / 2,
);
const need = Math.max(wl / 2 + wm / 2, wm / 2 + wr / 2) + CHIP_GAP;
if (fit >= need || chipSize < 72 * K) { pitch = Math.max(need, Math.min(fit, need + 80 * K)); break; }
chipSize *= 0.96;
}
function phone(id, cx, label) {
const sw = screenH * SCREEN_ASPECT;
return {
id, label, scale: 1,
screen: { x: cx - sw / 2, y: screenTop, w: sw, h: screenH, r: sw * 0.128 },
bezel, frame,
};
}
const PHONES = [
phone('clipboard', W / 2 - pitch, LABELS.clipboard),
phone('voice', W / 2, LABELS.voice),
phone('ask', W / 2 + pitch, LABELS.ask),
];
// Integer pixel rects for ffmpeg (even sizes keep yuv420 happy).
const even = (v) => Math.round(v / 2) * 2;
window.LAYOUT = {
width: W, height: H, duration: DURATION,
screens: PHONES.map((p) => ({
id: p.id,
x: Math.round(p.screen.x), y: Math.round(p.screen.y),
w: even(p.screen.w), h: even(p.screen.h),
r: Math.round(p.screen.r),
})),
};
function rr(c, x, y, w, h, r) { c.beginPath(); c.roundRect(x, y, w, h, r); }
// ---------- Under layer ----------
const grain = (() => {
const g = new OffscreenCanvas(256, 256);
const gc = g.getContext('2d');
const img = gc.createImageData(256, 256);
let seed = 4242;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
for (let i = 0; i < img.data.length; i += 4) {
const v = 128 + (rnd() - 0.5) * 255;
img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
img.data[i + 3] = 10;
}
gc.putImageData(img, 0, 0);
return g;
})();
function drawBackground(c, t) {
const base = c.createLinearGradient(0, 0, 0, H);
base.addColorStop(0, '#FAF9F6');
base.addColorStop(1, '#EFF3EC');
c.fillStyle = base;
c.fillRect(0, 0, W, H);
const R = Math.max(W, H);
const blobs = [
{ x: 0.16, y: 0.22, r: 0.40, col: [168, 226, 186], a: 0.62, k: 1, ph: 0.0 },
{ x: 0.86, y: 0.20, r: 0.36, col: [176, 230, 220], a: 0.55, k: 1, ph: 2.1 },
{ x: 0.55, y: 0.98, r: 0.42, col: [200, 236, 186], a: 0.55, k: 1, ph: 4.0 },
{ x: 0.04, y: 0.90, r: 0.28, col: [250, 236, 210], a: 0.55, k: 2, ph: 1.0 },
{ x: 0.98, y: 0.86, r: 0.28, col: [250, 240, 222], a: 0.55, k: 1, ph: 3.2 },
];
for (const b of blobs) {
const a = TAU * b.k * t / DURATION + b.ph;
const x = (b.x + 0.03 * Math.cos(a)) * W;
const y = (b.y + 0.04 * Math.sin(a)) * H;
const g = c.createRadialGradient(x, y, 0, x, y, b.r * R);
const [r, gg, bb] = b.col;
g.addColorStop(0, `rgba(${r},${gg},${bb},${b.a})`);
g.addColorStop(0.55, `rgba(${r},${gg},${bb},${b.a * 0.35})`);
g.addColorStop(1, `rgba(${r},${gg},${bb},0)`);
c.fillStyle = g;
c.fillRect(0, 0, W, H);
}
}
// Same voice ribbon as the header, running behind the devices.
const STRANDS = [
{ f: 3.0, k: 4, ph: 0.0, a: 1.00, w: 6.0, col: [58, 160, 90] },
{ f: 4.2, k: 5, ph: 1.7, a: 0.70, w: 4.0, col: [43, 175, 164] },
{ f: 2.2, k: 3, ph: 3.1, a: 0.82, w: 3.5, col: [110, 200, 120] },
];
function strandGradient(c, col, alpha) {
const g = c.createLinearGradient(0, 0, W, 0);
const [r, gg, b] = col;
g.addColorStop(0.00, `rgba(${r},${gg},${b},0)`);
g.addColorStop(0.18, `rgba(${r},${gg},${b},${alpha})`);
g.addColorStop(0.50, `rgba(${r},${gg},${b},${alpha * 0.6})`);
g.addColorStop(0.82, `rgba(${r},${gg},${b},${alpha})`);
g.addColorStop(1.00, `rgba(${r},${gg},${b},0)`);
return g;
}
function drawWave(c, t) {
const waveY = cy;
const level = 0.55 + 0.25 * Math.sin(TAU * 2 * t / DURATION) + 0.12 * Math.sin(TAU * 7 * t / DURATION);
for (const s of STRANDS) {
c.beginPath();
for (let x = 0; x <= W; x += 6) {
const u = (x - W / 2) / (W / 2);
const au = Math.abs(u);
const env = Math.exp(-Math.pow((au - 0.72) / 0.2, 2));
const y = waveY + 200 * K * level * s.a * env
* Math.sin(TAU * s.f * au + TAU * s.k * t / DURATION + s.ph);
x === 0 ? c.moveTo(x, y) : c.lineTo(x, y);
}
c.lineWidth = s.w * K;
c.lineCap = 'round';
c.strokeStyle = strandGradient(c, s.col, 0.75);
c.stroke();
}
}
function drawDeviceBody(c, p) {
const { x, y, w, h, r } = p.screen;
const b = p.bezel;
const f = p.frame;
const ox = x - b - f, oy = y - b - f, ow = w + 2 * (b + f), oh = h + 2 * (b + f), or = r + b + f;
// Contact shadow + long ambient shadow.
c.save();
c.filter = `blur(${60 * K}px)`;
c.fillStyle = 'rgba(30,60,40,0.20)';
c.beginPath();
c.ellipse(ox + ow / 2, oy + oh + 30 * K, ow * 0.46, 46 * K, 0, 0, TAU);
c.fill();
c.restore();
c.save();
rr(c, ox, oy, ow, oh, or);
c.shadowColor = 'rgba(28,64,42,0.22)';
c.shadowBlur = 140 * K;
c.shadowOffsetY = 70 * K;
c.fillStyle = '#d9dbd8';
c.fill();
c.restore();
// Titanium band: brushed light metal with a bright upper-left rim.
const metal = c.createLinearGradient(ox, oy, ox + ow, oy + oh);
metal.addColorStop(0, '#f4f5f3');
metal.addColorStop(0.35, '#c9ccc8');
metal.addColorStop(0.6, '#e7e9e6');
metal.addColorStop(1, '#a9ada8');
rr(c, ox, oy, ow, oh, or);
c.fillStyle = metal;
c.fill();
c.lineWidth = 2 * K;
c.strokeStyle = 'rgba(255,255,255,0.9)';
c.stroke();
// Side buttons.
c.fillStyle = '#bfc2be';
const btn = (bx, by, bw, bh) => { rr(c, bx, by, bw, bh, bw / 2); c.fill(); };
btn(ox - 5 * K * p.scale, oy + oh * 0.19, 7 * K * p.scale, oh * 0.045);
btn(ox - 5 * K * p.scale, oy + oh * 0.26, 7 * K * p.scale, oh * 0.07);
btn(ox - 5 * K * p.scale, oy + oh * 0.345, 7 * K * p.scale, oh * 0.07);
btn(ox + ow - 2 * K * p.scale, oy + oh * 0.28, 7 * K * p.scale, oh * 0.1);
// Black glass bezel + screen well (footage lands on top of this).
rr(c, x - b, y - b, w + 2 * b, h + 2 * b, r + b);
c.fillStyle = '#0b0b0c';
c.fill();
rr(c, x, y, w, h, r);
c.fillStyle = '#000';
c.fill();
}
// ---------- Over layer ----------
function drawSheen(c, p) {
const { x, y, w, h, r } = p.screen;
c.save();
rr(c, x, y, w, h, r);
c.clip();
const g = c.createLinearGradient(x, y, x + w, y + h * 0.6);
g.addColorStop(0, 'rgba(255,255,255,0.10)');
g.addColorStop(0.35, 'rgba(255,255,255,0.03)');
g.addColorStop(0.36, 'rgba(255,255,255,0)');
c.fillStyle = g;
c.fillRect(x, y, w, h);
c.restore();
}
// Search cards render at ~0.28× on iPhone, so label copy stays near the
// ~100 px floor for legibility.
function drawChip(c, p, t, index) {
const { x, y, w, h } = p.screen;
const size = chipSize;
const [accent, rest] = p.label;
c.font = `600 ${size}px ${FONT}`;
const accentW = c.measureText(accent).width;
const textW = accentW + c.measureText(rest).width;
const padX = size * 0.62;
const chipH = size * 1.62;
const chipW = textW + padX * 2;
// Gentle float, one cycle per loop, phases offset per device.
const bob = Math.sin(TAU * t / DURATION + index * 2.1) * 7 * K;
const cxp = x + w / 2;
// Centred on the status bar / host title strip, never on host content.
const cyp = y + h * 0.055 + bob;
const x0 = cxp - chipW / 2, y0 = cyp - chipH / 2;
c.save();
rr(c, x0, y0, chipW, chipH, chipH / 2);
c.shadowColor = 'rgba(28,64,42,0.22)';
c.shadowBlur = 60 * K;
c.shadowOffsetY = 24 * K;
c.fillStyle = 'rgba(255,255,255,0.94)';
c.fill();
c.restore();
c.save();
rr(c, x0 + 1, y0 + 1, chipW - 2, chipH - 2, chipH / 2);
c.lineWidth = 2 * K;
c.strokeStyle = 'rgba(255,255,255,1)';
c.stroke();
c.restore();
c.textBaseline = 'middle';
const tx = x0 + padX;
const ty = cyp + size * 0.04;
const g = c.createLinearGradient(tx, 0, tx + accentW, 0);
g.addColorStop(0, '#2F9A50');
g.addColorStop(1, '#3FBF8A');
c.fillStyle = g;
c.fillText(accent, tx, ty);
c.fillStyle = '#111118';
c.fillText(rest, tx + accentW, ty);
}
// Brand mark (OSGLogo.svg) in the open band above the chips — decorative,
// so it may crop away on the tightest layouts.
const MARK = [
'M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z',
'M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z',
'M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z',
'M512.741 483.398V473.561H625.864V483.398H512.741Z',
'M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z',
].map((d) => new Path2D(d));
function drawMark(c) {
const markH = 84 * K;
const scale = markH / 251;
const top = 520 * K;
c.save();
c.translate(W / 2 - (912 * scale) / 2 - 56 * scale, top - 387 * scale);
c.scale(scale, scale);
c.fillStyle = '#3AA05A';
for (const p of MARK) c.fill(p);
c.restore();
}
window.render = (t) => {
t = ((t % DURATION) + DURATION) % DURATION;
ctx.clearRect(0, 0, W, H);
if (LAYER === 'under') {
drawBackground(ctx, t);
drawWave(ctx, t);
drawMark(ctx);
for (const p of PHONES) drawDeviceBody(ctx, p);
ctx.fillStyle = ctx.createPattern(grain, 'repeat');
ctx.fillRect(0, 0, W, H);
} else {
// The simulator footage already carries the Dynamic Island.
PHONES.forEach((p, i) => {
drawSheen(ctx, p);
drawChip(ctx, p, t, i);
});
}
return true;
};
window.SAFE_DEBUG = () => {
ctx.save();
ctx.strokeStyle = 'rgba(255,0,80,0.9)';
ctx.lineWidth = 4;
ctx.setLineDash([24, 16]);
ctx.strokeRect(SAFE.x, SAFE.y, SAFE.w, SAFE.h);
ctx.restore();
};
window.DURATION = DURATION;
window.READY = document.fonts.ready.then(() => true);
})();
</script>
</body>
</html>
+12 -4
View File
@@ -18,6 +18,8 @@
#
# Usage:
# Scripts/record_feature_previews.sh <UDID> [slug ...]
# PREVIEW_LANG=en Scripts/record_feature_previews.sh <UDID> [slug ...]
# -> English demo copy, footage in `.tmp/feature-previews/raw-en`
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -27,17 +29,23 @@ UDID="${1:?usage: record_feature_previews.sh <UDID> [slug ...]}"
shift || true
BUNDLE="com.osgkeyboard.ios"
RAW_DIR="$ROOT/.tmp/feature-previews/raw"
PREVIEW_LANG="${PREVIEW_LANG:-zh}"
if [[ "$PREVIEW_LANG" == "zh" ]]; then
RAW_DIR="$ROOT/.tmp/feature-previews/raw"
else
RAW_DIR="$ROOT/.tmp/feature-previews/raw-$PREVIEW_LANG"
fi
mkdir -p "$RAW_DIR"
LANG_ARG="--whats-new-lang=$PREVIEW_LANG"
# slug | seconds | launch args
CLIPS=(
"voice-polish|23|--edit-demo --preview-fullscreen"
"voice-polish|23|--edit-demo --preview-fullscreen $LANG_ARG"
# Static page — the motion is a post-production push-in, so this only needs
# to be long enough to fill the App Store 15s minimum after trimming.
"personal-style|22|--polish-styles-screenshot"
"clipboard-agent|46|--clipboard-demo --preview-fullscreen"
"ask-ai|36|--ai-demo --preview-fullscreen --whats-new-lang=zh"
"clipboard-agent|46|--clipboard-demo --preview-fullscreen $LANG_ARG"
"ask-ai|36|--ai-demo --preview-fullscreen $LANG_ARG"
)
want() {