New `OutputLocations` helper stores per-app folder choices as security- scoped bookmarks in UserDefaults under VPOC.SnapshotDirectoryBookmark and VPOC.RecordingDirectoryBookmark. Both default to ~/Desktop so existing behavior is unchanged until the user picks a different folder. Settings panel gains an "Output Locations" section with two rows — Snapshots and Recordings — each showing the current path (home-relative, e.g. "~/Movies/VisionPOC") and a Choose… button that runs NSOpenPanel for picking a directory. Selecting a folder stores the bookmark and refreshes the displayed path. Bookmarks are tried with .withSecurityScope first (forward-compatible with a future sandboxed build) and fall back to a plain bookmark when the security-scoped form isn't available — so this works in both the current unsigned dev build and any future signed/sandboxed build. Both Recorder.makeOutputURL and MainWindowController.saveSnapshot now read their destination from OutputLocations instead of hardcoding .desktopDirectory. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
142 lines
4.5 KiB
Swift
142 lines
4.5 KiB
Swift
import AppKit
|
|
import AVFoundation
|
|
import MetalKit
|
|
|
|
final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|
private let mtkView: MTKView
|
|
private let capture: CameraCapture
|
|
let detector: ObjectDetector
|
|
private let edgePass: EdgePass
|
|
private let jarvisPass: JarvisStylePass
|
|
private let asciiPass: AsciiPass
|
|
let renderer: Renderer
|
|
|
|
private static let frameDefaultsKey = "VPOC.MainWindowFrame"
|
|
|
|
init() {
|
|
guard let device = MTLCreateSystemDefaultDevice() else {
|
|
fatalError("Metal is required: no default system device available.")
|
|
}
|
|
|
|
let defaultSize = Theme.Layout.defaultWindowSize
|
|
let minSize = Theme.Layout.minWindowSize
|
|
|
|
let styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable]
|
|
let window = NSWindow(
|
|
contentRect: NSRect(origin: .zero, size: defaultSize),
|
|
styleMask: styleMask,
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "VisionPOC"
|
|
window.minSize = minSize
|
|
window.appearance = NSAppearance(named: .darkAqua)
|
|
window.isReleasedWhenClosed = false
|
|
window.center()
|
|
|
|
let view = MTKView(frame: NSRect(origin: .zero, size: defaultSize), device: device)
|
|
view.colorPixelFormat = .bgra8Unorm
|
|
view.preferredFramesPerSecond = Int(Theme.Tick.renderFPS)
|
|
view.framebufferOnly = true
|
|
let bg = Theme.Palette.background
|
|
view.clearColor = MTLClearColor(red: Double(bg.x), green: Double(bg.y), blue: Double(bg.z), alpha: Double(bg.w))
|
|
view.autoResizeDrawable = true
|
|
view.autoresizingMask = [.width, .height]
|
|
window.contentView = view
|
|
self.mtkView = view
|
|
|
|
let capture = CameraCapture(device: device)
|
|
let detector = ObjectDetector()
|
|
let edgePass = EdgePass(device: device)
|
|
let jarvisPass = JarvisStylePass()
|
|
guard let asciiPass = AsciiPass(device: device) else {
|
|
fatalError("Failed to build the ASCII atlas.")
|
|
}
|
|
|
|
guard let renderer = Renderer(
|
|
view: view,
|
|
device: device,
|
|
capture: capture,
|
|
detector: detector,
|
|
edgePass: edgePass,
|
|
jarvisPass: jarvisPass,
|
|
asciiPass: asciiPass
|
|
) else {
|
|
fatalError("Failed to initialize Renderer.")
|
|
}
|
|
|
|
self.capture = capture
|
|
self.detector = detector
|
|
self.edgePass = edgePass
|
|
self.jarvisPass = jarvisPass
|
|
self.asciiPass = asciiPass
|
|
self.renderer = renderer
|
|
|
|
view.delegate = renderer
|
|
|
|
super.init(window: window)
|
|
|
|
window.delegate = self
|
|
restoreFrame()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) is not supported")
|
|
}
|
|
|
|
override func showWindow(_ sender: Any?) {
|
|
super.showWindow(sender)
|
|
window?.makeKeyAndOrderFront(sender)
|
|
Task { await capture.start() }
|
|
}
|
|
|
|
// MARK: - Camera
|
|
|
|
var activeCameraUniqueID: String? {
|
|
capture.activeDeviceUniqueID
|
|
}
|
|
|
|
func switchCamera(to device: AVCaptureDevice) {
|
|
capture.switchToDevice(device)
|
|
}
|
|
|
|
// MARK: - Snapshot
|
|
|
|
@MainActor
|
|
func saveSnapshotToDesktop() {
|
|
guard let data = renderer.snapshotPNG() else {
|
|
LogStream.shared.log("snapshot failed (renderer returned nil)", level: .warn, source: .app)
|
|
return
|
|
}
|
|
let df = DateFormatter()
|
|
df.dateFormat = "yyyy-MM-dd-HHmmss"
|
|
let name = "VisionPOC-\(df.string(from: Date())).png"
|
|
let url = OutputLocations.snapshotDirectory().appendingPathComponent(name)
|
|
do {
|
|
try data.write(to: url)
|
|
LogStream.shared.log("saved \(url.lastPathComponent)", level: .info, source: .app)
|
|
} catch {
|
|
LogStream.shared.log("snapshot write failed: \(error)", level: .error, source: .app)
|
|
}
|
|
}
|
|
|
|
// MARK: - Window persistence
|
|
|
|
private func restoreFrame() {
|
|
guard let window,
|
|
let saved = UserDefaults.standard.string(forKey: MainWindowController.frameDefaultsKey) else {
|
|
return
|
|
}
|
|
let rect = NSRectFromString(saved)
|
|
if rect.size.width > 0 && rect.size.height > 0 {
|
|
window.setFrame(rect, display: false)
|
|
}
|
|
}
|
|
|
|
func windowWillClose(_ notification: Notification) {
|
|
guard let window else { return }
|
|
UserDefaults.standard.set(NSStringFromRect(window.frame),
|
|
forKey: MainWindowController.frameDefaultsKey)
|
|
}
|
|
}
|