Spec fixes come from a test where pi + deepseek-v4.1-flash wrote effects from EFFECTS.md alone: glitch-blocks and ghost-stamps both compiled on the first try. The agent's feedback led to in.dt (frame time), documented limits of in.prev as memory, and a --check warning for ignored slider lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
137 lines
6 KiB
Swift
137 lines
6 KiB
Swift
// MotionLab: camera -> chain of Metal shader effects -> window, recording, virtual camera.
|
|
//
|
|
// swift run -c release MotionLab [options]
|
|
//
|
|
// --effects DIR folder of .metal effect files (default ./effects)
|
|
// --only a,b,c start with exactly these effects enabled
|
|
// --virtualcam also send the output to the OBS virtual camera (for Meet, Zoom, ...)
|
|
// --check compile every effect file, report errors, and exit (no camera, no window)
|
|
// --snapshot OUT.png save one frame after --frames N camera frames (default 90) and exit
|
|
// --record OUT.mp4 record --frames N camera frames (default 90) and exit
|
|
//
|
|
// In the window: s saves a screenshot, r starts/stops recording (both into ./captures), q or Esc quits.
|
|
// Effect files are reloaded when saved.
|
|
|
|
import AppKit
|
|
import MetalKit
|
|
|
|
func option(_ name: String) -> String? {
|
|
let arguments = CommandLine.arguments
|
|
guard let i = arguments.firstIndex(of: name), i + 1 < arguments.count else { return nil }
|
|
return arguments[i + 1]
|
|
}
|
|
|
|
func fail(_ message: String) -> Never {
|
|
FileHandle.standardError.write((message + "\n").data(using: .utf8)!)
|
|
exit(1)
|
|
}
|
|
|
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
var onQuit: (() -> Void)?
|
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
|
|
func applicationWillTerminate(_ notification: Notification) { onQuit?() }
|
|
}
|
|
|
|
setvbuf(stdout, nil, _IOLBF, 0) // show "loaded" / shader errors immediately, even when piped
|
|
|
|
guard let device = MTLCreateSystemDefaultDevice() else { fail("Metal is not available on this machine.") }
|
|
let effectsDirectory = URL(fileURLWithPath: option("--effects") ?? "effects")
|
|
|
|
if CommandLine.arguments.contains("--check") {
|
|
// compile every effect file without opening the camera or a window
|
|
let files = ((try? FileManager.default.contentsOfDirectory(at: effectsDirectory, includingPropertiesForKeys: nil)) ?? [])
|
|
.filter { $0.pathExtension == "metal" }
|
|
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
|
var failures = 0
|
|
for url in files {
|
|
let name = url.deletingPathExtension().lastPathComponent
|
|
do {
|
|
let source = try String(contentsOf: url, encoding: .utf8)
|
|
let info = parseEffect(source)
|
|
_ = try makePipeline(device, source: effectShader(body: source, params: info.params),
|
|
fragment: "effectFragment", format: .bgra8Unorm)
|
|
print("ok \(name) sliders: \(info.params.isEmpty ? "none" : info.params.map(\.name).joined(separator: ", "))")
|
|
let sliderLines = source.split(separator: "\n").filter { $0.hasPrefix("// slider") }.count
|
|
if sliderLines != info.params.count {
|
|
print(" warning: \(sliderLines - info.params.count) slider line(s) ignored (malformed, or over the limit of \(maxParams))")
|
|
}
|
|
} catch {
|
|
failures += 1
|
|
print("FAIL \(name)\n\(error.localizedDescription)")
|
|
}
|
|
}
|
|
exit(failures == 0 ? 0 : 1)
|
|
}
|
|
|
|
let renderer: Renderer
|
|
do {
|
|
renderer = try Renderer(device: device, camera: Camera(device: device), effectsDirectory: effectsDirectory)
|
|
} catch {
|
|
fail("\(error)")
|
|
}
|
|
if renderer.layers.isEmpty { print("No .metal files in \(effectsDirectory.path) - showing the plain camera.") }
|
|
if let names = option("--only") {
|
|
let unknown = renderer.enableOnly(names.split(separator: ",").map(String.init))
|
|
if !unknown.isEmpty {
|
|
fail("Unknown effect(s): \(unknown.joined(separator: ", ")). Available: \(renderer.layers.map(\.name).joined(separator: ", "))")
|
|
}
|
|
}
|
|
if option("--snapshot") != nil || option("--record") != nil {
|
|
if !renderer.failedLayers.isEmpty { fail("shader compile failed: \(renderer.failedLayers.joined(separator: ", "))") }
|
|
renderer.snapshot = (Int(option("--frames") ?? "90") ?? 90, option("--snapshot").map { URL(fileURLWithPath: $0) })
|
|
renderer.recordURL = option("--record").map { URL(fileURLWithPath: $0) }
|
|
}
|
|
renderer.wantsVirtualCamera = CommandLine.arguments.contains("--virtualcam")
|
|
|
|
let app = NSApplication.shared
|
|
let delegate = AppDelegate()
|
|
delegate.onQuit = { renderer.finish() }
|
|
app.delegate = delegate
|
|
app.setActivationPolicy(.regular)
|
|
|
|
let size = NSSize(width: 960 + Panel.width, height: 540)
|
|
let window = NSWindow(contentRect: NSRect(origin: .zero, size: size),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered, defer: false)
|
|
let view = MTKView(frame: .zero, device: device)
|
|
view.colorPixelFormat = .bgra8Unorm
|
|
view.delegate = renderer
|
|
|
|
let panel = Panel(renderer: renderer)
|
|
renderer.onLayersChanged = { panel.rebuild() }
|
|
renderer.onStatusChanged = { panel.refreshStatus() }
|
|
|
|
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
|
|
if event.modifierFlags.contains(.command) { return event }
|
|
switch event.charactersIgnoringModifiers {
|
|
case "s": renderer.requestScreenshot()
|
|
case "r": renderer.toggleRecording()
|
|
case "q", "\u{1B}": app.terminate(nil)
|
|
default: return event
|
|
}
|
|
return nil
|
|
}
|
|
|
|
let content = NSView(frame: NSRect(origin: .zero, size: size))
|
|
for subview in [view, panel.view] {
|
|
subview.translatesAutoresizingMaskIntoConstraints = false
|
|
content.addSubview(subview)
|
|
}
|
|
NSLayoutConstraint.activate([
|
|
view.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
|
view.topAnchor.constraint(equalTo: content.topAnchor),
|
|
view.bottomAnchor.constraint(equalTo: content.bottomAnchor),
|
|
view.trailingAnchor.constraint(equalTo: panel.view.leadingAnchor),
|
|
panel.view.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
|
panel.view.topAnchor.constraint(equalTo: content.topAnchor),
|
|
panel.view.bottomAnchor.constraint(equalTo: content.bottomAnchor),
|
|
])
|
|
|
|
window.title = "MotionLab"
|
|
window.contentMinSize = NSSize(width: 480 + Panel.width, height: 270)
|
|
window.contentView = content
|
|
window.setContentSize(size) // auto layout would otherwise pick its own window size
|
|
window.center()
|
|
window.makeKeyAndOrderFront(nil)
|
|
app.activate(ignoringOtherApps: true)
|
|
app.run()
|