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.
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
#!/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 520–580 @ 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())
|
||||
Reference in New Issue
Block a user