merge: bring local uncommitted work onto the cloud-agent branch

Merges the wip/local-snapshot-20260901 safety commit (94 uncommitted files:
in-flight Account/Mac/Tip work, clipboard-semantics improvements, and the
feature-preview recording tooling) into the newer cloud-agent line.

Conflict resolutions:
- docs/*: honour the branch's deliberate removal in eac44da; drop the now
  dangling docs/archive/README.md that only pointed at the deleted files.
- CHANGELOG: both sides describe separate [Unreleased] features; keep both.
- ClipboardSkillSemanticRanker: take the branch's reply routing, which retires
  ranking playfulReply/acceptInvitation/... as separate skills in favour of
  ordinary/formal/playful variants inside one reply action. Keep the local
  link/phone/CJK-density work, which is orthogonal and still referenced.
- OfficialSkillCatalogTests: keep the local "installed exactly once" assertion;
  it matches the test's own comment and does not hardcode the default set.
- ClipboardSkillSemanticRankerTests: keep both sides' disjoint test functions;
  retarget the two link/phone cases at the branch's row, which no longer
  surfaces the retired specialised reply skills.

Verified: xcodebuild build succeeds and the 77 ranker/catalog/analyzer tests
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 12:34:23 -07:00
co-authored by Claude Opus 5
79 changed files with 3459 additions and 1178 deletions
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Compose short, silent, seamlessly looping feature clips for in-app cards.
Different deliverable from `compose_feature_previews.py`: no captions, no
title / end cards, no audio track, and cropped to the keyboard chrome so the
card shows the real keyboard rather than a stand-in host document.
Each clip is built to loop — the last frame matches the first, so a card can
autoplay it forever without a visible cut.
Reuses the raw recordings from `Scripts/record_feature_previews.sh` and the
CFR normalisation from `compose_feature_previews.py`.
Usage:
Scripts/compose_feature_cards.py [slug ...]
Output: docs/assets/feature-cards/<slug>.mp4 (+ <slug>-poster.png)
"""
from __future__ import annotations
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from compose_feature_previews import RAW_DIR, constant_frame_rate, run # noqa: F401
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "docs" / "assets" / "feature-cards"
WORK = ROOT / ".tmp" / "feature-previews" / "cards"
FPS = 30
# Source-frame crop covering the keyboard chrome across every scenario. The
# clipboard panel is the tallest thing that appears there (top edge y=1779),
# and the bottom 72px of the screen is empty padding below the key row.
CROP_X, CROP_W = 0, 1290
CROP_Y, CROP_H = 1780, 944
# Overlap used to make the cut back to frame 0 invisible.
LOOP_BLEND = 0.5
@dataclass
class Card:
slug: str
# Raw recording slug under `.tmp/feature-previews/raw/`.
raw: str
# Window into the CFR recording, in real seconds.
start: float
duration: float
# Static pages have nothing to animate; zoom in then back out so the clip
# still moves and still loops perfectly.
ping_pong_zoom: float = 1.0
# Crop override for clips that are not the keyboard (e.g. a settings page).
crop_y: int = CROP_Y
crop_h: int = CROP_H
def probe_duration(path: Path) -> float:
out = subprocess.check_output(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "json", str(path)],
text=True,
)
return float(json.loads(out)["format"]["duration"])
def encode_common(dest: Path) -> list[str]:
return [
"-an", # cards are silent
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-profile:v", "high", "-level", "4.0", "-crf", "20",
"-colorspace", "bt709", "-color_primaries", "bt709",
"-color_trc", "iec61966-2-1", "-color_range", "tv",
"-r", str(FPS), "-movflags", "+faststart",
str(dest),
]
def build_motion(card: Card, work: Path) -> Path:
"""Trim + crop the window, or build a ping-pong zoom over a frozen frame."""
src = constant_frame_rate(card.raw)
dest = work / f"{card.slug}-motion.mp4"
crop = f"crop={CROP_W}:{card.crop_h}:{CROP_X}:{card.crop_y}"
if card.ping_pong_zoom > 1.0:
frame = work / f"{card.slug}-still.png"
run(["ffmpeg", "-y", "-ss", f"{card.start:.2f}", "-i", str(src),
"-frames:v", "1", "-vf", crop, str(frame)])
# Build only the zoom-in half and mirror it. Driving both directions
# from a single zoompan expression relies on its output frame count
# matching `duration * FPS`, which it does not — the clip then stops
# part-way back out and the loop visibly jumps.
half = card.duration / 2
frames = max(2, int(half * FPS))
step = (card.ping_pong_zoom - 1.0) / frames
vf = (
f"zoompan=z='min(1+{step:.6f}*on,{card.ping_pong_zoom:.4f})'"
f":x='(iw-iw/zoom)/2':y='(ih-ih/zoom)/2'"
f":d=1:s={CROP_W}x{card.crop_h}:fps={FPS},setsar=1"
)
forward = work / f"{card.slug}-zoomin.mp4"
run([
# A looped PNG defaults to 25 fps; without this the 30 fps zoompan
# emits 25/30 of the frames asked for and the clip comes up short.
"ffmpeg", "-y", "-loop", "1", "-framerate", str(FPS),
"-t", f"{half:.2f}",
"-i", str(frame), "-vf", vf, "-t", f"{half:.2f}",
*encode_common(forward),
])
run([
"ffmpeg", "-y", "-i", str(forward), "-i", str(forward),
"-filter_complex",
"[1:v]reverse[r];[0:v][r]concat=n=2:v=1:a=0,format=yuv420p[v]",
"-map", "[v]", *encode_common(dest),
])
return dest
run([
"ffmpeg", "-y", "-ss", f"{card.start:.2f}", "-i", str(src),
"-vf", f"{crop},fps={FPS},setsar=1",
# The CFR pass already fixed timing, but clamp on the output side so
# the window length is exact regardless of keyframe placement.
"-t", f"{card.duration:.2f}",
*encode_common(dest),
])
return dest
def close_loop(source: Path, dest: Path, blend: float) -> None:
"""Fade the tail back over the head so frame 0 and the last frame match."""
total = probe_duration(source)
body = total - blend
chain = (
f"[0:v]split=2[head][tail];"
f"[head]trim=start=0:end={body:.3f},setpts=PTS-STARTPTS[h];"
f"[tail]trim=start={body:.3f}:end={total:.3f},setpts=PTS-STARTPTS,"
f"format=yuva420p,fade=t=out:st=0:d={blend:.3f}:alpha=1[t];"
f"[h][t]overlay=0:0:eof_action=pass,format=yuv420p[v]"
)
run([
"ffmpeg", "-y", "-i", str(source),
"-filter_complex", chain, "-map", "[v]",
*encode_common(dest),
])
CARDS: dict[str, Card] = {}
def build(card: Card) -> Path:
WORK.mkdir(parents=True, exist_ok=True)
OUT_DIR.mkdir(parents=True, exist_ok=True)
motion = build_motion(card, WORK)
final = OUT_DIR / f"{card.slug}.mp4"
if card.ping_pong_zoom > 1.0:
# A ping-pong already ends where it started; blending would only
# duplicate frames.
motion.replace(final)
else:
close_loop(motion, final, LOOP_BLEND)
poster = OUT_DIR / f"{card.slug}-poster.png"
run(["ffmpeg", "-y", "-i", str(final), "-frames:v", "1", str(poster)])
return final
def main(argv: list[str]) -> int:
from feature_card_specs import CARDS as configured # noqa: PLC0415
slugs = argv or list(configured)
failures = 0
for slug in slugs:
card = configured.get(slug)
if card is None:
sys.stderr.write(f"unknown card: {slug}\n")
failures += 1
continue
final = build(card)
info = json.loads(subprocess.check_output(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,nb_frames",
"-show_entries", "format=duration,size,nb_streams",
"-of", "json", str(final)],
text=True,
))
stream, fmt = info["streams"][0], info["format"]
duration = float(fmt["duration"])
silent = int(fmt["nb_streams"]) == 1
ok = 5.0 <= duration <= 11.0 and silent
print(
f"{'OK ' if ok else '!! '}{final.name} "
f"{stream['width']}x{stream['height']} {duration:.1f}s "
f"{int(fmt['size']) / 1000:.0f} KB {'silent' if silent else 'HAS AUDIO'}"
)
if not ok:
failures += 1
return 1 if failures else 0
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parent))
raise SystemExit(main(sys.argv[1:]))
+441
View File
@@ -0,0 +1,441 @@
#!/usr/bin/env python3
"""Compose the four Chinese feature preview clips for OSGKeyboard.
Takes raw Simulator screen recordings (1290x2796, iPhone 16 Plus) produced by
`Scripts/record_feature_previews.sh` and turns each into an App Store Connect
compliant preview: title card -> captioned footage -> end card, plus a soft
generated audio bed.
This ffmpeg build ships without libfreetype / libass, so every piece of text is
rendered to RGBA PNG with Pillow and composited with `overlay`.
Usage:
Scripts/compose_feature_previews.py [slug ...]
Output: docs/assets/app-preview/zh/OSGKeyboard-<slug>-6.7-zh.mp4
"""
from __future__ import annotations
import json
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
RAW_DIR = ROOT / ".tmp" / "feature-previews" / "raw"
WORK_ROOT = ROOT / ".tmp" / "feature-previews" / "work"
OUT_DIR = ROOT / "docs" / "assets" / "app-preview" / "zh"
W, H = 1290, 2796 # App Store Connect 6.7" / 6.9" portrait
FPS = 30
ICON = ROOT / "docs" / "assets" / "app-icon.png"
# No PingFang on every machine; Hiragino Sans GB is the cleanest CJK fallback.
FONT_CANDIDATES = [
(Path("/System/Library/Fonts/Hiragino Sans GB.ttc"), 1),
(Path("/System/Library/Fonts/Hiragino Sans GB.ttc"), 0),
(Path("/System/Library/Fonts/STHeiti Medium.ttc"), 0),
(Path("/System/Library/Fonts/Supplemental/Songti.ttc"), 0),
]
BG = (11, 13, 12)
FG = (245, 246, 248)
ACCENT = (52, 168, 96)
# --------------------------------------------------------------------------
# Timeline description
# --------------------------------------------------------------------------
@dataclass
class Caption:
"""A lower-third super burned over the footage."""
start: float
end: float
text: str
# Second line, rendered smaller under `text`.
sub: str | None = None
@dataclass
class Source:
"""One trimmed window of raw footage, with its own captions.
A clip can chain several — the voice clip cuts from the polish flow in the
keyboard to the Styles page that owns the playful personalities.
"""
# Raw recording slug, i.e. `.tmp/feature-previews/raw/<raw>.mov`.
raw: str
trim_start: float
trim_duration: float
captions: list[Caption] = field(default_factory=list)
# `simctl io recordVideo` only emits frames when the screen changes, so a
# static page yields a few seconds of footage no matter how long you wait.
# Set this to grab one frame at that timestamp and hold it for
# `trim_duration` instead, letting `ken_burns` supply the motion.
freeze_at: float | None = None
# Slow push-in over otherwise-static footage (e.g. a settings page that
# does not animate). Expressed as the final zoom factor; 1.0 disables it.
ken_burns: float = 1.0
# Normalised focal point for the push-in — (0.5, 0.5) is dead centre.
ken_burns_focus: tuple[float, float] = (0.5, 0.5)
@dataclass
class Clip:
slug: str
# Title card copy.
title: list[str]
subtitle: str
sources: list[Source]
title_seconds: float = 2.4
end_seconds: float = 2.6
end_title: str = "OSGKeyboard"
end_subtitle: str = "开口即文字"
@property
def body_seconds(self) -> float:
return sum(source.trim_duration for source in self.sources)
@property
def total_seconds(self) -> float:
return self.title_seconds + self.body_seconds + self.end_seconds
def font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for path, index in FONT_CANDIDATES:
if not path.exists():
continue
try:
return ImageFont.truetype(str(path), size=size, index=index)
except OSError:
continue
return ImageFont.load_default()
def text_width(draw: ImageDraw.ImageDraw, text: str, fnt) -> int:
box = draw.textbbox((0, 0), text, font=fnt)
return box[2] - box[0]
def centered(draw: ImageDraw.ImageDraw, y: int, text: str, fnt, fill) -> None:
draw.text(((W - text_width(draw, text, fnt)) // 2, y), text, font=fnt, fill=fill)
# --------------------------------------------------------------------------
# Card + caption rendering
# --------------------------------------------------------------------------
def title_card(lines: list[str], subtitle: str, with_icon: bool = True) -> Image.Image:
im = Image.new("RGB", (W, H), BG)
glow = Image.new("RGB", (W, H), BG)
gdraw = ImageDraw.Draw(glow)
cx, cy = W // 2, int(H * 0.40)
for radius, lift in ((560, 26), (380, 38), (230, 52)):
gdraw.ellipse(
(cx - radius, cy - radius, cx + radius, cy + radius),
fill=(BG[0] + lift // 5, BG[1] + lift // 2, BG[2] + lift // 4),
)
im = Image.blend(im, glow, 0.6)
draw = ImageDraw.Draw(im)
y = int(H * 0.36)
if with_icon and ICON.exists():
icon = Image.open(ICON).convert("RGBA").resize((216, 216), Image.Resampling.LANCZOS)
mask = Image.new("L", (216, 216), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, 215, 215), radius=48, fill=255)
im.paste(icon, ((W - 216) // 2, y - 300), mask)
title_font = font(104 if len(lines) == 1 else 92)
for line in lines:
centered(draw, y, line, title_font, FG)
y += 132
if subtitle:
centered(draw, y + 28, subtitle, font(46), (146, 166, 150))
return im
def caption_overlay(text: str, sub: str | None) -> Image.Image:
"""Full-frame RGBA layer: bottom scrim + caption. Composited via overlay.
The app UI is light, so the scrim has to carry real weight — a gentle
gradient leaves white supers unreadable over a white settings card.
"""
im = Image.new("RGBA", (W, H), (0, 0, 0, 0))
draw = ImageDraw.Draw(im)
scrim_height = 780
for i in range(scrim_height):
# Sub-linear ramp: the supers sit well above the bottom edge, so the
# gradient has to carry real weight by the time it reaches them.
alpha = int(246 * (i / (scrim_height - 1)) ** 0.85)
draw.line([(0, H - scrim_height + i), (W, H - scrim_height + i)], fill=(0, 0, 0, alpha))
main_font = font(66)
baseline = H - 320 if sub else H - 268
centered(draw, baseline, text, main_font, (255, 255, 255, 255))
if sub:
centered(draw, baseline + 104, sub, font(44), (203, 221, 207, 255))
return im
# --------------------------------------------------------------------------
# ffmpeg plumbing
# --------------------------------------------------------------------------
def run(cmd: list[str]) -> None:
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
sys.stderr.write(" ".join(cmd) + "\n")
sys.stderr.write(proc.stderr[-4000:] + "\n")
raise SystemExit(f"ffmpeg failed ({proc.returncode})")
def probe(path: Path) -> dict:
out = subprocess.check_output(
[
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height,r_frame_rate,codec_name",
"-show_entries", "format=duration,size",
"-of", "json",
str(path),
],
text=True,
)
return json.loads(out)
def still_to_segment(image: Image.Image, seconds: float, dest: Path, zoom_in: bool) -> None:
src = dest.with_suffix(".png")
image.save(src, optimize=True)
frames = max(1, int(seconds * FPS))
if zoom_in:
z = "min(zoom+0.00042,1.07)"
else:
z = "if(eq(on,1),1.07,max(zoom-0.00042,1.0))"
vf = (
f"scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},"
f"zoompan=z='{z}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':"
f"d={frames}:s={W}x{H}:fps={FPS},setsar=1,format=yuv420p"
)
run([
"ffmpeg", "-y", "-loop", "1", "-i", str(src),
"-vf", vf, "-t", f"{seconds:.2f}",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-profile:v", "high", "-level", "4.0", "-crf", "18", "-r", str(FPS),
"-colorspace", "bt709", "-color_primaries", "bt709",
"-color_trc", "iec61966-2-1", "-color_range", "tv",
str(dest),
])
def constant_frame_rate(slug: str) -> Path:
"""Return a CFR copy of a raw recording, building it on first use.
`simctl io recordVideo` writes variable-frame-rate footage whose timeline
does not advance at wall-clock rate, so seeking into it lands in the wrong
place and `-t` windows come out the wrong length. Normalising once up
front makes every `trim_start` / `trim_duration` below mean real seconds.
"""
raw = RAW_DIR / f"{slug}.mov"
if not raw.exists():
raise SystemExit(f"missing recording: {raw}")
cfr = WORK_ROOT / "cfr" / f"{slug}.mp4"
if cfr.exists() and cfr.stat().st_mtime >= raw.stat().st_mtime:
return cfr
cfr.parent.mkdir(parents=True, exist_ok=True)
run([
"ffmpeg", "-y", "-i", str(raw),
"-vf", f"fps={FPS},scale={W}:{H}:force_original_aspect_ratio=increase,"
f"crop={W}:{H},setsar=1",
"-vsync", "cfr",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "16",
"-colorspace", "bt709", "-color_primaries", "bt709",
"-color_trc", "iec61966-2-1", "-color_range", "tv",
"-r", str(FPS), str(cfr),
])
return cfr
def footage_segment(source: Source, work: Path, dest: Path, index: int) -> None:
"""Trim one recording, normalise to 1290x2796, burn its captions."""
raw = constant_frame_rate(source.raw)
if source.freeze_at is None:
inputs: list[str] = [
"-ss", f"{source.trim_start:.2f}",
"-t", f"{source.trim_duration:.2f}",
"-i", str(raw),
]
else:
frame = work / f"freeze-{index:02d}.png"
run([
"ffmpeg", "-y", "-ss", f"{source.freeze_at:.2f}", "-i", str(raw),
"-frames:v", "1", str(frame),
])
inputs = [
"-loop", "1", "-t", f"{source.trim_duration:.2f}", "-i", str(frame),
]
for idx, cap in enumerate(source.captions):
png = work / f"cap-{index:02d}-{idx:02d}.png"
caption_overlay(cap.text, cap.sub).save(png)
# A single-frame input has pts 0 forever, which freezes `fade` at its
# t=0 value and leaves the super permanently transparent. Loop it so
# the overlay carries a real timeline for the fades to run against.
inputs += ["-loop", "1", "-t", f"{source.trim_duration:.2f}", "-i", str(png)]
# Normalise the recording first, then stack caption overlays on top.
normalise = (
f"scale={W}:{H}:force_original_aspect_ratio=increase,"
f"crop={W}:{H},fps={FPS}"
)
if source.ken_burns > 1.0:
# Static pages (a settings screen) need the motion to come from the
# camera. Step per frame so the push-in lands exactly at `ken_burns`.
frames = max(1, int(source.trim_duration * FPS))
step = (source.ken_burns - 1.0) / frames
fx, fy = source.ken_burns_focus
normalise += (
f",zoompan=z='min(zoom+{step:.6f},{source.ken_burns:.4f})'"
f":x='(iw-iw/zoom)*{fx:.3f}':y='(ih-ih/zoom)*{fy:.3f}'"
f":d=1:s={W}x{H}:fps={FPS}"
)
chain = [f"[0:v]{normalise},setsar=1[base]"]
current = "base"
for idx, cap in enumerate(source.captions):
nxt = f"ov{idx}"
# Fade the supers in and out so cuts do not pop.
chain.append(
f"[{idx + 1}:v]format=rgba,"
f"fade=t=in:st={cap.start:.2f}:d=0.28:alpha=1,"
f"fade=t=out:st={max(cap.start, cap.end - 0.28):.2f}:d=0.28:alpha=1[c{idx}]"
)
chain.append(
f"[{current}][c{idx}]overlay=0:0:"
f"enable='between(t,{cap.start:.2f},{cap.end:.2f})'[{nxt}]"
)
current = nxt
chain.append(f"[{current}]format=yuv420p[vout]")
run([
"ffmpeg", "-y", *inputs,
"-filter_complex", ";".join(chain),
"-map", "[vout]",
# `simctl io recordVideo` writes variable-frame-rate footage, so an
# input-side `-t` drifts once `fps=` fills the gaps. Clamp the output
# instead or the finished clip overruns the App Store 30s ceiling.
"-t", f"{source.trim_duration:.2f}",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-profile:v", "high", "-level", "4.0", "-crf", "18", "-r", str(FPS),
"-colorspace", "bt709", "-color_primaries", "bt709",
"-color_trc", "iec61966-2-1", "-color_range", "tv",
str(dest),
])
def build(clip: Clip) -> Path:
work = WORK_ROOT / clip.slug
work.mkdir(parents=True, exist_ok=True)
OUT_DIR.mkdir(parents=True, exist_ok=True)
title = work / "00-title.mp4"
still_to_segment(title_card(clip.title, clip.subtitle), clip.title_seconds, title, True)
parts = [title]
for index, source in enumerate(clip.sources):
part = work / f"{index + 1:02d}-body.mp4"
footage_segment(source, work, part, index)
parts.append(part)
end = work / "99-end.mp4"
still_to_segment(
title_card([clip.end_title], clip.end_subtitle), clip.end_seconds, end, False
)
parts.append(end)
concat = work / "concat.txt"
concat.write_text("".join(f"file '{p}'\n" for p in parts), encoding="utf-8")
silent = work / "silent.mp4"
run([
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat),
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18",
"-colorspace", "bt709", "-color_primaries", "bt709",
"-color_trc", "iec61966-2-1", "-color_range", "tv",
"-r", str(FPS), "-movflags", "+faststart", str(silent),
])
# App previews need an audio track; generate a soft royalty-free bed.
total = clip.total_seconds
fade_out = max(0.0, total - 2.2)
final = OUT_DIR / f"OSGKeyboard-{clip.slug}-6.7-zh.mp4"
run([
"ffmpeg", "-y", "-i", str(silent),
"-f", "lavfi", "-i",
f"sine=frequency=196:sample_rate=44100,volume=0.030,"
f"afade=t=in:st=0:d=1.1,afade=t=out:st={fade_out:.2f}:d=2.0",
"-f", "lavfi", "-i",
f"sine=frequency=293.66:sample_rate=44100,volume=0.018,"
f"afade=t=in:st=0:d=1.4,afade=t=out:st={fade_out:.2f}:d=2.0",
"-filter_complex", "[1:a][2:a]amix=inputs=2:duration=first:dropout_transition=2[a]",
"-map", "0:v", "-map", "[a]",
"-c:v", "copy", "-c:a", "aac", "-b:a", "128k",
"-shortest", "-movflags", "+faststart", str(final),
])
return final
CLIPS: dict[str, Clip] = {}
def main(argv: list[str]) -> int:
from feature_preview_clips import CLIPS as configured # noqa: PLC0415
slugs = argv or list(configured)
failures = 0
for slug in slugs:
clip = configured.get(slug)
if clip is None:
sys.stderr.write(f"unknown clip: {slug}\n")
failures += 1
continue
final = build(clip)
info = probe(final)
duration = float(info["format"]["duration"])
stream = info["streams"][0]
size_mb = int(info["format"]["size"]) / 1_000_000
ok = (
stream["width"] == W
and stream["height"] == H
and 15.0 <= duration <= 30.0
and stream["codec_name"] == "h264"
)
flag = "OK " if ok else "!! "
print(
f"{flag}{final.name} {stream['width']}x{stream['height']} "
f"{duration:.1f}s {size_mb:.1f} MB {stream['codec_name']}"
)
if not ok:
failures += 1
if not 15.0 <= duration <= 30.0:
print(f" duration {duration:.1f}s outside the App Store 15-30s window")
return 1 if failures else 0
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parent))
raise SystemExit(main(sys.argv[1:]))
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Windows for the in-app feature cards.
Every `start` / `duration` is in real seconds against the CFR copy of the raw
recording, and was read off a frame-by-frame pass over that footage — re-time
after touching any demo timeline.
Windows are chosen so the clip both tells the feature in one glance and lands
close to where it started, which keeps the loop blend invisible.
"""
from __future__ import annotations
from compose_feature_cards import Card
CARDS: dict[str, Card] = {
# 语音说一句 → 自动润色 → 出结果。
# 5.5 空闲提示 · 6.5 聆听 · 10 编辑中 · 13 编辑后结果
"voice-polish": Card(
slug="voice-polish",
raw="voice-polish",
start=5.5,
duration=10.0,
),
# 润色风格页。静态页面,用极缓的推近-拉回提供动势,天然无缝。
# 画面落在「生成专属风格」卡片上,下面带出内置风格第一排。
"personal-style": Card(
slug="personal-style",
raw="personal-style",
start=4.0,
duration=8.0,
ping_pong_zoom=1.10,
# Starts below the page title — a half-cut「润色风格」heading reads as
# a framing mistake on a card.
crop_y=470,
),
# 复制一条消息 → 键盘按内容给出技能行 → 点「回复」出一条 → 回到行 →
# 点「澄清追问」出另一种口吻。技能行由真实的
# `ClipboardSkillSemanticRanker` 排出(这条消息排出「译为英语 / 回复 /
# 澄清追问 / 日程」),不是写死的——生产环境在通用「回复」之外最多只放
# 两个专门回复技能,硬凑一排回复风格会得到一个真实 app 里不存在的键盘。
# 21 技能行 · 23 思考 · 25 回复 · 27 技能行 · 29 澄清追问 · 30.5 技能行
# 首尾都停在技能行上,循环点本身就是同一状态。
"clipboard-agent": Card(
slug="clipboard-agent",
raw="clipboard-agent",
start=20.6,
duration=9.9,
),
# 长按问 AI:聆听(识别出问题)→ 生成 → 答案落在键盘答案区。
# 13.5 聆听 · 18 生成中 · 21 答案出现
"ask-ai": Card(
slug="ask-ai",
raw="ask-ai",
start=13.5,
duration=10.0,
),
}
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Copy + timing for the four Chinese feature previews.
Caption times are relative to the start of their own `Source` window (i.e.
after `trim_start`), not the finished file — title cards are prepended later.
Kept separate from `compose_feature_previews.py` so the copy can be retimed
without touching the ffmpeg plumbing.
Raw footage comes from `Scripts/record_feature_previews.sh`; every timing below
was read off those recordings, so re-time after changing a demo timeline.
"""
from __future__ import annotations
from compose_feature_previews import Caption, Clip, Source
CLIPS: dict[str, Clip] = {
# 1 — 语音输入与润色,收在趣味润色风格上
"voice-polish": Clip(
slug="voice-polish",
title=["说一句", "成一段"],
subtitle="语音输入 · 自动润色",
sources=[
# 备忘录 + 真实润色审阅面板:听写 → 润色 → 替换上屏
Source(
raw="voice-polish",
trim_start=3.4,
trim_duration=17.0,
captions=[
Caption(0.3, 5.2, "开口就说", sub="不用先在脑子里组织措辞"),
Caption(5.5, 8.4, "AI 正在润色"),
Caption(8.7, 13.4, "口水话变成能直接发出去的话"),
Caption(13.7, 16.7, "一键替换,落回原处"),
],
),
# 润色风格页:内置风格之外还有一整排趣味人格
Source(
raw="personal-style",
trim_start=0.0,
trim_duration=6.0,
freeze_at=4.0,
ken_burns=1.09,
ken_burns_focus=(0.5, 0.78),
captions=[
Caption(0.3, 5.7, "换个风格,换种说法",
sub="大厂黑话 · 装逼指南 · 帝吧大神 · 小红书"),
],
),
],
),
# 2 — 个性润色风格:从历史听写里学你的表达习惯
"personal-style": Clip(
slug="personal-style",
title=["它会学", "你说话"],
subtitle="从你的听写里长出专属风格",
sources=[
Source(
raw="personal-style",
trim_start=0.0,
trim_duration=15.0,
freeze_at=4.0,
# Slow push-in onto the「生成专属风格」card near the top.
ken_burns=1.16,
ken_burns_focus=(0.5, 0.24),
captions=[
Caption(0.3, 4.6, "内置风格之外,还有一个你"),
Caption(4.9, 9.4, "从历史听写里学你的表达习惯",
sub="用词、语气、断句,都是你的"),
Caption(9.7, 14.7, "一键生成专属风格",
sub="仅在生成时发送,保存前可预览可修改"),
],
),
],
),
# 3 — 剪贴板 AI Agent:复制的内容直接一键回复
"clipboard-agent": Clip(
slug="clipboard-agent",
title=["复制一段", "AI 帮你回"],
subtitle="剪贴板 AI Agent",
sources=[
Source(
raw="clipboard-agent",
# Beats in the recording: clipboard panel ~9-13s, suggestion
# strip to ~19s, AI skill row ~19-26s, reply lands ~33s. The
# 24s budget cannot hold all of it, so the window opens on the
# tail of the panel and runs through the payoff.
trim_start=11.0,
trim_duration=24.0,
captions=[
Caption(0.3, 3.6, "复制过的内容,键盘直接认得"),
Caption(3.9, 7.4, "不用来回切 App 粘来粘去"),
# Skill row is on screen for body 7.6-12.6 — keep the super
# inside that window or it names a row you cannot see.
Caption(7.8, 12.4, "一键回复",
sub="俏皮回复 · 译为英语 · 总结网页"),
Caption(19.5, 23.7, "点一下,回复就写好了"),
],
),
],
),
# 4 — 长按麦克风直接询问 AI
"ask-ai": Clip(
slug="ask-ai",
title=["轻点听写", "长按问 AI"],
subtitle="一个麦克风,两件事",
sources=[
Source(
raw="ask-ai",
trim_start=9.0,
trim_duration=24.0,
captions=[
Caption(0.3, 5.4, "长按麦克风,直接问 AI",
sub="不用退出当前 App"),
Caption(5.7, 11.0, "说完就在想答案"),
Caption(11.3, 17.4, "答案直接落进输入框"),
Caption(17.7, 23.7, "想好了,直接发出去"),
],
),
],
),
}
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Record the four Chinese feature preview clips off an iOS Simulator.
#
# Drives the DEBUG in-app demo hosts, which render the **real** keyboard views
# (`AIKeyboardView`, `LastInputEditView`, `ClipboardHistoryPanelView`, …) and
# the real `PolishStylesView` against a scripted `KeyboardState` — no ASR, no
# LLM, no network. `--preview-fullscreen` adds the Notes / Messages host
# document above the keyboard band so the frame works as a full-screen App
# Store preview instead of a cropped What's New card.
#
# Why not the keyboard extension (`--whats-new-host`)? Making a third-party
# keyboard the *active* one needs a globe-key tap that cannot be scripted
# through simctl. The in-app hosts use the same views and need no tap.
#
# Device must be an iPhone 16 Plus (native 1290x2796 — the App Store Connect
# 6.9" preview slot) on a runtime new enough for the app's Liquid Glass APIs
# (iOS 26.5 at time of writing; iOS 26.0 is missing `View.glassEffect(_:in:)`).
#
# Usage:
# Scripts/record_feature_previews.sh <UDID> [slug ...]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
UDID="${1:?usage: record_feature_previews.sh <UDID> [slug ...]}"
shift || true
BUNDLE="com.osgkeyboard.ios"
RAW_DIR="$ROOT/.tmp/feature-previews/raw"
mkdir -p "$RAW_DIR"
# slug | seconds | launch args
CLIPS=(
"voice-polish|23|--edit-demo --preview-fullscreen"
# 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"
)
want() {
local slug="$1"; shift
# No slug filter left on the command line -> record everything.
[[ $# -eq 0 ]] && return 0
for requested in "$@"; do
[[ "$slug" == "$requested" ]] && return 0
done
return 1
}
echo "==> Device: $UDID"
# Installing over an existing copy does not refresh the App Group container.
# When it is missing the app renders `AppGroupErrorView` instead of the demo,
# and the recording silently captures an error screen — so fail loudly here.
DEVICE_ROOT="$HOME/Library/Developer/CoreSimulator/Devices/$UDID"
if ! grep -qls "group.com.osgkeyboard.shared" \
"$DEVICE_ROOT"/data/Containers/Shared/AppGroup/*/.com.apple.mobile_container_manager.metadata.plist
then
echo "FAIL: App Group container missing on $UDID." >&2
echo " Reinstall cleanly, then retry:" >&2
echo " xcrun simctl uninstall $UDID $BUNDLE && xcrun simctl install $UDID <path>.app" >&2
exit 1
fi
# Marketing status bar: fixed time, full bars, full battery.
xcrun simctl status_bar "$UDID" override \
--time "9:41" \
--dataNetwork wifi --wifiMode active --wifiBars 3 \
--cellularMode active --cellularBars 4 \
--batteryState charged --batteryLevel 100
for entry in "${CLIPS[@]}"; do
IFS='|' read -r slug seconds args <<<"$entry"
want "$slug" "$@" || continue
out="$RAW_DIR/$slug.mov"
echo "==> Recording $slug (${seconds}s) [$args]"
xcrun simctl terminate "$UDID" "$BUNDLE" >/dev/null 2>&1 || true
sleep 1
rm -f "$out"
xcrun simctl io "$UDID" recordVideo --codec h264 --force "$out" &
RECORD_PID=$!
# Let the recorder attach before the first frame we care about.
sleep 1.5
# shellcheck disable=SC2086 -- args are intentionally word-split.
xcrun simctl launch "$UDID" "$BUNDLE" $args >/dev/null
sleep "$seconds"
kill -INT "$RECORD_PID" 2>/dev/null || true
wait "$RECORD_PID" 2>/dev/null || true
sleep 1
if [[ -s "$out" ]]; then
ffprobe -v error -select_streams v:0 \
-show_entries stream=width,height -show_entries format=duration \
-of default=noprint_wrappers=1 "$out"
else
echo "FAIL: no footage captured for $slug" >&2
exit 1
fi
done
xcrun simctl terminate "$UDID" "$BUNDLE" >/dev/null 2>&1 || true
echo "==> Raw footage in $RAW_DIR"