chore(apple): add screenshot studio

This commit is contained in:
2026-08-06 12:43:01 +02:00
parent 387298d137
commit 06d33361c2
17 changed files with 966 additions and 7 deletions

8
packaging/apple/studio/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
generated/
out-*.png
assets/shots/**/*.png
assets/mockup-*.png
assets/mask-*.png
assets/globe.png
assets/ribbon.png
!assets/README.md

View File

@@ -0,0 +1,57 @@
# App Store screenshot studio
Code-driven, fully local App Store screenshots. **Typst** composes each frame
(gradient background + blank device mockup + app screenshot + localized caption)
and renders it to an exact-size PNG. Captions come from `strings.json` (9 locales);
app screenshots come from the real app (see capture, step 3).
## Requirements
- `typst` (0.15+) — installed.
- `imagemagick` — only for the tilted hero shot's perspective warp (step 2). `brew install imagemagick`.
## Render
```sh
./build.sh # all locales × screens -> generated/<Language>/
./build.sh --publish # -> ../<Language>/ (ships to App Store)
LOCALES="fr de" SCREENS="share-securely" ./build.sh
```
Default writes to `generated/` (safe). `--publish` overwrites the real language folders.
Single frame while tuning:
```sh
typst compile screens.typ out.png --input locale=fr --input screen=share-securely --ppi 72
```
## Files
- `strings.json` — captions per locale (first-pass translations; review before shipping).
- `screens.typ` — the composition + per-screen layout data (`screens` dict). Tweak numbers, re-run.
- `build.sh` — loops locales × screens; maps locale→folder and screen→filename.
- `assets/mockup-straight.png` — blank straight device (real alpha, black screen).
- `assets/mockup-rotated.png` — blank tilted device (for the hero shot).
- `assets/globe.png` — circle-clipped in Typst (the export has a baked checkerboard, not true alpha).
- `assets/shots/<locale>/<screen>.png` — captured app screenshots (optional; placeholder if absent).
## How the screenshot gets into the phone
The mockup's screen glass is a rectangle. `screens.typ` places the screenshot inset
into that rectangle (`sx`/`sy`/`sr` fractions) with the bezel framing it. Measure those
fractions once against `mockup-straight.png`.
## Full pipeline
```sh
./capture.sh # real localized app screens -> assets/shots/<locale>/<screen>.png
./build.sh # composite everything -> generated/<Language>/
./build.sh --publish # when happy -> ships to ../<Language>/
```
`capture.sh` drives the app into each screen via the DEBUG `-VniScreenshot` launch
argument (see `apple/VniDrop/App/ScreenshotSupport.swift`), once per locale via
`-AppleLanguages`, in dark mode with a 9:41 status bar. Screen ⇄ scenario map:
`share-securely``share`, `choose-receivers``approval`, `send-anywhere``transfer-details`.
## Status
1. ✅ Typst + JSON + straight-mockup compositing.
2. ✅ Tilted hero (`send-anywhere`): `warp.sh` perspective-warps the screenshot onto the
rotated mockup's screen quad (4 auto-detected corners); `build.sh` runs it automatically.
3. ✅ Transparent globe layer (placed directly — use the alpha export, not a flattened one).
4. ✅ Screenshot capture: `capture.sh` + the `#if DEBUG` fixture gateway, per locale, deterministic.
5. ⬜ Ribbon art layer for `stay-private` (export as its own transparent PNG).
6. ⬜ Layout polish: tune device positions/sizes in `screens.typ` against the originals.

61
packaging/apple/studio/build.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Render all (locale x screen) App Store screenshots with Typst.
#
# ./build.sh # -> generated/<Language>/<Name>.png (safe default)
# ./build.sh --publish # -> ../<Language>/<Name>.png (ships to App Store)
# LOCALES="fr de" SCREENS="share-securely" ./build.sh # subset
#
# Screenshots are read from assets/shots/<locale>/<screen>.png when present
# (pass nothing and you get the "screenshot here" placeholder).
set -euo pipefail
cd "$(dirname "$0")"
PUBLISH=""; [[ "${1:-}" == "--publish" ]] && PUBLISH=1
LOCALES=${LOCALES:-"en fr de es it nl pl pt ru"}
SCREENS=${SCREENS:-"choose-receivers send-anywhere share-securely stay-private"}
# locale code -> output folder name (matches existing packaging/apple/<Language>/)
folder_for() { case "$1" in
en) echo English;; fr) echo French;; de) echo German;; es) echo Spanish;;
it) echo Italian;; nl) echo Dutch;; pl) echo Polish;; pt) echo Portuguese;; ru) echo Russian;;
*) echo "$1";; esac; }
# screen id -> output file basename (matches existing filenames)
name_for() { case "$1" in
choose-receivers) echo "Choose Receivers";; send-anywhere) echo "Send Anywhere";;
share-securely) echo "Share Securely";; stay-private) echo "Stay private";;
*) echo "$1";; esac; }
# screen id -> device mockup mode (which mockup frames the screenshot), or empty for none
mode_for() { case "$1" in
send-anywhere) echo "rotated";; choose-receivers|share-securely) echo "straight";;
*) echo "";; esac; }
n=0
for loc in $LOCALES; do
folder=$(folder_for "$loc")
outdir=$([[ -n "$PUBLISH" ]] && echo "../$folder" || echo "generated/$folder")
mkdir -p "$outdir"
for scr in $SCREENS; do
shot="assets/shots/$loc/$scr.png"
mode="$(mode_for "$scr")"
# Frame the screenshot into its mockup (frame.sh masks it to the real screen
# shape) when a capture and a mockup mode exist; regenerate if the shot is newer.
device_arg="none"
if [[ -n "$mode" && -f "$shot" ]]; then
device="assets/shots/$loc/$scr.device.png"
[[ ! -f "$device" || "$shot" -nt "$device" ]] && ./frame.sh "$mode" "$shot" "$device" >/dev/null
device_arg="$device"
fi
out="$outdir/$(name_for "$scr").png"
typst compile screens.typ "$out" \
--input locale="$loc" --input screen="$scr" --input device="$device_arg" --ppi 72
echo "$out"
n=$((n+1))
done
done
echo ""
echo "Done — $n screenshot(s)$([[ -n "$PUBLISH" ]] && echo ' (published)')."

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Capture real, localized app screens for the studio pipeline.
#
# Drives the app straight into each marketing screen via the DEBUG `-VniScreenshot`
# launch argument (no UI automation needed), once per locale via `-AppleLanguages`,
# and writes assets/shots/<locale>/<screen>.png at the device's native resolution.
#
# ./capture.sh # all locales
# LOCALES="en fr" ./capture.sh
# SCREENSHOT_DEVICE="iPhone 17 Pro Max" ./capture.sh
#
# Requires a Debug build (the fixture gateway is compiled under #if DEBUG).
set -euo pipefail
trap 'echo "capture.sh: failed (rc=$?) at line $LINENO" >&2' ERR
cd "$(dirname "$0")"
APPLE_DIR="$(cd ../../../apple && pwd)"
DEVICE="${SCREENSHOT_DEVICE:-iPhone 17 Pro Max}"
BUNDLE_ID="com.vnidrop.app"
LOCALES=${LOCALES:-"en fr de es it nl pl pt ru"}
# scenario (launch arg value) -> studio screen id (output filename stem)
SCENARIOS="share:share-securely approval:choose-receivers transfer-details:send-anywhere"
echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)
echo "==> Building VniDrop (Debug) for $DEVICE"
xcodebuild build -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
-destination "platform=iOS Simulator,name=$DEVICE" -configuration Debug \
CODE_SIGNING_ALLOWED=NO >/dev/null
APP="$(xcodebuild -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
-destination "platform=iOS Simulator,name=$DEVICE" -configuration Debug \
-showBuildSettings 2>/dev/null | awk -F' = ' '/ BUILT_PRODUCTS_DIR /{print $2; exit}')/VniDrop.app"
UDID="$(xcrun simctl list devices available | grep -F "$DEVICE (" | head -1 \
| grep -oiE '[0-9a-f-]{36}' | head -1)"
[ -n "$UDID" ] || { echo "error: no simulator '$DEVICE'"; exit 1; }
echo " device: $UDID"
echo " app: $APP"
xcrun simctl boot "$UDID" 2>/dev/null || true
xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true
xcrun simctl ui "$UDID" appearance dark >/dev/null 2>&1 || true # match the dark marketing look
xcrun simctl status_bar "$UDID" override --time "9:41" \
--batteryState charged --batteryLevel 100 --cellularBars 4 --wifiBars 3 >/dev/null 2>&1 || true
xcrun simctl install "$UDID" "$APP"
for loc in $LOCALES; do
mkdir -p "assets/shots/$loc"
for pair in $SCENARIOS; do
scenario="${pair%%:*}"; screen="${pair##*:}"
xcrun simctl launch --terminate-running-process "$UDID" "$BUNDLE_ID" \
-VniScreenshot "$scenario" -AppleLanguages "($loc)" -AppleLocale "$loc" >/dev/null
sleep 4
# simctl can't write into the project tree (TCC blocks the CoreSimulator helper
# on external/again-protected volumes), so capture to a temp file and move it in.
tmp="$(mktemp -t vnishot).png"
xcrun simctl io "$UDID" screenshot "$tmp" >/dev/null 2>&1
mv "$tmp" "assets/shots/$loc/$screen.png"
echo " 📸 $loc/$screen.png"
done
done
echo ""
echo "Done. Now run ./build.sh to composite."

38
packaging/apple/studio/frame.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Composite a screenshot into a device mockup using the mockup's real screen shape
# as the mask — so the screen curvature comes from the mockup, never a guessed radius.
# Output is a device PNG the size of the mockup, transparent outside the phone, for
# Typst to place directly.
#
# ./frame.sh straight assets/shots/en/share-securely.png out-device.png
# ./frame.sh rotated assets/shots/en/send-anywhere.png out-device.png
set -euo pipefail
cd "$(dirname "$0")"
MODE="$1"; SHOT="$2"; OUT="$3"
MOCKUP="assets/mockup-$MODE.png"
MASK="assets/mask-$MODE.png"
# Cache the screen-glass mask (near-black region of the mockup, flattened off transparency).
if [[ ! -f "$MASK" || "$MOCKUP" -nt "$MASK" ]]; then
magick "$MOCKUP" -background magenta -flatten -colorspace Gray -threshold 6% -negate "$MASK"
fi
CW=$(magick identify -format "%w" "$MOCKUP"); CH=$(magick identify -format "%h" "$MOCKUP")
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
if [[ "$MODE" == "rotated" ]]; then
# Perspective-warp onto the tilted glass quad (canvas-sized already).
./warp.sh "$SHOT" "$tmp/screen.png" >/dev/null
else
# Cover-fit the screenshot to the screen's bounding box, placed at its offset.
read SW SH SX SY <<<"$(magick "$MASK" -format "%@" info: | tr 'x+' ' ')"
magick "$SHOT" -resize "${SW}x${SH}^" -gravity center -extent "${SW}x${SH}" "$tmp/fit.png"
magick -size "${CW}x${CH}" xc:none "$tmp/fit.png" -geometry "+${SX}+${SY}" -composite "$tmp/screen.png"
fi
# Clip the screen layer to the exact glass shape (rounded corners from the mask),
# then lay it over the mockup so the bezel frames it.
magick "$tmp/screen.png" "$MASK" -alpha off -compose CopyOpacity -composite "$tmp/clipped.png"
magick "$MOCKUP" "$tmp/clipped.png" -compose over -composite "$OUT"
echo "framed -> $OUT"

View File

@@ -0,0 +1,78 @@
// App Store screenshot composition.
// Rendered per (locale, screen) via:
// typst compile screens.typ out.png --input locale=fr --input screen=share-securely --ppi 72
// Canvas is 1284x2778pt -> at 72ppi that's exactly 1284x2778px.
#let strings = json("strings.json")
// ---- inputs (with dev-friendly defaults) --------------------------------
#let locale = sys.inputs.at("locale", default: "en")
#let screen-id = sys.inputs.at("screen", default: "share-securely")
// A pre-framed device PNG (mockup + screenshot, produced by frame.sh), or "none"
// to show the empty mockup. Screen curvature comes from the mockup mask, not Typst.
#let device-image = sys.inputs.at("device", default: "none")
#let cap = strings.locales.at(locale).at(screen-id)
// ---- canvas -------------------------------------------------------------
#let CW = 1284pt
#let CH = 2778pt
// ---- per-screen layout data --------------------------------------------
#let screens = (
"choose-receivers": (
bg: gradient.linear(angle: 160deg, rgb("#f1eafc"), rgb("#e6d8fb"), rgb("#d9c4f7")),
text: (place: "top", theme: "dark"),
device: (mockup: "straight", w: 860pt, dx: 212pt, dy: 560pt, rot: 4deg),
),
"share-securely": (
bg: gradient.linear(angle: 165deg, rgb("#efe7fb"), rgb("#e3d3f8"), rgb("#d5bff4")),
text: (place: "top", theme: "dark"),
device: (mockup: "straight", w: 820pt, dx: 232pt, dy: 470pt, rot: 0deg),
),
"send-anywhere": (
bg: gradient.linear(dir: ttb, rgb("#e9ddf9"), rgb("#ddc9f4")),
text: (place: "bottom", theme: "dark"),
globe: (w: 1180pt, dx: 52pt, dy: -160pt),
device: (mockup: "rotated", w: 900pt, dx: 90pt, dy: 980pt, rot: 0deg),
),
"stay-private": (
bg: gradient.linear(dir: ttb, rgb("#2a0f4d"), rgb("#6b3fa0"), rgb("#e9dcf8"), rgb("#ddc7f4")),
text: (place: "top", theme: "light"),
),
)
#let cfg = screens.at(screen-id)
#set page(width: CW, height: CH, margin: 0pt, fill: cfg.bg)
#set text(font: ("SF NS", "Helvetica Neue")) // "SF NS" is macOS San Francisco (= SF Pro)
// ---- compose ------------------------------------------------------------
// globe (transparent PNG)
#if "globe" in cfg [
#place(top + left, dx: cfg.globe.dx, dy: cfg.globe.dy,
image("assets/globe.png", width: cfg.globe.w))
]
// device: a pre-framed PNG (frame.sh) when available, else the empty mockup.
#if "device" in cfg [
#let d = cfg.device
#let img = if device-image != "none" { device-image } else { "assets/mockup-" + d.mockup + ".png" }
#place(top + left, dx: d.dx, dy: d.dy,
rotate(d.rot, origin: center, image(img, width: d.w)))
]
// caption
#let theme-color = if cfg.text.theme == "light" { white } else { rgb("#1b1226") }
#let sub-color = if cfg.text.theme == "light" { rgb("#f3ecfb") } else { rgb("#2c2138") }
#let caption = align(center)[
#text(size: 104pt, weight: 800, fill: theme-color)[#cap.title]
#v(10pt, weak: true)
#text(size: 60pt, weight: 600, fill: sub-color)[#cap.subtitle]
]
#if cfg.text.place == "top" [
#place(top + center, dy: 96pt, box(width: CW - 160pt, caption))
] else [
#place(bottom + center, dy: -220pt, box(width: CW - 160pt, caption))
]

View File

@@ -0,0 +1,69 @@
{
"_comment": "Marketing captions for App Store screenshots. NOT app strings — these live only here. Translations below the English source are a first pass and should be reviewed by a native speaker.",
"screens": ["choose-receivers", "send-anywhere", "share-securely", "stay-private"],
"locales": {
"en": {
"_folder": "English",
"choose-receivers": { "title": "Choose Receivers", "subtitle": "Approve each person invited" },
"send-anywhere": { "title": "Send Anywhere", "subtitle": "Direct file transfer worldwide" },
"share-securely": { "title": "Share Securely", "subtitle": "QR, NFC or file" },
"stay-private": { "title": "Stay Private", "subtitle": "Encrypted with no server copy" }
},
"fr": {
"_folder": "French",
"choose-receivers": { "title": "Choisissez les destinataires", "subtitle": "Approuvez chaque personne invitée" },
"send-anywhere": { "title": "Envoyez partout", "subtitle": "Transfert direct dans le monde entier" },
"share-securely": { "title": "Partagez en sécurité", "subtitle": "QR, NFC ou fichier" },
"stay-private": { "title": "Restez privé", "subtitle": "Chiffré, sans copie serveur" }
},
"de": {
"_folder": "German",
"choose-receivers": { "title": "Empfänger auswählen", "subtitle": "Jede eingeladene Person bestätigen" },
"send-anywhere": { "title": "Überallhin senden", "subtitle": "Direkte Dateiübertragung weltweit" },
"share-securely": { "title": "Sicher teilen", "subtitle": "QR, NFC oder Datei" },
"stay-private": { "title": "Privat bleiben", "subtitle": "Verschlüsselt, ohne Server-Kopie" }
},
"es": {
"_folder": "Spanish",
"choose-receivers": { "title": "Elige destinatarios", "subtitle": "Aprueba a cada invitado" },
"send-anywhere": { "title": "Envía a cualquier lugar", "subtitle": "Transferencia directa en todo el mundo" },
"share-securely": { "title": "Comparte con seguridad", "subtitle": "QR, NFC o archivo" },
"stay-private": { "title": "Mantén la privacidad", "subtitle": "Cifrado, sin copia en servidor" }
},
"it": {
"_folder": "Italian",
"choose-receivers": { "title": "Scegli i destinatari", "subtitle": "Approva ogni invitato" },
"send-anywhere": { "title": "Invia ovunque", "subtitle": "Trasferimento diretto in tutto il mondo" },
"share-securely": { "title": "Condividi in sicurezza","subtitle": "QR, NFC o file" },
"stay-private": { "title": "Resta privato", "subtitle": "Crittografato, senza copia sul server" }
},
"nl": {
"_folder": "Dutch",
"choose-receivers": { "title": "Kies ontvangers", "subtitle": "Keur elke genodigde goed" },
"send-anywhere": { "title": "Verstuur overal", "subtitle": "Directe bestandsoverdracht wereldwijd" },
"share-securely": { "title": "Deel veilig", "subtitle": "QR, NFC of bestand" },
"stay-private": { "title": "Blijf privé", "subtitle": "Versleuteld, geen serverkopie" }
},
"pl": {
"_folder": "Polish",
"choose-receivers": { "title": "Wybierz odbiorców", "subtitle": "Zatwierdź każdą zaproszoną osobę" },
"send-anywhere": { "title": "Wysyłaj wszędzie", "subtitle": "Bezpośredni transfer plików na całym świecie" },
"share-securely": { "title": "Udostępniaj bezpiecznie", "subtitle": "Kod QR, NFC lub plik" },
"stay-private": { "title": "Zachowaj prywatność", "subtitle": "Szyfrowane, bez kopii na serwerze" }
},
"pt": {
"_folder": "Portuguese",
"choose-receivers": { "title": "Escolha os destinatários", "subtitle": "Aprove cada pessoa convidada" },
"send-anywhere": { "title": "Envie para qualquer lugar", "subtitle": "Transferência direta pelo mundo todo" },
"share-securely": { "title": "Compartilhe com segurança", "subtitle": "QR, NFC ou arquivo" },
"stay-private": { "title": "Mantenha a privacidade", "subtitle": "Criptografado, sem cópia no servidor" }
},
"ru": {
"_folder": "Russian",
"choose-receivers": { "title": "Выбирайте получателей", "subtitle": "Подтверждайте каждого приглашённого" },
"send-anywhere": { "title": "Отправляйте куда угодно", "subtitle": "Прямая передача файлов по всему миру" },
"share-securely": { "title": "Делитесь безопасно", "subtitle": "QR, NFC или файл" },
"stay-private": { "title": "Оставайтесь приватными", "subtitle": "Шифрование без копии на сервере" }
}
}
}

25
packaging/apple/studio/warp.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Perspective-warp a flat screenshot onto the tilted mockup's screen glass.
# Output is a transparent PNG the exact size of mockup-rotated.png, so Typst can
# stack it directly over the mockup (bezel frames it).
#
# ./warp.sh assets/shots/en/send-anywhere.png assets/shots/en/send-anywhere.warped.png
#
# The 4 destination corners are the glass corners of mockup-rotated.png (1696x2528),
# auto-detected once. Re-detect if the mockup changes (see README).
set -euo pipefail
cd "$(dirname "$0")"
SRC="$1"; OUT="$2"
MW=1696; MH=2528 # mockup-rotated.png dimensions
TL="136,28"; TR="836,236"; BL="944,2136"; BR="1668,2312"
W=$(magick identify -format "%w" "$SRC")
H=$(magick identify -format "%h" "$SRC")
magick \
\( -size ${MW}x${MH} xc:none \) \
\( "$SRC" -virtual-pixel transparent +distort Perspective \
"0,0 $TL $((W-1)),0 $TR 0,$((H-1)) $BL $((W-1)),$((H-1)) $BR" \) \
-background none -flatten "$OUT"
echo "warped -> $OUT"