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 bodyYaw: Double = 0 // deg about Y to face the screen toward the camera (-Z)
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 {
DeviceModel(url: assets.appendingPathComponent("iphone-17-pro-max.usdz"),
@@ -22,16 +28,24 @@ struct DeviceModel {
screenMaterial: "Material_002", glassMaterial: nil,
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.
enum Platform: String {
case iphone, ipad
case iphone, ipad, mac
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)
case .mac: CGSize(width: 2880, height: 1800) // Mac App Store (16:10 landscape)
}
}
@@ -39,6 +53,7 @@ enum Platform: String {
switch self {
case .iphone: DeviceModel.iphone(assets)
case .ipad: DeviceModel.ipad(assets)
case .mac: DeviceModel.macbook(assets)
}
}
@@ -47,14 +62,16 @@ enum Platform: String {
switch self {
case .iphone: "iPhone 17 Pro Max"
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 {
switch self {
case .iphone: ""
case .iphone: "iPhone"
case .ipad: "iPad"
case .mac: "Mac"
}
}
}

View File

@@ -9,14 +9,14 @@ import AppKit
struct SceneKitDeviceRenderer: DeviceRenderer {
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
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 displaySide = spec.height / model.fillFraction
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))
@@ -60,8 +60,12 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
// 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.
// 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 {
mat.diffuse.contents = spec.blackScreen ? NSColor.black : shot
mat.diffuse.contents = spec.blackScreen ? NSColor.black : screenShot
mat.lightingModel = .constant
mat.normal.contents = nil
mat.emission.contents = nil
@@ -98,7 +102,7 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
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))
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.eulerAngles = SCNVector3(0, Double.pi, 0)
root.addChildNode(camNode)
@@ -223,9 +227,12 @@ struct SceneKitDeviceRenderer: DeviceRenderer {
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])
// 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 {
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 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) }
}
// 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] {
var out = (node.geometry?.materials ?? []).filter {
($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame

View File

@@ -116,9 +116,12 @@ struct ScreenFrame: View {
return Canvas { ctx, _ in
for i in 0..<b.count {
let f = b.count == 1 ? 0 : Double(i) / Double(b.count - 1) - 0.5 // -0.50.5
let x0 = b.cx + CGFloat(f) * 2 * b.spread
// 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()
path.move(to: CGPoint(x: x0, y: b.y0))
path.move(to: start)
path.addLine(to: CGPoint(x: b.cx, y: b.cy))
let op = 0.25 + 0.35 * (1 - abs(f) * 2) // brighter toward the centre beam
ctx.stroke(path, with: .color(color.opacity(op)),
@@ -138,14 +141,15 @@ struct ScreenFrame: View {
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 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"
// 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)
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))
ctx.draw(res, at: at)
}
}
.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).
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 y0: CGFloat // start-line coordinate: the y the beams fan from (x when horizontal)
var spread: CGFloat // half-extent the beams fan across at the start line
var cy: CGFloat // converge to this point
var count: Int = 22
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 {
@@ -86,10 +89,11 @@ 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 // cross-axis centre (x for a vertical stream, y for a horizontal one)
var y0: CGFloat // along-axis start (below the lock; right of the lock when horizontal)
var y1: CGFloat // along-axis end (the receiving device)
var color: String = "#9ec3ff"
var horizontal: Bool = false // flow leftright instead of topbottom
}
// Localized banner labels (CHIFFREMENT / PROTECTION), text taken from strings.json.
@@ -122,6 +126,7 @@ struct ScreenSpec {
switch platform {
case .iphone: iphone
case .ipad: ipad
case .mac: mac
}
}
@@ -230,7 +235,7 @@ struct ScreenSpec {
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),
stream: StreamSpec(cx: 1024, y0: 1600, y1: 2360),
banners: [
@@ -240,4 +245,58 @@ struct ScreenSpec {
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)
- **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)
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

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/.
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 "==> Building VniDrop (Debug) for $DEVICE"