feat(studio): iPad screenshot support + cleanup

Generalize the studio into a Platform/DeviceModel abstraction so it renders
both iPhone and iPad sets from one code path.

- Platform: per-target canvas (iPad 2064x2752), 3D model, capture sim, and
  output subfolder (iPad -> <Language>/iPad/). PLATFORM=iphone|ipad.
- DeviceModel: screen material, orientation (yaw) fix, optional glass mesh,
  graphite recolor — the per-model quirks the renderer needs.
- SceneKit renderer: auto-generates planar UVs for screen meshes that ship
  without them; textures every material matching the screen name; zFar scaled
  for large-unit models; body recolor skips the real screen material.
- iPad uses a CC BY 4.0 model (commercial-safe) with real bezel geometry;
  first-pass iPad layouts for all four screens. capture.sh is PLATFORM-aware.

Remove now-dead code: the 2D mockup fallback (ClipDeviceRenderer,
MockupGeometry), the orbit ring and binary-tunnel (superseded by the route arc
and the encryption-flow), unused DeviceModel UV/inset workarounds; refresh the
README and .gitignore to match.
This commit is contained in:
2026-08-09 10:44:03 +02:00
parent 4bac61e505
commit 98661cef15
11 changed files with 303 additions and 361 deletions

View File

@@ -1,83 +1,62 @@
# App Store screenshot studio
Code-driven, fully local App Store screenshots — composed natively with **SwiftUI +
`ImageRenderer`** (no Typst, no ImageMagick). Each frame is a SwiftUI view (gradient
background + globe + device with the app screenshot + localized caption) rendered
off-screen to an exact-size PNG. Captions come from `strings.json` (9 locales); app
screenshots come from the real app (see capture, below).
Why SwiftUI: the screen curvature is the real iOS squircle
(`RoundedRectangle(style: .continuous)`), SF Pro resolves for free, and the hero tilt
is a native `.rotation3DEffect` (real perspective) — no mask-guessing or warp math.
`ImageRenderer`** and **SceneKit** (no Typst, no ImageMagick). Each marketing screen is a
SwiftUI view (gradient + 3D device + captions + generated artwork) rendered off-screen to
an exact-size PNG. Captions come from `strings.json` (9 locales); the app screenshots on
the device screens are captured from the real app.
## Requirements
- Xcode / Swift toolchain (macOS 14+). That's it.
- Xcode / Swift toolchain (macOS 26+). That's it.
## Render
## Run
```sh
swift run studio # all locales × screens -> generated/<Language>/
swift run studio --publish # -> ../<Language>/ (ships to App Store)
LOCALES="fr de" SCREENS="share-securely" swift run studio # subset
swift run studio # all locales × screens -> generated/<Language>/
swift run studio --publish # -> ../<Language>/ (ships to App Store)
LOCALES="fr de" SCREENS="share-securely" swift run studio # subset
PLATFORM=ipad swift run studio # iPad set -> generated/<Language>/iPad/
```
Default writes to `generated/` (safe). `--publish` overwrites the real language folders.
Run from this directory (it reads `strings.json` and `assets/` relative to cwd).
## Full pipeline
```sh
./capture.sh # real localized app screens -> assets/shots/<locale>/<screen>.png
swift run studio # composite everything -> generated/<Language>/
swift run studio --publish # when happy -> ships to ../<Language>/
./capture.sh # real localized app screens -> generated/shots/<platform>/<locale>/
swift run studio # composite everything -> generated/<Language>/[iPad/]
./generate.sh # capture + composite in one shot (forwards PLATFORM / --publish)
```
`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.
`-AppleLanguages`, in dark mode with a 9:41 status bar. Screenshots are transient
(git-ignored, regenerated per run) — during layout iteration, capture once then re-run
`swift run studio` on its own.
## Device rendering (3D by default)
The device is a real iPhone 17 Pro Max `.usdz` (`assets/iphone-17-pro-max.usdz`, CC BY
4.0 — see `assets/ATTRIBUTION.md`). `SceneKitDeviceRenderer` textures the app screenshot
onto the screen mesh and renders it off-screen with SceneKit. The screen curvature,
bezel and Dynamic Island are the model's real geometry; the hero tilt is a real camera
perspective (`spec.device.tilt`), not a warp.
## Platforms
`PLATFORM=iphone` (default) or `ipad`. Each `Platform` (`Sources/studio/Platform.swift`)
carries its canvas size, capture simulator, output subfolder, and a `DeviceModel` — the
`.usdz`, its screen material, the orientation fix (yaw so the screen faces the camera),
optional front-glass mesh to hide, and whether to tint the body graphite. Adding a device
(e.g. Mac) is a new `DeviceModel` + `Platform` case + a layout set.
Model-specific quirks handled in `SceneKitDeviceRenderer.swift` (re-derive if the model
changes — see the inspection scripts approach in git history):
- **Warm-up render**: `SCNRenderer.snapshot` returns empty on its first call; we render
a throwaway 16×32 first, then the real frame.
- **Bounding box**: use `rootNode.boundingBox` (the model is ~16 units tall via an
ancestor scale). `flattenedClone()` collapses this model to nothing — don't use it.
- **Camera orientation**: front is Z; `look(at:)` renders nothing, so the camera is
yawed 180° by hand (`eulerAngles`).
- **Crisp screen**: the screen material has a wavy normal map and there's a front glass
mesh with its own waviness — both ripple the image. We clear the screen normal and
hide the glass mesh so the app content stays flat/crisp (App Store requirement).
## Device rendering
`SceneKitDeviceRenderer` textures the captured screenshot onto the model's screen mesh and
renders it off-screen. Curvature, bezel and body are the model's real geometry; poses
(pitch/yaw/roll) and a studio environment (IBL + bloom) are applied in the scene. It
auto-generates planar UVs for screen meshes that ship without them.
Set `DEVICE=2d` to fall back to the flat mockup compositor (`ClipDeviceRenderer` +
`assets/mockup-straight.png`, `.continuous` clip) if you ever want the non-3D path.
## Assets (git-ignored except the model — you supply the rest)
- `assets/iphone-17-pro-max.usdz` — the 3D device (committed, with `ATTRIBUTION.md`).
- `assets/globe.png` — transparent globe for the hero.
- `assets/shots/<locale>/<screen>.png` — captured app screenshots (from `capture.sh`).
- `assets/mockup-straight.png` — only needed for the `DEVICE=2d` fallback.
## Assets (`assets/`)
- `iphone-17-pro-max.usdz`, `ipad-pro.usdz` — the 3D devices (committed; CC BY 4.0, see
`ATTRIBUTION.md`).
- `globe.png` — for the send-anywhere hero (committed).
- `generated/shots/<platform>/<locale>/*.png` — captured app screens (transient).
## Layout & tuning
- `Sources/studio/ScreenSpec.swift` — per-screen layout (gradient stops, caption
placement, globe + device position/size, hero tilt). Numbers are in pixels at scale 1.
- `MockupGeometry.straight` in the same file — where the glass sits inside
`mockup-straight.png` (fractions of the mockup) + corner radius. **Measure once**
against your mockup export and set these; placeholders are approximate.
- `Sources/studio/ScreenFrame.swift` — the composition (layer order, caption styling).
- `Sources/studio/DeviceView.swift` — the device layer. `ClipDeviceRenderer` does the
2D `.continuous` clip today; a `SceneKitDeviceRenderer` (texture the shot onto a real
iPhone `.usdz`, render with `SCNRenderer.snapshot()`) can drop in behind the same
`DeviceRenderer` protocol for full 3D — nothing else changes.
- `Sources/studio/ScreenSpec.swift` — per-platform layouts (`iphone` / `ipad`): gradient,
device pose/position/size, globe + route (send-anywhere), the encryption flow
(stay-private: `beams` `lock``stream` + `banners`), caption placement.
- `Sources/studio/ScreenFrame.swift` — the composition (layer order, caption fitting,
generated artwork).
## Status
1. ✅ SwiftUI compositor: gradient + caption + globe + device, exact 1284×2778, headless.
2. ✅ Real 3D iPhone: screenshot textured onto the `.usdz` screen mesh (SceneKit), crisp.
3. ✅ Real hero tilt via 3D camera perspective (replaces `warp.sh` + rotated mockup).
4. ✅ Screenshot capture: `capture.sh` + the `#if DEBUG` fixture gateway, per locale.
5. ⬜ Tune device position/size/tilt per screen in `ScreenSpec.swift` against the originals
(esp. `send-anywhere`: caption overlaps the phone; globe layer needs its asset).
6. ⬜ Ribbon art layer for `stay-private`.
7. ✅ 2D fallback (`DEVICE=2d`) retained for the flat mockup path.
## Screens
`share-securely`, `choose-receivers`, `send-anywhere` (globe + Paris→LA route arc,
reuses the share screenshot), `stay-private` (two stacked phones + encryption beams →
padlock → binary protection stream, with localized CHIFFREMENT/PROTECTION banners).