feat(apple): add MacBook screenshot studio target

Add a Mac (16:10, 2880x1800) platform to the screenshot studio: a
MacBook .usdz model, native-app capture path in capture.sh (no simulator),
and all four marketing layouts. Fix the screen texturing so the capture
fills the display exactly — plain [0,1] planar UVs with a thin black matte
baked into the shot for the rounded-corner margin (replacing the UV
overscan that smeared the edge). Add a horizontal encryption-flow mode
(beams/stream) for the side-by-side laptops in stay-private.
This commit is contained in:
2026-08-09 17:36:56 +02:00
parent 98661cef15
commit 7aabfdf065
7 changed files with 169 additions and 19 deletions

View File

@@ -9,6 +9,12 @@ struct DeviceModel {
var glassMaterial: String? // nil = no separate glass mesh var glassMaterial: String? // nil = no separate glass mesh
var bodyYaw: Double = 0 // deg about Y to face the screen toward the camera (-Z) var bodyYaw: Double = 0 // deg about Y to face the screen toward the camera (-Z)
var recolorBody: Bool = false // graphite tint var recolorBody: Bool = false // graphite tint
// Fraction of the square render the device's upright height fills. Lower = more margin
// for wide 3/4 poses (a laptop's fanned base projects past a tight frame and clips).
var fillFraction: CGFloat = 0.66
// Overscan for auto-generated screen UVs: >0 shrinks the screenshot slightly so its
// edges aren't hidden when the screen mesh is a touch larger than the visible display.
var screenPad: CGFloat = 0
static func iphone(_ assets: URL) -> DeviceModel { static func iphone(_ assets: URL) -> DeviceModel {
DeviceModel(url: assets.appendingPathComponent("iphone-17-pro-max.usdz"), DeviceModel(url: assets.appendingPathComponent("iphone-17-pro-max.usdz"),
@@ -22,16 +28,24 @@ struct DeviceModel {
screenMaterial: "Material_002", glassMaterial: nil, screenMaterial: "Material_002", glassMaterial: nil,
bodyYaw: 180, recolorBody: true) bodyYaw: 180, recolorBody: true)
} }
static func macbook(_ assets: URL) -> DeviceModel {
// Open-laptop model; display mesh "screen_black" (no UVs auto-generated). Front
// faces +Z, so yaw 180° to face -Z.
DeviceModel(url: assets.appendingPathComponent("macbook-air.usdz"),
screenMaterial: "screen_black", glassMaterial: nil,
bodyYaw: 180, recolorBody: false, fillFraction: 0.46, screenPad: 0.008)
}
} }
// An App Store target: canvas size, device model, capture simulator, output subfolder. // An App Store target: canvas size, device model, capture simulator, output subfolder.
enum Platform: String { enum Platform: String {
case iphone, ipad case iphone, ipad, mac
var canvas: CGSize { var canvas: CGSize {
switch self { switch self {
case .iphone: CGSize(width: 1284, height: 2778) // 6.5" iPhone case .iphone: CGSize(width: 1284, height: 2778) // 6.5" iPhone
case .ipad: CGSize(width: 2064, height: 2752) // 13" iPad Pro (matches the M5 sim) case .ipad: CGSize(width: 2064, height: 2752) // 13" iPad Pro (matches the M5 sim)
case .mac: CGSize(width: 2880, height: 1800) // Mac App Store (16:10 landscape)
} }
} }
@@ -39,6 +53,7 @@ enum Platform: String {
switch self { switch self {
case .iphone: DeviceModel.iphone(assets) case .iphone: DeviceModel.iphone(assets)
case .ipad: DeviceModel.ipad(assets) case .ipad: DeviceModel.ipad(assets)
case .mac: DeviceModel.macbook(assets)
} }
} }
@@ -47,14 +62,16 @@ enum Platform: String {
switch self { switch self {
case .iphone: "iPhone 17 Pro Max" case .iphone: "iPhone 17 Pro Max"
case .ipad: "iPad Pro 13-inch (M5)" case .ipad: "iPad Pro 13-inch (M5)"
case .mac: "My Mac"
} }
} }
// Output goes under <Language>/<subfolder> so iPhone/iPad sets stay separate. // Output goes under <Language>/<subfolder> so the sets stay separate.
var outputSubfolder: String { var outputSubfolder: String {
switch self { switch self {
case .iphone: "" case .iphone: "iPhone"
case .ipad: "iPad" case .ipad: "iPad"
case .mac: "Mac"
} }
} }
} }

View File

@@ -9,14 +9,14 @@ import AppKit
struct SceneKitDeviceRenderer: DeviceRenderer { struct SceneKitDeviceRenderer: DeviceRenderer {
let model: DeviceModel 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 var supersample: CGFloat = 2 // render big, SwiftUI downscales for clean edges
@MainActor @MainActor
func view(shot: NSImage?, spec: DeviceSpec) -> AnyView { func view(shot: NSImage?, spec: DeviceSpec) -> AnyView {
// Render into a square (so any pose fits without clipping); the upright phone // Render into a square (so any pose fits without clipping); the upright phone
// occupies `heightFraction` of it, so displaySide maps that to spec.height. // occupies `heightFraction` of it, so displaySide maps that to spec.height.
let displaySide = spec.height / heightFraction let displaySide = spec.height / model.fillFraction
let renderSide = min(displaySide * supersample, 3200) let renderSide = min(displaySide * supersample, 3200)
guard let img = render(shot: shot, spec: spec, side: renderSide) else { guard let img = render(shot: shot, spec: spec, side: renderSide) else {
return AnyView(Color.clear.frame(width: displaySide, height: displaySide)) return AnyView(Color.clear.frame(width: displaySide, height: displaySide))
@@ -60,8 +60,12 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
// Texture EVERY material with the screen name (some models split the display into // 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 // several meshes that share the material name); setting only the first leaves the
// rest white. // rest white.
// A thin black margin baked around the shot so the window's own corners clear the
// display's rounded edge done in the image (not via UV overscan) so the clamped
// border samples black instead of smearing the capture's rounded-corner pixels.
let screenShot = (shot != nil && model.screenPad > 0) ? matted(shot!, pad: model.screenPad) : shot
for mat in materials(named: model.screenMaterial, in: pivot) where spec.blackScreen || shot != nil { for mat in materials(named: model.screenMaterial, in: pivot) where spec.blackScreen || shot != nil {
mat.diffuse.contents = spec.blackScreen ? NSColor.black : shot mat.diffuse.contents = spec.blackScreen ? NSColor.black : screenShot
mat.lightingModel = .constant mat.lightingModel = .constant
mat.normal.contents = nil mat.normal.contents = nil
mat.emission.contents = nil mat.emission.contents = nil
@@ -98,7 +102,7 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
cam.bloomIntensity = 0.25 cam.bloomIntensity = 0.25
cam.bloomBlurRadius = 6 cam.bloomBlurRadius = 6
let camNode = SCNNode(); camNode.camera = cam let camNode = SCNNode(); camNode.camera = cam
let dist = Double(height) / (2 * Double(heightFraction) * tan(fovV / 2 * .pi / 180)) let dist = Double(height) / (2 * Double(model.fillFraction) * tan(fovV / 2 * .pi / 180))
camNode.position = SCNVector3(center.x, center.y, center.z - SCNFloat(dist)) camNode.position = SCNVector3(center.x, center.y, center.z - SCNFloat(dist))
camNode.eulerAngles = SCNVector3(0, Double.pi, 0) camNode.eulerAngles = SCNVector3(0, Double.pi, 0)
root.addChildNode(camNode) root.addChildNode(camNode)
@@ -223,9 +227,12 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
let ua = planeAxes[0], va = planeAxes[1] let ua = planeAxes[0], va = planeAxes[1]
let umin = [xs, ys, zs][ua].min()!, urange = max(1e-6, ext[ua]) 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 vmin = [xs, ys, zs][va].min()!, vrange = max(1e-6, ext[va])
// Plain [0,1] planar mapping the screenshot fills the mesh exactly. Any margin the
// display needs is baked into the image (see `matted`), not added here, so the clamped
// edge stays black instead of smearing the capture's corner pixels.
let uvs: [CGPoint] = pos.map { let uvs: [CGPoint] = pos.map {
CGPoint(x: CGFloat((comp($0, ua) - umin) / urange), CGPoint(x: CGFloat((comp($0, ua) - umin) / urange),
y: CGFloat(1 - (comp($0, va) - vmin) / vrange)) // flip V for top-left origin y: CGFloat(1 - (comp($0, va) - vmin) / vrange)) // flip V for top-left origin
} }
let uvSource = SCNGeometrySource(textureCoordinates: uvs) let uvSource = SCNGeometrySource(textureCoordinates: uvs)
let newGeo = SCNGeometry(sources: g.sources(for: .vertex) + g.sources(for: .normal) + [uvSource], let newGeo = SCNGeometry(sources: g.sources(for: .vertex) + g.sources(for: .normal) + [uvSource],
@@ -235,6 +242,20 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) } node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) }
} }
// Return the shot centred on a black canvas `pad` larger on each side, so texturing it
// leaves a thin black border around the window inside the display's rounded corners.
private func matted(_ shot: NSImage, pad: CGFloat) -> NSImage {
let s = shot.size
let canvas = NSSize(width: s.width * (1 + 2 * pad), height: s.height * (1 + 2 * pad))
let out = NSImage(size: canvas)
out.lockFocus()
NSColor.black.setFill()
NSRect(origin: .zero, size: canvas).fill()
shot.draw(in: NSRect(x: s.width * pad, y: s.height * pad, width: s.width, height: s.height))
out.unlockFocus()
return out
}
private func materials(named name: String, in node: SCNNode) -> [SCNMaterial] { private func materials(named name: String, in node: SCNNode) -> [SCNMaterial] {
var out = (node.geometry?.materials ?? []).filter { var out = (node.geometry?.materials ?? []).filter {
($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame ($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame

View File

@@ -116,9 +116,12 @@ struct ScreenFrame: View {
return Canvas { ctx, _ in return Canvas { ctx, _ in
for i in 0..<b.count { for i in 0..<b.count {
let f = b.count == 1 ? 0 : Double(i) / Double(b.count - 1) - 0.5 // -0.50.5 let f = b.count == 1 ? 0 : Double(i) / Double(b.count - 1) - 0.5 // -0.50.5
let x0 = b.cx + CGFloat(f) * 2 * b.spread // Fan across the cross axis from the start line, converging on (cx, cy).
let start = b.horizontal
? CGPoint(x: b.y0, y: b.cy + CGFloat(f) * 2 * b.spread)
: CGPoint(x: b.cx + CGFloat(f) * 2 * b.spread, y: b.y0)
var path = Path() var path = Path()
path.move(to: CGPoint(x: x0, y: b.y0)) path.move(to: start)
path.addLine(to: CGPoint(x: b.cx, y: b.cy)) path.addLine(to: CGPoint(x: b.cx, y: b.cy))
let op = 0.25 + 0.35 * (1 - abs(f) * 2) // brighter toward the centre beam let op = 0.25 + 0.35 * (1 - abs(f) * 2) // brighter toward the centre beam
ctx.stroke(path, with: .color(color.opacity(op)), ctx.stroke(path, with: .color(color.opacity(op)),
@@ -138,14 +141,15 @@ struct ScreenFrame: View {
let count = max(1, Int((s.y1 - s.y0) / spacing)) let count = max(1, Int((s.y1 - s.y0) / spacing))
for i in 0...count { for i in 0...count {
let t = Double(i) / Double(count) let t = Double(i) / Double(count)
let y = s.y0 + (s.y1 - s.y0) * t let along = s.y0 + (s.y1 - s.y0) * t
let at = s.horizontal ? CGPoint(x: along, y: s.cx) : CGPoint(x: s.cx, y: along)
let bit = (i % 3 == 0) ? "0" : "1" let bit = (i % 3 == 0) ? "0" : "1"
// fade in near the lock and out near the phone // fade in near the lock and out near the receiving device
let op = 0.5 + 0.5 * sin(Double.pi * t) let op = 0.5 + 0.5 * sin(Double.pi * t)
var res = ctx.resolve(Text(bit) var res = ctx.resolve(Text(bit)
.font(.system(size: 30, weight: .semibold, design: .monospaced))) .font(.system(size: 30, weight: .semibold, design: .monospaced)))
res.shading = .color(color.opacity(op)) res.shading = .color(color.opacity(op))
ctx.draw(res, at: CGPoint(x: s.cx, y: y)) ctx.draw(res, at: at)
} }
} }
.frame(width: canvas.width, height: canvas.height) .frame(width: canvas.width, height: canvas.height)

View File

@@ -71,11 +71,14 @@ struct RouteSpec {
// converging light beams (encryption) a glowing padlock a binary stream (protection). // converging light beams (encryption) a glowing padlock a binary stream (protection).
struct BeamsSpec { struct BeamsSpec {
var cx: CGFloat // convergence x var cx: CGFloat // convergence x
var y0: CGFloat // beams start at the top phone's bottom edge var y0: CGFloat // start-line coordinate: the y the beams fan from (x when horizontal)
var spread: CGFloat // half-width the beams fan across at y0 var spread: CGFloat // half-extent the beams fan across at the start line
var cy: CGFloat // converge to this point var cy: CGFloat // converge to this point
var count: Int = 22 var count: Int = 22
var color: String = "#9ec3ff" var color: String = "#9ec3ff"
// Horizontal: beams fan across y from a vertical start line and converge leftright
// (for side-by-side devices) instead of fanning across x from a horizontal line.
var horizontal: Bool = false
} }
struct LockSpec { struct LockSpec {
@@ -86,10 +89,11 @@ struct LockSpec {
} }
struct StreamSpec { struct StreamSpec {
var cx: CGFloat // vertical binary stream centre var cx: CGFloat // cross-axis centre (x for a vertical stream, y for a horizontal one)
var y0: CGFloat // from (below the lock) var y0: CGFloat // along-axis start (below the lock; right of the lock when horizontal)
var y1: CGFloat // to (the bottom phone) var y1: CGFloat // along-axis end (the receiving device)
var color: String = "#9ec3ff" var color: String = "#9ec3ff"
var horizontal: Bool = false // flow leftright instead of topbottom
} }
// Localized banner labels (CHIFFREMENT / PROTECTION), text taken from strings.json. // Localized banner labels (CHIFFREMENT / PROTECTION), text taken from strings.json.
@@ -122,6 +126,7 @@ struct ScreenSpec {
switch platform { switch platform {
case .iphone: iphone case .iphone: iphone
case .ipad: ipad case .ipad: ipad
case .mac: mac
} }
} }
@@ -230,7 +235,7 @@ struct ScreenSpec {
height: 1300, cx: 1024, cy: 2760, pose: Pose(), shadow: false, blackScreen: true height: 1300, cx: 1024, cy: 2760, pose: Pose(), shadow: false, blackScreen: true
), ),
], ],
beams: BeamsSpec(cx: 1024, y0: 820, spread: 200, cy: 1220, count: 24), beams: BeamsSpec(cx: 1024, y0: 720, spread: 200, cy: 1220, count: 24),
lock: LockSpec(cx: 1024, cy: 1400, size: 320), lock: LockSpec(cx: 1024, cy: 1400, size: 320),
stream: StreamSpec(cx: 1024, y0: 1600, y1: 2360), stream: StreamSpec(cx: 1024, y0: 1600, y1: 2360),
banners: [ banners: [
@@ -240,4 +245,58 @@ struct ScreenSpec {
headerBackdrop: true headerBackdrop: true
), ),
] ]
// MacBook layouts (canvas 2880x1800, landscape, centre x = 1440). First pass.
static let mac: [String: ScreenSpec] = [
"share-securely": ScreenSpec(
id: "share-securely",
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .dark,
device: DeviceSpec(height: 1500, cx: 1440, cy: 1110, pose: Pose())
),
"choose-receivers": ScreenSpec(
id: "choose-receivers",
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .dark,
device: DeviceSpec(height: 1500, cx: 1440, cy: 1110, pose: Pose())
),
// Globe backdrop in the upper half, route arcing through a centred laptop, caption
// at the bottom. Reuses the share capture (matches the other platforms).
"send-anywhere": ScreenSpec(
id: "send-anywhere",
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
captionPlace: .bottom, captionTheme: .light,
globe: GlobeSpec(width: 1320, dx: 780, dy: -150),
route: RouteSpec(
from: CGPoint(x: 1650, y: 222), to: CGPoint(x: 976, y: 274),
c1: CGPoint(x: 5200, y: 1150), c2: CGPoint(x: -2000, y: 1150), lineWidth: 13),
device: DeviceSpec(height: 1300, cx: 1440, cy: 960, pose: Pose()),
headerBackdrop: true,
shotId: "share-securely"
),
// Vertical encryption flow between two stacked laptops (same structure as the phone
// layouts, retuned for the landscape canvas).
"stay-private": ScreenSpec(
id: "stay-private",
bg: GradientSpec(
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"], start: .top, end: .bottom),
captionPlace: .top, captionTheme: .light,
// Two laptops on the left and right; encryption flows horizontally between them:
// beams converge leftlock, a binary stream runs lockright.
devices: [
DeviceSpec(
height: 1040, cx: 120, cy: 1000, pose: Pose(), shadow: false, blackScreen: true),
DeviceSpec(
height: 1040, cx: 2760, cy: 1000, pose: Pose(), shadow: false, blackScreen: true
),
],
beams: BeamsSpec(cx: 1290, y0: 760, spread: 300, cy: 1000, count: 26, horizontal: true),
lock: LockSpec(cx: 1440, cy: 1000, size: 300),
stream: StreamSpec(cx: 1000, y0: 1600, y1: 2140, horizontal: true),
banners: [
Banner(kind: .encryption, cx: 940, cy: 1360),
Banner(kind: .protection, cx: 1960, cy: 1360),
]
),
]
} }

View File

@@ -11,6 +11,11 @@
- **Author:** lazercar (Sketchfab) - **Author:** lazercar (Sketchfab)
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/ - **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
## macbook-air.usdz
- **Title:** MacBook Air
- **Author:** Jakob (Sketchfab — https://sketchfab.com/jakob3dlindblom)
- **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) 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 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 keep this attribution with the project. If a suitable place exists (e.g. the app's

Binary file not shown.

View File

@@ -34,6 +34,50 @@ SCENARIOS="share:share-securely approval:choose-receivers"
# live under generated/ (git-ignored), not in assets/. # live under generated/ (git-ignored), not in assets/.
SHOTS_DIR="${SHOTS_DIR:-generated/shots/$PLATFORM}" SHOTS_DIR="${SHOTS_DIR:-generated/shots/$PLATFORM}"
# ---- macOS: no simulator. Build the native app, drive it with the fixture args, size its
# window to 16:10 and grab it with screencapture. Needs Accessibility + Screen Recording
# permission granted to the terminal (you'll be prompted the first time).
if [ "$PLATFORM" = "mac" ]; then
WIN_X=60; WIN_Y=80; WIN_W=1440; WIN_H=900 # 16:10, matches the MacBook screen aspect
echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)
echo "==> Building VniDrop (Debug) for macOS"
xcodebuild build -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
-destination 'platform=macOS' -configuration Debug CODE_SIGNING_ALLOWED=NO >/dev/null
APP="$(xcodebuild -project "$APPLE_DIR/VniDrop.xcodeproj" -scheme VniDrop \
-destination 'platform=macOS' -configuration Debug -showBuildSettings 2>/dev/null \
| awk -F' = ' '/ BUILT_PRODUCTS_DIR /{print $2; exit}')/VniDrop.app"
EXE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist" 2>/dev/null || echo VniDrop)"
EXE="$APP/Contents/MacOS/$EXE_NAME"
echo " app: $APP (exe: $EXE_NAME)"
echo " (grant Accessibility + Screen Recording to your terminal if prompted)"
for loc in $LOCALES; do
mkdir -p "$SHOTS_DIR/$loc"
for pair in $SCENARIOS; do
scenario="${pair%%:*}"; screen="${pair##*:}"
pkill -x "$EXE_NAME" 2>/dev/null || true; sleep 1
# Launch the binary directly (avoids LaunchServices -1712 for DerivedData apps).
"$EXE" -VniScreenshot "$scenario" \
-AppleLanguages "($loc)" -AppleLocale "$loc" -AppleInterfaceStyle Dark &
sleep 4
osascript >/dev/null 2>&1 <<-OSA || true
tell application "System Events" to tell process "$EXE_NAME"
set frontmost to true
set position of front window to {$WIN_X, $WIN_Y}
set size of front window to {$WIN_W, $WIN_H}
end tell
OSA
sleep 1
tmp="$(mktemp -t vnishot).png"
screencapture -x -R${WIN_X},${WIN_Y},${WIN_W},${WIN_H} "$tmp"
mv "$tmp" "$SHOTS_DIR/$loc/$screen.png"
echo " 🖥️ $SHOTS_DIR/$loc/$screen.png"
done
done
pkill -x "$EXE_NAME" 2>/dev/null || true
echo ""; echo "Done. Now run 'PLATFORM=mac swift run studio' to composite."
exit 0
fi
echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null) echo "==> Regenerating project"; (cd "$APPLE_DIR" && xcodegen generate >/dev/null)
echo "==> Building VniDrop (Debug) for $DEVICE" echo "==> Building VniDrop (Debug) for $DEVICE"