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:
2026-08-08 18:59:21 +02:00
parent 06d33361c2
commit 4bac61e505
19 changed files with 1282 additions and 290 deletions

View File

@@ -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

View 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"
)
]
)

View File

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

View 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)))
}
}

View File

@@ -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
}
}

View 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.50.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)
}
}

View 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, topbottom / startend
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
)
}

View 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))
}
}

View 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) }

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

View File

@@ -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)')."

View File

@@ -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."

View File

@@ -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"

View 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 "$@"

View File

@@ -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))
]

View File

@@ -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": "ЗАЩИТА"
}
}
}
}
}

View File

@@ -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"