diff --git a/packaging/apple/studio/Sources/studio/Platform.swift b/packaging/apple/studio/Sources/studio/Platform.swift index bdf185a..2cbef21 100644 --- a/packaging/apple/studio/Sources/studio/Platform.swift +++ b/packaging/apple/studio/Sources/studio/Platform.swift @@ -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 / so iPhone/iPad sets stay separate. + // Output goes under / so the sets stay separate. var outputSubfolder: String { switch self { - case .iphone: "" + case .iphone: "iPhone" case .ipad: "iPad" + case .mac: "Mac" } } } diff --git a/packaging/apple/studio/Sources/studio/SceneKitDeviceRenderer.swift b/packaging/apple/studio/Sources/studio/SceneKitDeviceRenderer.swift index e6c0bcd..6d588fb 100644 --- a/packaging/apple/studio/Sources/studio/SceneKitDeviceRenderer.swift +++ b/packaging/apple/studio/Sources/studio/SceneKitDeviceRenderer.swift @@ -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 diff --git a/packaging/apple/studio/Sources/studio/ScreenFrame.swift b/packaging/apple/studio/Sources/studio/ScreenFrame.swift index c215a8a..079ff1e 100644 --- a/packaging/apple/studio/Sources/studio/ScreenFrame.swift +++ b/packaging/apple/studio/Sources/studio/ScreenFrame.swift @@ -116,9 +116,12 @@ struct ScreenFrame: View { return Canvas { ctx, _ in for i in 0.. 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"