motionlab/Sources/MotionLab/Camera.swift
0xrsydn a20d3310db Go native-only: remove the Python POC, move the Swift app to the top level
Adds --check (compile every effect without camera or window), EFFECTS.md
(the contract for writing effects) and AGENTS.md. The POC stays available
at the 'poc' tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:33:18 +07:00

95 lines
3.9 KiB
Swift

import AVFoundation
import CoreVideo
import Metal
enum CameraError: Error, CustomStringConvertible {
case noPermission
case noCamera
var description: String {
switch self {
case .noPermission:
return "No camera permission. Allow your terminal under System Settings > Privacy & Security > Camera, then restart it."
case .noCamera:
return "No camera found."
}
}
}
/// Streams the built-in camera as Metal textures; `latest` is the newest frame and the one before it.
final class Camera: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
private let session = AVCaptureSession()
private var cache: CVMetalTextureCache?
private let lock = NSLock()
// each entry keeps its CVMetalTexture, which keeps the pixel buffer behind the Metal texture alive
private var current: (texture: MTLTexture, backing: CVMetalTexture)?
private var previous: (texture: MTLTexture, backing: CVMetalTexture)?
private var serial = 0 // counts frames, so the renderer can tell a new frame from one it already saw
var latest: (current: MTLTexture, last: MTLTexture, serial: Int)? {
lock.lock()
defer { lock.unlock() }
guard let current else { return nil }
return (current.texture, (previous ?? current).texture, serial)
}
init(device: MTLDevice) throws {
super.init()
guard Camera.requestAccess() else { throw CameraError.noPermission }
// prefer the built-in camera over an iPhone that happens to be nearby
guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .unspecified)
?? AVCaptureDevice.default(for: .video)
else { throw CameraError.noCamera }
CVMetalTextureCacheCreate(nil, nil, device, nil, &cache)
let output = AVCaptureVideoDataOutput()
output.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferMetalCompatibilityKey as String: true,
]
output.alwaysDiscardsLateVideoFrames = true
output.setSampleBufferDelegate(self, queue: DispatchQueue(label: "motionlab.camera"))
session.beginConfiguration()
session.addInput(try AVCaptureDeviceInput(device: camera))
session.addOutput(output)
if session.canSetSessionPreset(.hd1280x720) { session.sessionPreset = .hd1280x720 }
print("camera: \(camera.localizedName)")
if let connection = output.connection(with: .video), connection.isVideoMirroringSupported {
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = true // behave like a mirror
}
session.commitConfiguration()
session.startRunning()
}
private static func requestAccess() -> Bool {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
return true
case .notDetermined:
let done = DispatchSemaphore(value: 0)
var granted = false
AVCaptureDevice.requestAccess(for: .video) { granted = $0; done.signal() }
done.wait()
return granted
default:
return false
}
}
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
guard let cache, let buffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
var created: CVMetalTexture?
CVMetalTextureCacheCreateTextureFromImage(
nil, cache, buffer, nil, .bgra8Unorm,
CVPixelBufferGetWidth(buffer), CVPixelBufferGetHeight(buffer), 0, &created)
guard let created, let frame = CVMetalTextureGetTexture(created) else { return }
lock.lock()
previous = current
current = (frame, created)
serial += 1
lock.unlock()
}
}