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 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.5…0.5
|
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()
|
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)
|
||||||
|
|||||||
@@ -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 left→right
|
||||||
|
// (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 left→right instead of top→bottom
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 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)
|
- **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
|
||||||
|
|||||||
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/.
|
# 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user