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=