motionlab/Sources/MotionLab/main.swift
0xrsydn 169c5f9b63 Add in.dt, slider warnings in --check, and two agent-written effects
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>
2026-10-05 20:50:31 +07:00

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()