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 out-*.png
.build/ .build/
.swiftpm/ .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 # App Store screenshot studio
Code-driven, fully local App Store screenshots — composed natively with **SwiftUI + Code-driven, fully local App Store screenshots — composed natively with **SwiftUI +
`ImageRenderer`** (no Typst, no ImageMagick). Each frame is a SwiftUI view (gradient `ImageRenderer`** and **SceneKit** (no Typst, no ImageMagick). Each marketing screen is a
background + globe + device with the app screenshot + localized caption) rendered SwiftUI view (gradient + 3D device + captions + generated artwork) rendered off-screen to
off-screen to an exact-size PNG. Captions come from `strings.json` (9 locales); app an exact-size PNG. Captions come from `strings.json` (9 locales); the app screenshots on
screenshots come from the real app (see capture, below). the device screens are captured from the real app.
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.
## Requirements ## Requirements
- Xcode / Swift toolchain (macOS 14+). That's it. - Xcode / Swift toolchain (macOS 26+). That's it.
## Render ## Run
```sh ```sh
swift run studio # all locales × screens -> generated/<Language>/ swift run studio # all locales × screens -> generated/<Language>/
swift run studio --publish # -> ../<Language>/ (ships to App Store) swift run studio --publish # -> ../<Language>/ (ships to App Store)
LOCALES="fr de" SCREENS="share-securely" swift run studio # subset 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). Run from this directory (it reads `strings.json` and `assets/` relative to cwd).
## Full pipeline ## Full pipeline
```sh ```sh
./capture.sh # real localized app screens -> assets/shots/<locale>/<screen>.png ./capture.sh # real localized app screens -> generated/shots/<platform>/<locale>/
swift run studio # composite everything -> generated/<Language>/ swift run studio # composite everything -> generated/<Language>/[iPad/]
swift run studio --publish # when happy -> ships to ../<Language>/ ./generate.sh # capture + composite in one shot (forwards PLATFORM / --publish)
``` ```
`capture.sh` drives the app into each screen via the DEBUG `-VniScreenshot` launch `capture.sh` drives the app into each screen via the DEBUG `-VniScreenshot` launch
argument (see `apple/VniDrop/App/ScreenshotSupport.swift`), once per locale via 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) ## Platforms
The device is a real iPhone 17 Pro Max `.usdz` (`assets/iphone-17-pro-max.usdz`, CC BY `PLATFORM=iphone` (default) or `ipad`. Each `Platform` (`Sources/studio/Platform.swift`)
4.0 — see `assets/ATTRIBUTION.md`). `SceneKitDeviceRenderer` textures the app screenshot carries its canvas size, capture simulator, output subfolder, and a `DeviceModel` — the
onto the screen mesh and renders it off-screen with SceneKit. The screen curvature, `.usdz`, its screen material, the orientation fix (yaw so the screen faces the camera),
bezel and Dynamic Island are the model's real geometry; the hero tilt is a real camera optional front-glass mesh to hide, and whether to tint the body graphite. Adding a device
perspective (`spec.device.tilt`), not a warp. (e.g. Mac) is a new `DeviceModel` + `Platform` case + a layout set.
Model-specific quirks handled in `SceneKitDeviceRenderer.swift` (re-derive if the model ## Device rendering
changes — see the inspection scripts approach in git history): `SceneKitDeviceRenderer` textures the captured screenshot onto the model's screen mesh and
- **Warm-up render**: `SCNRenderer.snapshot` returns empty on its first call; we render renders it off-screen. Curvature, bezel and body are the model's real geometry; poses
a throwaway 16×32 first, then the real frame. (pitch/yaw/roll) and a studio environment (IBL + bloom) are applied in the scene. It
- **Bounding box**: use `rootNode.boundingBox` (the model is ~16 units tall via an auto-generates planar UVs for screen meshes that ship without them.
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).
Set `DEVICE=2d` to fall back to the flat mockup compositor (`ClipDeviceRenderer` + ## Assets (`assets/`)
`assets/mockup-straight.png`, `.continuous` clip) if you ever want the non-3D path. - `iphone-17-pro-max.usdz`, `ipad-pro.usdz` — the 3D devices (committed; CC BY 4.0, see
`ATTRIBUTION.md`).
## Assets (git-ignored except the model — you supply the rest) - `globe.png` — for the send-anywhere hero (committed).
- `assets/iphone-17-pro-max.usdz` — the 3D device (committed, with `ATTRIBUTION.md`). - `generated/shots/<platform>/<locale>/*.png` — captured app screens (transient).
- `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.
## Layout & tuning ## Layout & tuning
- `Sources/studio/ScreenSpec.swift` — per-screen layout (gradient stops, caption - `Sources/studio/ScreenSpec.swift` — per-platform layouts (`iphone` / `ipad`): gradient,
placement, globe + device position/size, hero tilt). Numbers are in pixels at scale 1. device pose/position/size, globe + route (send-anywhere), the encryption flow
- `MockupGeometry.straight` in the same file — where the glass sits inside (stay-private: `beams` `lock``stream` + `banners`), caption placement.
`mockup-straight.png` (fractions of the mockup) + corner radius. **Measure once** - `Sources/studio/ScreenFrame.swift` — the composition (layer order, caption fitting,
against your mockup export and set these; placeholders are approximate. generated artwork).
- `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.
## Status ## Screens
1. ✅ SwiftUI compositor: gradient + caption + globe + device, exact 1284×2778, headless. `share-securely`, `choose-receivers`, `send-anywhere` (globe + Paris→LA route arc,
2. ✅ Real 3D iPhone: screenshot textured onto the `.usdz` screen mesh (SceneKit), crisp. reuses the share screenshot), `stay-private` (two stacked phones + encryption beams →
3. ✅ Real hero tilt via 3D camera perspective (replaces `warp.sh` + rotated mockup). padlock → binary protection stream, with localized CHIFFREMENT/PROTECTION banners).
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.

View File

@@ -1,55 +1,10 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
// The device is one swappable layer. `ClipDeviceRenderer` does a 2D composite // The device is a swappable layer behind this protocol; `SceneKitDeviceRenderer` is the
// (screenshot clipped into the straight mockup with iOS-correct `.continuous` // current implementation (textures the app screenshot onto a real .usdz model).
// curvature). `SceneKitDeviceRenderer` textures the shot onto a real iPhone .usdz.
// Both conform to DeviceRenderer, so ScreenFrame never changes.
protocol DeviceRenderer { protocol DeviceRenderer {
// Produces the device as a SwiftUI view sized to `spec.width` (height follows the // Produces the device as a SwiftUI view sized to `spec.height` (width follows the
// device aspect), already framing `shot` and oriented per `spec`. // device aspect), already framing `shot` and posed per `spec`.
@MainActor func view(shot: NSImage?, spec: DeviceSpec) -> AnyView @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. // scene (a real camera + perspective), not faked.
struct SceneKitDeviceRenderer: DeviceRenderer { struct SceneKitDeviceRenderer: DeviceRenderer {
let modelURL: URL let model: DeviceModel
var screenMaterial = "_7ProMax_Screen" // material on the screen mesh (from inspection) var heightFraction: CGFloat = 0.66 // upright device height as fraction of the square render
var glassMaterial = "glass" // front-glass material (substring match)
var heightFraction: CGFloat = 0.66 // upright phone height as fraction of the square render
var supersample: CGFloat = 2 // render big, SwiftUI downscales for clean edges var supersample: CGFloat = 2 // render big, SwiftUI downscales for clean edges
@MainActor @MainActor
@@ -28,31 +26,41 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
@MainActor @MainActor
private func render(shot: NSImage?, spec: DeviceSpec, side: CGFloat) -> NSImage? { 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 device = MTLCreateSystemDefaultDevice() else { return nil }
let root = scene.rootNode 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 // Orient the model so its screen faces the camera (-Z). Some models (iPad) have
// ~16 units tall. rootNode.boundingBox aggregates that correctly in world space // the screen on ±X, so a per-model yaw fix is applied first.
// (flattenedClone collapses this model to nothing do not use it here). let oriented = SCNNode()
let (bmin, bmax) = root.boundingBox 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 center = SCNVector3((bmin.x + bmax.x) / 2, (bmin.y + bmax.y) / 2, (bmin.z + bmax.z) / 2)
let height = CGFloat(bmax.y - bmin.y) 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.pivot = SCNMatrix4MakeTranslation(center.x, center.y, center.z)
pivot.position = center pivot.position = center
let d2r = Double.pi / 180
pivot.eulerAngles = SCNVector3(spec.pose.pitch * d2r, spec.pose.yaw * d2r, spec.pose.roll * d2r) 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. // 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 // 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. // 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.diffuse.contents = spec.blackScreen ? NSColor.black : shot
mat.lightingModel = .constant mat.lightingModel = .constant
mat.normal.contents = nil mat.normal.contents = nil
@@ -64,14 +72,12 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
mat.diffuse.wrapT = .clamp mat.diffuse.wrapT = .clamp
} }
// Hide the front glass mesh it has its own normal-mapped waviness that reflects // Hide the front glass mesh (if any) it has its own normal-mapped waviness that
// as swirls over the screen. We keep the crisp display instead. // reflects as swirls over the screen. We keep the crisp display instead.
hideMeshes(withMaterial: glassMaterial, in: pivot) if let glass = model.glassMaterial { hideMeshes(withMaterial: glass, in: pivot) }
// Recolor the silver body to graphite (like Hardware.png): tint every body // Recolor a silver body to graphite (iPhone); models already dark (iPad) skip it.
// material dark while keeping it metallic, so the rails still catch a sharp if model.recolorBody { recolorBody(in: pivot) }
// highlight but the body reads dark instead of "lit up".
recolorBody(in: pivot)
// Camera on the screen side (front = -Z), oriented by hand: look(at:) renders // 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 // 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.usesOrthographicProjection = false
cam.fieldOfView = fovV cam.fieldOfView = fovV
cam.projectionDirection = .vertical 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 // HDR + subtle bloom so bright specular highlights on the metal/glass glow like a
// real product shot instead of clipping flat. // real product shot instead of clipping flat.
cam.wantsHDR = true cam.wantsHDR = true
@@ -163,6 +171,9 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
func walk(_ n: SCNNode) { func walk(_ n: SCNNode) {
for m in n.geometry?.materials ?? [] { for m in n.geometry?.materials ?? [] {
let name = (m.name ?? "").lowercased() 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 } if skip.contains(where: { name.contains($0) }) { continue }
m.multiply.contents = graphite m.multiply.contents = graphite
// Sharper, brighter specular so the frame highlights "pop" like polished // 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) } for c in node.childNodes { hideMeshes(withMaterial: substring, in: c) }
} }
private func material(named name: String, in node: SCNNode) -> SCNMaterial? { // Give the screen mesh planar UVs (mapped over its two largest-extent axes) when it
if let m = node.geometry?.materials.first(where: { // 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 ($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame
}) { return m } }
for c in node.childNodes { if let m = material(named: name, in: c) { return m } } for c in node.childNodes { out += materials(named: name, in: c) }
return nil return out
} }
} }

View File

@@ -5,13 +5,12 @@ import SwiftUI
// (dx, dy); captions are centered and pinned to the top or bottom edge. // (dx, dy); captions are centered and pinned to the top or bottom edge.
struct ScreenFrame: View { struct ScreenFrame: View {
static let canvas = CGSize(width: 1284, height: 2778)
let spec: ScreenSpec let spec: ScreenSpec
let caption: Caption let caption: Caption
let globe: Image? let globe: Image?
let shot: NSImage? let shot: NSImage?
let device: DeviceRenderer? let device: DeviceRenderer?
var canvas: CGSize = CGSize(width: 1284, height: 2778)
private var allDevices: [DeviceSpec] { spec.device.map { [$0] } ?? spec.devices } 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") } private var subColor: Color { spec.captionTheme == .light ? Color(hex: "#f3ecfb") : Color(hex: "#2c2138") }
var body: some View { var body: some View {
let cw = Self.canvas.width, ch = Self.canvas.height let cw = canvas.width, ch = canvas.height
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
spec.bg.gradient spec.bg.gradient
if let o = spec.orbit { orbitLayer(o) }
if let r = spec.ribbon { ribbonLayer(r) }
if let g = spec.globe, let globe { if let g = spec.globe, let globe {
globe.resizable().scaledToFit() globe.resizable().scaledToFit()
.frame(width: g.width) .frame(width: g.width)
@@ -69,133 +64,6 @@ struct ScreenFrame: View {
.clipped() .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 { static func bezier(_ t: Double, _ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint) -> CGPoint {
let u = 1 - t let u = 1 - t
let a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * 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.from, color)
marker(at: r.to, 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 { private func marker(at pt: CGPoint, _ color: Color) -> some View {
@@ -257,7 +125,7 @@ struct ScreenFrame: View {
style: StrokeStyle(lineWidth: 2.2, lineCap: .round)) 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) .blur(radius: 0.6)
.shadow(color: color.opacity(0.7), radius: 10) .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)) 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) .shadow(color: color.opacity(0.6), radius: 8)
} }
@@ -317,7 +185,7 @@ struct ScreenFrame: View {
.foregroundStyle(subColor) .foregroundStyle(subColor)
} }
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.frame(width: Self.canvas.width - (spec.headerBackdrop ? 300 : 160)) .frame(width: canvas.width - (spec.headerBackdrop ? 300 : 160))
if spec.headerBackdrop { if spec.headerBackdrop {
// The panel is the caption's background, so it grows with the content long // 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(82, 48)
captionBlock(72, 44) 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 return fitted
.padding(top ? .top : .bottom, top ? 96 : 110) .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) alignment: top ? .top : .bottom)
} }
} }

View File

@@ -55,16 +55,6 @@ struct GlobeSpec {
var dy: CGFloat 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 // 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, // glowing arc that swoops down and passes behind the phone "send from Paris to LA,
// through your phone". Generated natively. // through your phone". Generated natively.
@@ -80,10 +70,10 @@ struct RouteSpec {
// Stay-private redesign: a vertical encryption flow between two stacked phones // Stay-private redesign: a vertical encryption flow between two stacked phones
// converging light beams (encryption) a glowing padlock a binary stream (protection). // converging light beams (encryption) a glowing padlock a binary stream (protection).
struct BeamsSpec { struct BeamsSpec {
var cx: CGFloat // convergence x var cx: CGFloat // convergence x
var y0: CGFloat // beams start at the top phone's bottom edge var y0: CGFloat // beams start at the top phone's bottom edge
var spread: CGFloat // half-width the beams fan across at y0 var spread: CGFloat // half-width the beams fan across at y0
var cy: CGFloat // converge to this point var cy: CGFloat // converge to this point
var count: Int = 22 var count: Int = 22
var color: String = "#9ec3ff" var color: String = "#9ec3ff"
} }
@@ -96,25 +86,18 @@ struct LockSpec {
} }
struct StreamSpec { struct StreamSpec {
var cx: CGFloat // vertical binary stream centre var cx: CGFloat // vertical binary stream centre
var y0: CGFloat // from (below the lock) var y0: CGFloat // from (below the lock)
var y1: CGFloat // to (the bottom phone) var y1: CGFloat // to (the bottom phone)
var color: String = "#9ec3ff" var color: String = "#9ec3ff"
} }
// Localized banner labels (CHIFFREMENT / PROTECTION), text taken from strings.json. // Localized banner labels (CHIFFREMENT / PROTECTION), text taken from strings.json.
enum BannerKind { case encryption, protection } enum BannerKind { case encryption, protection }
struct Banner { var kind: BannerKind; var cx: CGFloat; var cy: CGFloat } struct Banner {
var kind: BannerKind
// 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 {
var cx: CGFloat var cx: CGFloat
var cy: CGFloat // ellipse center on the canvas var cy: CGFloat
var w: CGFloat
var h: CGFloat // ellipse size
var rotation: CGFloat = 0 // deg
var lineWidth: CGFloat = 16
} }
struct ScreenSpec { struct ScreenSpec {
@@ -123,21 +106,26 @@ struct ScreenSpec {
let captionPlace: CaptionPlace let captionPlace: CaptionPlace
let captionTheme: CaptionTheme let captionTheme: CaptionTheme
var globe: GlobeSpec? = nil var globe: GlobeSpec? = nil
var orbit: OrbitSpec? = nil
var route: RouteSpec? = nil var route: RouteSpec? = nil
var device: DeviceSpec? = nil var device: DeviceSpec? = nil
var devices: [DeviceSpec] = [] // multiple phones (e.g. stay-private) var devices: [DeviceSpec] = [] // multiple phones (e.g. stay-private)
var ribbon: RibbonSpec? = nil
var beams: BeamsSpec? = nil var beams: BeamsSpec? = nil
var lock: LockSpec? = nil var lock: LockSpec? = nil
var stream: StreamSpec? = nil var stream: StreamSpec? = nil
var banners: [Banner] = [] 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 // Which captured screenshot to texture (defaults to `id`). The hero reuses the
// approval modal shot, matching the original. // approval modal shot, matching the original.
var shotId: String? = nil 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. // Straight-on hero, large and centered, showing the Share/QR sheet.
"share-securely": ScreenSpec( "share-securely": ScreenSpec(
id: "share-securely", id: "share-securely",
@@ -184,10 +172,10 @@ struct ScreenSpec {
captionPlace: .top, captionTheme: .light, captionPlace: .top, captionTheme: .light,
devices: [ devices: [
DeviceSpec( 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), pose: Pose(roll: 180), shadow: false, blackScreen: true),
DeviceSpec( 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), pose: Pose(), shadow: false, blackScreen: true),
], ],
beams: BeamsSpec(cx: 642, y0: 700, spread: 150, cy: 1120, count: 22), beams: BeamsSpec(cx: 642, y0: 700, spread: 150, cy: 1120, count: 22),
@@ -200,22 +188,56 @@ struct ScreenSpec {
headerBackdrop: true headerBackdrop: true
), ),
] ]
}
// Where the glass sits inside the STRAIGHT mockup image, as fractions of the mockup's // iPad Pro layouts (canvas 2048x2732, centre x = 1024). First pass same
// own pixel size, plus the screen corner radius as a fraction of screen width. Measure // compositions as iPhone, retuned for the squarer, larger canvas.
// once against assets/mockup-straight.png and set here. static let ipad: [String: ScreenSpec] = [
struct MockupGeometry { "share-securely": ScreenSpec(
var screenXFrac: CGFloat id: "share-securely",
var screenYFrac: CGFloat bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
var screenWFrac: CGFloat captionPlace: .top, captionTheme: .dark,
var screenHFrac: CGFloat device: DeviceSpec(height: 1950, cx: 1024, cy: 1520, pose: Pose())
var cornerFrac: CGFloat // corner radius / screen width ),
"choose-receivers": ScreenSpec(
// Placeholder tune against the real mockup export. id: "choose-receivers",
static let straight = MockupGeometry( bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
screenXFrac: 0.036, screenYFrac: 0.028, captionPlace: .top, captionTheme: .dark,
screenWFrac: 0.928, screenHFrac: 0.944, device: DeviceSpec(
cornerFrac: 0.075 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) // swift run studio --publish # -> ../<Language>/<Name>.png (ships)
// LOCALES="fr de" SCREENS="share-securely" swift run studio // LOCALES="fr de" SCREENS="share-securely" swift run studio
// //
// Screenshots come from generated/shots/<locale>/<screen>.png (from ./capture.sh), // Screenshots come from generated/shots/<platform>/<locale>/<screen>.png (from
// transient build output regenerated per run never committed; // ./capture.sh) transient build output, regenerated per run, never committed. The 3D
// the straight mockup + globe come from assets/. Run from the studio directory. // device models + globe come from assets/. Run from the studio directory.
// Screen id -> output file basename (matches the existing App Store filenames). // Screen id -> output file basename (matches the existing App Store filenames).
let nameFor: [String: String] = [ 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) } 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. // Target platform: canvas size + 3D device model. PLATFORM=iphone|ipad (default iphone).
// DEVICE=2d forces the flat compositor; DEVICE=3d requires the .usdz. let platform = Platform(rawValue: env("PLATFORM", "iphone")) ?? .iphone
let deviceMode = env("DEVICE", "auto") let canvas = platform.canvas
let usdz = base.appendingPathComponent("assets/iphone-17-pro-max.usdz") let model = platform.deviceModel(assets: base.appendingPathComponent("assets"))
let hasModel = FileManager.default.fileExists(atPath: usdz.path) let device: DeviceRenderer? = FileManager.default.fileExists(atPath: model.url.path)
let mockup = loadNSImage(base.appendingPathComponent("assets/mockup-straight.png").path) ? SceneKitDeviceRenderer(model: model) : nil
if device == nil {
let device: DeviceRenderer? FileHandle.standardError.write(Data("warning: missing model \(model.url.lastPathComponent) — devices will be blank\n".utf8))
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))
} }
let specs = ScreenSpec.all(for: platform)
var count = 0 var count = 0
for loc in locales { for loc in locales {
guard let ls = strings.locales[loc] else { guard let ls = strings.locales[loc] else {
FileHandle.standardError.write(Data("warning: no strings for locale \(loc)\n".utf8)); continue 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("../\(ls.folder)").standardized
: base.appendingPathComponent("generated/\(ls.folder)") : base.appendingPathComponent("generated/\(ls.folder)")
if !platform.outputSubfolder.isEmpty {
outDir = outDir.appendingPathComponent(platform.outputSubfolder)
}
for scr in screens { 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 FileHandle.standardError.write(Data("warning: skipping \(loc)/\(scr)\n".utf8)); continue
} }
let shotId = spec.shotId ?? scr 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 shot = loadNSImage(base.appendingPathComponent("\(shotsDir)/\(loc)/\(shotId).png").path)?.0
let hasDevice = spec.device != nil || !spec.devices.isEmpty let hasDevice = spec.device != nil || !spec.devices.isEmpty
let frame = ScreenFrame(spec: spec, caption: caption, globe: globe, shot: shot, 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") let out = outDir.appendingPathComponent("\(nameFor[scr] ?? scr).png")
try writePNG(frame, to: out) try writePNG(frame, to: out)
print("\(out.path)") print("\(out.path)")

View File

@@ -6,7 +6,12 @@
- **Source:** https://sketchfab.com/3d-models/iphone-17-pro-max-silver-9dbfe0d846f341bf9fc501a854f5de1a - **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/ - **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) ## ipad-pro.usdz
is published. The App Store screenshots produced by this studio are derivatives, so - **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 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. 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")" cd "$(dirname "$0")"
APPLE_DIR="$(cd ../../../apple && pwd)" APPLE_DIR="$(cd ../../../apple && pwd)"
DEVICE="${SCREENSHOT_DEVICE:-iPhone 17 Pro Max}"
BUNDLE_ID="com.vnidrop.app" BUNDLE_ID="com.vnidrop.app"
LOCALES=${LOCALES:-"en fr de es it nl pl pt ru"} 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). # scenario (launch arg value) -> studio screen id (output filename stem).
# send-anywhere reuses the share screenshot (see ScreenSpec shotId), so it isn't # send-anywhere reuses the share screenshot (see ScreenSpec shotId), so it isn't
# captured separately; stay-private uses black screens (no capture). # 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 # Screenshots are transient build output, regenerated per run — never committed. They
# live under generated/ (git-ignored), not in assets/. # 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) echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)