diff --git a/OSGKeyboard/Views/ClipboardHistoryDemoView.swift b/OSGKeyboard/Views/ClipboardHistoryDemoView.swift index 3a3cf93..7c8734c 100644 --- a/OSGKeyboard/Views/ClipboardHistoryDemoView.swift +++ b/OSGKeyboard/Views/ClipboardHistoryDemoView.swift @@ -4,7 +4,8 @@ // What's New 1.7.0 recording host. Voice chrome + clipboard panel are the // **real** extension views (`KeyboardTopControls`, `RecordButton`, // `KeyboardTranslationMenuButton`, `ClipboardHistoryPanelView`, …) driven by -// scripted `KeyboardState`. Launch with `--clipboard-demo`. +// scripted `KeyboardState`. Launch with `--clipboard-demo` and optional +// `--whats-new-lang=zh|en`. #if DEBUG import OSGKeyboardShared @@ -35,10 +36,17 @@ struct ClipboardHistoryDemoView: View { /// Closing beat: hand the copied text to the real AI skill row. @State private var showsSkills = false + private static let language = FeaturePreviewFlags.language + private static var isEnglish: Bool { language == .en } + private var palette: ThemePalette { colorScheme == .dark ? Palette.dark : Palette.light } + init() { + FeaturePreviewFlags.applyRecordingLanguage() + } + var body: some View { ZStack { if FeaturePreviewFlags.isFullscreen { @@ -48,13 +56,10 @@ struct ClipboardHistoryDemoView: View { } VStack(spacing: 0) { if FeaturePreviewFlags.isFullscreen { - // Chinese-only recording — see the `zh-Hans` locale pin on - // this view's body; the sample conversation has no English - // variant, so the host chrome stays Chinese to match. FeaturePreviewHostDocument( kind: .messages, - title: "信息", - language: .zh, + title: Self.isEnglish ? "Messages" : "信息", + language: Self.language, text: hostDraft, incoming: Self.incomingMessage ) @@ -71,7 +76,7 @@ struct ClipboardHistoryDemoView: View { } } .environment(\.themePalette, palette) - .environment(\.locale, Locale(identifier: "zh-Hans")) + .environment(\.locale, Locale(identifier: Self.isEnglish ? "en" : "zh-Hans")) .preferredColorScheme(.light) .task { await runTimeline() } } @@ -290,7 +295,9 @@ struct ClipboardHistoryDemoView: View { }) else { return } await runReplySkill( skill: generic, - answer: "好的,预算版我整理一下,明天上午发你。" + answer: Self.isEnglish + ? "Sure, I'll pull the budget version together and send it tomorrow morning." + : "好的,预算版我整理一下,明天上午发你。" ) state.pendingClipboardSkillID = nil try? await sleep(0.6) @@ -337,6 +344,7 @@ struct ClipboardHistoryDemoView: View { /// Demo copy per reply style. Keyed by skill so the answer matches whatever /// chip the ranker put in the row. private static func answer(for skillID: String) -> String { + if isEnglish { return englishAnswer(for: skillID) } switch skillID { case AIClipboardSkillCatalog.playfulReplyID: return "行嘞,预算表这就去梳妆打扮,明早准时上桌。" @@ -353,6 +361,23 @@ struct ClipboardHistoryDemoView: View { } } + private static func englishAnswer(for skillID: String) -> String { + switch skillID { + case AIClipboardSkillCatalog.playfulReplyID: + return "On it! The budget sheet is getting dressed up and will be on your desk by morning." + case AIClipboardSkillCatalog.businessReplyID: + return "Noted. I'll finalize the budget version tonight and send it over tomorrow morning." + case AIClipboardSkillCatalog.empathyReplyID: + return "Totally get that you need this soon. I'll prioritize it and send it first thing tomorrow." + case AIClipboardSkillCatalog.acceptTaskID: + return "Got it, I'll take this on and send the budget version tomorrow morning." + case AIClipboardSkillCatalog.clarifyRequestID: + return "Sure. Do you need the version with staffing costs, or just procurement?" + default: + return "Sure, I'll put it together and send it tomorrow morning." + } + } + /// One tap on a reply-style chip: the row gives way to the thinking state, /// the answer streams in, then it is offered for insertion. private func runReplySkill(skill: AIClipboardSkill, answer: String) async { @@ -394,6 +419,8 @@ struct ClipboardHistoryDemoView: View { state.layoutWidth = 390 state.usesIPadLayoutMetrics = false state.showsSystemGlobeKey = false + // The skill ranker localizes chip titles off this. + state.uiLanguage = Self.isEnglish ? .english : .chinese // `AIKeyboardView.showsClipboardSkills` gates on this — without it the // real skill row never appears no matter what the ranker returns. state.clipboardHistoryEnabled = true @@ -414,7 +441,10 @@ struct ClipboardHistoryDemoView: View { private func seedHistory() { history.clearAll() - _ = history.ingest(rawText: "订单号 OSG-20260811-8842", changeCount: 3) + _ = history.ingest( + rawText: Self.isEnglish ? "Order OSG-20260811-8842" : "订单号 OSG-20260811-8842", + changeCount: 3 + ) _ = history.ingest(rawText: "https://osglab.com", changeCount: 2) _ = history.ingest(rawText: Self.incomingMessage, changeCount: 1) history.reload() @@ -422,7 +452,11 @@ struct ClipboardHistoryDemoView: View { /// The message the clip replies to. Copying it is what puts the reply /// skills in the row, so it has to be the newest clipboard entry. - static let incomingMessage = "方案我看了,明天能不能把预算那一版也发我?" + static var incomingMessage: String { + isEnglish + ? "Read the proposal. Could you also send me the budget version tomorrow?" + : "方案我看了,明天能不能把预算那一版也发我?" + } private func sleep(_ seconds: Double) async throws { try await Task.sleep(nanoseconds: UInt64(seconds * 2.4 * 1_000_000_000)) diff --git a/OSGKeyboard/Views/EditDemoView.swift b/OSGKeyboard/Views/EditDemoView.swift index 67d5851..450ea94 100644 --- a/OSGKeyboard/Views/EditDemoView.swift +++ b/OSGKeyboard/Views/EditDemoView.swift @@ -4,15 +4,27 @@ // What's New "Edit last input" recording host. Uses the **real** // `LastInputEditView` (compiled into the app target) driven by a scripted // `KeyboardState` — no ASR / LLM. Opening beat shows the voice mic + hint. -// Launch with `--edit-demo`. Not shipped in Release. +// Launch with `--edit-demo` and optional `--whats-new-lang=zh|en`. +// Not shipped in Release. #if DEBUG import OSGKeyboardShared import SwiftUI struct EditDemoView: View { - private static let originalText = "明天下午三点开会讨论方案" - private static let editedText = "各位好,明天下午三点在 A 会议室召开方案讨论会,请准时参加。" + private static let language = FeaturePreviewFlags.language + + private static var originalText: String { + language == .en + ? "meeting tomorrow at 3 to discuss the plan" + : "明天下午三点开会讨论方案" + } + + private static var editedText: String { + language == .en + ? "Hi all, we'll review the proposal tomorrow at 3 PM in Room A. Please be on time." + : "各位好,明天下午三点在 A 会议室召开方案讨论会,请准时参加。" + } private enum Scene: Equatable { case hint @@ -24,6 +36,10 @@ struct EditDemoView: View { /// Mirrors the host document for `--preview-fullscreen` recordings. @State private var hostText: String = EditDemoView.originalText + init() { + FeaturePreviewFlags.applyRecordingLanguage() + } + private var source: EditSessionSource { let reference = EditableInputReference( displayText: Self.originalText, @@ -45,13 +61,10 @@ struct EditDemoView: View { } VStack(spacing: 0) { if FeaturePreviewFlags.isFullscreen { - // Chinese-only recording: the sample text below has no - // English variant, so the host chrome is pinned to match - // (see the `zh-Hans` locale pin on this view's body). FeaturePreviewHostDocument( kind: .notes, - title: "备忘录", - language: .zh, + title: Self.language == .en ? "Notes" : "备忘录", + language: Self.language, text: hostText ) } else { @@ -74,7 +87,7 @@ struct EditDemoView: View { } } .environment(\.themePalette, palette) - .environment(\.locale, Locale(identifier: "zh-Hans")) + .environment(\.locale, Locale(identifier: Self.language == .en ? "en" : "zh-Hans")) .preferredColorScheme(.light) .task { await runTimeline() } } diff --git a/OSGKeyboard/Views/FeaturePreviewHostDocument.swift b/OSGKeyboard/Views/FeaturePreviewHostDocument.swift index cf598b1..e503ef2 100644 --- a/OSGKeyboard/Views/FeaturePreviewHostDocument.swift +++ b/OSGKeyboard/Views/FeaturePreviewHostDocument.swift @@ -18,6 +18,26 @@ enum FeaturePreviewFlags { static var isFullscreen: Bool { ProcessInfo.processInfo.arguments.contains("--preview-fullscreen") } + + /// Recording language from `--whats-new-lang=zh|en`; Chinese when absent. + static var language: WhatsNewDemoScenario.Language { + let prefix = "--whats-new-lang=" + guard let argument = ProcessInfo.processInfo.arguments.first( + where: { $0.hasPrefix(prefix) } + ) else { + return .zh + } + return WhatsNewDemoScenario.Language( + rawValue: String(argument.dropFirst(prefix.count)) + ) ?? .zh + } + + /// Points the extension strings (`ExtL10n` reads the App Group setting) + /// at the recording language before the first frame renders. + @MainActor + static func applyRecordingLanguage() { + ProviderConfig.shared.uiLanguage = language == .en ? .english : .chinese + } } /// A Notes- or Messages-like document that reacts to the demo timeline. diff --git a/Scripts/appstore-creative/compose.py b/Scripts/appstore-creative/compose.py new file mode 100644 index 0000000..3dc7d56 --- /dev/null +++ b/Scripts/appstore-creative/compose.py @@ -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:])) diff --git a/Scripts/appstore-creative/header.html b/Scripts/appstore-creative/header.html new file mode 100644 index 0000000..962b9d7 --- /dev/null +++ b/Scripts/appstore-creative/header.html @@ -0,0 +1,506 @@ + + + + + + + + + + + + diff --git a/Scripts/appstore-creative/render.cjs b/Scripts/appstore-creative/render.cjs new file mode 100644 index 0000000..284d04e --- /dev/null +++ b/Scripts/appstore-creative/render.cjs @@ -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= 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); }); diff --git a/Scripts/appstore-creative/search.html b/Scripts/appstore-creative/search.html new file mode 100644 index 0000000..876ae4c --- /dev/null +++ b/Scripts/appstore-creative/search.html @@ -0,0 +1,365 @@ + + + + + + + + + + + + diff --git a/Scripts/record_feature_previews.sh b/Scripts/record_feature_previews.sh index 2930943..3f4e1fa 100755 --- a/Scripts/record_feature_previews.sh +++ b/Scripts/record_feature_previews.sh @@ -18,6 +18,8 @@ # # Usage: # Scripts/record_feature_previews.sh [slug ...] +# PREVIEW_LANG=en Scripts/record_feature_previews.sh [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 [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() { diff --git a/docs/FEATURE_PREVIEW_RECORDING.md b/docs/FEATURE_PREVIEW_RECORDING.md index 44b45a4..378e503 100644 --- a/docs/FEATURE_PREVIEW_RECORDING.md +++ b/docs/FEATURE_PREVIEW_RECORDING.md @@ -59,6 +59,8 @@ xcrun simctl install "$UDID" \ # 3) 录制(脚本会先校验 App Group 容器,缺失直接报错退出) Scripts/record_feature_previews.sh "$UDID" # 全部四段 Scripts/record_feature_previews.sh "$UDID" ask-ai # 只录一段 +# 英文文案(--whats-new-lang=en),素材落在 .tmp/feature-previews/raw-en +PREVIEW_LANG=en Scripts/record_feature_previews.sh "$UDID" voice-polish clipboard-agent ask-ai # 4) 合成。需要 Pillow —— 这版 ffmpeg 没有 libfreetype / libass, # 所有文字都由 PIL 渲染成 RGBA PNG 再 overlay。 @@ -137,6 +139,24 @@ app 里**没有**「点一次回复,弹出多条候选」这种交互:点一 `zoompan` 表达式同时做进和出——它的实际出帧数对不上 `duration * FPS`,片子会 停在半路,循环处肉眼可见地跳。现在的做法是只渲染推近的一半再 `reverse` 拼接。 +## App Store 创意素材(Header / Search Results) + +同一批原始素材(中英文各一套)还驱动 iOS 27 起的 App Store 创意素材,脚本在 +`Scripts/appstore-creative/`:`header.html` / `search.html` 是逐帧确定的 canvas +场景,`render.cjs` 用 Playwright 的 Chromium 渲染,`compose.py` 把录屏合进设备 +屏幕并按 App Store Connect 规格出片。 + +```bash +(cd .tmp/appstore-assets && npm install playwright-core) +NODE_PATH=$PWD/.tmp/appstore-assets/node_modules \ + python3 Scripts/appstore-creative/compose.py # header + stills + search +``` + +- 5244×2950 只接受 PNG;视频 Header 只能是 3840×1646。 +- 安全区取自 Apple 官方 Sketch 模板,`compose.py` 顶部有数值。 +- 3840×2560 的 H.264 需要 Level 6,另出一份 3072×2048 / Level 5.1 兼容版。 +- `compose.py` 的 `WINDOWS` 是照 0.5 秒联系表量出来的,重录后要重新对时。 + ## 改动了 demo 时间轴之后 `Scripts/feature_preview_clips.py` 里的裁切点和字幕时间是照着实际录像量出来的。