mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-09 20:29:58 +02:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.5…0.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)
|
||||
|
||||
@@ -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 left→right
|
||||
// (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 left→right instead of top→bottom
|
||||
}
|
||||
|
||||
// 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 left→lock, a binary stream runs lock→right.
|
||||
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),
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
packaging/apple/studio/assets/macbook-air.usdz
Normal file
BIN
packaging/apple/studio/assets/macbook-air.usdz
Normal file
Binary file not shown.
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user