diff --git a/packaging/apple/studio/.gitignore b/packaging/apple/studio/.gitignore index 313c8b4..fecdbda 100644 --- a/packaging/apple/studio/.gitignore +++ b/packaging/apple/studio/.gitignore @@ -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 diff --git a/packaging/apple/studio/Package.swift b/packaging/apple/studio/Package.swift new file mode 100644 index 0000000..feb73d9 --- /dev/null +++ b/packaging/apple/studio/Package.swift @@ -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" + ) + ] +) diff --git a/packaging/apple/studio/README.md b/packaging/apple/studio/README.md index 217763a..682fbb9 100644 --- a/packaging/apple/studio/README.md +++ b/packaging/apple/studio/README.md @@ -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// -./build.sh --publish # -> ..// (ships to App Store) -LOCALES="fr de" SCREENS="share-securely" ./build.sh +swift run studio # all locales × screens -> generated// +swift run studio --publish # -> ..// (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//.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//.png -./build.sh # composite everything -> generated// -./build.sh --publish # when happy -> ships to ..// +./capture.sh # real localized app screens -> assets/shots//.png +swift run studio # composite everything -> generated// +swift run studio --publish # when happy -> ships to ..// ``` `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//.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. diff --git a/packaging/apple/studio/Sources/studio/DeviceView.swift b/packaging/apple/studio/Sources/studio/DeviceView.swift new file mode 100644 index 0000000..7e991e1 --- /dev/null +++ b/packaging/apple/studio/Sources/studio/DeviceView.swift @@ -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))) + } +} diff --git a/packaging/apple/studio/Sources/studio/SceneKitDeviceRenderer.swift b/packaging/apple/studio/Sources/studio/SceneKitDeviceRenderer.swift new file mode 100644 index 0000000..6c223df --- /dev/null +++ b/packaging/apple/studio/Sources/studio/SceneKitDeviceRenderer.swift @@ -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 + } +} diff --git a/packaging/apple/studio/Sources/studio/ScreenFrame.swift b/packaging/apple/studio/Sources/studio/ScreenFrame.swift new file mode 100644 index 0000000..7f7d1f2 --- /dev/null +++ b/packaging/apple/studio/Sources/studio/ScreenFrame.swift @@ -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.. [(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.. 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.. 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) + } +} diff --git a/packaging/apple/studio/Sources/studio/ScreenSpec.swift b/packaging/apple/studio/Sources/studio/ScreenSpec.swift new file mode 100644 index 0000000..cad6ee8 --- /dev/null +++ b/packaging/apple/studio/Sources/studio/ScreenSpec.swift @@ -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 + ) +} diff --git a/packaging/apple/studio/Sources/studio/Strings.swift b/packaging/apple/studio/Sources/studio/Strings.swift new file mode 100644 index 0000000..3fd142a --- /dev/null +++ b/packaging/apple/studio/Sources/studio/Strings.swift @@ -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)) + } +} diff --git a/packaging/apple/studio/Sources/studio/main.swift b/packaging/apple/studio/Sources/studio/main.swift new file mode 100644 index 0000000..3a3ab85 --- /dev/null +++ b/packaging/apple/studio/Sources/studio/main.swift @@ -0,0 +1,113 @@ +import AppKit +import SwiftUI + +// Code-driven App Store screenshots, rendered natively with SwiftUI + ImageRenderer. +// +// swift run studio # -> generated//.png +// swift run studio --publish # -> ..//.png (ships) +// LOCALES="fr de" SCREENS="share-securely" swift run studio +// +// Screenshots come from generated/shots//.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) } diff --git a/packaging/apple/studio/assets/ATTRIBUTION.md b/packaging/apple/studio/assets/ATTRIBUTION.md new file mode 100644 index 0000000..f62f825 --- /dev/null +++ b/packaging/apple/studio/assets/ATTRIBUTION.md @@ -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. diff --git a/packaging/apple/studio/assets/globe.png b/packaging/apple/studio/assets/globe.png new file mode 100644 index 0000000..76535c8 Binary files /dev/null and b/packaging/apple/studio/assets/globe.png differ diff --git a/packaging/apple/studio/assets/iphone-17-pro-max.usdz b/packaging/apple/studio/assets/iphone-17-pro-max.usdz new file mode 100644 index 0000000..debe33d Binary files /dev/null and b/packaging/apple/studio/assets/iphone-17-pro-max.usdz differ diff --git a/packaging/apple/studio/build.sh b/packaging/apple/studio/build.sh deleted file mode 100755 index 6881af3..0000000 --- a/packaging/apple/studio/build.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -# Render all (locale x screen) App Store screenshots with Typst. -# -# ./build.sh # -> generated//.png (safe default) -# ./build.sh --publish # -> ..//.png (ships to App Store) -# LOCALES="fr de" SCREENS="share-securely" ./build.sh # subset -# -# Screenshots are read from assets/shots//.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//) -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)')." diff --git a/packaging/apple/studio/capture.sh b/packaging/apple/studio/capture.sh index cb1732a..fa69554 100755 --- a/packaging/apple/studio/capture.sh +++ b/packaging/apple/studio/capture.sh @@ -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." diff --git a/packaging/apple/studio/frame.sh b/packaging/apple/studio/frame.sh deleted file mode 100755 index c44bd07..0000000 --- a/packaging/apple/studio/frame.sh +++ /dev/null @@ -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" diff --git a/packaging/apple/studio/generate.sh b/packaging/apple/studio/generate.sh new file mode 100755 index 0000000..6cf5583 --- /dev/null +++ b/packaging/apple/studio/generate.sh @@ -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// +# ./generate.sh --publish # capture + composite -> ..// (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 "$@" diff --git a/packaging/apple/studio/screens.typ b/packaging/apple/studio/screens.typ deleted file mode 100644 index 9bcc4c5..0000000 --- a/packaging/apple/studio/screens.typ +++ /dev/null @@ -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)) -] diff --git a/packaging/apple/studio/strings.json b/packaging/apple/studio/strings.json index 97de87f..f6978e2 100644 --- a/packaging/apple/studio/strings.json +++ b/packaging/apple/studio/strings.json @@ -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": "ЗАЩИТА" + } } } -} +} \ No newline at end of file diff --git a/packaging/apple/studio/warp.sh b/packaging/apple/studio/warp.sh deleted file mode 100755 index 0dd9dee..0000000 --- a/packaging/apple/studio/warp.sh +++ /dev/null @@ -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"