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:
@@ -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:]))
|
||||
Executable
+441
@@ -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:]))
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
@@ -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, "想好了,直接发出去"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
}
|
||||
Executable
+111
@@ -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"
|
||||
Reference in New Issue
Block a user