Files
OSGKeyboard/Scripts/compose_app_preview_zh.py
T
Rocky 9da32b81e9 feat(keyboard): harden clipboard command and add What's New sheet
Stabilize clipboard long-press prepare/resume across paste alerts and cold start, add an in-app release-notes sheet with remote bilingual HTML, localize typing input settings, and bump build to 55.
2026-08-08 00:20:42 +08:00

313 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Compose a Chinese App Store preview (iPhone 6.7\") for OSGKeyboard.
Uses real Simulator / marketing UI stills + ffmpeg Ken Burns + ASS titles.
Output: docs/assets/app-preview/zh/OSGKeyboard-preview-6.7-zh.mp4
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "docs" / "assets" / "app-preview" / "zh"
WORK = ROOT / ".tmp" / "app-preview-zh" / "compose"
W, H = 1290, 2796 # App Store Connect 6.7"
FPS = 30
HOME_SRC = ROOT / ".tmp" / "app-preview-zh" / "shot-home-dark.png"
KB_IDLE = ROOT / "docs" / "assets" / "screenshots" / "zh" / "dark" / "iphone-keyboard_idle.png"
KB_REC = ROOT / "docs" / "assets" / "screenshots" / "zh" / "dark" / "iphone-keyboard_recording.png"
ICON = ROOT / "docs" / "assets" / "app-icon.png"
# Prefer a clean Chinese UI font available on this machine.
FONT_CANDIDATES = [
Path("/Users/rocky/Library/Fonts/OPPO Sans 4.0.ttf"),
Path("/System/Library/Fonts/STHeiti Medium.ttc"),
Path("/System/Library/Fonts/Supplemental/Songti.ttc"),
]
def pick_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for path in FONT_CANDIDATES:
if not path.exists():
continue
try:
return ImageFont.truetype(str(path), size=size, index=0)
except OSError:
continue
return ImageFont.load_default()
def cover(img: Image.Image) -> Image.Image:
"""Scale to cover WxH and center-crop."""
src = img.convert("RGB")
scale = max(W / src.width, H / src.height)
nw, nh = int(src.width * scale), int(src.height * scale)
resized = src.resize((nw, nh), Image.Resampling.LANCZOS)
left = (nw - W) // 2
top = (nh - H) // 2
return resized.crop((left, top, left + W, top + H))
def patch_home_warning(src: Path) -> Image.Image:
"""Hide Simulator-only Flow warning banner for marketing stills."""
im = Image.open(src).convert("RGB")
# Banner sits under the three status dots (~y 520580 @ 1320×2868).
sample = im.getpixel((im.width // 2, 500))
draw = ImageDraw.Draw(im)
draw.rounded_rectangle((48, 518, im.width - 48, 586), radius=28, fill=sample)
return cover(im)
def title_card(lines: list[str], subtitle: str | None = None, with_icon: bool = False) -> Image.Image:
im = Image.new("RGB", (W, H), (12, 12, 14))
# Soft brand glow
glow = Image.new("RGB", (W, H), (12, 12, 14))
gdraw = ImageDraw.Draw(glow)
cx, cy = W // 2, int(H * 0.38)
for r, alpha in ((520, 28), (360, 40), (220, 55)):
color = (18 + alpha // 4, 40 + alpha // 3, 28 + alpha // 5)
gdraw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=color)
im = Image.blend(im, glow, 0.55)
draw = ImageDraw.Draw(im)
y = int(H * 0.34)
if with_icon and ICON.exists():
icon = Image.open(ICON).convert("RGBA").resize((220, 220), Image.Resampling.LANCZOS)
# Rounded mask
mask = Image.new("L", (220, 220), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, 219, 219), radius=48, fill=255)
im.paste(icon, ((W - 220) // 2, y - 280), mask)
y = int(H * 0.42)
title_font = pick_font(96 if len(lines) == 1 else 84)
sub_font = pick_font(44)
for line in lines:
bbox = draw.textbbox((0, 0), line, font=title_font)
tw = bbox[2] - bbox[0]
draw.text(((W - tw) // 2, y), line, fill=(245, 246, 248), font=title_font)
y += 120
if subtitle:
bbox = draw.textbbox((0, 0), subtitle, font=sub_font)
tw = bbox[2] - bbox[0]
draw.text(((W - tw) // 2, y + 24), subtitle, fill=(140, 160, 145), font=sub_font)
return im
def overlay_caption(base: Image.Image, text: str) -> Image.Image:
"""Bottom gradient + caption for ad-style supers."""
im = base.copy().convert("RGBA")
shade = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(shade)
for i in range(420):
a = int(190 * (i / 419))
y = H - 420 + i
d.line([(0, y), (W, y)], fill=(0, 0, 0, a))
im = Image.alpha_composite(im, shade)
draw = ImageDraw.Draw(im)
font = pick_font(64)
bbox = draw.textbbox((0, 0), text, font=font)
tw = bbox[2] - bbox[0]
draw.text(((W - tw) // 2, H - 260), text, fill=(255, 255, 255, 255), font=font)
return im.convert("RGB")
def write_ass(path: Path) -> None:
# Kept for optional future hardsubs; primary captions are burned into stills.
path.write_text(
"""[Script Info]
ScriptType: v4.00+
PlayResX: 1290
PlayResY: 2796
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Heiti SC,64,&H00FFFFFF,&H000000FF,&H64000000,&H80000000,0,0,0,0,100,100,0,0,1,0,0,2,60,60,120,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
""",
encoding="utf-8",
)
def run(cmd: list[str]) -> None:
print("+", " ".join(cmd[:8]), "..." if len(cmd) > 8 else "")
subprocess.run(cmd, check=True)
def main() -> int:
if not HOME_SRC.exists():
print(f"missing home still: {HOME_SRC}", file=sys.stderr)
return 1
WORK.mkdir(parents=True, exist_ok=True)
OUT_DIR.mkdir(parents=True, exist_ok=True)
home = patch_home_warning(HOME_SRC)
kb_idle = cover(Image.open(KB_IDLE))
kb_rec = cover(Image.open(KB_REC))
scenes = {
"01_open": title_card(["开口即文字"], with_icon=True),
"02_hook": overlay_caption(home, "说,就好了"),
"03_voice": overlay_caption(kb_rec, "说完就能用"),
"04_type": overlay_caption(kb_idle, "打字也在行"),
"05_privacy": overlay_caption(home, "默认不上传录音"),
"06_end": title_card(["OSGKeyboard"], "开口即文字", with_icon=True),
}
for name, img in scenes.items():
img.save(WORK / f"{name}.png", optimize=True)
# Durations (seconds) — total ~28s
timeline = [
("01_open", 2.2),
("02_hook", 3.2),
("03_voice", 7.0),
("04_type", 6.0),
("05_privacy", 5.0),
("06_end", 4.0),
]
# Build concat list with zoompan Ken Burns per still.
parts: list[Path] = []
for idx, (name, dur) in enumerate(timeline):
src = WORK / f"{name}.png"
part = WORK / f"part-{idx:02d}.mp4"
frames = max(1, int(dur * FPS))
# Gentle zoom: alternate direction for rhythm
if idx % 2 == 0:
z = f"min(zoom+0.00045,1.08)"
x = "iw/2-(iw/zoom/2)"
y = "ih/2-(ih/zoom/2)"
else:
z = f"if(eq(on,1),1.08,max(zoom-0.00045,1.0))"
x = "iw/2-(iw/zoom/2)"
y = "ih/2-(ih/zoom/2)"
vf = (
f"scale={W}:{H}:force_original_aspect_ratio=increase,"
f"crop={W}:{H},"
f"zoompan=z='{z}':x='{x}':y='{y}':d={frames}:s={W}x{H}:fps={FPS},"
f"setsar=1,format=yuv420p"
)
run(
[
"ffmpeg",
"-y",
"-loop",
"1",
"-i",
str(src),
"-vf",
vf,
"-t",
f"{dur:.2f}",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-profile:v",
"high",
"-level",
"4.0",
"-crf",
"18",
"-r",
str(FPS),
str(part),
]
)
parts.append(part)
concat_list = WORK / "concat.txt"
concat_list.write_text("".join(f"file '{p}'\n" for p in parts), encoding="utf-8")
silent = WORK / "preview-silent.mp4"
run(
[
"ffmpeg",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(concat_list),
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-r",
str(FPS),
str(silent),
]
)
# Soft generated bed (no third-party music / no copyright risk).
final = OUT_DIR / "OSGKeyboard-preview-6.7-zh.mp4"
run(
[
"ffmpeg",
"-y",
"-i",
str(silent),
"-f",
"lavfi",
"-i",
"sine=frequency=196:sample_rate=44100,volume=0.035,afade=t=in:st=0:d=1.2,afade=t=out:st=25:d=2.5",
"-f",
"lavfi",
"-i",
"sine=frequency=293.66:sample_rate=44100,volume=0.02,afade=t=in:st=0:d=1.5,afade=t=out:st=25:d=2.5",
"-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),
]
)
# Probe
probe = subprocess.check_output(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,duration",
"-show_entries",
"format=duration,size",
"-of",
"default=noprint_wrappers=1",
str(final),
],
text=True,
)
print(probe)
print(f"Wrote {final}")
return 0
if __name__ == "__main__":
raise SystemExit(main())