diff --git a/packaging/apple/studio/.gitignore b/packaging/apple/studio/.gitignore index fecdbda..49c3f2e 100644 --- a/packaging/apple/studio/.gitignore +++ b/packaging/apple/studio/.gitignore @@ -2,7 +2,3 @@ generated/ out-*.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 diff --git a/packaging/apple/studio/README.md b/packaging/apple/studio/README.md index 682fbb9..c1736ce 100644 --- a/packaging/apple/studio/README.md +++ b/packaging/apple/studio/README.md @@ -1,83 +1,62 @@ # App Store screenshot studio 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. +`ImageRenderer`** and **SceneKit** (no Typst, no ImageMagick). Each marketing screen is a +SwiftUI view (gradient + 3D device + captions + generated artwork) rendered off-screen to +an exact-size PNG. Captions come from `strings.json` (9 locales); the app screenshots on +the device screens are captured from the real app. ## Requirements -- Xcode / Swift toolchain (macOS 14+). That's it. +- Xcode / Swift toolchain (macOS 26+). That's it. -## Render +## Run ```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 +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 +PLATFORM=ipad swift run studio # iPad set -> generated//iPad/ ``` -Default writes to `generated/` (safe). `--publish` overwrites the real language folders. Run from this directory (it reads `strings.json` and `assets/` relative to cwd). ## Full pipeline ```sh -./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 # real localized app screens -> generated/shots/// +swift run studio # composite everything -> generated//[iPad/] +./generate.sh # capture + composite in one shot (forwards PLATFORM / --publish) ``` `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. +`-AppleLanguages`, in dark mode with a 9:41 status bar. Screenshots are transient +(git-ignored, regenerated per run) — during layout iteration, capture once then re-run +`swift run studio` on its own. -## Device rendering (3D by default) -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. +## Platforms +`PLATFORM=iphone` (default) or `ipad`. Each `Platform` (`Sources/studio/Platform.swift`) +carries its canvas size, capture simulator, output subfolder, and a `DeviceModel` — the +`.usdz`, its screen material, the orientation fix (yaw so the screen faces the camera), +optional front-glass mesh to hide, and whether to tint the body graphite. Adding a device +(e.g. Mac) is a new `DeviceModel` + `Platform` case + a layout set. -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). +## Device rendering +`SceneKitDeviceRenderer` textures the captured screenshot onto the model's screen mesh and +renders it off-screen. Curvature, bezel and body are the model's real geometry; poses +(pitch/yaw/roll) and a studio environment (IBL + bloom) are applied in the scene. It +auto-generates planar UVs for screen meshes that ship without them. -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. +## Assets (`assets/`) +- `iphone-17-pro-max.usdz`, `ipad-pro.usdz` — the 3D devices (committed; CC BY 4.0, see + `ATTRIBUTION.md`). +- `globe.png` — for the send-anywhere hero (committed). +- `generated/shots///*.png` — captured app screens (transient). ## 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. +- `Sources/studio/ScreenSpec.swift` — per-platform layouts (`iphone` / `ipad`): gradient, + device pose/position/size, globe + route (send-anywhere), the encryption flow + (stay-private: `beams` → `lock` → `stream` + `banners`), caption placement. +- `Sources/studio/ScreenFrame.swift` — the composition (layer order, caption fitting, + generated artwork). -## Status -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. +## Screens +`share-securely`, `choose-receivers`, `send-anywhere` (globe + Paris→LA route arc, +reuses the share screenshot), `stay-private` (two stacked phones + encryption beams → +padlock → binary protection stream, with localized CHIFFREMENT/PROTECTION banners). diff --git a/packaging/apple/studio/Sources/studio/DeviceView.swift b/packaging/apple/studio/Sources/studio/DeviceView.swift index 7e991e1..bee6005 100644 --- a/packaging/apple/studio/Sources/studio/DeviceView.swift +++ b/packaging/apple/studio/Sources/studio/DeviceView.swift @@ -1,55 +1,10 @@ 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. - +// The device is a swappable layer behind this protocol; `SceneKitDeviceRenderer` is the +// current implementation (textures the app screenshot onto a real .usdz model). 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`. + // Produces the device as a SwiftUI view sized to `spec.height` (width follows the + // device aspect), already framing `shot` and posed 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/Platform.swift b/packaging/apple/studio/Sources/studio/Platform.swift new file mode 100644 index 0000000..bdf185a --- /dev/null +++ b/packaging/apple/studio/Sources/studio/Platform.swift @@ -0,0 +1,60 @@ +import Foundation + +// A 3D device model + the per-model quirks the renderer needs (screen material name, +// orientation fix so the screen faces the camera, optional front-glass mesh to hide, +// whether to tint the body graphite). +struct DeviceModel { + var url: URL + var screenMaterial: String + var glassMaterial: String? // nil = no separate glass mesh + var bodyYaw: Double = 0 // deg about Y to face the screen toward the camera (-Z) + var recolorBody: Bool = false // graphite tint + + static func iphone(_ assets: URL) -> DeviceModel { + DeviceModel(url: assets.appendingPathComponent("iphone-17-pro-max.usdz"), + screenMaterial: "_7ProMax_Screen", glassMaterial: "glass", + bodyYaw: 0, recolorBody: true) + } + static func ipad(_ assets: URL) -> DeviceModel { + // Screen mesh "Material_002" (display only; the bezel is real geometry). Front + // faces +Z, so yaw 180° to face -Z. Standard UVs — no remap or bezel padding. + DeviceModel(url: assets.appendingPathComponent("ipad-pro.usdz"), + screenMaterial: "Material_002", glassMaterial: nil, + bodyYaw: 180, recolorBody: true) + } +} + +// An App Store target: canvas size, device model, capture simulator, output subfolder. +enum Platform: String { + case iphone, ipad + + var canvas: CGSize { + switch self { + case .iphone: CGSize(width: 1284, height: 2778) // 6.5" iPhone + case .ipad: CGSize(width: 2064, height: 2752) // 13" iPad Pro (matches the M5 sim) + } + } + + func deviceModel(assets: URL) -> DeviceModel { + switch self { + case .iphone: DeviceModel.iphone(assets) + case .ipad: DeviceModel.ipad(assets) + } + } + + // Simulator used by capture.sh (informational here; capture reads its own env). + var simulator: String { + switch self { + case .iphone: "iPhone 17 Pro Max" + case .ipad: "iPad Pro 13-inch (M5)" + } + } + + // Output goes under / so iPhone/iPad sets stay separate. + var outputSubfolder: String { + switch self { + case .iphone: "" + case .ipad: "iPad" + } + } +} diff --git a/packaging/apple/studio/Sources/studio/SceneKitDeviceRenderer.swift b/packaging/apple/studio/Sources/studio/SceneKitDeviceRenderer.swift index 6c223df..e6c0bcd 100644 --- a/packaging/apple/studio/Sources/studio/SceneKitDeviceRenderer.swift +++ b/packaging/apple/studio/Sources/studio/SceneKitDeviceRenderer.swift @@ -8,10 +8,8 @@ import AppKit // 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 + let model: DeviceModel + var heightFraction: CGFloat = 0.66 // upright device height as fraction of the square render var supersample: CGFloat = 2 // render big, SwiftUI downscales for clean edges @MainActor @@ -28,31 +26,41 @@ struct SceneKitDeviceRenderer: DeviceRenderer { @MainActor private func render(shot: NSImage?, spec: DeviceSpec, side: CGFloat) -> NSImage? { - guard let scene = try? SCNScene(url: modelURL), + guard let scene = try? SCNScene(url: model.url), let device = MTLCreateSystemDefaultDevice() else { return nil } let root = scene.rootNode + let d2r = Double.pi / 180 - // 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 + // Orient the model so its screen faces the camera (-Z). Some models (iPad) have + // the screen on ±X, so a per-model yaw fix is applied first. + let oriented = SCNNode() + for child in root.childNodes { oriented.addChildNode(child) } + oriented.eulerAngles = SCNVector3(0, model.bodyYaw * d2r, 0) + + // Re-parent under a pivot so the pose rotates it about its own centre. Compute the + // (oriented) bounding box before applying the pose. rootNode.boundingBox aggregates + // the big ancestor unit-scale correctly (flattenedClone collapses these models). + let pivot = SCNNode() + pivot.addChildNode(oriented) + root.addChildNode(pivot) + let (bmin, bmax) = pivot.boundingBox let center = SCNVector3((bmin.x + bmax.x) / 2, (bmin.y + bmax.y) / 2, (bmin.z + bmax.z) / 2) let 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 { + // Some screen meshes ship with no UV coordinates (an image can't map onto them — + // it renders as a flat colour). Generate planar UVs from the vertex positions. + addPlanarUVsToScreen(in: pivot, material: model.screenMaterial) + + // Texture EVERY material with the screen name (some models split the display into + // several meshes that share the material name); setting only the first leaves the + // rest white. + for mat in materials(named: model.screenMaterial, in: pivot) where spec.blackScreen || shot != nil { mat.diffuse.contents = spec.blackScreen ? NSColor.black : shot mat.lightingModel = .constant mat.normal.contents = nil @@ -64,14 +72,12 @@ struct SceneKitDeviceRenderer: DeviceRenderer { 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) + // Hide the front glass mesh (if any) — it has its own normal-mapped waviness that + // reflects as swirls over the screen. We keep the crisp display instead. + if let glass = model.glassMaterial { hideMeshes(withMaterial: glass, 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) + // Recolor a silver body to graphite (iPhone); models already dark (iPad) skip it. + if model.recolorBody { 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 @@ -81,7 +87,9 @@ struct SceneKitDeviceRenderer: DeviceRenderer { cam.usesOrthographicProjection = false cam.fieldOfView = fovV cam.projectionDirection = .vertical - cam.zNear = 0.001; cam.zFar = 1000 + // zFar must comfortably exceed the camera distance; models differ hugely in unit + // scale (iPhone ~16 units tall, iPad ~300), so keep this large. + cam.zNear = 0.01; cam.zFar = 100_000 // HDR + subtle bloom so bright specular highlights on the metal/glass glow like a // real product shot instead of clipping flat. cam.wantsHDR = true @@ -163,6 +171,9 @@ struct SceneKitDeviceRenderer: DeviceRenderer { func walk(_ n: SCNNode) { for m in n.geometry?.materials ?? [] { let name = (m.name ?? "").lowercased() + // Never tint the screen — its material name may not contain "screen" + // (the iPad's is just "Material"). + if m.name == model.screenMaterial { continue } if skip.contains(where: { name.contains($0) }) { continue } m.multiply.contents = graphite // Sharper, brighter specular so the frame highlights "pop" like polished @@ -182,11 +193,53 @@ struct SceneKitDeviceRenderer: DeviceRenderer { for c in node.childNodes { hideMeshes(withMaterial: substring, in: c) } } - private func material(named name: String, in node: SCNNode) -> SCNMaterial? { - if let m = node.geometry?.materials.first(where: { + // Give the screen mesh planar UVs (mapped over its two largest-extent axes) when it + // has none, so a screenshot texture maps across the display. + private func addPlanarUVsToScreen(in node: SCNNode, material name: String) { + guard let g = node.geometry, + g.materials.contains(where: { ($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame }), + g.sources(for: .texcoord).isEmpty, + let vsrc = g.sources(for: .vertex).first else { + node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) } + return + } + // Read vertex positions. + var pos: [(Float, Float, Float)] = [] + let stride = vsrc.dataStride, off = vsrc.dataOffset + vsrc.data.withUnsafeBytes { (p: UnsafeRawBufferPointer) in + for i in 0.. Float { a == 0 ? p.0 : (a == 1 ? p.1 : p.2) } + let ua = planeAxes[0], va = planeAxes[1] + let umin = [xs, ys, zs][ua].min()!, urange = max(1e-6, ext[ua]) + let vmin = [xs, ys, zs][va].min()!, vrange = max(1e-6, ext[va]) + let uvs: [CGPoint] = pos.map { + CGPoint(x: CGFloat((comp($0, ua) - umin) / urange), + y: CGFloat(1 - (comp($0, va) - vmin) / vrange)) // flip V for top-left origin + } + let uvSource = SCNGeometrySource(textureCoordinates: uvs) + let newGeo = SCNGeometry(sources: g.sources(for: .vertex) + g.sources(for: .normal) + [uvSource], + elements: g.elements) + newGeo.materials = g.materials + node.geometry = newGeo + node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) } + } + + private func materials(named name: String, in node: SCNNode) -> [SCNMaterial] { + var out = (node.geometry?.materials ?? []).filter { ($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame - }) { return m } - for c in node.childNodes { if let m = material(named: name, in: c) { return m } } - return nil + } + for c in node.childNodes { out += materials(named: name, in: c) } + return out } } diff --git a/packaging/apple/studio/Sources/studio/ScreenFrame.swift b/packaging/apple/studio/Sources/studio/ScreenFrame.swift index 7f7d1f2..c215a8a 100644 --- a/packaging/apple/studio/Sources/studio/ScreenFrame.swift +++ b/packaging/apple/studio/Sources/studio/ScreenFrame.swift @@ -5,13 +5,12 @@ import SwiftUI // (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? + var canvas: CGSize = CGSize(width: 1284, height: 2778) private var allDevices: [DeviceSpec] { spec.device.map { [$0] } ?? spec.devices } @@ -19,15 +18,11 @@ struct ScreenFrame: View { private var subColor: Color { spec.captionTheme == .light ? Color(hex: "#f3ecfb") : Color(hex: "#2c2138") } var body: some View { - let cw = Self.canvas.width, ch = Self.canvas.height + let cw = canvas.width, ch = 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) @@ -69,133 +64,6 @@ struct ScreenFrame: View { .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 @@ -229,7 +97,7 @@ struct ScreenFrame: View { marker(at: r.from, color) marker(at: r.to, color) } - .frame(width: Self.canvas.width, height: Self.canvas.height) + .frame(width: canvas.width, height: canvas.height) } private func marker(at pt: CGPoint, _ color: Color) -> some View { @@ -257,7 +125,7 @@ struct ScreenFrame: View { style: StrokeStyle(lineWidth: 2.2, lineCap: .round)) } } - .frame(width: Self.canvas.width, height: Self.canvas.height) + .frame(width: canvas.width, height: canvas.height) .blur(radius: 0.6) .shadow(color: color.opacity(0.7), radius: 10) } @@ -280,7 +148,7 @@ struct ScreenFrame: View { ctx.draw(res, at: CGPoint(x: s.cx, y: y)) } } - .frame(width: Self.canvas.width, height: Self.canvas.height) + .frame(width: canvas.width, height: canvas.height) .shadow(color: color.opacity(0.6), radius: 8) } @@ -317,7 +185,7 @@ struct ScreenFrame: View { .foregroundStyle(subColor) } .multilineTextAlignment(.center) - .frame(width: Self.canvas.width - (spec.headerBackdrop ? 300 : 160)) + .frame(width: canvas.width - (spec.headerBackdrop ? 300 : 160)) if spec.headerBackdrop { // The panel is the caption's background, so it grows with the content — long @@ -348,11 +216,11 @@ struct ScreenFrame: View { captionBlock(82, 48) captionBlock(72, 44) } - .frame(width: Self.canvas.width, height: region, alignment: top ? .top : .bottom) + .frame(width: canvas.width, height: region, alignment: top ? .top : .bottom) return fitted .padding(top ? .top : .bottom, top ? 96 : 110) - .frame(width: Self.canvas.width, height: Self.canvas.height, + .frame(width: canvas.width, height: canvas.height, alignment: top ? .top : .bottom) } } diff --git a/packaging/apple/studio/Sources/studio/ScreenSpec.swift b/packaging/apple/studio/Sources/studio/ScreenSpec.swift index cad6ee8..dd286c9 100644 --- a/packaging/apple/studio/Sources/studio/ScreenSpec.swift +++ b/packaging/apple/studio/Sources/studio/ScreenSpec.swift @@ -55,16 +55,6 @@ struct GlobeSpec { 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. @@ -80,10 +70,10 @@ struct RouteSpec { // 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 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" } @@ -96,25 +86,18 @@ struct LockSpec { } struct StreamSpec { - var cx: CGFloat // vertical binary stream centre - var y0: CGFloat // from (below the lock) - var y1: CGFloat // to (the bottom phone) + 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 { +struct Banner { + var kind: BannerKind 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 + var cy: CGFloat } struct ScreenSpec { @@ -123,21 +106,26 @@ struct ScreenSpec { 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 + 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] = [ + static func all(for platform: Platform) -> [String: ScreenSpec] { + switch platform { + case .iphone: iphone + case .ipad: ipad + } + } + + static let iphone: [String: ScreenSpec] = [ // Straight-on hero, large and centered, showing the Share/QR sheet. "share-securely": ScreenSpec( id: "share-securely", @@ -184,10 +172,10 @@ struct ScreenSpec { captionPlace: .top, captionTheme: .light, devices: [ DeviceSpec( - height: 1500, cx: 642, cy: -20, // top phone, only its lower part shows + height: 1500, cx: 642, cy: -20, // top phone, only its lower part shows pose: Pose(roll: 180), shadow: false, blackScreen: true), DeviceSpec( - height: 1500, cx: 642, cy: 2820, // bottom phone, only its upper part shows + height: 1500, cx: 642, cy: 2820, // bottom phone, only its upper part shows pose: Pose(), shadow: false, blackScreen: true), ], beams: BeamsSpec(cx: 642, y0: 700, spread: 150, cy: 1120, count: 22), @@ -200,22 +188,56 @@ struct ScreenSpec { 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 - ) + // iPad Pro layouts (canvas 2048x2732, centre x = 1024). First pass — same + // compositions as iPhone, retuned for the squarer, larger canvas. + static let ipad: [String: ScreenSpec] = [ + "share-securely": ScreenSpec( + id: "share-securely", + bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom), + captionPlace: .top, captionTheme: .dark, + device: DeviceSpec(height: 1950, cx: 1024, cy: 1520, pose: Pose()) + ), + "choose-receivers": ScreenSpec( + id: "choose-receivers", + bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom), + captionPlace: .top, captionTheme: .dark, + device: DeviceSpec( + height: 1950, cx: 1000, cy: 1460, pose: Pose(pitch: -9, yaw: -14, roll: 7)) + ), + "send-anywhere": ScreenSpec( + id: "send-anywhere", + bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom), + captionPlace: .bottom, captionTheme: .dark, + globe: GlobeSpec(width: 2000, dx: 24, dy: -240), + route: RouteSpec( + from: CGPoint(x: 1342, y: 323), to: CGPoint(x: 315, y: 423), + c1: CGPoint(x: 2100, y: 2000), c2: CGPoint(x: -100, y: 2000), lineWidth: 14), + device: DeviceSpec( + height: 1180, cx: 1000, cy: 1720, pose: Pose(pitch: 12, yaw: 18, roll: -11)), + shotId: "share-securely" + ), + "stay-private": ScreenSpec( + id: "stay-private", + bg: GradientSpec( + stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"], start: .top, end: .bottom), + captionPlace: .top, captionTheme: .light, + devices: [ + DeviceSpec( + height: 1300, cx: 1024, cy: 120, pose: Pose(roll: 180), shadow: false, + blackScreen: true), + DeviceSpec( + height: 1300, cx: 1024, cy: 2760, pose: Pose(), shadow: false, blackScreen: true + ), + ], + beams: BeamsSpec(cx: 1024, y0: 820, spread: 200, cy: 1220, count: 24), + lock: LockSpec(cx: 1024, cy: 1400, size: 320), + stream: StreamSpec(cx: 1024, y0: 1600, y1: 2360), + banners: [ + Banner(kind: .encryption, cx: 1024, cy: 1200), + Banner(kind: .protection, cx: 1024, cy: 1680), + ], + headerBackdrop: true + ), + ] } diff --git a/packaging/apple/studio/Sources/studio/main.swift b/packaging/apple/studio/Sources/studio/main.swift index 3a3ab85..345ee85 100644 --- a/packaging/apple/studio/Sources/studio/main.swift +++ b/packaging/apple/studio/Sources/studio/main.swift @@ -7,9 +7,9 @@ import SwiftUI // 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. +// Screenshots come from generated/shots///.png (from +// ./capture.sh) — transient build output, regenerated per run, never committed. The 3D +// device models + globe come from assets/. Run from the studio directory. // Screen id -> output file basename (matches the existing App Store filenames). let nameFor: [String: String] = [ @@ -60,43 +60,41 @@ func run() throws { let globe = loadNSImage(base.appendingPathComponent("assets/globe.png").path).map { Image(nsImage: $0.0) } - // 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)) + // Target platform: canvas size + 3D device model. PLATFORM=iphone|ipad (default iphone). + let platform = Platform(rawValue: env("PLATFORM", "iphone")) ?? .iphone + let canvas = platform.canvas + let model = platform.deviceModel(assets: base.appendingPathComponent("assets")) + let device: DeviceRenderer? = FileManager.default.fileExists(atPath: model.url.path) + ? SceneKitDeviceRenderer(model: model) : nil + if device == nil { + FileHandle.standardError.write(Data("warning: missing model \(model.url.lastPathComponent) — devices will be blank\n".utf8)) } + let specs = ScreenSpec.all(for: platform) 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 + // iPhone -> /, iPad -> /iPad/ so the sets stay separate. + var outDir = publish ? base.appendingPathComponent("../\(ls.folder)").standardized : base.appendingPathComponent("generated/\(ls.folder)") + if !platform.outputSubfolder.isEmpty { + outDir = outDir.appendingPathComponent(platform.outputSubfolder) + } for scr in screens { - guard let spec = ScreenSpec.all[scr], let caption = ls[scr] else { + guard let spec = specs[scr], let caption = ls[scr] else { FileHandle.standardError.write(Data("warning: skipping \(loc)/\(scr)\n".utf8)); continue } let shotId = spec.shotId ?? scr - let shotsDir = env("SHOTS_DIR", "generated/shots") + let shotsDir = env("SHOTS_DIR", "generated/shots/\(platform.rawValue)") let shot = loadNSImage(base.appendingPathComponent("\(shotsDir)/\(loc)/\(shotId).png").path)?.0 let hasDevice = spec.device != nil || !spec.devices.isEmpty let frame = ScreenFrame(spec: spec, caption: caption, globe: globe, shot: shot, - device: hasDevice ? device : nil) + device: hasDevice ? device : nil, canvas: canvas) let out = outDir.appendingPathComponent("\(nameFor[scr] ?? scr).png") try writePNG(frame, to: out) print(" ✅ \(out.path)") diff --git a/packaging/apple/studio/assets/ATTRIBUTION.md b/packaging/apple/studio/assets/ATTRIBUTION.md index f62f825..d5b6893 100644 --- a/packaging/apple/studio/assets/ATTRIBUTION.md +++ b/packaging/apple/studio/assets/ATTRIBUTION.md @@ -6,7 +6,12 @@ - **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 +## ipad-pro.usdz +- **Title:** 2018 Apple iPad Pro +- **Author:** lazercar (Sketchfab) +- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/ + +CC BY 4.0 requires visible credit wherever these models (or renders derived from them) +are published. The App Store screenshots produced by this studio are derivatives, so keep this attribution with the project. If a suitable place exists (e.g. the app's acknowledgements / licenses screen), surface the credit there as well. diff --git a/packaging/apple/studio/assets/ipad-pro.usdz b/packaging/apple/studio/assets/ipad-pro.usdz new file mode 100644 index 0000000..9941dd1 Binary files /dev/null and b/packaging/apple/studio/assets/ipad-pro.usdz differ diff --git a/packaging/apple/studio/capture.sh b/packaging/apple/studio/capture.sh index fa69554..6c344c8 100755 --- a/packaging/apple/studio/capture.sh +++ b/packaging/apple/studio/capture.sh @@ -15,10 +15,16 @@ trap 'echo "capture.sh: failed (rc=$?) at line $LINENO" >&2' ERR cd "$(dirname "$0")" APPLE_DIR="$(cd ../../../apple && pwd)" -DEVICE="${SCREENSHOT_DEVICE:-iPhone 17 Pro Max}" BUNDLE_ID="com.vnidrop.app" LOCALES=${LOCALES:-"en fr de es it nl pl pt ru"} +# PLATFORM=iphone|ipad selects the simulator and the shots subdir (must match main.swift). +PLATFORM="${PLATFORM:-iphone}" +case "$PLATFORM" in + ipad) DEVICE="${SCREENSHOT_DEVICE:-iPad Pro 13-inch (M5)}";; + *) DEVICE="${SCREENSHOT_DEVICE:-iPhone 17 Pro Max}";; +esac + # scenario (launch arg value) -> studio screen id (output filename stem). # send-anywhere reuses the share screenshot (see ScreenSpec shotId), so it isn't # captured separately; stay-private uses black screens (no capture). @@ -26,7 +32,7 @@ SCENARIOS="share:share-securely approval:choose-receivers" # Screenshots are transient build output, regenerated per run — never committed. They # live under generated/ (git-ignored), not in assets/. -SHOTS_DIR="${SHOTS_DIR:-generated/shots}" +SHOTS_DIR="${SHOTS_DIR:-generated/shots/$PLATFORM}" echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)