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>
This commit is contained in:
parent
6591138bbf
commit
a20d3310db
29 changed files with 160 additions and 775 deletions
|
|
@ -1,110 +0,0 @@
|
|||
// 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, ...)
|
||||
// --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")
|
||||
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue