mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-09 20:29:58 +02:00
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:
4
packaging/apple/studio/.gitignore
vendored
4
packaging/apple/studio/.gitignore
vendored
@@ -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
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
60
packaging/apple/studio/Sources/studio/Platform.swift
Normal file
60
packaging/apple/studio/Sources/studio/Platform.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
BIN
packaging/apple/studio/assets/ipad-pro.usdz
Normal file
BIN
packaging/apple/studio/assets/ipad-pro.usdz
Normal file
Binary file not shown.
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user