mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-09 20:29:58 +02:00
feat(studio): native SwiftUI + SceneKit App Store screenshot pipeline
Replace the Typst/ImageMagick studio with a SwiftPM executable that composes each marketing screen as a SwiftUI view and renders it headlessly via ImageRenderer to exact 1284x2778 PNGs. - Devices are a real iPhone 17 Pro Max .usdz textured + posed in SceneKit (graphite body, studio IBL); screen curvature/bezel are the model's geometry. - Per-screen layouts (pose, globe, route arc, encryption flow) in ScreenSpec. - send-anywhere: globe + Paris->LA route arc; uses the app's share screen. - stay-private: converging beams -> glowing padlock -> binary protection stream, with localized CHIFFREMENT/PROTECTION banners and an auto-sizing header panel. - Captions auto-shrink per locale (ViewThatFits) so long translations never clip. - Screenshots are transient (generated/shots, git-ignored), captured per locale; generate.sh does capture + composite in one shot. - Commit the .usdz model (CC BY 4.0, see assets/ATTRIBUTION.md) and globe asset. - Remove the superseded Typst pipeline (screens.typ, build.sh, frame.sh, warp.sh).
This commit is contained in:
8
packaging/apple/studio/.gitignore
vendored
8
packaging/apple/studio/.gitignore
vendored
@@ -1,8 +1,8 @@
|
||||
generated/
|
||||
out-*.png
|
||||
assets/shots/**/*.png
|
||||
.build/
|
||||
.swiftpm/
|
||||
# Only the DEVICE=2d fallback mockups are ignored; the globe + 3D model are committed
|
||||
# because the default pipeline needs them.
|
||||
assets/mockup-*.png
|
||||
assets/mask-*.png
|
||||
assets/globe.png
|
||||
assets/ribbon.png
|
||||
!assets/README.md
|
||||
|
||||
13
packaging/apple/studio/Package.swift
Normal file
13
packaging/apple/studio/Package.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
// swift-tools-version:6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "studio",
|
||||
platforms: [.macOS(.v14)],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "studio",
|
||||
path: "Sources/studio"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -1,57 +1,83 @@
|
||||
# App Store screenshot studio
|
||||
|
||||
Code-driven, fully local App Store screenshots. **Typst** composes each frame
|
||||
(gradient background + blank device mockup + app screenshot + localized caption)
|
||||
and renders it to an exact-size PNG. Captions come from `strings.json` (9 locales);
|
||||
app screenshots come from the real app (see capture, step 3).
|
||||
Code-driven, fully local App Store screenshots — composed natively with **SwiftUI +
|
||||
`ImageRenderer`** (no Typst, no ImageMagick). Each frame is a SwiftUI view (gradient
|
||||
background + globe + device with the app screenshot + localized caption) rendered
|
||||
off-screen to an exact-size PNG. Captions come from `strings.json` (9 locales); app
|
||||
screenshots come from the real app (see capture, below).
|
||||
|
||||
Why SwiftUI: the screen curvature is the real iOS squircle
|
||||
(`RoundedRectangle(style: .continuous)`), SF Pro resolves for free, and the hero tilt
|
||||
is a native `.rotation3DEffect` (real perspective) — no mask-guessing or warp math.
|
||||
|
||||
## Requirements
|
||||
- `typst` (0.15+) — installed.
|
||||
- `imagemagick` — only for the tilted hero shot's perspective warp (step 2). `brew install imagemagick`.
|
||||
- Xcode / Swift toolchain (macOS 14+). That's it.
|
||||
|
||||
## Render
|
||||
```sh
|
||||
./build.sh # all locales × screens -> generated/<Language>/
|
||||
./build.sh --publish # -> ../<Language>/ (ships to App Store)
|
||||
LOCALES="fr de" SCREENS="share-securely" ./build.sh
|
||||
swift run studio # all locales × screens -> generated/<Language>/
|
||||
swift run studio --publish # -> ../<Language>/ (ships to App Store)
|
||||
LOCALES="fr de" SCREENS="share-securely" swift run studio # subset
|
||||
```
|
||||
Default writes to `generated/` (safe). `--publish` overwrites the real language folders.
|
||||
|
||||
Single frame while tuning:
|
||||
```sh
|
||||
typst compile screens.typ out.png --input locale=fr --input screen=share-securely --ppi 72
|
||||
```
|
||||
|
||||
## Files
|
||||
- `strings.json` — captions per locale (first-pass translations; review before shipping).
|
||||
- `screens.typ` — the composition + per-screen layout data (`screens` dict). Tweak numbers, re-run.
|
||||
- `build.sh` — loops locales × screens; maps locale→folder and screen→filename.
|
||||
- `assets/mockup-straight.png` — blank straight device (real alpha, black screen).
|
||||
- `assets/mockup-rotated.png` — blank tilted device (for the hero shot).
|
||||
- `assets/globe.png` — circle-clipped in Typst (the export has a baked checkerboard, not true alpha).
|
||||
- `assets/shots/<locale>/<screen>.png` — captured app screenshots (optional; placeholder if absent).
|
||||
|
||||
## How the screenshot gets into the phone
|
||||
The mockup's screen glass is a rectangle. `screens.typ` places the screenshot inset
|
||||
into that rectangle (`sx`/`sy`/`sr` fractions) with the bezel framing it. Measure those
|
||||
fractions once against `mockup-straight.png`.
|
||||
Run from this directory (it reads `strings.json` and `assets/` relative to cwd).
|
||||
|
||||
## Full pipeline
|
||||
```sh
|
||||
./capture.sh # real localized app screens -> assets/shots/<locale>/<screen>.png
|
||||
./build.sh # composite everything -> generated/<Language>/
|
||||
./build.sh --publish # when happy -> ships to ../<Language>/
|
||||
./capture.sh # real localized app screens -> assets/shots/<locale>/<screen>.png
|
||||
swift run studio # composite everything -> generated/<Language>/
|
||||
swift run studio --publish # when happy -> ships to ../<Language>/
|
||||
```
|
||||
`capture.sh` drives the app into each screen via the DEBUG `-VniScreenshot` launch
|
||||
argument (see `apple/VniDrop/App/ScreenshotSupport.swift`), once per locale via
|
||||
`-AppleLanguages`, in dark mode with a 9:41 status bar. Screen ⇄ scenario map:
|
||||
`share-securely`→`share`, `choose-receivers`→`approval`, `send-anywhere`→`transfer-details`.
|
||||
`-AppleLanguages`, in dark mode with a 9:41 status bar.
|
||||
|
||||
## Device rendering (3D by default)
|
||||
The device is a real iPhone 17 Pro Max `.usdz` (`assets/iphone-17-pro-max.usdz`, CC BY
|
||||
4.0 — see `assets/ATTRIBUTION.md`). `SceneKitDeviceRenderer` textures the app screenshot
|
||||
onto the screen mesh and renders it off-screen with SceneKit. The screen curvature,
|
||||
bezel and Dynamic Island are the model's real geometry; the hero tilt is a real camera
|
||||
perspective (`spec.device.tilt`), not a warp.
|
||||
|
||||
Model-specific quirks handled in `SceneKitDeviceRenderer.swift` (re-derive if the model
|
||||
changes — see the inspection scripts approach in git history):
|
||||
- **Warm-up render**: `SCNRenderer.snapshot` returns empty on its first call; we render
|
||||
a throwaway 16×32 first, then the real frame.
|
||||
- **Bounding box**: use `rootNode.boundingBox` (the model is ~16 units tall via an
|
||||
ancestor scale). `flattenedClone()` collapses this model to nothing — don't use it.
|
||||
- **Camera orientation**: front is −Z; `look(at:)` renders nothing, so the camera is
|
||||
yawed 180° by hand (`eulerAngles`).
|
||||
- **Crisp screen**: the screen material has a wavy normal map and there's a front glass
|
||||
mesh with its own waviness — both ripple the image. We clear the screen normal and
|
||||
hide the glass mesh so the app content stays flat/crisp (App Store requirement).
|
||||
|
||||
Set `DEVICE=2d` to fall back to the flat mockup compositor (`ClipDeviceRenderer` +
|
||||
`assets/mockup-straight.png`, `.continuous` clip) if you ever want the non-3D path.
|
||||
|
||||
## Assets (git-ignored except the model — you supply the rest)
|
||||
- `assets/iphone-17-pro-max.usdz` — the 3D device (committed, with `ATTRIBUTION.md`).
|
||||
- `assets/globe.png` — transparent globe for the hero.
|
||||
- `assets/shots/<locale>/<screen>.png` — captured app screenshots (from `capture.sh`).
|
||||
- `assets/mockup-straight.png` — only needed for the `DEVICE=2d` fallback.
|
||||
|
||||
## Layout & tuning
|
||||
- `Sources/studio/ScreenSpec.swift` — per-screen layout (gradient stops, caption
|
||||
placement, globe + device position/size, hero tilt). Numbers are in pixels at scale 1.
|
||||
- `MockupGeometry.straight` in the same file — where the glass sits inside
|
||||
`mockup-straight.png` (fractions of the mockup) + corner radius. **Measure once**
|
||||
against your mockup export and set these; placeholders are approximate.
|
||||
- `Sources/studio/ScreenFrame.swift` — the composition (layer order, caption styling).
|
||||
- `Sources/studio/DeviceView.swift` — the device layer. `ClipDeviceRenderer` does the
|
||||
2D `.continuous` clip today; a `SceneKitDeviceRenderer` (texture the shot onto a real
|
||||
iPhone `.usdz`, render with `SCNRenderer.snapshot()`) can drop in behind the same
|
||||
`DeviceRenderer` protocol for full 3D — nothing else changes.
|
||||
|
||||
## Status
|
||||
1. ✅ Typst + JSON + straight-mockup compositing.
|
||||
2. ✅ Tilted hero (`send-anywhere`): `warp.sh` perspective-warps the screenshot onto the
|
||||
rotated mockup's screen quad (4 auto-detected corners); `build.sh` runs it automatically.
|
||||
3. ✅ Transparent globe layer (placed directly — use the alpha export, not a flattened one).
|
||||
4. ✅ Screenshot capture: `capture.sh` + the `#if DEBUG` fixture gateway, per locale, deterministic.
|
||||
5. ⬜ Ribbon art layer for `stay-private` (export as its own transparent PNG).
|
||||
6. ⬜ Layout polish: tune device positions/sizes in `screens.typ` against the originals.
|
||||
1. ✅ SwiftUI compositor: gradient + caption + globe + device, exact 1284×2778, headless.
|
||||
2. ✅ Real 3D iPhone: screenshot textured onto the `.usdz` screen mesh (SceneKit), crisp.
|
||||
3. ✅ Real hero tilt via 3D camera perspective (replaces `warp.sh` + rotated mockup).
|
||||
4. ✅ Screenshot capture: `capture.sh` + the `#if DEBUG` fixture gateway, per locale.
|
||||
5. ⬜ Tune device position/size/tilt per screen in `ScreenSpec.swift` against the originals
|
||||
(esp. `send-anywhere`: caption overlaps the phone; globe layer needs its asset).
|
||||
6. ⬜ Ribbon art layer for `stay-private`.
|
||||
7. ✅ 2D fallback (`DEVICE=2d`) retained for the flat mockup path.
|
||||
|
||||
55
packaging/apple/studio/Sources/studio/DeviceView.swift
Normal file
55
packaging/apple/studio/Sources/studio/DeviceView.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
// The device is one swappable layer. `ClipDeviceRenderer` does a 2D composite
|
||||
// (screenshot clipped into the straight mockup with iOS-correct `.continuous`
|
||||
// curvature). `SceneKitDeviceRenderer` textures the shot onto a real iPhone .usdz.
|
||||
// Both conform to DeviceRenderer, so ScreenFrame never changes.
|
||||
|
||||
protocol DeviceRenderer {
|
||||
// Produces the device as a SwiftUI view sized to `spec.width` (height follows the
|
||||
// device aspect), already framing `shot` and oriented per `spec`.
|
||||
@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)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import SwiftUI
|
||||
import SceneKit
|
||||
import AppKit
|
||||
|
||||
// Full 3D device layer: textures the app screenshot onto the real iPhone .usdz screen
|
||||
// mesh and renders it with SceneKit off-screen. Slots behind the same DeviceRenderer
|
||||
// protocol as the 2D compositor — ScreenFrame is unchanged. Tilt is baked into the
|
||||
// scene (a real camera + perspective), not faked.
|
||||
|
||||
struct SceneKitDeviceRenderer: DeviceRenderer {
|
||||
let modelURL: URL
|
||||
var screenMaterial = "_7ProMax_Screen" // material on the screen mesh (from inspection)
|
||||
var glassMaterial = "glass" // front-glass material (substring match)
|
||||
var heightFraction: CGFloat = 0.66 // upright phone height as fraction of the square render
|
||||
var supersample: CGFloat = 2 // render big, SwiftUI downscales for clean edges
|
||||
|
||||
@MainActor
|
||||
func view(shot: NSImage?, spec: DeviceSpec) -> AnyView {
|
||||
// Render into a square (so any pose fits without clipping); the upright phone
|
||||
// occupies `heightFraction` of it, so displaySide maps that to spec.height.
|
||||
let displaySide = spec.height / heightFraction
|
||||
let renderSide = min(displaySide * supersample, 3200)
|
||||
guard let img = render(shot: shot, spec: spec, side: renderSide) else {
|
||||
return AnyView(Color.clear.frame(width: displaySide, height: displaySide))
|
||||
}
|
||||
return AnyView(Image(nsImage: img).resizable().frame(width: displaySide, height: displaySide))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func render(shot: NSImage?, spec: DeviceSpec, side: CGFloat) -> NSImage? {
|
||||
guard let scene = try? SCNScene(url: modelURL),
|
||||
let device = MTLCreateSystemDefaultDevice() else { return nil }
|
||||
let root = scene.rootNode
|
||||
|
||||
// The USDZ carries a big unit scale on its ancestor chain, so the real model is
|
||||
// ~16 units tall. rootNode.boundingBox aggregates that correctly in world space
|
||||
// (flattenedClone collapses this model to nothing — do not use it here).
|
||||
let (bmin, bmax) = root.boundingBox
|
||||
let center = SCNVector3((bmin.x + bmax.x) / 2, (bmin.y + bmax.y) / 2, (bmin.z + bmax.z) / 2)
|
||||
let height = CGFloat(bmax.y - bmin.y)
|
||||
|
||||
// Re-parent the model under a pivot so the pose rotates it about its own center
|
||||
// (children keep their own transforms; the pivot is identity + our rotation).
|
||||
let pivot = SCNNode()
|
||||
for child in root.childNodes { pivot.addChildNode(child) }
|
||||
root.addChildNode(pivot)
|
||||
pivot.pivot = SCNMatrix4MakeTranslation(center.x, center.y, center.z)
|
||||
pivot.position = center
|
||||
let d2r = Double.pi / 180
|
||||
pivot.eulerAngles = SCNVector3(spec.pose.pitch * d2r, spec.pose.yaw * d2r, spec.pose.roll * d2r)
|
||||
|
||||
// Texture the screenshot onto the screen mesh, shown flat and full-brightness.
|
||||
// The model's screen carries a wavy normal map (a "screen protector" look) that
|
||||
// ripples the image — clear it so the app content stays crisp, as Apple requires.
|
||||
if let mat = material(named: screenMaterial, in: pivot), spec.blackScreen || shot != nil {
|
||||
mat.diffuse.contents = spec.blackScreen ? NSColor.black : shot
|
||||
mat.lightingModel = .constant
|
||||
mat.normal.contents = nil
|
||||
mat.emission.contents = nil
|
||||
mat.metalness.contents = NSNumber(value: 0)
|
||||
mat.roughness.contents = NSNumber(value: 1)
|
||||
mat.isDoubleSided = false
|
||||
mat.diffuse.wrapS = .clamp
|
||||
mat.diffuse.wrapT = .clamp
|
||||
}
|
||||
|
||||
// Hide the front glass mesh — it has its own normal-mapped waviness that reflects
|
||||
// as swirls over the screen. We keep the crisp display instead.
|
||||
hideMeshes(withMaterial: glassMaterial, in: pivot)
|
||||
|
||||
// Recolor the silver body to graphite (like Hardware.png): tint every body
|
||||
// material dark while keeping it metallic, so the rails still catch a sharp
|
||||
// highlight but the body reads dark instead of "lit up".
|
||||
recolorBody(in: pivot)
|
||||
|
||||
// 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
|
||||
// fills `heightFraction` of the square canvas (leaving margin for the pose).
|
||||
let fovV = 18.0
|
||||
let cam = SCNCamera()
|
||||
cam.usesOrthographicProjection = false
|
||||
cam.fieldOfView = fovV
|
||||
cam.projectionDirection = .vertical
|
||||
cam.zNear = 0.001; cam.zFar = 1000
|
||||
// HDR + subtle bloom so bright specular highlights on the metal/glass glow like a
|
||||
// real product shot instead of clipping flat.
|
||||
cam.wantsHDR = true
|
||||
cam.wantsExposureAdaptation = false
|
||||
cam.bloomThreshold = 0.9
|
||||
cam.bloomIntensity = 0.25
|
||||
cam.bloomBlurRadius = 6
|
||||
let camNode = SCNNode(); camNode.camera = cam
|
||||
let dist = Double(height) / (2 * Double(heightFraction) * tan(fovV / 2 * .pi / 180))
|
||||
camNode.position = SCNVector3(center.x, center.y, center.z - SCNFloat(dist))
|
||||
camNode.eulerAngles = SCNVector3(0, Double.pi, 0)
|
||||
root.addChildNode(camNode)
|
||||
|
||||
lightRig(into: root, center: center)
|
||||
// Image-based lighting: a studio softbox environment gives the metal frame and
|
||||
// glass real reflections/highlights — this is what stops it looking plasticky.
|
||||
scene.lightingEnvironment.contents = Self.studioEnvironment
|
||||
scene.lightingEnvironment.intensity = 1.5
|
||||
|
||||
let renderer = SCNRenderer(device: device, options: nil)
|
||||
renderer.scene = scene
|
||||
renderer.pointOfView = camNode
|
||||
renderer.autoenablesDefaultLighting = false
|
||||
let px = CGSize(width: side, height: side)
|
||||
// First snapshot primes the renderer and comes back empty; the second is real.
|
||||
_ = renderer.snapshot(atTime: 0, with: CGSize(width: 16, height: 16), antialiasingMode: .none)
|
||||
return renderer.snapshot(atTime: 0, with: px, antialiasingMode: .multisampling4X)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func lightRig(into root: SCNNode, center: SCNVector3) {
|
||||
func light(_ type: SCNLight.LightType, _ intensity: CGFloat, at pos: SCNVector3) {
|
||||
let l = SCNLight(); l.type = type; l.intensity = intensity; l.temperature = 6500
|
||||
l.castsShadow = false
|
||||
let n = SCNNode(); n.light = l; n.position = pos; n.look(at: center)
|
||||
root.addChildNode(n)
|
||||
}
|
||||
// Low ambient (the environment map does the fill); a crisp key for the highlight
|
||||
// streak down the frame, a fill from the other side, and a top rim so the upper
|
||||
// frame/bezel catches a highlight too instead of reading flat.
|
||||
light(.ambient, 180, at: center)
|
||||
light(.directional, 850, at: SCNVector3(center.x - 0.5, center.y + 0.6, center.z - 0.8))
|
||||
light(.directional, 320, at: SCNVector3(center.x + 0.7, center.y - 0.2, center.z - 0.6))
|
||||
light(.directional, 1100, at: SCNVector3(center.x - 0.15, center.y + 1.3, center.z - 0.5)) // top rim
|
||||
light(.spot, 900, at: SCNVector3(center.x + 0.2, center.y + 1.1, center.z - 0.7)) // top glint
|
||||
}
|
||||
|
||||
// A procedural equirectangular studio environment: bright "ceiling" softbox fading to
|
||||
// a darker floor, so reflective surfaces show a gradient with a hot highlight band.
|
||||
static let studioEnvironment: NSImage = {
|
||||
let w = 1024, h = 512
|
||||
let img = NSImage(size: NSSize(width: w, height: h))
|
||||
img.lockFocus()
|
||||
let grad = NSGradient(colorsAndLocations:
|
||||
(NSColor(white: 1.0, alpha: 1), 0.0), // top = ceiling (bright)
|
||||
(NSColor(white: 0.9, alpha: 1), 0.28),
|
||||
(NSColor(white: 0.5, alpha: 1), 0.55),
|
||||
(NSColor(white: 0.22, alpha: 1), 0.8),
|
||||
(NSColor(white: 0.1, alpha: 1), 1.0)) // bottom = floor (dark)
|
||||
grad?.draw(in: NSRect(x: 0, y: 0, width: w, height: h), angle: 90)
|
||||
// Two softbox bands (upper "ceiling" + lower "floor bounce") so reflective
|
||||
// surfaces catch a highlight at both the top and bottom of the frame.
|
||||
NSColor.white.setFill()
|
||||
NSBezierPath(roundedRect: NSRect(x: w / 4, y: Int(Double(h) * 0.66), width: w / 2, height: h / 8),
|
||||
xRadius: 20, yRadius: 20).fill()
|
||||
NSColor(white: 0.75, alpha: 1).setFill()
|
||||
NSBezierPath(roundedRect: NSRect(x: w / 5, y: Int(Double(h) * 0.24), width: Int(Double(w) * 0.6), height: h / 10),
|
||||
xRadius: 16, yRadius: 16).fill()
|
||||
img.unlockFocus()
|
||||
return img
|
||||
}()
|
||||
|
||||
// Tint the phone body graphite. Skips the screen, hidden glass, and camera lenses.
|
||||
// Uses `multiply` so the metallic reflections survive (just darkened), giving a
|
||||
// space-black look with sharp rail highlights rather than bright silver.
|
||||
private func recolorBody(in node: SCNNode) {
|
||||
let skip = ["screen", "glass", "lens", "logo"]
|
||||
let graphite = NSColor(calibratedRed: 0.19, green: 0.19, blue: 0.21, alpha: 1)
|
||||
func walk(_ n: SCNNode) {
|
||||
for m in n.geometry?.materials ?? [] {
|
||||
let name = (m.name ?? "").lowercased()
|
||||
if skip.contains(where: { name.contains($0) }) { continue }
|
||||
m.multiply.contents = graphite
|
||||
// Sharper, brighter specular so the frame highlights "pop" like polished
|
||||
// metal instead of a soft satin.
|
||||
m.metalness.contents = NSNumber(value: 1.0)
|
||||
m.roughness.contents = NSNumber(value: 0.22)
|
||||
}
|
||||
n.childNodes.forEach(walk)
|
||||
}
|
||||
walk(node)
|
||||
}
|
||||
|
||||
private func hideMeshes(withMaterial substring: String, in node: SCNNode) {
|
||||
if node.geometry?.materials.contains(where: { ($0.name ?? "").localizedCaseInsensitiveContains(substring) }) == true {
|
||||
node.isHidden = true
|
||||
}
|
||||
for c in node.childNodes { hideMeshes(withMaterial: substring, in: c) }
|
||||
}
|
||||
|
||||
private func material(named name: String, in node: SCNNode) -> SCNMaterial? {
|
||||
if let m = node.geometry?.materials.first(where: {
|
||||
($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame
|
||||
}) { return m }
|
||||
for c in node.childNodes { if let m = material(named: name, in: c) { return m } }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
358
packaging/apple/studio/Sources/studio/ScreenFrame.swift
Normal file
358
packaging/apple/studio/Sources/studio/ScreenFrame.swift
Normal file
@@ -0,0 +1,358 @@
|
||||
import SwiftUI
|
||||
|
||||
// The full 1284x2778 marketing frame: gradient + globe + device + caption.
|
||||
// Placement mirrors the old screens.typ: images are pinned top-left and pushed by
|
||||
// (dx, dy); captions are centered and pinned to the top or bottom edge.
|
||||
|
||||
struct ScreenFrame: View {
|
||||
static let canvas = CGSize(width: 1284, height: 2778)
|
||||
|
||||
let spec: ScreenSpec
|
||||
let caption: Caption
|
||||
let globe: Image?
|
||||
let shot: NSImage?
|
||||
let device: DeviceRenderer?
|
||||
|
||||
private var allDevices: [DeviceSpec] { spec.device.map { [$0] } ?? spec.devices }
|
||||
|
||||
private var titleColor: Color { spec.captionTheme == .light ? .white : Color(hex: "#1b1226") }
|
||||
private var subColor: Color { spec.captionTheme == .light ? Color(hex: "#f3ecfb") : Color(hex: "#2c2138") }
|
||||
|
||||
var body: some View {
|
||||
let cw = Self.canvas.width, ch = Self.canvas.height
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
spec.bg.gradient
|
||||
|
||||
if let o = spec.orbit { orbitLayer(o) }
|
||||
|
||||
if let r = spec.ribbon { ribbonLayer(r) }
|
||||
|
||||
if let g = spec.globe, let globe {
|
||||
globe.resizable().scaledToFit()
|
||||
.frame(width: g.width)
|
||||
.saturation(0.82)
|
||||
.brightness(-0.06)
|
||||
.offset(x: g.dx, y: g.dy)
|
||||
}
|
||||
|
||||
// Route sits on the globe but behind the phone, so it appears to pass through.
|
||||
if let rt = spec.route { routeLayer(rt) }
|
||||
|
||||
// Encryption flow (behind the phones so beams/stream tuck into them).
|
||||
if let b = spec.beams { beamsLayer(b) }
|
||||
if let s = spec.stream { streamLayer(s) }
|
||||
|
||||
if let device {
|
||||
// The renderer bakes the pose into the 3D scene; we place each phone by
|
||||
// its center on the canvas and add a soft contact shadow.
|
||||
ForEach(Array(allDevices.enumerated()), id: \.offset) { _, d in
|
||||
device.view(shot: shot, spec: d)
|
||||
.shadow(color: d.shadow ? .black.opacity(0.28) : .clear,
|
||||
radius: 60, x: 0, y: 34)
|
||||
.position(x: d.cx, y: d.cy)
|
||||
}
|
||||
}
|
||||
|
||||
// Padlock + banner labels sit on top of the flow.
|
||||
if let l = spec.lock { lockLayer(l) }
|
||||
ForEach(Array(spec.banners.enumerated()), id: \.offset) { _, b in
|
||||
bannerLayer(b)
|
||||
}
|
||||
|
||||
captionLayer
|
||||
}
|
||||
// Pin to top-leading: oversized layers (e.g. the globe frame is wider than the
|
||||
// canvas) must be clipped from the origin, NOT re-centered — otherwise the whole
|
||||
// composition, captions included, shifts left by (contentWidth - cw) / 2.
|
||||
.frame(width: cw, height: ch, alignment: .topLeading)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
// Glowing purple orbit ring: a soft blurred halo under a bright thin stroke.
|
||||
private func orbitLayer(_ o: OrbitSpec) -> some View {
|
||||
let purple = Color(hex: "#a855f7")
|
||||
return ZStack {
|
||||
Ellipse()
|
||||
.stroke(purple.opacity(0.55), lineWidth: o.lineWidth * 2.4)
|
||||
.blur(radius: 26)
|
||||
Ellipse()
|
||||
.stroke(
|
||||
LinearGradient(colors: [Color(hex: "#c98bff"), Color(hex: "#7c3aed"), Color(hex: "#c98bff")],
|
||||
startPoint: .topLeading, endPoint: .bottomTrailing),
|
||||
lineWidth: o.lineWidth)
|
||||
Ellipse()
|
||||
.stroke(.white.opacity(0.85), lineWidth: o.lineWidth * 0.3)
|
||||
.blur(radius: 1)
|
||||
}
|
||||
.frame(width: o.w, height: o.h)
|
||||
.rotationEffect(.degrees(o.rotation))
|
||||
.position(x: o.cx, y: o.cy)
|
||||
}
|
||||
|
||||
// Encrypted data tunnel: a conduit of binary between the two phones. The digits run
|
||||
// in longitudinal columns along the path; the columns are spaced by sin(θ) across the
|
||||
// width so they bunch toward the edges, reading as the curved wall of a cylinder.
|
||||
// Generated natively.
|
||||
private func ribbonLayer(_ r: RibbonSpec) -> some View {
|
||||
// Sample the centreline once (point + unit normal), running smoothly through the
|
||||
// waypoints with rounded corners.
|
||||
let pts = Self.smoothPath(r.waypoints, samplesPerSegment: 70)
|
||||
return Canvas { ctx, _ in
|
||||
let halfW = r.width / 2
|
||||
func offsetPath(_ off: CGFloat) -> Path {
|
||||
var path = Path()
|
||||
for (i, s) in pts.enumerated() {
|
||||
let pt = CGPoint(x: s.p.x + s.n.dx * off, y: s.p.y + s.n.dy * off)
|
||||
i == 0 ? path.move(to: pt) : path.addLine(to: pt)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// 1. Solid opaque tube body: stacked offset strokes across the width shaded
|
||||
// like a cylinder — dark grey at the rim, light toward the centre — so it
|
||||
// fills completely with no see-through gaps and reads as round.
|
||||
let fillN = 60
|
||||
for j in 0..<fillN {
|
||||
let theta = (Double(j) / Double(fillN - 1) - 0.5) * Double.pi
|
||||
let c = cos(theta) // 1 centre → 0 rim
|
||||
// Blue/indigo cylinder: deep at the rim, bright toward the centre.
|
||||
ctx.stroke(offsetPath(CGFloat(sin(theta)) * halfW),
|
||||
with: .color(Color(.sRGB, red: 0.03 + 0.12 * c,
|
||||
green: 0.06 + 0.34 * c, blue: 0.28 + 0.62 * c)),
|
||||
style: StrokeStyle(lineWidth: 13, lineCap: .round))
|
||||
}
|
||||
|
||||
// 2. Bright cyan-white binary on top, in longitudinal columns bunched at the
|
||||
// rim, rows spaced along the length so the digits read without cramping.
|
||||
let lines = 26, rowStride = max(1, pts.count / 90)
|
||||
for j in 0..<lines {
|
||||
let theta = (Double(j) / Double(lines - 1) - 0.5) * Double.pi
|
||||
let off = CGFloat(sin(theta)) * halfW
|
||||
let wv = 0.85 + 0.15 * abs(sin(theta))
|
||||
for rowI in stride(from: 0, to: pts.count, by: rowStride) {
|
||||
let s = pts[rowI]
|
||||
let bit = ((rowI + j * 13) % 5 < 2) ? "0" : "1"
|
||||
var res = ctx.resolve(Text(bit)
|
||||
.font(.system(size: 20, weight: .bold, design: .monospaced)))
|
||||
res.shading = .color(Color(.sRGB, red: wv * 0.62, green: wv * 0.8, blue: wv))
|
||||
ctx.draw(res, at: CGPoint(x: s.p.x + s.n.dx * off, y: s.p.y + s.n.dy * off))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: Self.canvas.width, height: Self.canvas.height)
|
||||
.shadow(color: Color(.sRGB, red: 0.28, green: 0.45, blue: 1.0).opacity(0.6), radius: 16) // blue glow
|
||||
}
|
||||
|
||||
// A centreline through `waypoints` as STRAIGHT segments joined by rounded corners
|
||||
// (a pipe elbow): the runs stay straight, only the corners curve. Returns evenly
|
||||
// spaced samples of position + unit normal. `samplesPerSegment` is unused (kept for
|
||||
// the call site); density is driven by a fixed spacing.
|
||||
static func smoothPath(_ waypoints: [CGPoint], samplesPerSegment: Int) -> [(p: CGPoint, n: CGVector)] {
|
||||
guard waypoints.count >= 2 else { return waypoints.map { ($0, CGVector(dx: 1, dy: 0)) } }
|
||||
let corner: CGFloat = 185 // corner radius (≥ tube half-width to avoid pinching)
|
||||
let spacing: CGFloat = 8
|
||||
var out: [(p: CGPoint, n: CGVector)] = []
|
||||
|
||||
func addLine(_ a: CGPoint, _ b: CGPoint) {
|
||||
let dx = b.x - a.x, dy = b.y - a.y
|
||||
let L = max(0.0001, hypot(dx, dy))
|
||||
let n = CGVector(dx: -dy / L, dy: dx / L)
|
||||
let count = max(1, Int(L / spacing))
|
||||
for k in 0..<count {
|
||||
let u = CGFloat(k) / CGFloat(count)
|
||||
out.append((CGPoint(x: a.x + dx * u, y: a.y + dy * u), n))
|
||||
}
|
||||
}
|
||||
// Quadratic corner: P0 → (control V) → P1.
|
||||
func addCorner(_ p0: CGPoint, _ v: CGPoint, _ p1: CGPoint) {
|
||||
let L = hypot(v.x - p0.x, v.y - p0.y) + hypot(p1.x - v.x, p1.y - v.y)
|
||||
let count = max(2, Int(L / spacing))
|
||||
for k in 0..<count {
|
||||
let u = CGFloat(k) / CGFloat(count), w = 1 - u
|
||||
let x = w * w * p0.x + 2 * w * u * v.x + u * u * p1.x
|
||||
let y = w * w * p0.y + 2 * w * u * v.y + u * u * p1.y
|
||||
let tx = 2 * w * (v.x - p0.x) + 2 * u * (p1.x - v.x)
|
||||
let ty = 2 * w * (v.y - p0.y) + 2 * u * (p1.y - v.y)
|
||||
let tl = max(0.0001, hypot(tx, ty))
|
||||
out.append((CGPoint(x: x, y: y), CGVector(dx: -ty / tl, dy: tx / tl)))
|
||||
}
|
||||
}
|
||||
|
||||
var cursor = waypoints[0]
|
||||
for i in 1..<(waypoints.count - 1) {
|
||||
let prev = waypoints[i - 1], v = waypoints[i], next = waypoints[i + 1]
|
||||
let ax = v.x - prev.x, ay = v.y - prev.y, al = max(0.0001, hypot(ax, ay))
|
||||
let bx = next.x - v.x, by = next.y - v.y, bl = max(0.0001, hypot(bx, by))
|
||||
let s = min(corner, al * 0.5, bl * 0.5) // clamp to segment lengths
|
||||
let pIn = CGPoint(x: v.x - ax / al * s, y: v.y - ay / al * s)
|
||||
let pOut = CGPoint(x: v.x + bx / bl * s, y: v.y + by / bl * s)
|
||||
addLine(cursor, pIn)
|
||||
addCorner(pIn, v, pOut)
|
||||
cursor = pOut
|
||||
}
|
||||
addLine(cursor, waypoints[waypoints.count - 1])
|
||||
out.append((waypoints[waypoints.count - 1], out.last?.n ?? CGVector(dx: 1, dy: 0)))
|
||||
return out
|
||||
}
|
||||
|
||||
static func bezier(_ t: Double, _ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint) -> CGPoint {
|
||||
let u = 1 - t
|
||||
let a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t
|
||||
return CGPoint(x: a * p0.x + b * c1.x + c * c2.x + d * p1.x,
|
||||
y: a * p0.y + b * c1.y + c * c2.y + d * p1.y)
|
||||
}
|
||||
static func bezierTangent(_ t: Double, _ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint) -> CGPoint {
|
||||
let u = 1 - t
|
||||
let a = 3 * u * u, b = 6 * u * t, c = 3 * t * t
|
||||
return CGPoint(x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p1.x - c2.x),
|
||||
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p1.y - c2.y))
|
||||
}
|
||||
|
||||
// Transfer route: a glowing arc from the departure city to the arrival city, with a
|
||||
// pulsing marker at each end. Drawn on the globe, behind the phone.
|
||||
private func routeLayer(_ r: RouteSpec) -> some View {
|
||||
let color = Color(hex: r.color)
|
||||
let path = Path { p in
|
||||
p.move(to: r.from)
|
||||
p.addCurve(to: r.to, control1: r.c1, control2: r.c2)
|
||||
}
|
||||
return ZStack {
|
||||
path.stroke(color.opacity(0.55), style: StrokeStyle(lineWidth: r.lineWidth * 2.6, lineCap: .round))
|
||||
.blur(radius: 22)
|
||||
path.stroke(
|
||||
LinearGradient(colors: [Color(hex: "#c98bff"), Color(hex: "#7c3aed"), Color(hex: "#c98bff")],
|
||||
startPoint: .topTrailing, endPoint: .bottomLeading),
|
||||
style: StrokeStyle(lineWidth: r.lineWidth, lineCap: .round))
|
||||
path.stroke(.white.opacity(0.85), style: StrokeStyle(lineWidth: r.lineWidth * 0.35, lineCap: .round))
|
||||
.blur(radius: 1)
|
||||
marker(at: r.from, color)
|
||||
marker(at: r.to, color)
|
||||
}
|
||||
.frame(width: Self.canvas.width, height: Self.canvas.height)
|
||||
}
|
||||
|
||||
private func marker(at pt: CGPoint, _ color: Color) -> some View {
|
||||
ZStack {
|
||||
Circle().fill(color.opacity(0.5)).frame(width: 60, height: 60).blur(radius: 14)
|
||||
Circle().fill(color).frame(width: 30, height: 30)
|
||||
Circle().fill(.white).frame(width: 14, height: 14)
|
||||
}
|
||||
.position(pt)
|
||||
}
|
||||
|
||||
// Converging encryption beams: thin glowing lines fanning from the top phone's edge
|
||||
// down to a single convergence point (above the lock).
|
||||
private func beamsLayer(_ b: BeamsSpec) -> some View {
|
||||
let color = Color(hex: b.color)
|
||||
return Canvas { ctx, _ in
|
||||
for i in 0..<b.count {
|
||||
let f = b.count == 1 ? 0 : Double(i) / Double(b.count - 1) - 0.5 // -0.5…0.5
|
||||
let x0 = b.cx + CGFloat(f) * 2 * b.spread
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: x0, y: b.y0))
|
||||
path.addLine(to: CGPoint(x: b.cx, y: b.cy))
|
||||
let op = 0.25 + 0.35 * (1 - abs(f) * 2) // brighter toward the centre beam
|
||||
ctx.stroke(path, with: .color(color.opacity(op)),
|
||||
style: StrokeStyle(lineWidth: 2.2, lineCap: .round))
|
||||
}
|
||||
}
|
||||
.frame(width: Self.canvas.width, height: Self.canvas.height)
|
||||
.blur(radius: 0.6)
|
||||
.shadow(color: color.opacity(0.7), radius: 10)
|
||||
}
|
||||
|
||||
// Vertical binary "protection" stream from the lock down to the receiving phone.
|
||||
private func streamLayer(_ s: StreamSpec) -> some View {
|
||||
let color = Color(hex: s.color)
|
||||
return Canvas { ctx, _ in
|
||||
let spacing = 44.0
|
||||
let count = max(1, Int((s.y1 - s.y0) / spacing))
|
||||
for i in 0...count {
|
||||
let t = Double(i) / Double(count)
|
||||
let y = s.y0 + (s.y1 - s.y0) * t
|
||||
let bit = (i % 3 == 0) ? "0" : "1"
|
||||
// fade in near the lock and out near the phone
|
||||
let op = 0.5 + 0.5 * sin(Double.pi * t)
|
||||
var res = ctx.resolve(Text(bit)
|
||||
.font(.system(size: 30, weight: .semibold, design: .monospaced)))
|
||||
res.shading = .color(color.opacity(op))
|
||||
ctx.draw(res, at: CGPoint(x: s.cx, y: y))
|
||||
}
|
||||
}
|
||||
.frame(width: Self.canvas.width, height: Self.canvas.height)
|
||||
.shadow(color: color.opacity(0.6), radius: 8)
|
||||
}
|
||||
|
||||
// Glowing padlock (SF Symbol) — the encryption focal point.
|
||||
private func lockLayer(_ l: LockSpec) -> some View {
|
||||
let color = Color(hex: l.color)
|
||||
return Image(systemName: "lock.fill")
|
||||
.font(.system(size: l.size, weight: .regular))
|
||||
.foregroundStyle(
|
||||
LinearGradient(colors: [.white, color], startPoint: .top, endPoint: .bottom))
|
||||
.shadow(color: color.opacity(0.9), radius: 30)
|
||||
.shadow(color: color.opacity(0.6), radius: 60)
|
||||
.position(x: l.cx, y: l.cy)
|
||||
}
|
||||
|
||||
// Localized banner label (CHIFFREMENT / PROTECTION) from strings.json.
|
||||
private func bannerLayer(_ b: Banner) -> some View {
|
||||
let text = (b.kind == .encryption ? caption.encryption : caption.protection) ?? ""
|
||||
return Text(text)
|
||||
.font(.system(size: 48, weight: .bold))
|
||||
.tracking(3)
|
||||
.foregroundStyle(Color(hex: "#e7dcf7"))
|
||||
.position(x: b.cx, y: b.cy)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func captionBlock(_ titleSize: CGFloat, _ subSize: CGFloat) -> some View {
|
||||
let content = VStack(spacing: 10) {
|
||||
Text(caption.title)
|
||||
.font(.system(size: titleSize, weight: .heavy))
|
||||
.foregroundStyle(titleColor)
|
||||
Text(caption.subtitle)
|
||||
.font(.system(size: subSize, weight: .semibold))
|
||||
.foregroundStyle(subColor)
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(width: Self.canvas.width - (spec.headerBackdrop ? 300 : 160))
|
||||
|
||||
if spec.headerBackdrop {
|
||||
// The panel is the caption's background, so it grows with the content — long
|
||||
// translations (Russian, etc.) get a taller/wider backdrop automatically.
|
||||
let shape = RoundedRectangle(cornerRadius: 48, style: .continuous)
|
||||
content
|
||||
.padding(.horizontal, 56)
|
||||
.padding(.vertical, 44)
|
||||
.background(shape.fill(Color(hex: "#180a30").opacity(0.82))
|
||||
.overlay(shape.stroke(Color.white.opacity(0.16), lineWidth: 1.5)))
|
||||
.shadow(color: .black.opacity(0.35), radius: 24, y: 10)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private var captionLayer: some View {
|
||||
let top = spec.captionPlace == .top
|
||||
// Keep the size for short captions, but shrink long translations (e.g. Russian /
|
||||
// Polish / Portuguese wrap both lines) so the taller block still fits its band and
|
||||
// never overlaps the phone. ViewThatFits picks the largest variant that fits.
|
||||
// The top band is shorter than the bottom one because those screens' phones are
|
||||
// large and start high, leaving less room above them.
|
||||
let region: CGFloat = top ? (spec.headerBackdrop ? 560 : 290) : 360
|
||||
let fitted = ViewThatFits(in: .vertical) {
|
||||
captionBlock(104, 60)
|
||||
captionBlock(92, 54)
|
||||
captionBlock(82, 48)
|
||||
captionBlock(72, 44)
|
||||
}
|
||||
.frame(width: Self.canvas.width, height: region, alignment: top ? .top : .bottom)
|
||||
|
||||
return fitted
|
||||
.padding(top ? .top : .bottom, top ? 96 : 110)
|
||||
.frame(width: Self.canvas.width, height: Self.canvas.height,
|
||||
alignment: top ? .top : .bottom)
|
||||
}
|
||||
}
|
||||
221
packaging/apple/studio/Sources/studio/ScreenSpec.swift
Normal file
221
packaging/apple/studio/Sources/studio/ScreenSpec.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
import SwiftUI
|
||||
|
||||
// Layout data for each marketing screen. Numbers are in pixels (the canvas renders
|
||||
// at scale 1, so a value of 104 == 104px). Ported from the old screens.typ `screens`
|
||||
// dict — tune these against the originals.
|
||||
|
||||
extension Color {
|
||||
// "#rrggbb"
|
||||
init(hex: String) {
|
||||
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||
let v = UInt64(s, radix: 16) ?? 0
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((v >> 16) & 0xff) / 255,
|
||||
green: Double((v >> 8) & 0xff) / 255,
|
||||
blue: Double(v & 0xff) / 255,
|
||||
opacity: 1)
|
||||
}
|
||||
}
|
||||
|
||||
enum CaptionPlace { case top, bottom }
|
||||
enum CaptionTheme { case dark, light }
|
||||
|
||||
struct GradientSpec {
|
||||
let stops: [String] // hex colors, top→bottom / start→end
|
||||
let start: UnitPoint
|
||||
let end: UnitPoint
|
||||
|
||||
var gradient: LinearGradient {
|
||||
LinearGradient(colors: stops.map { Color(hex: $0) }, startPoint: start, endPoint: end)
|
||||
}
|
||||
}
|
||||
|
||||
// A 3D device layer. The screenshot is textured onto the model's screen and the phone
|
||||
// is posed in real 3D (pitch/yaw/roll), positioned by its center on the canvas and
|
||||
// sized by its upright height — matching how the originals were laid out.
|
||||
struct Pose {
|
||||
var pitch: CGFloat = 0 // deg, about X (top tips away/toward viewer)
|
||||
var yaw: CGFloat = 0 // deg, about Y (turn left/right, reveals a side edge)
|
||||
var roll: CGFloat = 0 // deg, about Z (in-plane spin)
|
||||
}
|
||||
|
||||
struct DeviceSpec {
|
||||
var height: CGFloat // upright phone height in canvas px (drives scale)
|
||||
var cx: CGFloat // phone center X on the 1284-wide canvas
|
||||
var cy: CGFloat // phone center Y on the 2778-tall canvas
|
||||
var pose: Pose = Pose()
|
||||
var shadow: Bool = true // soft contact shadow under the phone
|
||||
var blackScreen: Bool = false // texture the screen solid black (ignore the shot)
|
||||
}
|
||||
|
||||
struct GlobeSpec {
|
||||
var width: CGFloat
|
||||
var dx: 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
|
||||
// glowing arc that swoops down and passes behind the phone — "send from Paris to LA,
|
||||
// through your phone". Generated natively.
|
||||
struct RouteSpec {
|
||||
var from: CGPoint // departure marker (on the globe)
|
||||
var to: CGPoint // arrival marker (on the globe)
|
||||
var c1: CGPoint // Bézier controls — pull down to bulge the arc through the phone
|
||||
var c2: CGPoint
|
||||
var lineWidth: CGFloat = 12
|
||||
var color: String = "#a855f7"
|
||||
}
|
||||
|
||||
// Stay-private redesign: a vertical encryption flow between two stacked phones —
|
||||
// converging light beams (encryption) → a glowing padlock → a binary stream (protection).
|
||||
struct BeamsSpec {
|
||||
var cx: CGFloat // convergence x
|
||||
var y0: CGFloat // beams start at the top phone's bottom edge
|
||||
var spread: CGFloat // half-width the beams fan across at y0
|
||||
var cy: CGFloat // converge to this point
|
||||
var count: Int = 22
|
||||
var color: String = "#9ec3ff"
|
||||
}
|
||||
|
||||
struct LockSpec {
|
||||
var cx: CGFloat
|
||||
var cy: CGFloat
|
||||
var size: CGFloat
|
||||
var color: String = "#a9c9ff"
|
||||
}
|
||||
|
||||
struct StreamSpec {
|
||||
var cx: CGFloat // vertical binary stream centre
|
||||
var y0: CGFloat // from (below the lock)
|
||||
var y1: CGFloat // to (the bottom phone)
|
||||
var color: String = "#9ec3ff"
|
||||
}
|
||||
|
||||
// Localized banner labels (CHIFFREMENT / PROTECTION), text taken from strings.json.
|
||||
enum BannerKind { case encryption, protection }
|
||||
struct Banner { var kind: BannerKind; var cx: CGFloat; var cy: CGFloat }
|
||||
|
||||
// A glowing purple orbit ring, drawn behind the globe (which occludes its top) and
|
||||
// looping in front down toward the phone. Generated natively, no asset.
|
||||
struct OrbitSpec {
|
||||
var cx: CGFloat
|
||||
var cy: CGFloat // ellipse center on the canvas
|
||||
var w: CGFloat
|
||||
var h: CGFloat // ellipse size
|
||||
var rotation: CGFloat = 0 // deg
|
||||
var lineWidth: CGFloat = 16
|
||||
}
|
||||
|
||||
struct ScreenSpec {
|
||||
let id: String
|
||||
let bg: GradientSpec
|
||||
let captionPlace: CaptionPlace
|
||||
let captionTheme: CaptionTheme
|
||||
var globe: GlobeSpec? = nil
|
||||
var orbit: OrbitSpec? = nil
|
||||
var route: RouteSpec? = nil
|
||||
var device: DeviceSpec? = nil
|
||||
var devices: [DeviceSpec] = [] // multiple phones (e.g. stay-private)
|
||||
var ribbon: RibbonSpec? = nil
|
||||
var beams: BeamsSpec? = nil
|
||||
var lock: LockSpec? = nil
|
||||
var stream: StreamSpec? = nil
|
||||
var banners: [Banner] = []
|
||||
var headerBackdrop: Bool = false // dark scrim behind the top caption
|
||||
// Which captured screenshot to texture (defaults to `id`). The hero reuses the
|
||||
// approval modal shot, matching the original.
|
||||
var shotId: String? = nil
|
||||
|
||||
static let all: [String: ScreenSpec] = [
|
||||
// Straight-on hero, large and centered, showing the Share/QR sheet.
|
||||
"share-securely": ScreenSpec(
|
||||
id: "share-securely",
|
||||
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
|
||||
captionPlace: .top, captionTheme: .dark,
|
||||
device: DeviceSpec(
|
||||
height: 2300, cx: 642, cy: 1560,
|
||||
pose: Pose(pitch: 0, yaw: 0, roll: 0))
|
||||
),
|
||||
// Tilted hero: turned to reveal the right edge, top sloping down-right.
|
||||
"choose-receivers": ScreenSpec(
|
||||
id: "choose-receivers",
|
||||
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
|
||||
captionPlace: .top, captionTheme: .dark,
|
||||
device: DeviceSpec(
|
||||
height: 2160, cx: 620, cy: 1620,
|
||||
pose: Pose(pitch: -9, yaw: -14, roll: 7))
|
||||
),
|
||||
// Strongly tilted, lying diagonally in the lower-left; globe + orbit above.
|
||||
"send-anywhere": ScreenSpec(
|
||||
id: "send-anywhere",
|
||||
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
|
||||
captionPlace: .bottom, captionTheme: .dark,
|
||||
globe: GlobeSpec(width: 1520, dx: -10, dy: -200),
|
||||
route: RouteSpec(
|
||||
from: CGPoint(x: 1284 - 289, y: 226), // departure — Paris (Europe, right of globe)
|
||||
to: CGPoint(x: 211, y: 296), // arrival — Los Angeles (upper-left of globe)
|
||||
c1: CGPoint(x: 1500, y: 1980), // pull the arc down through the phone
|
||||
c2: CGPoint(x: -300, y: 1980),
|
||||
lineWidth: 12),
|
||||
device: DeviceSpec(
|
||||
height: 1550, cx: 581, cy: 1600,
|
||||
pose: Pose(pitch: 35, yaw: 35, roll: 15)),
|
||||
shotId: "share-securely"
|
||||
),
|
||||
// Two partial phones with black screens + a generated binary data ribbon.
|
||||
// Vertical encryption flow: two stacked centred phones, converging beams into a
|
||||
// glowing padlock, then a binary "protection" stream down to the receiver.
|
||||
"stay-private": ScreenSpec(
|
||||
id: "stay-private",
|
||||
bg: GradientSpec(
|
||||
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"],
|
||||
start: .top, end: .bottom),
|
||||
captionPlace: .top, captionTheme: .light,
|
||||
devices: [
|
||||
DeviceSpec(
|
||||
height: 1500, cx: 642, cy: -20, // top phone, only its lower part shows
|
||||
pose: Pose(roll: 180), shadow: false, blackScreen: true),
|
||||
DeviceSpec(
|
||||
height: 1500, cx: 642, cy: 2820, // bottom phone, only its upper part shows
|
||||
pose: Pose(), shadow: false, blackScreen: true),
|
||||
],
|
||||
beams: BeamsSpec(cx: 642, y0: 700, spread: 150, cy: 1120, count: 22),
|
||||
lock: LockSpec(cx: 642, cy: 1330, size: 300),
|
||||
stream: StreamSpec(cx: 642, y0: 1520, y1: 2360),
|
||||
banners: [
|
||||
Banner(kind: .encryption, cx: 642, cy: 1120),
|
||||
Banner(kind: .protection, cx: 642, cy: 1620),
|
||||
],
|
||||
headerBackdrop: true
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// Where the glass sits inside the STRAIGHT mockup image, as fractions of the mockup's
|
||||
// own pixel size, plus the screen corner radius as a fraction of screen width. Measure
|
||||
// once against assets/mockup-straight.png and set here.
|
||||
struct MockupGeometry {
|
||||
var screenXFrac: CGFloat
|
||||
var screenYFrac: CGFloat
|
||||
var screenWFrac: CGFloat
|
||||
var screenHFrac: CGFloat
|
||||
var cornerFrac: CGFloat // corner radius / screen width
|
||||
|
||||
// Placeholder — tune against the real mockup export.
|
||||
static let straight = MockupGeometry(
|
||||
screenXFrac: 0.036, screenYFrac: 0.028,
|
||||
screenWFrac: 0.928, screenHFrac: 0.944,
|
||||
cornerFrac: 0.075
|
||||
)
|
||||
}
|
||||
51
packaging/apple/studio/Sources/studio/Strings.swift
Normal file
51
packaging/apple/studio/Sources/studio/Strings.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
// Mirrors strings.json. Captions live only here (not app strings).
|
||||
struct Caption: Decodable {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
// Optional in-composition banner labels (stay-private): CHIFFREMENT / PROTECTION.
|
||||
let encryption: String?
|
||||
let protection: String?
|
||||
}
|
||||
|
||||
struct LocaleStrings: Decodable {
|
||||
let folder: String
|
||||
private let screens: [String: Caption]
|
||||
|
||||
subscript(_ screen: String) -> Caption? { screens[screen] }
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case folder = "_folder" }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
// `_folder` is a reserved key; every other key is a screen id -> Caption.
|
||||
let dyn = try decoder.container(keyedBy: DynamicKey.self)
|
||||
var acc: [String: Caption] = [:]
|
||||
var folderName = ""
|
||||
for key in dyn.allKeys {
|
||||
if key.stringValue == "_folder" {
|
||||
folderName = try dyn.decode(String.self, forKey: key)
|
||||
} else if let cap = try? dyn.decode(Caption.self, forKey: key) {
|
||||
acc[key.stringValue] = cap
|
||||
}
|
||||
}
|
||||
self.folder = folderName
|
||||
self.screens = acc
|
||||
}
|
||||
|
||||
private struct DynamicKey: CodingKey {
|
||||
var stringValue: String
|
||||
var intValue: Int? { nil }
|
||||
init?(stringValue: String) { self.stringValue = stringValue }
|
||||
init?(intValue: Int) { nil }
|
||||
}
|
||||
}
|
||||
|
||||
struct Strings: Decodable {
|
||||
let screens: [String]
|
||||
let locales: [String: LocaleStrings]
|
||||
|
||||
static func load(_ url: URL) throws -> Strings {
|
||||
try JSONDecoder().decode(Strings.self, from: Data(contentsOf: url))
|
||||
}
|
||||
}
|
||||
113
packaging/apple/studio/Sources/studio/main.swift
Normal file
113
packaging/apple/studio/Sources/studio/main.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// Code-driven App Store screenshots, rendered natively with SwiftUI + ImageRenderer.
|
||||
//
|
||||
// swift run studio # -> generated/<Language>/<Name>.png
|
||||
// swift run studio --publish # -> ../<Language>/<Name>.png (ships)
|
||||
// LOCALES="fr de" SCREENS="share-securely" swift run studio
|
||||
//
|
||||
// Screenshots come from generated/shots/<locale>/<screen>.png (from ./capture.sh),
|
||||
// transient build output regenerated per run — never committed;
|
||||
// the straight mockup + globe come from assets/. Run from the studio directory.
|
||||
|
||||
// Screen id -> output file basename (matches the existing App Store filenames).
|
||||
let nameFor: [String: String] = [
|
||||
"choose-receivers": "Choose Receivers", "send-anywhere": "Send Anywhere",
|
||||
"share-securely": "Share Securely", "stay-private": "Stay private",
|
||||
]
|
||||
|
||||
func env(_ key: String, _ fallback: String) -> String {
|
||||
let v = ProcessInfo.processInfo.environment[key]
|
||||
return (v?.isEmpty == false) ? v! : fallback
|
||||
}
|
||||
|
||||
func loadNSImage(_ path: String) -> (NSImage, CGSize)? {
|
||||
guard let ns = NSImage(contentsOfFile: path) else { return nil }
|
||||
let size = ns.representations.first.map { CGSize(width: $0.pixelsWide, height: $0.pixelsHigh) } ?? ns.size
|
||||
return (ns, size)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func writePNG(_ view: some View, to url: URL) throws {
|
||||
let renderer = ImageRenderer(content: view)
|
||||
renderer.scale = 1
|
||||
guard let cg = renderer.cgImage else {
|
||||
throw NSError(domain: "studio", code: 1, userInfo: [NSLocalizedDescriptionKey: "render failed"])
|
||||
}
|
||||
let rep = NSBitmapImageRep(cgImage: cg)
|
||||
rep.size = NSSize(width: cg.width, height: cg.height)
|
||||
guard let data = rep.representation(using: .png, properties: [:]) else {
|
||||
throw NSError(domain: "studio", code: 2, userInfo: [NSLocalizedDescriptionKey: "png encode failed"])
|
||||
}
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() throws {
|
||||
let args = CommandLine.arguments
|
||||
let publish = args.contains("--publish")
|
||||
let cwd = FileManager.default.currentDirectoryPath
|
||||
let base = URL(fileURLWithPath: cwd)
|
||||
|
||||
let strings = try Strings.load(base.appendingPathComponent("strings.json"))
|
||||
|
||||
let locales = env("LOCALES", "en fr de es it nl pl pt ru").split(separator: " ").map(String.init)
|
||||
let screens = env("SCREENS", "choose-receivers send-anywhere share-securely stay-private")
|
||||
.split(separator: " ").map(String.init)
|
||||
|
||||
let globe = loadNSImage(base.appendingPathComponent("assets/globe.png").path).map { Image(nsImage: $0.0) }
|
||||
|
||||
// Device layer: prefer the 3D iPhone model; fall back to the 2D mockup composite.
|
||||
// DEVICE=2d forces the flat compositor; DEVICE=3d requires the .usdz.
|
||||
let deviceMode = env("DEVICE", "auto")
|
||||
let usdz = base.appendingPathComponent("assets/iphone-17-pro-max.usdz")
|
||||
let hasModel = FileManager.default.fileExists(atPath: usdz.path)
|
||||
let mockup = loadNSImage(base.appendingPathComponent("assets/mockup-straight.png").path)
|
||||
|
||||
let device: DeviceRenderer?
|
||||
if deviceMode != "2d" && hasModel {
|
||||
device = SceneKitDeviceRenderer(modelURL: usdz)
|
||||
} else if let mockup {
|
||||
device = ClipDeviceRenderer(mockup: mockup.0, mockupSize: mockup.1, geo: .straight)
|
||||
} else {
|
||||
device = nil
|
||||
FileHandle.standardError.write(Data("warning: no device model or mockup — devices will be blank\n".utf8))
|
||||
}
|
||||
|
||||
var count = 0
|
||||
for loc in locales {
|
||||
guard let ls = strings.locales[loc] else {
|
||||
FileHandle.standardError.write(Data("warning: no strings for locale \(loc)\n".utf8)); continue
|
||||
}
|
||||
let outDir = publish
|
||||
? base.appendingPathComponent("../\(ls.folder)").standardized
|
||||
: base.appendingPathComponent("generated/\(ls.folder)")
|
||||
|
||||
for scr in screens {
|
||||
guard let spec = ScreenSpec.all[scr], let caption = ls[scr] else {
|
||||
FileHandle.standardError.write(Data("warning: skipping \(loc)/\(scr)\n".utf8)); continue
|
||||
}
|
||||
|
||||
let shotId = spec.shotId ?? scr
|
||||
let shotsDir = env("SHOTS_DIR", "generated/shots")
|
||||
let shot = loadNSImage(base.appendingPathComponent("\(shotsDir)/\(loc)/\(shotId).png").path)?.0
|
||||
let hasDevice = spec.device != nil || !spec.devices.isEmpty
|
||||
let frame = ScreenFrame(spec: spec, caption: caption, globe: globe, shot: shot,
|
||||
device: hasDevice ? device : nil)
|
||||
let out = outDir.appendingPathComponent("\(nameFor[scr] ?? scr).png")
|
||||
try writePNG(frame, to: out)
|
||||
print(" ✅ \(out.path)")
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
print("\nDone — \(count) screenshot(s)\(publish ? " (published)" : "").")
|
||||
}
|
||||
|
||||
// ImageRenderer needs an AppKit context for text/font resolution.
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.prohibited)
|
||||
do { try MainActor.assumeIsolated { try run() } }
|
||||
catch { FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)); exit(1) }
|
||||
12
packaging/apple/studio/assets/ATTRIBUTION.md
Normal file
12
packaging/apple/studio/assets/ATTRIBUTION.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Third-party asset attributions
|
||||
|
||||
## iphone-17-pro-max.usdz
|
||||
- **Title:** iphone 17 pro max silver
|
||||
- **Author:** TechFreak (Sketchfab)
|
||||
- **Source:** https://sketchfab.com/3d-models/iphone-17-pro-max-silver-9dbfe0d846f341bf9fc501a854f5de1a
|
||||
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
|
||||
|
||||
CC BY 4.0 requires visible credit wherever this model (or a render derived from it)
|
||||
is published. The App Store screenshots produced by this studio are derivatives, so
|
||||
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.
|
||||
BIN
packaging/apple/studio/assets/globe.png
Normal file
BIN
packaging/apple/studio/assets/globe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 MiB |
BIN
packaging/apple/studio/assets/iphone-17-pro-max.usdz
Normal file
BIN
packaging/apple/studio/assets/iphone-17-pro-max.usdz
Normal file
Binary file not shown.
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render all (locale x screen) App Store screenshots with Typst.
|
||||
#
|
||||
# ./build.sh # -> generated/<Language>/<Name>.png (safe default)
|
||||
# ./build.sh --publish # -> ../<Language>/<Name>.png (ships to App Store)
|
||||
# LOCALES="fr de" SCREENS="share-securely" ./build.sh # subset
|
||||
#
|
||||
# Screenshots are read from assets/shots/<locale>/<screen>.png when present
|
||||
# (pass nothing and you get the "screenshot here" placeholder).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PUBLISH=""; [[ "${1:-}" == "--publish" ]] && PUBLISH=1
|
||||
|
||||
LOCALES=${LOCALES:-"en fr de es it nl pl pt ru"}
|
||||
SCREENS=${SCREENS:-"choose-receivers send-anywhere share-securely stay-private"}
|
||||
|
||||
# locale code -> output folder name (matches existing packaging/apple/<Language>/)
|
||||
folder_for() { case "$1" in
|
||||
en) echo English;; fr) echo French;; de) echo German;; es) echo Spanish;;
|
||||
it) echo Italian;; nl) echo Dutch;; pl) echo Polish;; pt) echo Portuguese;; ru) echo Russian;;
|
||||
*) echo "$1";; esac; }
|
||||
|
||||
# screen id -> output file basename (matches existing filenames)
|
||||
name_for() { case "$1" in
|
||||
choose-receivers) echo "Choose Receivers";; send-anywhere) echo "Send Anywhere";;
|
||||
share-securely) echo "Share Securely";; stay-private) echo "Stay private";;
|
||||
*) echo "$1";; esac; }
|
||||
|
||||
# screen id -> device mockup mode (which mockup frames the screenshot), or empty for none
|
||||
mode_for() { case "$1" in
|
||||
send-anywhere) echo "rotated";; choose-receivers|share-securely) echo "straight";;
|
||||
*) echo "";; esac; }
|
||||
|
||||
n=0
|
||||
for loc in $LOCALES; do
|
||||
folder=$(folder_for "$loc")
|
||||
outdir=$([[ -n "$PUBLISH" ]] && echo "../$folder" || echo "generated/$folder")
|
||||
mkdir -p "$outdir"
|
||||
for scr in $SCREENS; do
|
||||
shot="assets/shots/$loc/$scr.png"
|
||||
mode="$(mode_for "$scr")"
|
||||
|
||||
# Frame the screenshot into its mockup (frame.sh masks it to the real screen
|
||||
# shape) when a capture and a mockup mode exist; regenerate if the shot is newer.
|
||||
device_arg="none"
|
||||
if [[ -n "$mode" && -f "$shot" ]]; then
|
||||
device="assets/shots/$loc/$scr.device.png"
|
||||
[[ ! -f "$device" || "$shot" -nt "$device" ]] && ./frame.sh "$mode" "$shot" "$device" >/dev/null
|
||||
device_arg="$device"
|
||||
fi
|
||||
|
||||
out="$outdir/$(name_for "$scr").png"
|
||||
typst compile screens.typ "$out" \
|
||||
--input locale="$loc" --input screen="$scr" --input device="$device_arg" --ppi 72
|
||||
echo " ✅ $out"
|
||||
n=$((n+1))
|
||||
done
|
||||
done
|
||||
echo ""
|
||||
echo "Done — $n screenshot(s)$([[ -n "$PUBLISH" ]] && echo ' (published)')."
|
||||
@@ -19,8 +19,14 @@ DEVICE="${SCREENSHOT_DEVICE:-iPhone 17 Pro Max}"
|
||||
BUNDLE_ID="com.vnidrop.app"
|
||||
LOCALES=${LOCALES:-"en fr de es it nl pl pt ru"}
|
||||
|
||||
# scenario (launch arg value) -> studio screen id (output filename stem)
|
||||
SCENARIOS="share:share-securely approval:choose-receivers transfer-details:send-anywhere"
|
||||
# scenario (launch arg value) -> studio screen id (output filename stem).
|
||||
# send-anywhere reuses the share screenshot (see ScreenSpec shotId), so it isn't
|
||||
# captured separately; stay-private uses black screens (no capture).
|
||||
SCENARIOS="share:share-securely approval:choose-receivers"
|
||||
|
||||
# Screenshots are transient build output, regenerated per run — never committed. They
|
||||
# live under generated/ (git-ignored), not in assets/.
|
||||
SHOTS_DIR="${SHOTS_DIR:-generated/shots}"
|
||||
|
||||
echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)
|
||||
|
||||
@@ -46,7 +52,7 @@ xcrun simctl status_bar "$UDID" override --time "9:41" \
|
||||
xcrun simctl install "$UDID" "$APP"
|
||||
|
||||
for loc in $LOCALES; do
|
||||
mkdir -p "assets/shots/$loc"
|
||||
mkdir -p "$SHOTS_DIR/$loc"
|
||||
for pair in $SCENARIOS; do
|
||||
scenario="${pair%%:*}"; screen="${pair##*:}"
|
||||
xcrun simctl launch --terminate-running-process "$UDID" "$BUNDLE_ID" \
|
||||
@@ -56,9 +62,9 @@ for loc in $LOCALES; do
|
||||
# on external/again-protected volumes), so capture to a temp file and move it in.
|
||||
tmp="$(mktemp -t vnishot).png"
|
||||
xcrun simctl io "$UDID" screenshot "$tmp" >/dev/null 2>&1
|
||||
mv "$tmp" "assets/shots/$loc/$screen.png"
|
||||
echo " 📸 $loc/$screen.png"
|
||||
mv "$tmp" "$SHOTS_DIR/$loc/$screen.png"
|
||||
echo " 📸 $SHOTS_DIR/$loc/$screen.png"
|
||||
done
|
||||
done
|
||||
echo ""
|
||||
echo "Done. Now run ./build.sh to composite."
|
||||
echo "Done. Now run 'swift run studio' to composite."
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Composite a screenshot into a device mockup using the mockup's real screen shape
|
||||
# as the mask — so the screen curvature comes from the mockup, never a guessed radius.
|
||||
# Output is a device PNG the size of the mockup, transparent outside the phone, for
|
||||
# Typst to place directly.
|
||||
#
|
||||
# ./frame.sh straight assets/shots/en/share-securely.png out-device.png
|
||||
# ./frame.sh rotated assets/shots/en/send-anywhere.png out-device.png
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
MODE="$1"; SHOT="$2"; OUT="$3"
|
||||
MOCKUP="assets/mockup-$MODE.png"
|
||||
MASK="assets/mask-$MODE.png"
|
||||
|
||||
# Cache the screen-glass mask (near-black region of the mockup, flattened off transparency).
|
||||
if [[ ! -f "$MASK" || "$MOCKUP" -nt "$MASK" ]]; then
|
||||
magick "$MOCKUP" -background magenta -flatten -colorspace Gray -threshold 6% -negate "$MASK"
|
||||
fi
|
||||
|
||||
CW=$(magick identify -format "%w" "$MOCKUP"); CH=$(magick identify -format "%h" "$MOCKUP")
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
if [[ "$MODE" == "rotated" ]]; then
|
||||
# Perspective-warp onto the tilted glass quad (canvas-sized already).
|
||||
./warp.sh "$SHOT" "$tmp/screen.png" >/dev/null
|
||||
else
|
||||
# Cover-fit the screenshot to the screen's bounding box, placed at its offset.
|
||||
read SW SH SX SY <<<"$(magick "$MASK" -format "%@" info: | tr 'x+' ' ')"
|
||||
magick "$SHOT" -resize "${SW}x${SH}^" -gravity center -extent "${SW}x${SH}" "$tmp/fit.png"
|
||||
magick -size "${CW}x${CH}" xc:none "$tmp/fit.png" -geometry "+${SX}+${SY}" -composite "$tmp/screen.png"
|
||||
fi
|
||||
|
||||
# Clip the screen layer to the exact glass shape (rounded corners from the mask),
|
||||
# then lay it over the mockup so the bezel frames it.
|
||||
magick "$tmp/screen.png" "$MASK" -alpha off -compose CopyOpacity -composite "$tmp/clipped.png"
|
||||
magick "$MOCKUP" "$tmp/clipped.png" -compose over -composite "$OUT"
|
||||
echo "framed -> $OUT"
|
||||
16
packaging/apple/studio/generate.sh
Executable file
16
packaging/apple/studio/generate.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot: capture fresh per-locale screenshots from the app, then composite every
|
||||
# marketing screen. Screenshots are transient (generated/shots, git-ignored) and are
|
||||
# regenerated each run — never committed.
|
||||
#
|
||||
# ./generate.sh # capture + composite -> generated/<Language>/
|
||||
# ./generate.sh --publish # capture + composite -> ../<Language>/ (ships)
|
||||
# LOCALES="en fr" ./generate.sh # subset of locales
|
||||
#
|
||||
# During layout iteration you don't need to re-capture every time: run `./capture.sh`
|
||||
# once, then `swift run studio` on its own (it reads the existing generated/shots).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
./capture.sh
|
||||
swift run studio "$@"
|
||||
@@ -1,78 +0,0 @@
|
||||
// App Store screenshot composition.
|
||||
// Rendered per (locale, screen) via:
|
||||
// typst compile screens.typ out.png --input locale=fr --input screen=share-securely --ppi 72
|
||||
// Canvas is 1284x2778pt -> at 72ppi that's exactly 1284x2778px.
|
||||
|
||||
#let strings = json("strings.json")
|
||||
|
||||
// ---- inputs (with dev-friendly defaults) --------------------------------
|
||||
#let locale = sys.inputs.at("locale", default: "en")
|
||||
#let screen-id = sys.inputs.at("screen", default: "share-securely")
|
||||
// A pre-framed device PNG (mockup + screenshot, produced by frame.sh), or "none"
|
||||
// to show the empty mockup. Screen curvature comes from the mockup mask, not Typst.
|
||||
#let device-image = sys.inputs.at("device", default: "none")
|
||||
|
||||
#let cap = strings.locales.at(locale).at(screen-id)
|
||||
|
||||
// ---- canvas -------------------------------------------------------------
|
||||
#let CW = 1284pt
|
||||
#let CH = 2778pt
|
||||
|
||||
// ---- per-screen layout data --------------------------------------------
|
||||
#let screens = (
|
||||
"choose-receivers": (
|
||||
bg: gradient.linear(angle: 160deg, rgb("#f1eafc"), rgb("#e6d8fb"), rgb("#d9c4f7")),
|
||||
text: (place: "top", theme: "dark"),
|
||||
device: (mockup: "straight", w: 860pt, dx: 212pt, dy: 560pt, rot: 4deg),
|
||||
),
|
||||
"share-securely": (
|
||||
bg: gradient.linear(angle: 165deg, rgb("#efe7fb"), rgb("#e3d3f8"), rgb("#d5bff4")),
|
||||
text: (place: "top", theme: "dark"),
|
||||
device: (mockup: "straight", w: 820pt, dx: 232pt, dy: 470pt, rot: 0deg),
|
||||
),
|
||||
"send-anywhere": (
|
||||
bg: gradient.linear(dir: ttb, rgb("#e9ddf9"), rgb("#ddc9f4")),
|
||||
text: (place: "bottom", theme: "dark"),
|
||||
globe: (w: 1180pt, dx: 52pt, dy: -160pt),
|
||||
device: (mockup: "rotated", w: 900pt, dx: 90pt, dy: 980pt, rot: 0deg),
|
||||
),
|
||||
"stay-private": (
|
||||
bg: gradient.linear(dir: ttb, rgb("#2a0f4d"), rgb("#6b3fa0"), rgb("#e9dcf8"), rgb("#ddc7f4")),
|
||||
text: (place: "top", theme: "light"),
|
||||
),
|
||||
)
|
||||
|
||||
#let cfg = screens.at(screen-id)
|
||||
|
||||
#set page(width: CW, height: CH, margin: 0pt, fill: cfg.bg)
|
||||
#set text(font: ("SF NS", "Helvetica Neue")) // "SF NS" is macOS San Francisco (= SF Pro)
|
||||
|
||||
// ---- compose ------------------------------------------------------------
|
||||
|
||||
// globe (transparent PNG)
|
||||
#if "globe" in cfg [
|
||||
#place(top + left, dx: cfg.globe.dx, dy: cfg.globe.dy,
|
||||
image("assets/globe.png", width: cfg.globe.w))
|
||||
]
|
||||
|
||||
// device: a pre-framed PNG (frame.sh) when available, else the empty mockup.
|
||||
#if "device" in cfg [
|
||||
#let d = cfg.device
|
||||
#let img = if device-image != "none" { device-image } else { "assets/mockup-" + d.mockup + ".png" }
|
||||
#place(top + left, dx: d.dx, dy: d.dy,
|
||||
rotate(d.rot, origin: center, image(img, width: d.w)))
|
||||
]
|
||||
|
||||
// caption
|
||||
#let theme-color = if cfg.text.theme == "light" { white } else { rgb("#1b1226") }
|
||||
#let sub-color = if cfg.text.theme == "light" { rgb("#f3ecfb") } else { rgb("#2c2138") }
|
||||
#let caption = align(center)[
|
||||
#text(size: 104pt, weight: 800, fill: theme-color)[#cap.title]
|
||||
#v(10pt, weak: true)
|
||||
#text(size: 60pt, weight: 600, fill: sub-color)[#cap.subtitle]
|
||||
]
|
||||
#if cfg.text.place == "top" [
|
||||
#place(top + center, dy: 96pt, box(width: CW - 160pt, caption))
|
||||
] else [
|
||||
#place(bottom + center, dy: -220pt, box(width: CW - 160pt, caption))
|
||||
]
|
||||
@@ -1,69 +1,200 @@
|
||||
{
|
||||
"_comment": "Marketing captions for App Store screenshots. NOT app strings — these live only here. Translations below the English source are a first pass and should be reviewed by a native speaker.",
|
||||
"screens": ["choose-receivers", "send-anywhere", "share-securely", "stay-private"],
|
||||
"screens": [
|
||||
"choose-receivers",
|
||||
"send-anywhere",
|
||||
"share-securely",
|
||||
"stay-private"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"_folder": "English",
|
||||
"choose-receivers": { "title": "Choose Receivers", "subtitle": "Approve each person invited" },
|
||||
"send-anywhere": { "title": "Send Anywhere", "subtitle": "Direct file transfer worldwide" },
|
||||
"share-securely": { "title": "Share Securely", "subtitle": "QR, NFC or file" },
|
||||
"stay-private": { "title": "Stay Private", "subtitle": "Encrypted with no server copy" }
|
||||
"choose-receivers": {
|
||||
"title": "Choose Receivers",
|
||||
"subtitle": "Approve each person invited"
|
||||
},
|
||||
"send-anywhere": {
|
||||
"title": "Send Anywhere",
|
||||
"subtitle": "Direct file transfer worldwide"
|
||||
},
|
||||
"share-securely": {
|
||||
"title": "Share Securely",
|
||||
"subtitle": "QR, NFC or file"
|
||||
},
|
||||
"stay-private": {
|
||||
"title": "Stay Private",
|
||||
"subtitle": "Encrypted with no server copy",
|
||||
"encryption": "ENCRYPTION",
|
||||
"protection": "PROTECTION"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"_folder": "French",
|
||||
"choose-receivers": { "title": "Choisissez les destinataires", "subtitle": "Approuvez chaque personne invitée" },
|
||||
"send-anywhere": { "title": "Envoyez partout", "subtitle": "Transfert direct dans le monde entier" },
|
||||
"share-securely": { "title": "Partagez en sécurité", "subtitle": "QR, NFC ou fichier" },
|
||||
"stay-private": { "title": "Restez privé", "subtitle": "Chiffré, sans copie serveur" }
|
||||
"choose-receivers": {
|
||||
"title": "Choisissez les destinataires",
|
||||
"subtitle": "Approuvez chaque personne invitée"
|
||||
},
|
||||
"send-anywhere": {
|
||||
"title": "Envoyez partout",
|
||||
"subtitle": "Transfert direct dans le monde entier"
|
||||
},
|
||||
"share-securely": {
|
||||
"title": "Partagez en sécurité",
|
||||
"subtitle": "QR, NFC ou fichier"
|
||||
},
|
||||
"stay-private": {
|
||||
"title": "Restez privé",
|
||||
"subtitle": "Chiffré, sans copie serveur",
|
||||
"encryption": "CHIFFREMENT",
|
||||
"protection": "PROTECTION"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"_folder": "German",
|
||||
"choose-receivers": { "title": "Empfänger auswählen", "subtitle": "Jede eingeladene Person bestätigen" },
|
||||
"send-anywhere": { "title": "Überallhin senden", "subtitle": "Direkte Dateiübertragung weltweit" },
|
||||
"share-securely": { "title": "Sicher teilen", "subtitle": "QR, NFC oder Datei" },
|
||||
"stay-private": { "title": "Privat bleiben", "subtitle": "Verschlüsselt, ohne Server-Kopie" }
|
||||
"choose-receivers": {
|
||||
"title": "Empfänger auswählen",
|
||||
"subtitle": "Jede eingeladene Person bestätigen"
|
||||
},
|
||||
"send-anywhere": {
|
||||
"title": "Überallhin senden",
|
||||
"subtitle": "Direkte Dateiübertragung weltweit"
|
||||
},
|
||||
"share-securely": {
|
||||
"title": "Sicher teilen",
|
||||
"subtitle": "QR, NFC oder Datei"
|
||||
},
|
||||
"stay-private": {
|
||||
"title": "Privat bleiben",
|
||||
"subtitle": "Verschlüsselt, ohne Server-Kopie",
|
||||
"encryption": "VERSCHLÜSSELUNG",
|
||||
"protection": "SCHUTZ"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"_folder": "Spanish",
|
||||
"choose-receivers": { "title": "Elige destinatarios", "subtitle": "Aprueba a cada invitado" },
|
||||
"send-anywhere": { "title": "Envía a cualquier lugar", "subtitle": "Transferencia directa en todo el mundo" },
|
||||
"share-securely": { "title": "Comparte con seguridad", "subtitle": "QR, NFC o archivo" },
|
||||
"stay-private": { "title": "Mantén la privacidad", "subtitle": "Cifrado, sin copia en servidor" }
|
||||
"choose-receivers": {
|
||||
"title": "Elige destinatarios",
|
||||
"subtitle": "Aprueba a cada invitado"
|
||||
},
|
||||
"send-anywhere": {
|
||||
"title": "Envía a cualquier lugar",
|
||||
"subtitle": "Transferencia directa en todo el mundo"
|
||||
},
|
||||
"share-securely": {
|
||||
"title": "Comparte con seguridad",
|
||||
"subtitle": "QR, NFC o archivo"
|
||||
},
|
||||
"stay-private": {
|
||||
"title": "Mantén la privacidad",
|
||||
"subtitle": "Cifrado, sin copia en servidor",
|
||||
"encryption": "CIFRADO",
|
||||
"protection": "PROTECCIÓN"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"_folder": "Italian",
|
||||
"choose-receivers": { "title": "Scegli i destinatari", "subtitle": "Approva ogni invitato" },
|
||||
"send-anywhere": { "title": "Invia ovunque", "subtitle": "Trasferimento diretto in tutto il mondo" },
|
||||
"share-securely": { "title": "Condividi in sicurezza","subtitle": "QR, NFC o file" },
|
||||
"stay-private": { "title": "Resta privato", "subtitle": "Crittografato, senza copia sul server" }
|
||||
"choose-receivers": {
|
||||
"title": "Scegli i destinatari",
|
||||
"subtitle": "Approva ogni invitato"
|
||||
},
|
||||
"send-anywhere": {
|
||||
"title": "Invia ovunque",
|
||||
"subtitle": "Trasferimento diretto in tutto il mondo"
|
||||
},
|
||||
"share-securely": {
|
||||
"title": "Condividi in sicurezza",
|
||||
"subtitle": "QR, NFC o file"
|
||||
},
|
||||
"stay-private": {
|
||||
"title": "Resta privato",
|
||||
"subtitle": "Crittografato, senza copia sul server",
|
||||
"encryption": "CRITTOGRAFIA",
|
||||
"protection": "PROTEZIONE"
|
||||
}
|
||||
},
|
||||
"nl": {
|
||||
"_folder": "Dutch",
|
||||
"choose-receivers": { "title": "Kies ontvangers", "subtitle": "Keur elke genodigde goed" },
|
||||
"send-anywhere": { "title": "Verstuur overal", "subtitle": "Directe bestandsoverdracht wereldwijd" },
|
||||
"share-securely": { "title": "Deel veilig", "subtitle": "QR, NFC of bestand" },
|
||||
"stay-private": { "title": "Blijf privé", "subtitle": "Versleuteld, geen serverkopie" }
|
||||
"choose-receivers": {
|
||||
"title": "Kies ontvangers",
|
||||
"subtitle": "Keur elke genodigde goed"
|
||||
},
|
||||
"send-anywhere": {
|
||||
"title": "Verstuur overal",
|
||||
"subtitle": "Directe bestandsoverdracht wereldwijd"
|
||||
},
|
||||
"share-securely": {
|
||||
"title": "Deel veilig",
|
||||
"subtitle": "QR, NFC of bestand"
|
||||
},
|
||||
"stay-private": {
|
||||
"title": "Blijf privé",
|
||||
"subtitle": "Versleuteld, geen serverkopie",
|
||||
"encryption": "VERSLEUTELING",
|
||||
"protection": "BESCHERMING"
|
||||
}
|
||||
},
|
||||
"pl": {
|
||||
"_folder": "Polish",
|
||||
"choose-receivers": { "title": "Wybierz odbiorców", "subtitle": "Zatwierdź każdą zaproszoną osobę" },
|
||||
"send-anywhere": { "title": "Wysyłaj wszędzie", "subtitle": "Bezpośredni transfer plików na całym świecie" },
|
||||
"share-securely": { "title": "Udostępniaj bezpiecznie", "subtitle": "Kod QR, NFC lub plik" },
|
||||
"stay-private": { "title": "Zachowaj prywatność", "subtitle": "Szyfrowane, bez kopii na serwerze" }
|
||||
"choose-receivers": {
|
||||
"title": "Wybierz odbiorców",
|
||||
"subtitle": "Zatwierdź każdą zaproszoną osobę"
|
||||
},
|
||||
"send-anywhere": {
|
||||
"title": "Wysyłaj wszędzie",
|
||||
"subtitle": "Bezpośredni transfer plików na całym świecie"
|
||||
},
|
||||
"share-securely": {
|
||||
"title": "Udostępniaj bezpiecznie",
|
||||
"subtitle": "Kod QR, NFC lub plik"
|
||||
},
|
||||
"stay-private": {
|
||||
"title": "Zachowaj prywatność",
|
||||
"subtitle": "Szyfrowane, bez kopii na serwerze",
|
||||
"encryption": "SZYFROWANIE",
|
||||
"protection": "OCHRONA"
|
||||
}
|
||||
},
|
||||
"pt": {
|
||||
"_folder": "Portuguese",
|
||||
"choose-receivers": { "title": "Escolha os destinatários", "subtitle": "Aprove cada pessoa convidada" },
|
||||
"send-anywhere": { "title": "Envie para qualquer lugar", "subtitle": "Transferência direta pelo mundo todo" },
|
||||
"share-securely": { "title": "Compartilhe com segurança", "subtitle": "QR, NFC ou arquivo" },
|
||||
"stay-private": { "title": "Mantenha a privacidade", "subtitle": "Criptografado, sem cópia no servidor" }
|
||||
"choose-receivers": {
|
||||
"title": "Escolha os destinatários",
|
||||
"subtitle": "Aprove cada pessoa convidada"
|
||||
},
|
||||
"send-anywhere": {
|
||||
"title": "Envie para qualquer lugar",
|
||||
"subtitle": "Transferência direta pelo mundo todo"
|
||||
},
|
||||
"share-securely": {
|
||||
"title": "Compartilhe com segurança",
|
||||
"subtitle": "QR, NFC ou arquivo"
|
||||
},
|
||||
"stay-private": {
|
||||
"title": "Mantenha a privacidade",
|
||||
"subtitle": "Criptografado, sem cópia no servidor",
|
||||
"encryption": "CRIPTOGRAFIA",
|
||||
"protection": "PROTEÇÃO"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"_folder": "Russian",
|
||||
"choose-receivers": { "title": "Выбирайте получателей", "subtitle": "Подтверждайте каждого приглашённого" },
|
||||
"send-anywhere": { "title": "Отправляйте куда угодно", "subtitle": "Прямая передача файлов по всему миру" },
|
||||
"share-securely": { "title": "Делитесь безопасно", "subtitle": "QR, NFC или файл" },
|
||||
"stay-private": { "title": "Оставайтесь приватными", "subtitle": "Шифрование без копии на сервере" }
|
||||
"choose-receivers": {
|
||||
"title": "Выбирайте получателей",
|
||||
"subtitle": "Подтверждайте каждого приглашённого"
|
||||
},
|
||||
"send-anywhere": {
|
||||
"title": "Отправляйте куда угодно",
|
||||
"subtitle": "Прямая передача файлов по всему миру"
|
||||
},
|
||||
"share-securely": {
|
||||
"title": "Делитесь безопасно",
|
||||
"subtitle": "QR, NFC или файл"
|
||||
},
|
||||
"stay-private": {
|
||||
"title": "Оставайтесь приватными",
|
||||
"subtitle": "Шифрование без копии на сервере",
|
||||
"encryption": "ШИФРОВАНИЕ",
|
||||
"protection": "ЗАЩИТА"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Perspective-warp a flat screenshot onto the tilted mockup's screen glass.
|
||||
# Output is a transparent PNG the exact size of mockup-rotated.png, so Typst can
|
||||
# stack it directly over the mockup (bezel frames it).
|
||||
#
|
||||
# ./warp.sh assets/shots/en/send-anywhere.png assets/shots/en/send-anywhere.warped.png
|
||||
#
|
||||
# The 4 destination corners are the glass corners of mockup-rotated.png (1696x2528),
|
||||
# auto-detected once. Re-detect if the mockup changes (see README).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SRC="$1"; OUT="$2"
|
||||
MW=1696; MH=2528 # mockup-rotated.png dimensions
|
||||
TL="136,28"; TR="836,236"; BL="944,2136"; BR="1668,2312"
|
||||
|
||||
W=$(magick identify -format "%w" "$SRC")
|
||||
H=$(magick identify -format "%h" "$SRC")
|
||||
|
||||
magick \
|
||||
\( -size ${MW}x${MH} xc:none \) \
|
||||
\( "$SRC" -virtual-pixel transparent +distort Perspective \
|
||||
"0,0 $TL $((W-1)),0 $TR 0,$((H-1)) $BL $((W-1)),$((H-1)) $BR" \) \
|
||||
-background none -flatten "$OUT"
|
||||
echo "warped -> $OUT"
|
||||
Reference in New Issue
Block a user