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:
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) }
|
||||
Reference in New Issue
Block a user