chore(previews): record English feature previews and add App Store creative asset scripts
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:]))
|
||||
@@ -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>
|
||||
@@ -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); });
|
||||
@@ -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>
|
||||
@@ -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() {
|
||||
|
||||
@@ -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` 里的裁切点和字幕时间是照着实际录像量出来的。
|
||||
|
||||
Reference in New Issue
Block a user