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

@@ -2,7 +2,3 @@ generated/
out-*.png
.build/
.swiftpm/
# Only the DEVICE=2d fallback mockups are ignored; the globe + 3D model are committed
# because the default pipeline needs them.
assets/mockup-*.png
assets/mask-*.png

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).

View File

@@ -1,55 +1,10 @@
import SwiftUI
import AppKit
// The device is one swappable layer. `ClipDeviceRenderer` does a 2D composite
// (screenshot clipped into the straight mockup with iOS-correct `.continuous`
// curvature). `SceneKitDeviceRenderer` textures the shot onto a real iPhone .usdz.
// Both conform to DeviceRenderer, so ScreenFrame never changes.
// The device is a swappable layer behind this protocol; `SceneKitDeviceRenderer` is the
// current implementation (textures the app screenshot onto a real .usdz model).
protocol DeviceRenderer {
// Produces the device as a SwiftUI view sized to `spec.width` (height follows the
// device aspect), already framing `shot` and oriented per `spec`.
// Produces the device as a SwiftUI view sized to `spec.height` (width follows the
// device aspect), already framing `shot` and posed per `spec`.
@MainActor func view(shot: NSImage?, spec: DeviceSpec) -> AnyView
}
// 2D compositor: RoundedRectangle(.continuous) clip == the real iOS squircle.
struct ClipDeviceRenderer: DeviceRenderer {
let mockup: NSImage
let mockupSize: CGSize // native px size of the mockup image
let geo: MockupGeometry
@MainActor
func view(shot: NSImage?, spec: DeviceSpec) -> AnyView {
// Fallback 2D path (DEVICE=2d). Authored in the mockup's native pixel space,
// scaled to the target height, with the pose approximated by rotation effects.
let mw = mockupSize.width, mh = mockupSize.height
let sw = mw * geo.screenWFrac
let sh = mh * geo.screenHFrac
let sx = mw * geo.screenXFrac
let sy = mh * geo.screenYFrac
let radius = sw * geo.cornerFrac
let displayWidth = spec.height * mw / mh
let device = ZStack(alignment: .topLeading) {
if let shot {
Image(nsImage: shot)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: sw, height: sh)
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
.offset(x: sx, y: sy)
}
Image(nsImage: mockup)
.resizable()
.frame(width: mw, height: mh)
}
.frame(width: mw, height: mh)
.scaleEffect(displayWidth / mw, anchor: .topLeading)
.frame(width: displayWidth, height: spec.height)
return AnyView(device
.rotation3DEffect(.degrees(spec.pose.yaw), axis: (x: 0, y: 1, z: 0), anchor: .center, perspective: 0.3)
.rotation3DEffect(.degrees(spec.pose.pitch), axis: (x: 1, y: 0, z: 0), anchor: .center, perspective: 0.3)
.rotationEffect(.degrees(spec.pose.roll)))
}
}

View File

@@ -0,0 +1,60 @@
import Foundation
// A 3D device model + the per-model quirks the renderer needs (screen material name,
// orientation fix so the screen faces the camera, optional front-glass mesh to hide,
// whether to tint the body graphite).
struct DeviceModel {
var url: URL
var screenMaterial: String
var glassMaterial: String? // nil = no separate glass mesh
var bodyYaw: Double = 0 // deg about Y to face the screen toward the camera (-Z)
var recolorBody: Bool = false // graphite tint
static func iphone(_ assets: URL) -> DeviceModel {
DeviceModel(url: assets.appendingPathComponent("iphone-17-pro-max.usdz"),
screenMaterial: "_7ProMax_Screen", glassMaterial: "glass",
bodyYaw: 0, recolorBody: true)
}
static func ipad(_ assets: URL) -> DeviceModel {
// Screen mesh "Material_002" (display only; the bezel is real geometry). Front
// faces +Z, so yaw 180° to face -Z. Standard UVs no remap or bezel padding.
DeviceModel(url: assets.appendingPathComponent("ipad-pro.usdz"),
screenMaterial: "Material_002", glassMaterial: nil,
bodyYaw: 180, recolorBody: true)
}
}
// An App Store target: canvas size, device model, capture simulator, output subfolder.
enum Platform: String {
case iphone, ipad
var canvas: CGSize {
switch self {
case .iphone: CGSize(width: 1284, height: 2778) // 6.5" iPhone
case .ipad: CGSize(width: 2064, height: 2752) // 13" iPad Pro (matches the M5 sim)
}
}
func deviceModel(assets: URL) -> DeviceModel {
switch self {
case .iphone: DeviceModel.iphone(assets)
case .ipad: DeviceModel.ipad(assets)
}
}
// Simulator used by capture.sh (informational here; capture reads its own env).
var simulator: String {
switch self {
case .iphone: "iPhone 17 Pro Max"
case .ipad: "iPad Pro 13-inch (M5)"
}
}
// Output goes under <Language>/<subfolder> so iPhone/iPad sets stay separate.
var outputSubfolder: String {
switch self {
case .iphone: ""
case .ipad: "iPad"
}
}
}

View File

@@ -8,10 +8,8 @@ import AppKit
// scene (a real camera + perspective), not faked.
struct SceneKitDeviceRenderer: DeviceRenderer {
let modelURL: URL
var screenMaterial = "_7ProMax_Screen" // material on the screen mesh (from inspection)
var glassMaterial = "glass" // front-glass material (substring match)
var heightFraction: CGFloat = 0.66 // upright phone height as fraction of the square render
let model: DeviceModel
var heightFraction: CGFloat = 0.66 // upright device height as fraction of the square render
var supersample: CGFloat = 2 // render big, SwiftUI downscales for clean edges
@MainActor
@@ -28,31 +26,41 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
@MainActor
private func render(shot: NSImage?, spec: DeviceSpec, side: CGFloat) -> NSImage? {
guard let scene = try? SCNScene(url: modelURL),
guard let scene = try? SCNScene(url: model.url),
let device = MTLCreateSystemDefaultDevice() else { return nil }
let root = scene.rootNode
let d2r = Double.pi / 180
// The USDZ carries a big unit scale on its ancestor chain, so the real model is
// ~16 units tall. rootNode.boundingBox aggregates that correctly in world space
// (flattenedClone collapses this model to nothing do not use it here).
let (bmin, bmax) = root.boundingBox
// Orient the model so its screen faces the camera (-Z). Some models (iPad) have
// the screen on ±X, so a per-model yaw fix is applied first.
let oriented = SCNNode()
for child in root.childNodes { oriented.addChildNode(child) }
oriented.eulerAngles = SCNVector3(0, model.bodyYaw * d2r, 0)
// Re-parent under a pivot so the pose rotates it about its own centre. Compute the
// (oriented) bounding box before applying the pose. rootNode.boundingBox aggregates
// the big ancestor unit-scale correctly (flattenedClone collapses these models).
let pivot = SCNNode()
pivot.addChildNode(oriented)
root.addChildNode(pivot)
let (bmin, bmax) = pivot.boundingBox
let center = SCNVector3((bmin.x + bmax.x) / 2, (bmin.y + bmax.y) / 2, (bmin.z + bmax.z) / 2)
let height = CGFloat(bmax.y - bmin.y)
// Re-parent the model under a pivot so the pose rotates it about its own center
// (children keep their own transforms; the pivot is identity + our rotation).
let pivot = SCNNode()
for child in root.childNodes { pivot.addChildNode(child) }
root.addChildNode(pivot)
pivot.pivot = SCNMatrix4MakeTranslation(center.x, center.y, center.z)
pivot.position = center
let d2r = Double.pi / 180
pivot.eulerAngles = SCNVector3(spec.pose.pitch * d2r, spec.pose.yaw * d2r, spec.pose.roll * d2r)
// Texture the screenshot onto the screen mesh, shown flat and full-brightness.
// The model's screen carries a wavy normal map (a "screen protector" look) that
// ripples the image clear it so the app content stays crisp, as Apple requires.
if let mat = material(named: screenMaterial, in: pivot), spec.blackScreen || shot != nil {
// Some screen meshes ship with no UV coordinates (an image can't map onto them
// it renders as a flat colour). Generate planar UVs from the vertex positions.
addPlanarUVsToScreen(in: pivot, material: model.screenMaterial)
// Texture EVERY material with the screen name (some models split the display into
// several meshes that share the material name); setting only the first leaves the
// rest white.
for mat in materials(named: model.screenMaterial, in: pivot) where spec.blackScreen || shot != nil {
mat.diffuse.contents = spec.blackScreen ? NSColor.black : shot
mat.lightingModel = .constant
mat.normal.contents = nil
@@ -64,14 +72,12 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
mat.diffuse.wrapT = .clamp
}
// Hide the front glass mesh it has its own normal-mapped waviness that reflects
// as swirls over the screen. We keep the crisp display instead.
hideMeshes(withMaterial: glassMaterial, in: pivot)
// Hide the front glass mesh (if any) it has its own normal-mapped waviness that
// reflects as swirls over the screen. We keep the crisp display instead.
if let glass = model.glassMaterial { hideMeshes(withMaterial: glass, in: pivot) }
// Recolor the silver body to graphite (like Hardware.png): tint every body
// material dark while keeping it metallic, so the rails still catch a sharp
// highlight but the body reads dark instead of "lit up".
recolorBody(in: pivot)
// Recolor a silver body to graphite (iPhone); models already dark (iPad) skip it.
if model.recolorBody { recolorBody(in: pivot) }
// Camera on the screen side (front = -Z), oriented by hand: look(at:) renders
// nothing here, but a straight 180° yaw does. Framed so an upright phone height
@@ -81,7 +87,9 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
cam.usesOrthographicProjection = false
cam.fieldOfView = fovV
cam.projectionDirection = .vertical
cam.zNear = 0.001; cam.zFar = 1000
// zFar must comfortably exceed the camera distance; models differ hugely in unit
// scale (iPhone ~16 units tall, iPad ~300), so keep this large.
cam.zNear = 0.01; cam.zFar = 100_000
// HDR + subtle bloom so bright specular highlights on the metal/glass glow like a
// real product shot instead of clipping flat.
cam.wantsHDR = true
@@ -163,6 +171,9 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
func walk(_ n: SCNNode) {
for m in n.geometry?.materials ?? [] {
let name = (m.name ?? "").lowercased()
// Never tint the screen its material name may not contain "screen"
// (the iPad's is just "Material").
if m.name == model.screenMaterial { continue }
if skip.contains(where: { name.contains($0) }) { continue }
m.multiply.contents = graphite
// Sharper, brighter specular so the frame highlights "pop" like polished
@@ -182,11 +193,53 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
for c in node.childNodes { hideMeshes(withMaterial: substring, in: c) }
}
private func material(named name: String, in node: SCNNode) -> SCNMaterial? {
if let m = node.geometry?.materials.first(where: {
// Give the screen mesh planar UVs (mapped over its two largest-extent axes) when it
// has none, so a screenshot texture maps across the display.
private func addPlanarUVsToScreen(in node: SCNNode, material name: String) {
guard let g = node.geometry,
g.materials.contains(where: { ($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame }),
g.sources(for: .texcoord).isEmpty,
let vsrc = g.sources(for: .vertex).first else {
node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) }
return
}
// Read vertex positions.
var pos: [(Float, Float, Float)] = []
let stride = vsrc.dataStride, off = vsrc.dataOffset
vsrc.data.withUnsafeBytes { (p: UnsafeRawBufferPointer) in
for i in 0..<vsrc.vectorCount {
let b = off + i * stride
pos.append((p.load(fromByteOffset: b, as: Float.self),
p.load(fromByteOffset: b + 4, as: Float.self),
p.load(fromByteOffset: b + 8, as: Float.self)))
}
}
let xs = pos.map(\.0), ys = pos.map(\.1), zs = pos.map(\.2)
let ext = [xs.max()! - xs.min()!, ys.max()! - ys.min()!, zs.max()! - zs.min()!]
// Screen plane = the two axes with the largest extent (drop the thin normal axis).
let normalAxis = ext.firstIndex(of: ext.min()!)!
let planeAxes = [0, 1, 2].filter { $0 != normalAxis } // [u-axis, v-axis]
func comp(_ p: (Float, Float, Float), _ a: Int) -> Float { a == 0 ? p.0 : (a == 1 ? p.1 : p.2) }
let ua = planeAxes[0], va = planeAxes[1]
let umin = [xs, ys, zs][ua].min()!, urange = max(1e-6, ext[ua])
let vmin = [xs, ys, zs][va].min()!, vrange = max(1e-6, ext[va])
let uvs: [CGPoint] = pos.map {
CGPoint(x: CGFloat((comp($0, ua) - umin) / urange),
y: CGFloat(1 - (comp($0, va) - vmin) / vrange)) // flip V for top-left origin
}
let uvSource = SCNGeometrySource(textureCoordinates: uvs)
let newGeo = SCNGeometry(sources: g.sources(for: .vertex) + g.sources(for: .normal) + [uvSource],
elements: g.elements)
newGeo.materials = g.materials
node.geometry = newGeo
node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) }
}
private func materials(named name: String, in node: SCNNode) -> [SCNMaterial] {
var out = (node.geometry?.materials ?? []).filter {
($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame
}) { return m }
for c in node.childNodes { if let m = material(named: name, in: c) { return m } }
return nil
}
for c in node.childNodes { out += materials(named: name, in: c) }
return out
}
}

View File

@@ -5,13 +5,12 @@ import SwiftUI
// (dx, dy); captions are centered and pinned to the top or bottom edge.
struct ScreenFrame: View {
static let canvas = CGSize(width: 1284, height: 2778)
let spec: ScreenSpec
let caption: Caption
let globe: Image?
let shot: NSImage?
let device: DeviceRenderer?
var canvas: CGSize = CGSize(width: 1284, height: 2778)
private var allDevices: [DeviceSpec] { spec.device.map { [$0] } ?? spec.devices }
@@ -19,15 +18,11 @@ struct ScreenFrame: View {
private var subColor: Color { spec.captionTheme == .light ? Color(hex: "#f3ecfb") : Color(hex: "#2c2138") }
var body: some View {
let cw = Self.canvas.width, ch = Self.canvas.height
let cw = canvas.width, ch = canvas.height
ZStack(alignment: .topLeading) {
spec.bg.gradient
if let o = spec.orbit { orbitLayer(o) }
if let r = spec.ribbon { ribbonLayer(r) }
if let g = spec.globe, let globe {
globe.resizable().scaledToFit()
.frame(width: g.width)
@@ -69,133 +64,6 @@ struct ScreenFrame: View {
.clipped()
}
// Glowing purple orbit ring: a soft blurred halo under a bright thin stroke.
private func orbitLayer(_ o: OrbitSpec) -> some View {
let purple = Color(hex: "#a855f7")
return ZStack {
Ellipse()
.stroke(purple.opacity(0.55), lineWidth: o.lineWidth * 2.4)
.blur(radius: 26)
Ellipse()
.stroke(
LinearGradient(colors: [Color(hex: "#c98bff"), Color(hex: "#7c3aed"), Color(hex: "#c98bff")],
startPoint: .topLeading, endPoint: .bottomTrailing),
lineWidth: o.lineWidth)
Ellipse()
.stroke(.white.opacity(0.85), lineWidth: o.lineWidth * 0.3)
.blur(radius: 1)
}
.frame(width: o.w, height: o.h)
.rotationEffect(.degrees(o.rotation))
.position(x: o.cx, y: o.cy)
}
// Encrypted data tunnel: a conduit of binary between the two phones. The digits run
// in longitudinal columns along the path; the columns are spaced by sin(θ) across the
// width so they bunch toward the edges, reading as the curved wall of a cylinder.
// Generated natively.
private func ribbonLayer(_ r: RibbonSpec) -> some View {
// Sample the centreline once (point + unit normal), running smoothly through the
// waypoints with rounded corners.
let pts = Self.smoothPath(r.waypoints, samplesPerSegment: 70)
return Canvas { ctx, _ in
let halfW = r.width / 2
func offsetPath(_ off: CGFloat) -> Path {
var path = Path()
for (i, s) in pts.enumerated() {
let pt = CGPoint(x: s.p.x + s.n.dx * off, y: s.p.y + s.n.dy * off)
i == 0 ? path.move(to: pt) : path.addLine(to: pt)
}
return path
}
// 1. Solid opaque tube body: stacked offset strokes across the width shaded
// like a cylinder dark grey at the rim, light toward the centre so it
// fills completely with no see-through gaps and reads as round.
let fillN = 60
for j in 0..<fillN {
let theta = (Double(j) / Double(fillN - 1) - 0.5) * Double.pi
let c = cos(theta) // 1 centre 0 rim
// Blue/indigo cylinder: deep at the rim, bright toward the centre.
ctx.stroke(offsetPath(CGFloat(sin(theta)) * halfW),
with: .color(Color(.sRGB, red: 0.03 + 0.12 * c,
green: 0.06 + 0.34 * c, blue: 0.28 + 0.62 * c)),
style: StrokeStyle(lineWidth: 13, lineCap: .round))
}
// 2. Bright cyan-white binary on top, in longitudinal columns bunched at the
// rim, rows spaced along the length so the digits read without cramping.
let lines = 26, rowStride = max(1, pts.count / 90)
for j in 0..<lines {
let theta = (Double(j) / Double(lines - 1) - 0.5) * Double.pi
let off = CGFloat(sin(theta)) * halfW
let wv = 0.85 + 0.15 * abs(sin(theta))
for rowI in stride(from: 0, to: pts.count, by: rowStride) {
let s = pts[rowI]
let bit = ((rowI + j * 13) % 5 < 2) ? "0" : "1"
var res = ctx.resolve(Text(bit)
.font(.system(size: 20, weight: .bold, design: .monospaced)))
res.shading = .color(Color(.sRGB, red: wv * 0.62, green: wv * 0.8, blue: wv))
ctx.draw(res, at: CGPoint(x: s.p.x + s.n.dx * off, y: s.p.y + s.n.dy * off))
}
}
}
.frame(width: Self.canvas.width, height: Self.canvas.height)
.shadow(color: Color(.sRGB, red: 0.28, green: 0.45, blue: 1.0).opacity(0.6), radius: 16) // blue glow
}
// A centreline through `waypoints` as STRAIGHT segments joined by rounded corners
// (a pipe elbow): the runs stay straight, only the corners curve. Returns evenly
// spaced samples of position + unit normal. `samplesPerSegment` is unused (kept for
// the call site); density is driven by a fixed spacing.
static func smoothPath(_ waypoints: [CGPoint], samplesPerSegment: Int) -> [(p: CGPoint, n: CGVector)] {
guard waypoints.count >= 2 else { return waypoints.map { ($0, CGVector(dx: 1, dy: 0)) } }
let corner: CGFloat = 185 // corner radius ( tube half-width to avoid pinching)
let spacing: CGFloat = 8
var out: [(p: CGPoint, n: CGVector)] = []
func addLine(_ a: CGPoint, _ b: CGPoint) {
let dx = b.x - a.x, dy = b.y - a.y
let L = max(0.0001, hypot(dx, dy))
let n = CGVector(dx: -dy / L, dy: dx / L)
let count = max(1, Int(L / spacing))
for k in 0..<count {
let u = CGFloat(k) / CGFloat(count)
out.append((CGPoint(x: a.x + dx * u, y: a.y + dy * u), n))
}
}
// Quadratic corner: P0 (control V) P1.
func addCorner(_ p0: CGPoint, _ v: CGPoint, _ p1: CGPoint) {
let L = hypot(v.x - p0.x, v.y - p0.y) + hypot(p1.x - v.x, p1.y - v.y)
let count = max(2, Int(L / spacing))
for k in 0..<count {
let u = CGFloat(k) / CGFloat(count), w = 1 - u
let x = w * w * p0.x + 2 * w * u * v.x + u * u * p1.x
let y = w * w * p0.y + 2 * w * u * v.y + u * u * p1.y
let tx = 2 * w * (v.x - p0.x) + 2 * u * (p1.x - v.x)
let ty = 2 * w * (v.y - p0.y) + 2 * u * (p1.y - v.y)
let tl = max(0.0001, hypot(tx, ty))
out.append((CGPoint(x: x, y: y), CGVector(dx: -ty / tl, dy: tx / tl)))
}
}
var cursor = waypoints[0]
for i in 1..<(waypoints.count - 1) {
let prev = waypoints[i - 1], v = waypoints[i], next = waypoints[i + 1]
let ax = v.x - prev.x, ay = v.y - prev.y, al = max(0.0001, hypot(ax, ay))
let bx = next.x - v.x, by = next.y - v.y, bl = max(0.0001, hypot(bx, by))
let s = min(corner, al * 0.5, bl * 0.5) // clamp to segment lengths
let pIn = CGPoint(x: v.x - ax / al * s, y: v.y - ay / al * s)
let pOut = CGPoint(x: v.x + bx / bl * s, y: v.y + by / bl * s)
addLine(cursor, pIn)
addCorner(pIn, v, pOut)
cursor = pOut
}
addLine(cursor, waypoints[waypoints.count - 1])
out.append((waypoints[waypoints.count - 1], out.last?.n ?? CGVector(dx: 1, dy: 0)))
return out
}
static func bezier(_ t: Double, _ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint) -> CGPoint {
let u = 1 - t
let a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t
@@ -229,7 +97,7 @@ struct ScreenFrame: View {
marker(at: r.from, color)
marker(at: r.to, color)
}
.frame(width: Self.canvas.width, height: Self.canvas.height)
.frame(width: canvas.width, height: canvas.height)
}
private func marker(at pt: CGPoint, _ color: Color) -> some View {
@@ -257,7 +125,7 @@ struct ScreenFrame: View {
style: StrokeStyle(lineWidth: 2.2, lineCap: .round))
}
}
.frame(width: Self.canvas.width, height: Self.canvas.height)
.frame(width: canvas.width, height: canvas.height)
.blur(radius: 0.6)
.shadow(color: color.opacity(0.7), radius: 10)
}
@@ -280,7 +148,7 @@ struct ScreenFrame: View {
ctx.draw(res, at: CGPoint(x: s.cx, y: y))
}
}
.frame(width: Self.canvas.width, height: Self.canvas.height)
.frame(width: canvas.width, height: canvas.height)
.shadow(color: color.opacity(0.6), radius: 8)
}
@@ -317,7 +185,7 @@ struct ScreenFrame: View {
.foregroundStyle(subColor)
}
.multilineTextAlignment(.center)
.frame(width: Self.canvas.width - (spec.headerBackdrop ? 300 : 160))
.frame(width: canvas.width - (spec.headerBackdrop ? 300 : 160))
if spec.headerBackdrop {
// The panel is the caption's background, so it grows with the content long
@@ -348,11 +216,11 @@ struct ScreenFrame: View {
captionBlock(82, 48)
captionBlock(72, 44)
}
.frame(width: Self.canvas.width, height: region, alignment: top ? .top : .bottom)
.frame(width: canvas.width, height: region, alignment: top ? .top : .bottom)
return fitted
.padding(top ? .top : .bottom, top ? 96 : 110)
.frame(width: Self.canvas.width, height: Self.canvas.height,
.frame(width: canvas.width, height: canvas.height,
alignment: top ? .top : .bottom)
}
}

View File

@@ -55,16 +55,6 @@ struct GlobeSpec {
var dy: CGFloat
}
// An encrypted-data tunnel between two phones (stay-private). The tube passes THROUGH
// each waypoint in order, with the corners auto-rounded (Catmull-Rom), so an "up, right,
// up" routing is just: the bottom-phone point, a corner, a corner, the top-phone point.
// Generated natively (no asset).
struct RibbonSpec {
var waypoints: [CGPoint] // the tube runs through these, in order
var width: CGFloat // tube diameter
var color: String // hex (glow tint)
}
// A transfer route: two city markers (departure + arrival) on the globe joined by a
// glowing arc that swoops down and passes behind the phone "send from Paris to LA,
// through your phone". Generated natively.
@@ -80,10 +70,10 @@ struct RouteSpec {
// Stay-private redesign: a vertical encryption flow between two stacked phones
// converging light beams (encryption) a glowing padlock a binary stream (protection).
struct BeamsSpec {
var cx: CGFloat // convergence x
var y0: CGFloat // beams start at the top phone's bottom edge
var spread: CGFloat // half-width the beams fan across at y0
var cy: CGFloat // converge to this point
var cx: CGFloat // convergence x
var y0: CGFloat // beams start at the top phone's bottom edge
var spread: CGFloat // half-width the beams fan across at y0
var cy: CGFloat // converge to this point
var count: Int = 22
var color: String = "#9ec3ff"
}
@@ -96,25 +86,18 @@ struct LockSpec {
}
struct StreamSpec {
var cx: CGFloat // vertical binary stream centre
var y0: CGFloat // from (below the lock)
var y1: CGFloat // to (the bottom phone)
var cx: CGFloat // vertical binary stream centre
var y0: CGFloat // from (below the lock)
var y1: CGFloat // to (the bottom phone)
var color: String = "#9ec3ff"
}
// Localized banner labels (CHIFFREMENT / PROTECTION), text taken from strings.json.
enum BannerKind { case encryption, protection }
struct Banner { var kind: BannerKind; var cx: CGFloat; var cy: CGFloat }
// A glowing purple orbit ring, drawn behind the globe (which occludes its top) and
// looping in front down toward the phone. Generated natively, no asset.
struct OrbitSpec {
struct Banner {
var kind: BannerKind
var cx: CGFloat
var cy: CGFloat // ellipse center on the canvas
var w: CGFloat
var h: CGFloat // ellipse size
var rotation: CGFloat = 0 // deg
var lineWidth: CGFloat = 16
var cy: CGFloat
}
struct ScreenSpec {
@@ -123,21 +106,26 @@ struct ScreenSpec {
let captionPlace: CaptionPlace
let captionTheme: CaptionTheme
var globe: GlobeSpec? = nil
var orbit: OrbitSpec? = nil
var route: RouteSpec? = nil
var device: DeviceSpec? = nil
var devices: [DeviceSpec] = [] // multiple phones (e.g. stay-private)
var ribbon: RibbonSpec? = nil
var beams: BeamsSpec? = nil
var lock: LockSpec? = nil
var stream: StreamSpec? = nil
var banners: [Banner] = []
var headerBackdrop: Bool = false // dark scrim behind the top caption
var headerBackdrop: Bool = false // dark scrim behind the top caption
// Which captured screenshot to texture (defaults to `id`). The hero reuses the
// approval modal shot, matching the original.
var shotId: String? = nil
static let all: [String: ScreenSpec] = [
static func all(for platform: Platform) -> [String: ScreenSpec] {
switch platform {
case .iphone: iphone
case .ipad: ipad
}
}
static let iphone: [String: ScreenSpec] = [
// Straight-on hero, large and centered, showing the Share/QR sheet.
"share-securely": ScreenSpec(
id: "share-securely",
@@ -184,10 +172,10 @@ struct ScreenSpec {
captionPlace: .top, captionTheme: .light,
devices: [
DeviceSpec(
height: 1500, cx: 642, cy: -20, // top phone, only its lower part shows
height: 1500, cx: 642, cy: -20, // top phone, only its lower part shows
pose: Pose(roll: 180), shadow: false, blackScreen: true),
DeviceSpec(
height: 1500, cx: 642, cy: 2820, // bottom phone, only its upper part shows
height: 1500, cx: 642, cy: 2820, // bottom phone, only its upper part shows
pose: Pose(), shadow: false, blackScreen: true),
],
beams: BeamsSpec(cx: 642, y0: 700, spread: 150, cy: 1120, count: 22),
@@ -200,22 +188,56 @@ struct ScreenSpec {
headerBackdrop: true
),
]
}
// Where the glass sits inside the STRAIGHT mockup image, as fractions of the mockup's
// own pixel size, plus the screen corner radius as a fraction of screen width. Measure
// once against assets/mockup-straight.png and set here.
struct MockupGeometry {
var screenXFrac: CGFloat
var screenYFrac: CGFloat
var screenWFrac: CGFloat
var screenHFrac: CGFloat
var cornerFrac: CGFloat // corner radius / screen width
// Placeholder tune against the real mockup export.
static let straight = MockupGeometry(
screenXFrac: 0.036, screenYFrac: 0.028,
screenWFrac: 0.928, screenHFrac: 0.944,
cornerFrac: 0.075
)
// iPad Pro layouts (canvas 2048x2732, centre x = 1024). First pass same
// compositions as iPhone, retuned for the squarer, larger canvas.
static let ipad: [String: ScreenSpec] = [
"share-securely": ScreenSpec(
id: "share-securely",
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .dark,
device: DeviceSpec(height: 1950, cx: 1024, cy: 1520, pose: Pose())
),
"choose-receivers": ScreenSpec(
id: "choose-receivers",
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .dark,
device: DeviceSpec(
height: 1950, cx: 1000, cy: 1460, pose: Pose(pitch: -9, yaw: -14, roll: 7))
),
"send-anywhere": ScreenSpec(
id: "send-anywhere",
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
captionPlace: .bottom, captionTheme: .dark,
globe: GlobeSpec(width: 2000, dx: 24, dy: -240),
route: RouteSpec(
from: CGPoint(x: 1342, y: 323), to: CGPoint(x: 315, y: 423),
c1: CGPoint(x: 2100, y: 2000), c2: CGPoint(x: -100, y: 2000), lineWidth: 14),
device: DeviceSpec(
height: 1180, cx: 1000, cy: 1720, pose: Pose(pitch: 12, yaw: 18, roll: -11)),
shotId: "share-securely"
),
"stay-private": ScreenSpec(
id: "stay-private",
bg: GradientSpec(
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .light,
devices: [
DeviceSpec(
height: 1300, cx: 1024, cy: 120, pose: Pose(roll: 180), shadow: false,
blackScreen: true),
DeviceSpec(
height: 1300, cx: 1024, cy: 2760, pose: Pose(), shadow: false, blackScreen: true
),
],
beams: BeamsSpec(cx: 1024, y0: 820, spread: 200, cy: 1220, count: 24),
lock: LockSpec(cx: 1024, cy: 1400, size: 320),
stream: StreamSpec(cx: 1024, y0: 1600, y1: 2360),
banners: [
Banner(kind: .encryption, cx: 1024, cy: 1200),
Banner(kind: .protection, cx: 1024, cy: 1680),
],
headerBackdrop: true
),
]
}

View File

@@ -7,9 +7,9 @@ import SwiftUI
// swift run studio --publish # -> ../<Language>/<Name>.png (ships)
// LOCALES="fr de" SCREENS="share-securely" swift run studio
//
// Screenshots come from generated/shots/<locale>/<screen>.png (from ./capture.sh),
// transient build output regenerated per run never committed;
// the straight mockup + globe come from assets/. Run from the studio directory.
// Screenshots come from generated/shots/<platform>/<locale>/<screen>.png (from
// ./capture.sh) transient build output, regenerated per run, never committed. The 3D
// device models + globe come from assets/. Run from the studio directory.
// Screen id -> output file basename (matches the existing App Store filenames).
let nameFor: [String: String] = [
@@ -60,43 +60,41 @@ func run() throws {
let globe = loadNSImage(base.appendingPathComponent("assets/globe.png").path).map { Image(nsImage: $0.0) }
// Device layer: prefer the 3D iPhone model; fall back to the 2D mockup composite.
// DEVICE=2d forces the flat compositor; DEVICE=3d requires the .usdz.
let deviceMode = env("DEVICE", "auto")
let usdz = base.appendingPathComponent("assets/iphone-17-pro-max.usdz")
let hasModel = FileManager.default.fileExists(atPath: usdz.path)
let mockup = loadNSImage(base.appendingPathComponent("assets/mockup-straight.png").path)
let device: DeviceRenderer?
if deviceMode != "2d" && hasModel {
device = SceneKitDeviceRenderer(modelURL: usdz)
} else if let mockup {
device = ClipDeviceRenderer(mockup: mockup.0, mockupSize: mockup.1, geo: .straight)
} else {
device = nil
FileHandle.standardError.write(Data("warning: no device model or mockup — devices will be blank\n".utf8))
// Target platform: canvas size + 3D device model. PLATFORM=iphone|ipad (default iphone).
let platform = Platform(rawValue: env("PLATFORM", "iphone")) ?? .iphone
let canvas = platform.canvas
let model = platform.deviceModel(assets: base.appendingPathComponent("assets"))
let device: DeviceRenderer? = FileManager.default.fileExists(atPath: model.url.path)
? SceneKitDeviceRenderer(model: model) : nil
if device == nil {
FileHandle.standardError.write(Data("warning: missing model \(model.url.lastPathComponent) — devices will be blank\n".utf8))
}
let specs = ScreenSpec.all(for: platform)
var count = 0
for loc in locales {
guard let ls = strings.locales[loc] else {
FileHandle.standardError.write(Data("warning: no strings for locale \(loc)\n".utf8)); continue
}
let outDir = publish
// iPhone -> <Language>/, iPad -> <Language>/iPad/ so the sets stay separate.
var outDir = publish
? base.appendingPathComponent("../\(ls.folder)").standardized
: base.appendingPathComponent("generated/\(ls.folder)")
if !platform.outputSubfolder.isEmpty {
outDir = outDir.appendingPathComponent(platform.outputSubfolder)
}
for scr in screens {
guard let spec = ScreenSpec.all[scr], let caption = ls[scr] else {
guard let spec = specs[scr], let caption = ls[scr] else {
FileHandle.standardError.write(Data("warning: skipping \(loc)/\(scr)\n".utf8)); continue
}
let shotId = spec.shotId ?? scr
let shotsDir = env("SHOTS_DIR", "generated/shots")
let shotsDir = env("SHOTS_DIR", "generated/shots/\(platform.rawValue)")
let shot = loadNSImage(base.appendingPathComponent("\(shotsDir)/\(loc)/\(shotId).png").path)?.0
let hasDevice = spec.device != nil || !spec.devices.isEmpty
let frame = ScreenFrame(spec: spec, caption: caption, globe: globe, shot: shot,
device: hasDevice ? device : nil)
device: hasDevice ? device : nil, canvas: canvas)
let out = outDir.appendingPathComponent("\(nameFor[scr] ?? scr).png")
try writePNG(frame, to: out)
print("\(out.path)")

View File

@@ -6,7 +6,12 @@
- **Source:** https://sketchfab.com/3d-models/iphone-17-pro-max-silver-9dbfe0d846f341bf9fc501a854f5de1a
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
CC BY 4.0 requires visible credit wherever this model (or a render derived from it)
is published. The App Store screenshots produced by this studio are derivatives, so
## ipad-pro.usdz
- **Title:** 2018 Apple iPad Pro
- **Author:** lazercar (Sketchfab)
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
CC BY 4.0 requires visible credit wherever these models (or renders derived from them)
are published. The App Store screenshots produced by this studio are derivatives, so
keep this attribution with the project. If a suitable place exists (e.g. the app's
acknowledgements / licenses screen), surface the credit there as well.

Binary file not shown.

View File

@@ -15,10 +15,16 @@ 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"}
# PLATFORM=iphone|ipad selects the simulator and the shots subdir (must match main.swift).
PLATFORM="${PLATFORM:-iphone}"
case "$PLATFORM" in
ipad) DEVICE="${SCREENSHOT_DEVICE:-iPad Pro 13-inch (M5)}";;
*) DEVICE="${SCREENSHOT_DEVICE:-iPhone 17 Pro Max}";;
esac
# scenario (launch arg value) -> studio screen id (output filename stem).
# send-anywhere reuses the share screenshot (see ScreenSpec shotId), so it isn't
# captured separately; stay-private uses black screens (no capture).
@@ -26,7 +32,7 @@ SCENARIOS="share:share-securely approval:choose-receivers"
# Screenshots are transient build output, regenerated per run — never committed. They
# live under generated/ (git-ignored), not in assets/.
SHOTS_DIR="${SHOTS_DIR:-generated/shots}"
SHOTS_DIR="${SHOTS_DIR:-generated/shots/$PLATFORM}"
echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)