| author | |
| committer | |
| log | dc68ac0c426180e0b724d0b1acfe8bd30c199c36 |
| tree | 819109c642682b309c090060de44905df24282e6 |
| parent | c7e1a06c390d968e00840aceead85c877a0f2abb |
| signature |
Assisted-by: Claude:claude-opus-4.712 files changed, 1682 insertions(+), 8 deletions(-)
.gitignore+1| ... | @@ -1 +1,2 @@ | ... | @@ -1 +1,2 @@ |
| 1 | node_modules | 1 | node_modules |
| 2 | .build |
config/reaper.ts+2-2| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import * as config from "#config"; | 1 | import * as config from "#config"; |
| 2 | import { Reaper } from "@clo/creative-control/Reaper.ts"; | 2 | import { Reaper } from "@clo/creative-control/Reaper"; |
| 3 | import { SpeedEditor } from "@clo/creative-control/SpeedEditor.ts"; | 3 | import { SpeedEditor } from "@clo/creative-control/SpeedEditor"; |
| 4 | 4 | ||
| 5 | const cfg = config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) => { | 5 | const cfg = config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) => { |
| 6 | const reaper = new Reaper(); | 6 | const reaper = new Reaper(); |
config/reaper/scripts/generate_recorder_template.lua created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | -- Generate the Clover Recorder session template. | ||
| 2 | -- | ||
| 3 | -- Creates a fresh project with a single record-armed MIDI track listening to | ||
| 4 | -- "All MIDI Inputs / All Channels" (so any keyboard works), then saves it to | ||
| 5 | -- the template path. The recorder copies this per session. Open it in REAPER | ||
| 6 | -- afterwards to add your instrument / tweak settings and re-save — it's yours. | ||
| 7 | -- | ||
| 8 | -- Run via: REAPER -nonewinst generate_recorder_template.lua | ||
| 9 | |||
| 10 | local template_path = "/Volumes/Documents/Recorder Template.rpp" | ||
| 11 | |||
| 12 | -- Work in a fresh project tab so we never disturb whatever is already open. | ||
| 13 | reaper.Main_OnCommand(40859, 0) -- New project tab | ||
| 14 | |||
| 15 | reaper.InsertTrackAtIndex(0, false) | ||
| 16 | local track = reaper.GetTrack(0, 0) | ||
| 17 | reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "Improv MIDI", true) | ||
| 18 | reaper.SetMediaTrackInfo_Value(track, "I_RECARM", 1) | ||
| 19 | -- MIDI record input: 4096 + (device<<5) + channel; device 62 = all MIDI inputs, | ||
| 20 | -- channel 0 = omni (all channels). See REAPER API docs for I_RECINPUT. | ||
| 21 | reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", 4096 + (62 << 5)) | ||
| 22 | reaper.SetMediaTrackInfo_Value(track, "I_RECMON", 1) -- input monitoring on | ||
| 23 | |||
| 24 | reaper.Main_SaveProjectEx(0, template_path, 0) | ||
| 25 | |||
| 26 | local readback = reaper.GetMediaTrackInfo_Value(track, "I_RECINPUT") | ||
| 27 | local log = io.open("/tmp/reaper-template.log", "w") | ||
| 28 | if log then | ||
| 29 | log:write(string.format("saved=%s I_RECINPUT=%d\n", template_path, readback)) | ||
| 30 | log:close() | ||
| 31 | end | ||
| 32 | |||
| 33 | -- Close the template tab; leave REAPER as it was. | ||
| 34 | reaper.Main_OnCommand(40860, 0) -- Close current project tab | ||
package.json+4-4| ... | @@ -18,10 +18,10 @@ | ... | @@ -18,10 +18,10 @@ |
| 18 | "#config": "./src/config.ts" | 18 | "#config": "./src/config.ts" |
| 19 | }, | 19 | }, |
| 20 | "exports": { | 20 | "exports": { |
| 21 | "./Mac.ts": "./src/Mac.ts", | 21 | "./Mac": "./src/Mac.ts", |
| 22 | "./SpeedEditor.ts": "./src/SpeedEditor.ts", | 22 | "./SpeedEditor": "./src/SpeedEditor.ts", |
| 23 | "./Reaper.ts": "./src/Reaper.ts", | 23 | "./Reaper": "./src/Reaper.ts", |
| 24 | "./Reaper/actions.ts": "./src/Reaper/actions.ts" | 24 | "./Reaper/actions": "./src/Reaper/actions.ts" |
| 25 | }, | 25 | }, |
| 26 | "pnpm": { | 26 | "pnpm": { |
| 27 | "onlyBuiltDependencies": [ | 27 | "onlyBuiltDependencies": [ |
readme.md+2-2| ... | @@ -56,7 +56,7 @@ mac.on("app-change", (bundle) => { | ... | @@ -56,7 +56,7 @@ mac.on("app-change", (bundle) => { |
| 56 | With the help of an OSC extension, REAPER can be controlled with TypeScript. | 56 | With the help of an OSC extension, REAPER can be controlled with TypeScript. |
| 57 | 57 | ||
| 58 | ```ts | 58 | ```ts |
| 59 | import { Reaper } from "@clo/creative-control/Reaper.ts"; | 59 | import { Reaper } from "@clo/creative-control/Reaper"; |
| 60 | 60 | ||
| 61 | const reaper = new Reaper(); | 61 | const reaper = new Reaper(); |
| 62 | reaper.on("transport", (transport) => { | 62 | reaper.on("transport", (transport) => { |
| ... | @@ -96,7 +96,7 @@ with multiple readers conflicting. | ... | @@ -96,7 +96,7 @@ with multiple readers conflicting. |
| 96 | 96 | ||
| 97 | ```ts | 97 | ```ts |
| 98 | import * as config from "#config"; | 98 | import * as config from "#config"; |
| 99 | import { Reaper } from "@clo/creative-control/Reaper.ts"; | 99 | import { Reaper } from "@clo/creative-control/Reaper"; |
| 100 | 100 | ||
| 101 | export default config.forApp("com.cockos.reaper", ({ speededitor, mac }) => { | 101 | export default config.forApp("com.cockos.reaper", ({ speededitor, mac }) => { |
| 102 | const reaper = new Reaper(); | 102 | const reaper = new Reaper(); |
src/Recorder/build.sh created+61| ... | @@ -0,0 +1,61 @@ | ||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Build the Clover Recorder capture engine and wrap it in a minimal .app bundle. | ||
| 3 | # | ||
| 4 | # macOS only grants and reliably lists *app bundles* (stable bundle id) under | ||
| 5 | # Privacy → Screen Recording, so even the headless capture core ships as an app. | ||
| 6 | # | ||
| 7 | # Usage: ./build.sh (run on the Mac that will do the recording) | ||
| 8 | set -euo pipefail | ||
| 9 | |||
| 10 | HERE="$(cd "$(dirname "$0")" && pwd)" | ||
| 11 | ENGINE="$HERE/engine" | ||
| 12 | DIST="$HERE/dist" | ||
| 13 | APP="$DIST/Clover Recorder.app" | ||
| 14 | BUNDLE_ID="org.clover.recorder" | ||
| 15 | VERSION="0.1.0" | ||
| 16 | |||
| 17 | echo "==> swift build (release)" | ||
| 18 | ( cd "$ENGINE" && swift build -c release ) | ||
| 19 | BIN="$ENGINE/.build/release/recorder" | ||
| 20 | |||
| 21 | echo "==> assembling $APP" | ||
| 22 | rm -rf "$APP" | ||
| 23 | mkdir -p "$APP/Contents/MacOS" | ||
| 24 | cp "$BIN" "$APP/Contents/MacOS/recorder" | ||
| 25 | |||
| 26 | cat > "$APP/Contents/Info.plist" <<EOF | ||
| 27 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 28 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 29 | <plist version="1.0"><dict> | ||
| 30 | <key>CFBundleIdentifier</key><string>$BUNDLE_ID</string> | ||
| 31 | <key>CFBundleName</key><string>Clover Recorder</string> | ||
| 32 | <key>CFBundleExecutable</key><string>recorder</string> | ||
| 33 | <key>CFBundlePackageType</key><string>APPL</string> | ||
| 34 | <key>CFBundleShortVersionString</key><string>$VERSION</string> | ||
| 35 | <key>CFBundleVersion</key><string>$VERSION</string> | ||
| 36 | <key>LSMinimumSystemVersion</key><string>14.0</string> | ||
| 37 | <key>LSUIElement</key><true/> | ||
| 38 | <key>NSMicrophoneUsageDescription</key><string>Clover Recorder records your microphone for journaling and improv sessions.</string> | ||
| 39 | <key>NSCameraUsageDescription</key><string>Clover Recorder records your webcam for journaling and improv sessions.</string> | ||
| 40 | </dict></plist> | ||
| 41 | EOF | ||
| 42 | |||
| 43 | # Sign with the stable self-signed identity from the dedicated Clover keychain | ||
| 44 | # (see setup-signing.sh). This keeps the same designated requirement across | ||
| 45 | # rebuilds, so the Screen Recording grant survives. Falls back to ad-hoc. | ||
| 46 | CN="Clover Code Signing" | ||
| 47 | KC="$HOME/Library/Keychains/clover-signing.keychain-db" | ||
| 48 | KCPW="${CLOVER_KEYCHAIN_PW:-clover}" | ||
| 49 | |||
| 50 | if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then | ||
| 51 | security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true | ||
| 52 | echo "==> codesign with stable identity '$CN'" | ||
| 53 | codesign --force --keychain "$KC" --sign "$CN" --timestamp=none "$APP" | ||
| 54 | else | ||
| 55 | echo "==> codesign ad-hoc (run setup-signing.sh once for a stable identity)" | ||
| 56 | codesign --force --sign - "$APP" | ||
| 57 | fi | ||
| 58 | |||
| 59 | codesign -dv "$APP" 2>&1 | sed -n '1,4p' || true | ||
| 60 | echo "==> built: $APP" | ||
| 61 | echo " binary: $APP/Contents/MacOS/recorder" | ||
src/Recorder/engine/Package.swift created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | // swift-tools-version: 6.0 | ||
| 2 | import PackageDescription | ||
| 3 | |||
| 4 | // Capture core for Clover Recorder. | ||
| 5 | // | ||
| 6 | // A small ScreenCaptureKit + AVFoundation command-line engine that records any | ||
| 7 | // combination of displays, the system-audio mix, the microphone, and (later) a | ||
| 8 | // webcam into one session folder, each stream as its own file, all timestamped | ||
| 9 | // against the shared mach host clock so the streams can be re-aligned exactly. | ||
| 10 | // | ||
| 11 | // No external dependencies, so it builds fully offline. | ||
| 12 | let package = Package( | ||
| 13 | name: "recorder", | ||
| 14 | platforms: [.macOS(.v14)], | ||
| 15 | targets: [ | ||
| 16 | .executableTarget( | ||
| 17 | name: "recorder", | ||
| 18 | path: "Sources/recorder", | ||
| 19 | swiftSettings: [.swiftLanguageMode(.v5)] | ||
| 20 | ) | ||
| 21 | ] | ||
| 22 | ) | ||
src/Recorder/engine/Sources/recorder/CaptureEngine.swift created+307| ... | @@ -0,0 +1,307 @@ | ||
| 1 | import AVFoundation | ||
| 2 | import CoreGraphics | ||
| 3 | import CoreMedia | ||
| 4 | import Foundation | ||
| 5 | import ScreenCaptureKit | ||
| 6 | |||
| 7 | struct RecordConfig { | ||
| 8 | var outDir: URL | ||
| 9 | var label: String | ||
| 10 | var displayIDs: [CGDirectDisplayID] // empty = all | ||
| 11 | var systemAudio: Bool | ||
| 12 | var micUID: String? | ||
| 13 | var cameraUID: String? | ||
| 14 | var fps: Int | ||
| 15 | var maxWidth: Int // clamp longest side (HiDPI backing buffers are huge) | ||
| 16 | var bitsPerPixel: Double | ||
| 17 | var duration: Double? // auto-stop after N seconds; nil = until signal | ||
| 18 | var logPath: String? | ||
| 19 | } | ||
| 20 | |||
| 21 | final class CaptureEngine { | ||
| 22 | private let cfg: RecordConfig | ||
| 23 | private var sinks: [RecordingStream] = [] | ||
| 24 | private var cfrWriters: [CFRVideoWriter] = [] | ||
| 25 | private var streams: [SCStream] = [] | ||
| 26 | private var scOutputs: [SCOutput] = [] | ||
| 27 | private var captureSessions: [AVCaptureSession] = [] | ||
| 28 | private var avOutputs: [AVOutput] = [] | ||
| 29 | |||
| 30 | private var createdEpoch: Double = 0 | ||
| 31 | private var hostClockAtStart: Double = 0 | ||
| 32 | |||
| 33 | init(_ cfg: RecordConfig) { self.cfg = cfg } | ||
| 34 | |||
| 35 | func start() async throws { | ||
| 36 | if let logPath = cfg.logPath { openLogFile(logPath) } | ||
| 37 | try FileManager.default.createDirectory(at: cfg.outDir, withIntermediateDirectories: true) | ||
| 38 | createdEpoch = Date().timeIntervalSince1970 | ||
| 39 | hostClockAtStart = hostSeconds() | ||
| 40 | |||
| 41 | let content = try await SCShareableContent.excludingDesktopWindows( | ||
| 42 | false, onScreenWindowsOnly: false) | ||
| 43 | let allDisplays = content.displays.sorted { $0.frame.origin.x < $1.frame.origin.x } | ||
| 44 | |||
| 45 | let chosen: [SCDisplay] | ||
| 46 | if cfg.displayIDs.isEmpty { | ||
| 47 | chosen = allDisplays | ||
| 48 | } else { | ||
| 49 | chosen = cfg.displayIDs.compactMap { id in allDisplays.first { $0.displayID == id } } | ||
| 50 | } | ||
| 51 | if chosen.isEmpty { throw RecorderError("no matching displays to capture") } | ||
| 52 | |||
| 53 | // Capture system audio piggy-backed on the first display's stream. | ||
| 54 | var systemAudioWriter: StreamWriter? | ||
| 55 | if cfg.systemAudio { | ||
| 56 | systemAudioWriter = try makeAudioWriter(name: "desktop", kind: "system-audio") | ||
| 57 | sinks.append(systemAudioWriter!) | ||
| 58 | } | ||
| 59 | |||
| 60 | for (index, display) in chosen.enumerated() { | ||
| 61 | let name = screenName(index: index, count: chosen.count) | ||
| 62 | let (outW, outH) = outputSize(for: display) | ||
| 63 | |||
| 64 | let writer = try CFRVideoWriter( | ||
| 65 | url: cfg.outDir.appendingPathComponent("\(name).mov"), | ||
| 66 | name: name, width: outW, height: outH, fps: cfg.fps, bitrate: screenBitrate(outW, outH)) | ||
| 67 | writer.displayID = display.displayID | ||
| 68 | sinks.append(writer) | ||
| 69 | cfrWriters.append(writer) | ||
| 70 | |||
| 71 | let cfgSC = SCStreamConfiguration() | ||
| 72 | cfgSC.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(cfg.fps)) | ||
| 73 | cfgSC.queueDepth = 8 | ||
| 74 | cfgSC.showsCursor = true | ||
| 75 | cfgSC.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange | ||
| 76 | cfgSC.width = outW | ||
| 77 | cfgSC.height = outH | ||
| 78 | |||
| 79 | let attachAudioHere = (index == 0) && (systemAudioWriter != nil) | ||
| 80 | if attachAudioHere { | ||
| 81 | cfgSC.capturesAudio = true | ||
| 82 | cfgSC.sampleRate = 48_000 | ||
| 83 | cfgSC.channelCount = 2 | ||
| 84 | } | ||
| 85 | |||
| 86 | let filter = SCContentFilter(display: display, excludingWindows: []) | ||
| 87 | let output = SCOutput( | ||
| 88 | label: name, | ||
| 89 | onScreen: { [weak writer] sb in writer?.update(sb) }, | ||
| 90 | onAudio: attachAudioHere | ||
| 91 | ? { [weak systemAudioWriter] sb in systemAudioWriter?.append(sb) } : nil) | ||
| 92 | scOutputs.append(output) | ||
| 93 | |||
| 94 | let stream = SCStream(filter: filter, configuration: cfgSC, delegate: output) | ||
| 95 | try stream.addStreamOutput( | ||
| 96 | output, type: .screen, | ||
| 97 | sampleHandlerQueue: DispatchQueue(label: "clover.sc.screen.\(name)")) | ||
| 98 | if attachAudioHere { | ||
| 99 | try stream.addStreamOutput( | ||
| 100 | output, type: .audio, | ||
| 101 | sampleHandlerQueue: DispatchQueue(label: "clover.sc.audio")) | ||
| 102 | } | ||
| 103 | streams.append(stream) | ||
| 104 | } | ||
| 105 | |||
| 106 | if let micUID = cfg.micUID { | ||
| 107 | try startAVCapture(audioUID: micUID) | ||
| 108 | } | ||
| 109 | if let cameraUID = cfg.cameraUID { | ||
| 110 | try startAVCapture(videoUID: cameraUID) | ||
| 111 | } | ||
| 112 | |||
| 113 | for stream in streams { | ||
| 114 | try await stream.startCapture() | ||
| 115 | } | ||
| 116 | // Begin emitting constant-rate frames now that capture is live. | ||
| 117 | for writer in cfrWriters { | ||
| 118 | writer.start() | ||
| 119 | } | ||
| 120 | logInfo( | ||
| 121 | "recording \(chosen.count) screen(s)" | ||
| 122 | + (cfg.systemAudio ? " + desktop" : "") | ||
| 123 | + (cfg.micUID != nil ? " + mic" : "") | ||
| 124 | + (cfg.cameraUID != nil ? " + camera" : "")) | ||
| 125 | } | ||
| 126 | |||
| 127 | func stop() async { | ||
| 128 | for output in scOutputs { | ||
| 129 | logInfo( | ||
| 130 | "SC \(output.label): screen seen \(output.screenSeen) complete \(output.screenComplete)" | ||
| 131 | + (output.audioSeen > 0 ? " audio \(output.audioSeen)" : "")) | ||
| 132 | } | ||
| 133 | for stream in streams { | ||
| 134 | try? await stream.stopCapture() | ||
| 135 | } | ||
| 136 | for session in captureSessions { | ||
| 137 | session.stopRunning() | ||
| 138 | } | ||
| 139 | // Give in-flight buffers a moment to drain before finalizing. | ||
| 140 | try? await Task.sleep(nanoseconds: 200_000_000) | ||
| 141 | for sink in sinks { | ||
| 142 | await sink.finish() | ||
| 143 | } | ||
| 144 | writeManifest() | ||
| 145 | } | ||
| 146 | |||
| 147 | // MARK: writers | ||
| 148 | |||
| 149 | private func screenBitrate(_ width: Int, _ height: Int) -> Int { | ||
| 150 | max(2_000_000, Int(Double(width * height * cfg.fps) * cfg.bitsPerPixel)) | ||
| 151 | } | ||
| 152 | |||
| 153 | private func makeVideoWriter(name: String, kind: String, width: Int, height: Int) throws | ||
| 154 | -> StreamWriter | ||
| 155 | { | ||
| 156 | let settings: [String: Any] = [ | ||
| 157 | AVVideoCodecKey: AVVideoCodecType.hevc, | ||
| 158 | AVVideoWidthKey: width, | ||
| 159 | AVVideoHeightKey: height, | ||
| 160 | AVVideoCompressionPropertiesKey: [ | ||
| 161 | AVVideoAverageBitRateKey: screenBitrate(width, height), | ||
| 162 | AVVideoExpectedSourceFrameRateKey: cfg.fps, | ||
| 163 | AVVideoMaxKeyFrameIntervalKey: cfg.fps * 2, | ||
| 164 | ], | ||
| 165 | ] | ||
| 166 | return try StreamWriter( | ||
| 167 | url: cfg.outDir.appendingPathComponent("\(name).mov"), | ||
| 168 | name: name, kind: kind, fileType: .mov, settings: settings, mediaType: .video) | ||
| 169 | } | ||
| 170 | |||
| 171 | private func makeAudioWriter(name: String, kind: String) throws -> StreamWriter { | ||
| 172 | // AAC in .m4a: ~256 kbps stereo, transparent for voice/desktop and tiny next | ||
| 173 | // to the uncompressed PCM we used to write. | ||
| 174 | let settings: [String: Any] = [ | ||
| 175 | AVFormatIDKey: kAudioFormatMPEG4AAC, | ||
| 176 | AVSampleRateKey: 48_000, | ||
| 177 | AVNumberOfChannelsKey: 2, | ||
| 178 | AVEncoderBitRateKey: 256_000, | ||
| 179 | ] | ||
| 180 | return try StreamWriter( | ||
| 181 | url: cfg.outDir.appendingPathComponent("\(name).m4a"), | ||
| 182 | name: name, kind: kind, fileType: .m4a, settings: settings, mediaType: .audio) | ||
| 183 | } | ||
| 184 | |||
| 185 | // MARK: AVCapture (mic + camera) | ||
| 186 | |||
| 187 | private func startAVCapture(audioUID: String? = nil, videoUID: String? = nil) throws { | ||
| 188 | let session = AVCaptureSession() | ||
| 189 | session.beginConfiguration() | ||
| 190 | |||
| 191 | if let audioUID { | ||
| 192 | guard let device = Devices.audioDevice(matching: audioUID) else { | ||
| 193 | throw RecorderError("audio device not found: \(audioUID)") | ||
| 194 | } | ||
| 195 | let input = try AVCaptureDeviceInput(device: device) | ||
| 196 | guard session.canAddInput(input) else { throw RecorderError("cannot add mic input") } | ||
| 197 | session.addInput(input) | ||
| 198 | |||
| 199 | let writer = try makeAudioWriter(name: "mic", kind: "mic") | ||
| 200 | writer.deviceUID = device.uniqueID | ||
| 201 | sinks.append(writer) | ||
| 202 | |||
| 203 | let out = AVCaptureAudioDataOutput() | ||
| 204 | let delegate = AVOutput { [weak writer] sb in writer?.append(sb) } | ||
| 205 | avOutputs.append(delegate) | ||
| 206 | out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.mic")) | ||
| 207 | guard session.canAddOutput(out) else { throw RecorderError("cannot add mic output") } | ||
| 208 | session.addOutput(out) | ||
| 209 | } | ||
| 210 | |||
| 211 | if let videoUID { | ||
| 212 | guard let device = Devices.videoDevice(matching: videoUID) else { | ||
| 213 | throw RecorderError("camera not found: \(videoUID)") | ||
| 214 | } | ||
| 215 | let input = try AVCaptureDeviceInput(device: device) | ||
| 216 | guard session.canAddInput(input) else { throw RecorderError("cannot add camera input") } | ||
| 217 | session.addInput(input) | ||
| 218 | |||
| 219 | let camWriter = try makeVideoWriter( | ||
| 220 | name: "cam", kind: "camera", width: 1920, height: 1080) | ||
| 221 | camWriter.deviceUID = device.uniqueID | ||
| 222 | sinks.append(camWriter) | ||
| 223 | |||
| 224 | let out = AVCaptureVideoDataOutput() | ||
| 225 | let delegate = AVOutput { [weak camWriter] sb in camWriter?.append(sb) } | ||
| 226 | avOutputs.append(delegate) | ||
| 227 | out.setSampleBufferDelegate(delegate, queue: DispatchQueue(label: "clover.av.cam")) | ||
| 228 | guard session.canAddOutput(out) else { throw RecorderError("cannot add camera output") } | ||
| 229 | session.addOutput(out) | ||
| 230 | } | ||
| 231 | |||
| 232 | session.commitConfiguration() | ||
| 233 | session.startRunning() | ||
| 234 | captureSessions.append(session) | ||
| 235 | } | ||
| 236 | |||
| 237 | // MARK: helpers | ||
| 238 | |||
| 239 | private func screenName(index: Int, count: Int) -> String { | ||
| 240 | if count == 2 { return index == 0 ? "screen-left" : "screen-right" } | ||
| 241 | return "screen-\(index + 1)" | ||
| 242 | } | ||
| 243 | |||
| 244 | private func outputSize(for display: SCDisplay) -> (Int, Int) { | ||
| 245 | // Prefer the true framebuffer pixel size; fall back to the points frame. | ||
| 246 | var pxW = display.width | ||
| 247 | var pxH = display.height | ||
| 248 | if let mode = CGDisplayCopyDisplayMode(display.displayID) { | ||
| 249 | pxW = mode.pixelWidth | ||
| 250 | pxH = mode.pixelHeight | ||
| 251 | } | ||
| 252 | let longest = max(pxW, pxH) | ||
| 253 | guard longest > cfg.maxWidth, cfg.maxWidth > 0 else { return (even(pxW), even(pxH)) } | ||
| 254 | let scale = Double(cfg.maxWidth) / Double(longest) | ||
| 255 | return (even(Int(Double(pxW) * scale)), even(Int(Double(pxH) * scale))) | ||
| 256 | } | ||
| 257 | |||
| 258 | private func even(_ v: Int) -> Int { v - (v % 2) } | ||
| 259 | |||
| 260 | private func writeManifest() { | ||
| 261 | let withData = sinks.filter { !$0.firstPTS.isNaN } | ||
| 262 | let tStart = withData.map { $0.firstPTS }.min() ?? hostClockAtStart | ||
| 263 | |||
| 264 | var streamManifests = sinks.map { sink -> StreamManifest in | ||
| 265 | var m = sink.manifest() | ||
| 266 | m.offsetSeconds = m.firstSampleHostSeconds.isNaN ? 0 : (m.firstSampleHostSeconds - tStart) | ||
| 267 | return m | ||
| 268 | } | ||
| 269 | streamManifests.sort { $0.offsetSeconds < $1.offsetSeconds } | ||
| 270 | |||
| 271 | let manifest = SessionManifest( | ||
| 272 | recorderVersion: recorderVersion, | ||
| 273 | label: cfg.label, | ||
| 274 | createdEpoch: createdEpoch, | ||
| 275 | hostClockAtStart: hostClockAtStart, | ||
| 276 | tStartHostSeconds: tStart, | ||
| 277 | streams: streamManifests) | ||
| 278 | |||
| 279 | do { | ||
| 280 | let encoder = JSONEncoder() | ||
| 281 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | ||
| 282 | let data = try encoder.encode(manifest) | ||
| 283 | try data.write(to: cfg.outDir.appendingPathComponent("sync.json")) | ||
| 284 | } catch { | ||
| 285 | logErr("failed to write manifest: \(error)") | ||
| 286 | } | ||
| 287 | |||
| 288 | // Human-readable summary to stderr. | ||
| 289 | logInfo("session: \(cfg.outDir.path)") | ||
| 290 | for m in streamManifests { | ||
| 291 | let size = fileSize(cfg.outDir.appendingPathComponent(m.file)) | ||
| 292 | logInfo( | ||
| 293 | String( | ||
| 294 | format: " %-13@ %6.2fs %5d frames drop %-3d rep %-4d off %+0.3fs %@", | ||
| 295 | m.name as NSString, m.durationSeconds, m.frames, m.dropped, m.repeated, | ||
| 296 | m.offsetSeconds, size as NSString)) | ||
| 297 | } | ||
| 298 | } | ||
| 299 | |||
| 300 | private func fileSize(_ url: URL) -> String { | ||
| 301 | guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), | ||
| 302 | let bytes = attrs[.size] as? Int64 | ||
| 303 | else { return "—" } | ||
| 304 | let mb = Double(bytes) / 1_048_576 | ||
| 305 | return String(format: "%.1f MB", mb) | ||
| 306 | } | ||
| 307 | } | ||
src/Recorder/engine/Sources/recorder/MenubarApp.swift created+488| ... | @@ -0,0 +1,488 @@ | ||
| 1 | import AppKit | ||
| 2 | import AVFoundation | ||
| 3 | import Foundation | ||
| 4 | import SwiftUI | ||
| 5 | |||
| 6 | // MARK: - Entry | ||
| 7 | |||
| 8 | @MainActor | ||
| 9 | func runMenubar() { | ||
| 10 | let app = NSApplication.shared | ||
| 11 | let controller = AppController() | ||
| 12 | app.delegate = controller | ||
| 13 | app.setActivationPolicy(.accessory) // menubar only, no Dock icon | ||
| 14 | app.run() | ||
| 15 | } | ||
| 16 | |||
| 17 | // MARK: - Controller | ||
| 18 | |||
| 19 | @MainActor | ||
| 20 | final class AppController: NSObject, NSApplicationDelegate, ObservableObject { | ||
| 21 | // Settings (persisted) | ||
| 22 | @Published var destination: String = UserDefaults.standard.string(forKey: "destination") ?? "Sessions" | ||
| 23 | { | ||
| 24 | didSet { | ||
| 25 | // Sessions default to recording REAPER; Journal defaults to not. You can | ||
| 26 | // still override the checkbox afterwards. | ||
| 27 | guard destination != oldValue else { return } | ||
| 28 | reaperMidi = (destination == "Sessions") | ||
| 29 | } | ||
| 30 | } | ||
| 31 | @Published var includeDesktop = UserDefaults.standard.object(forKey: "desktop") as? Bool ?? true | ||
| 32 | @Published var includeMic = UserDefaults.standard.object(forKey: "mic") as? Bool ?? true | ||
| 33 | @Published var reaperMidi = UserDefaults.standard.object(forKey: "reaper") as? Bool ?? false | ||
| 34 | @Published var enabledDisplays: Set<UInt32> = [] | ||
| 35 | |||
| 36 | // Discovered hardware | ||
| 37 | @Published var displays: [DisplayInfo] = [] | ||
| 38 | @Published var hasMic = false | ||
| 39 | @Published var permissionOK = true | ||
| 40 | |||
| 41 | // Live state | ||
| 42 | @Published var isRecording = false | ||
| 43 | @Published var elapsed: TimeInterval = 0 | ||
| 44 | @Published var lastSession: String? | ||
| 45 | @Published var errorMessage: String? | ||
| 46 | |||
| 47 | private var statusItem: NSStatusItem? | ||
| 48 | private var popover: NSPopover? | ||
| 49 | private var engine: CaptureEngine? | ||
| 50 | private var starting = false | ||
| 51 | private var startHost: Double = 0 | ||
| 52 | private var tickTimer: Timer? | ||
| 53 | private var currentSessionDir: URL? | ||
| 54 | private var zenithDir: URL? | ||
| 55 | |||
| 56 | // Fast local scratch for video/audio; the NAS archive is the final home. | ||
| 57 | let tempRoot = URL(fileURLWithPath: "/Volumes/Documents/Temp") | ||
| 58 | let archiveRoot = URL(fileURLWithPath: "/Volumes/clover/Archive") | ||
| 59 | let reaperTemplate = URL(fileURLWithPath: "/Volumes/Documents/Recorder Template.rpp") | ||
| 60 | @Published var statusText: String? | ||
| 61 | |||
| 62 | func applicationDidFinishLaunching(_ notification: Notification) { | ||
| 63 | let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) | ||
| 64 | item.button?.image = NSImage( | ||
| 65 | systemSymbolName: "record.circle", accessibilityDescription: "Clover Recorder") | ||
| 66 | item.button?.action = #selector(togglePopover) | ||
| 67 | item.button?.target = self | ||
| 68 | statusItem = item | ||
| 69 | |||
| 70 | let pop = NSPopover() | ||
| 71 | pop.behavior = .transient | ||
| 72 | pop.contentSize = NSSize(width: 320, height: 460) | ||
| 73 | pop.contentViewController = NSHostingController(rootView: ContentView(controller: self)) | ||
| 74 | popover = pop | ||
| 75 | |||
| 76 | Task { await refreshDevices() } | ||
| 77 | } | ||
| 78 | |||
| 79 | @objc private func togglePopover() { | ||
| 80 | guard let button = statusItem?.button, let popover else { return } | ||
| 81 | if popover.isShown { | ||
| 82 | popover.performClose(nil) | ||
| 83 | } else { | ||
| 84 | Task { await refreshDevices() } // re-check displays + mic each time it opens | ||
| 85 | popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) | ||
| 86 | popover.contentViewController?.view.window?.makeKey() | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | func refreshDevices() async { | ||
| 91 | do { | ||
| 92 | let devices = try await Devices.discover() | ||
| 93 | displays = devices.displays | ||
| 94 | hasMic = !devices.audioInputs.isEmpty | ||
| 95 | permissionOK = true | ||
| 96 | // Display IDs change across sleep/wake, so drop stale selections and fall | ||
| 97 | // back to all current displays if nothing valid remains. | ||
| 98 | let currentIDs = Set(devices.displays.map { $0.id }) | ||
| 99 | enabledDisplays.formIntersection(currentIDs) | ||
| 100 | if enabledDisplays.isEmpty { enabledDisplays = currentIDs } | ||
| 101 | } catch { | ||
| 102 | permissionOK = false | ||
| 103 | errorMessage = "Screen Recording permission needed." | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | func setDisplay(_ id: UInt32, on: Bool) { | ||
| 108 | if on { enabledDisplays.insert(id) } else { enabledDisplays.remove(id) } | ||
| 109 | } | ||
| 110 | |||
| 111 | private func persist() { | ||
| 112 | let d = UserDefaults.standard | ||
| 113 | d.set(destination, forKey: "destination") | ||
| 114 | d.set(includeDesktop, forKey: "desktop") | ||
| 115 | d.set(includeMic, forKey: "mic") | ||
| 116 | d.set(reaperMidi, forKey: "reaper") | ||
| 117 | } | ||
| 118 | |||
| 119 | // MARK: recording | ||
| 120 | |||
| 121 | func start() { | ||
| 122 | guard !isRecording, !starting else { return } | ||
| 123 | starting = true | ||
| 124 | persist() | ||
| 125 | errorMessage = nil | ||
| 126 | statusText = "Checking zenith…" | ||
| 127 | Task { | ||
| 128 | guard await self.ensureZenithMounted() else { | ||
| 129 | self.starting = false | ||
| 130 | self.statusText = nil | ||
| 131 | self.errorMessage = "zenith archive isn't mounted — mount /Volumes/clover and try again." | ||
| 132 | return | ||
| 133 | } | ||
| 134 | await self.refreshDevices() // display IDs shift across sleep/wake | ||
| 135 | await self.beginRecording() | ||
| 136 | self.starting = false | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | private func beginRecording() async { | ||
| 141 | let name = resolveSessionName() | ||
| 142 | let temp = tempRoot.appendingPathComponent(name) | ||
| 143 | let zenith = zenithSessionDir(name) | ||
| 144 | try? FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) | ||
| 145 | |||
| 146 | // The final NAS home — created now so REAPER can record straight into it. | ||
| 147 | var zenithReady: URL? = zenith | ||
| 148 | do { | ||
| 149 | try FileManager.default.createDirectory(at: zenith, withIntermediateDirectories: true) | ||
| 150 | } catch { | ||
| 151 | errorMessage = "Archive folder unavailable (\(error.localizedDescription)); will keep local copy." | ||
| 152 | zenithReady = nil | ||
| 153 | } | ||
| 154 | |||
| 155 | // Record only currently-available displays. | ||
| 156 | let current = Set(displays.map { $0.id }) | ||
| 157 | var useDisplays = enabledDisplays.intersection(current) | ||
| 158 | if useDisplays.isEmpty { useDisplays = current } | ||
| 159 | |||
| 160 | let cfg = RecordConfig( | ||
| 161 | outDir: temp, | ||
| 162 | label: name, | ||
| 163 | displayIDs: Array(useDisplays), | ||
| 164 | systemAudio: includeDesktop, | ||
| 165 | micUID: (includeMic && hasMic) ? "default" : nil, | ||
| 166 | cameraUID: nil, | ||
| 167 | fps: 30, | ||
| 168 | maxWidth: 3840, | ||
| 169 | bitsPerPixel: 0.05, | ||
| 170 | duration: nil, | ||
| 171 | logPath: temp.appendingPathComponent("recorder.log").path) | ||
| 172 | |||
| 173 | // Start capture FIRST; only wire up REAPER + UI once it's confirmed live, so | ||
| 174 | // a failure cleans up instead of leaving empty session folders / a stray | ||
| 175 | // REAPER project behind. | ||
| 176 | let engine = CaptureEngine(cfg) | ||
| 177 | statusText = "Starting…" | ||
| 178 | do { | ||
| 179 | try await engine.start() | ||
| 180 | } catch { | ||
| 181 | statusText = nil | ||
| 182 | errorMessage = error.localizedDescription | ||
| 183 | if "\(error)".contains("declined") { permissionOK = false } | ||
| 184 | try? FileManager.default.removeItem(at: temp) | ||
| 185 | if let z = zenithReady { try? FileManager.default.removeItem(at: z) } | ||
| 186 | return | ||
| 187 | } | ||
| 188 | |||
| 189 | self.engine = engine | ||
| 190 | currentSessionDir = temp | ||
| 191 | zenithDir = zenithReady | ||
| 192 | if reaperMidi { setupReaper(name: name) } | ||
| 193 | isRecording = true | ||
| 194 | elapsed = 0 | ||
| 195 | startHost = hostSeconds() | ||
| 196 | statusText = "Recording…" | ||
| 197 | updateIcon() | ||
| 198 | startTick() | ||
| 199 | } | ||
| 200 | |||
| 201 | func stop() { | ||
| 202 | guard isRecording, let engine else { return } | ||
| 203 | isRecording = false | ||
| 204 | stopTick() | ||
| 205 | updateIcon() | ||
| 206 | statusText = "Finalizing…" | ||
| 207 | let temp = currentSessionDir | ||
| 208 | let zenith = zenithDir | ||
| 209 | Task { | ||
| 210 | await engine.stop() | ||
| 211 | self.engine = nil | ||
| 212 | guard let temp else { return } | ||
| 213 | guard let zenith else { | ||
| 214 | self.lastSession = temp.lastPathComponent | ||
| 215 | self.statusText = "Saved locally: \(temp.lastPathComponent)" | ||
| 216 | return | ||
| 217 | } | ||
| 218 | self.statusText = "Archiving to zenith…" | ||
| 219 | guard await self.ensureZenithMounted() else { | ||
| 220 | self.lastSession = temp.lastPathComponent | ||
| 221 | self.statusText = "zenith offline — kept local copy" | ||
| 222 | self.errorMessage = | ||
| 223 | "zenith not mounted; files remain in /Temp/\(temp.lastPathComponent)." | ||
| 224 | return | ||
| 225 | } | ||
| 226 | let ok = await self.archive(temp: temp, zenith: zenith) | ||
| 227 | self.lastSession = zenith.lastPathComponent | ||
| 228 | if ok { | ||
| 229 | self.statusText = "Saved: \(zenith.lastPathComponent)" | ||
| 230 | } else { | ||
| 231 | self.statusText = "Archive failed — kept local copy" | ||
| 232 | self.errorMessage = "rsync to zenith failed; files remain in /Temp/\(temp.lastPathComponent)." | ||
| 233 | } | ||
| 234 | } | ||
| 235 | } | ||
| 236 | |||
| 237 | func toggle() { isRecording ? stop() : start() } | ||
| 238 | |||
| 239 | private func startTick() { | ||
| 240 | tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in | ||
| 241 | Task { @MainActor in self?.elapsed = hostSeconds() - (self?.startHost ?? 0) } | ||
| 242 | } | ||
| 243 | } | ||
| 244 | private func stopTick() { | ||
| 245 | tickTimer?.invalidate() | ||
| 246 | tickTimer = nil | ||
| 247 | } | ||
| 248 | |||
| 249 | private func updateIcon() { | ||
| 250 | let name = isRecording ? "stop.circle.fill" : "record.circle" | ||
| 251 | let image = NSImage(systemSymbolName: name, accessibilityDescription: "Clover Recorder") | ||
| 252 | if isRecording { | ||
| 253 | image?.isTemplate = false | ||
| 254 | statusItem?.button?.contentTintColor = .systemRed | ||
| 255 | } else { | ||
| 256 | statusItem?.button?.contentTintColor = nil | ||
| 257 | } | ||
| 258 | statusItem?.button?.image = image | ||
| 259 | } | ||
| 260 | |||
| 261 | /// Session reference is the start time, `YYYY-MM-DD_HH.MM`. If one already | ||
| 262 | /// exists for this minute (locally or on the NAS), bump forward a minute until | ||
| 263 | /// it's unique. | ||
| 264 | private func resolveSessionName() -> String { | ||
| 265 | var date = Date() | ||
| 266 | for _ in 0..<240 { | ||
| 267 | let name = stamp("yyyy-MM-dd_HH.mm", date) | ||
| 268 | let taken = | ||
| 269 | FileManager.default.fileExists(atPath: tempRoot.appendingPathComponent(name).path) | ||
| 270 | || FileManager.default.fileExists(atPath: zenithSessionDir(name).path) | ||
| 271 | if !taken { return name } | ||
| 272 | date = date.addingTimeInterval(60) | ||
| 273 | } | ||
| 274 | return stamp("yyyy-MM-dd_HH.mm.ss") // fallback, should never hit | ||
| 275 | } | ||
| 276 | |||
| 277 | func copyReference() { | ||
| 278 | guard let ref = lastSession else { return } | ||
| 279 | NSPasteboard.general.clearContents() | ||
| 280 | NSPasteboard.general.setString(ref, forType: .string) | ||
| 281 | statusText = "Copied \(ref)" | ||
| 282 | } | ||
| 283 | |||
| 284 | private func zenithSessionDir(_ name: String) -> URL { | ||
| 285 | archiveRoot.appendingPathComponent(stamp("yyyy")) | ||
| 286 | .appendingPathComponent(destination) | ||
| 287 | .appendingPathComponent(name) | ||
| 288 | } | ||
| 289 | |||
| 290 | // MARK: REAPER | ||
| 291 | |||
| 292 | private func setupReaper(name: String) { | ||
| 293 | guard let zenith = zenithDir else { | ||
| 294 | errorMessage = "Can't set up REAPER without the archive folder." | ||
| 295 | return | ||
| 296 | } | ||
| 297 | guard FileManager.default.fileExists(atPath: reaperTemplate.path) else { | ||
| 298 | errorMessage = "REAPER template not found at \(reaperTemplate.path)" | ||
| 299 | return | ||
| 300 | } | ||
| 301 | let reaperDir = zenith.appendingPathComponent("reaper") | ||
| 302 | let project = reaperDir.appendingPathComponent("\(name).rpp") | ||
| 303 | do { | ||
| 304 | try FileManager.default.createDirectory(at: reaperDir, withIntermediateDirectories: true) | ||
| 305 | try FileManager.default.copyItem(at: reaperTemplate, to: project) | ||
| 306 | } catch { | ||
| 307 | errorMessage = "REAPER project setup failed: \(error.localizedDescription)" | ||
| 308 | return | ||
| 309 | } | ||
| 310 | // Open it; you drive transport. REAPER records its media next to the project | ||
| 311 | // (the final NAS location), so nothing needs repathing afterwards. | ||
| 312 | let open = Process() | ||
| 313 | open.executableURL = URL(fileURLWithPath: "/usr/bin/open") | ||
| 314 | open.arguments = ["-a", "REAPER", project.path] | ||
| 315 | try? open.run() | ||
| 316 | } | ||
| 317 | |||
| 318 | // MARK: Archive | ||
| 319 | |||
| 320 | /// rsync the local scratch session into its NAS home, then remove the scratch | ||
| 321 | /// copy on success. Runs off the main actor so the UI stays responsive. | ||
| 322 | private func archive(temp: URL, zenith: URL) async -> Bool { | ||
| 323 | await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in | ||
| 324 | DispatchQueue.global(qos: .utility).async { | ||
| 325 | let p = Process() | ||
| 326 | p.executableURL = URL(fileURLWithPath: "/usr/bin/rsync") | ||
| 327 | p.arguments = ["-a", "--exclude=recorder.log", temp.path + "/", zenith.path + "/"] | ||
| 328 | do { | ||
| 329 | try p.run() | ||
| 330 | p.waitUntilExit() | ||
| 331 | } catch { | ||
| 332 | cont.resume(returning: false) | ||
| 333 | return | ||
| 334 | } | ||
| 335 | let ok = p.terminationStatus == 0 | ||
| 336 | if ok { try? FileManager.default.removeItem(at: temp) } | ||
| 337 | cont.resume(returning: ok) | ||
| 338 | } | ||
| 339 | } | ||
| 340 | } | ||
| 341 | |||
| 342 | // MARK: zenith mount | ||
| 343 | |||
| 344 | private func zenithMounted() -> Bool { | ||
| 345 | let vols = FileManager.default.mountedVolumeURLs( | ||
| 346 | includingResourceValuesForKeys: nil, options: [.skipHiddenVolumes]) ?? [] | ||
| 347 | return vols.contains { $0.path == "/Volumes/clover" } | ||
| 348 | && FileManager.default.fileExists(atPath: archiveRoot.path) | ||
| 349 | } | ||
| 350 | |||
| 351 | /// Ensure the zenith SMB share is mounted, attempting to mount it with saved | ||
| 352 | /// keychain credentials and polling briefly if it wasn't. | ||
| 353 | private func ensureZenithMounted() async -> Bool { | ||
| 354 | if zenithMounted() { return true } | ||
| 355 | await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | ||
| 356 | DispatchQueue.global(qos: .utility).async { | ||
| 357 | let p = Process() | ||
| 358 | p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") | ||
| 359 | p.arguments = ["-e", "mount volume \"smb://clo@zenith.local/clover\""] | ||
| 360 | try? p.run() | ||
| 361 | p.waitUntilExit() | ||
| 362 | cont.resume() | ||
| 363 | } | ||
| 364 | } | ||
| 365 | for _ in 0..<12 { | ||
| 366 | if zenithMounted() { return true } | ||
| 367 | try? await Task.sleep(nanoseconds: 500_000_000) | ||
| 368 | } | ||
| 369 | return zenithMounted() | ||
| 370 | } | ||
| 371 | |||
| 372 | private func stamp(_ format: String, _ date: Date = Date()) -> String { | ||
| 373 | let f = DateFormatter() | ||
| 374 | f.dateFormat = format | ||
| 375 | return f.string(from: date) | ||
| 376 | } | ||
| 377 | } | ||
| 378 | |||
| 379 | // MARK: - View | ||
| 380 | |||
| 381 | struct ContentView: View { | ||
| 382 | @ObservedObject var controller: AppController | ||
| 383 | |||
| 384 | var body: some View { | ||
| 385 | VStack(alignment: .leading, spacing: 12) { | ||
| 386 | HStack { | ||
| 387 | Text("Clover Recorder").font(.headline) | ||
| 388 | Spacer() | ||
| 389 | if controller.isRecording { | ||
| 390 | Text(timeString(controller.elapsed)) | ||
| 391 | .font(.system(.body, design: .monospaced)).foregroundStyle(.red) | ||
| 392 | } | ||
| 393 | } | ||
| 394 | |||
| 395 | if !controller.permissionOK { | ||
| 396 | Label("Screen Recording permission needed", systemImage: "exclamationmark.triangle") | ||
| 397 | .font(.caption).foregroundStyle(.orange) | ||
| 398 | } | ||
| 399 | |||
| 400 | Picker("", selection: $controller.destination) { | ||
| 401 | Text("Sessions").tag("Sessions") | ||
| 402 | Text("Journal").tag("Journal") | ||
| 403 | } | ||
| 404 | .pickerStyle(.segmented) | ||
| 405 | .disabled(controller.isRecording) | ||
| 406 | |||
| 407 | Divider() | ||
| 408 | |||
| 409 | VStack(alignment: .leading, spacing: 6) { | ||
| 410 | Text("Capture").font(.caption).foregroundStyle(.secondary) | ||
| 411 | ForEach(controller.displays, id: \.id) { d in | ||
| 412 | Toggle( | ||
| 413 | displayLabel(d), | ||
| 414 | isOn: Binding( | ||
| 415 | get: { controller.enabledDisplays.contains(d.id) }, | ||
| 416 | set: { controller.setDisplay(d.id, on: $0) }) | ||
| 417 | ).disabled(controller.isRecording) | ||
| 418 | } | ||
| 419 | Toggle("Desktop audio", isOn: $controller.includeDesktop).disabled(controller.isRecording) | ||
| 420 | if controller.hasMic { | ||
| 421 | Toggle("Microphone", isOn: $controller.includeMic).disabled(controller.isRecording) | ||
| 422 | } else { | ||
| 423 | Label("No microphone connected", systemImage: "exclamationmark.triangle.fill") | ||
| 424 | .foregroundStyle(.yellow) | ||
| 425 | } | ||
| 426 | Toggle("REAPER (MIDI)", isOn: $controller.reaperMidi).disabled(controller.isRecording) | ||
| 427 | } | ||
| 428 | .toggleStyle(.checkbox) | ||
| 429 | |||
| 430 | Spacer() | ||
| 431 | |||
| 432 | if let last = controller.lastSession, !controller.isRecording { | ||
| 433 | Button(action: { controller.copyReference() }) { | ||
| 434 | HStack(spacing: 6) { | ||
| 435 | Image(systemName: "doc.on.doc") | ||
| 436 | Text(last).font(.system(.caption, design: .monospaced)) | ||
| 437 | } | ||
| 438 | } | ||
| 439 | .buttonStyle(.borderless) | ||
| 440 | .help("Copy session reference to clipboard") | ||
| 441 | } | ||
| 442 | if let status = controller.statusText { | ||
| 443 | Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(1) | ||
| 444 | } | ||
| 445 | if let err = controller.errorMessage { | ||
| 446 | Text(err).font(.caption).foregroundStyle(.red).lineLimit(2) | ||
| 447 | } | ||
| 448 | |||
| 449 | VStack(spacing: 4) { | ||
| 450 | Button(action: { controller.toggle() }) { | ||
| 451 | HStack(spacing: 6) { | ||
| 452 | if !controller.isRecording && !controller.hasMic { | ||
| 453 | Image(systemName: "exclamationmark.triangle.fill") | ||
| 454 | } | ||
| 455 | Text(controller.isRecording ? "Stop" : "Start Recording") | ||
| 456 | } | ||
| 457 | .frame(maxWidth: .infinity) | ||
| 458 | } | ||
| 459 | .controlSize(.large) | ||
| 460 | .tint(controller.isRecording ? .red : (controller.hasMic ? .accentColor : .yellow)) | ||
| 461 | .disabled(!controller.permissionOK && !controller.isRecording) | ||
| 462 | |||
| 463 | if !controller.isRecording && !controller.hasMic { | ||
| 464 | Text("Will record without microphone") | ||
| 465 | .font(.caption2).foregroundStyle(.yellow) | ||
| 466 | } | ||
| 467 | } | ||
| 468 | |||
| 469 | HStack { | ||
| 470 | Button("Quit") { NSApp.terminate(nil) }.font(.caption) | ||
| 471 | Spacer() | ||
| 472 | } | ||
| 473 | } | ||
| 474 | .padding(14) | ||
| 475 | .frame(width: 320) | ||
| 476 | } | ||
| 477 | |||
| 478 | private func displayLabel(_ d: DisplayInfo) -> String { | ||
| 479 | let idx = controller.displays.firstIndex(where: { $0.id == d.id }) ?? 0 | ||
| 480 | let side = controller.displays.count == 2 ? (idx == 0 ? " (left)" : " (right)") : "" | ||
| 481 | return "Screen \(idx + 1)\(side) — \(d.width)×\(d.height)" | ||
| 482 | } | ||
| 483 | |||
| 484 | private func timeString(_ t: TimeInterval) -> String { | ||
| 485 | let s = Int(t) | ||
| 486 | return String(format: "%02d:%02d", s / 60, s % 60) | ||
| 487 | } | ||
| 488 | } | ||
src/Recorder/engine/Sources/recorder/Recorder.swift created+512| ... | @@ -0,0 +1,512 @@ | ||
| 1 | import AVFoundation | ||
| 2 | import CoreGraphics | ||
| 3 | import CoreMedia | ||
| 4 | import Foundation | ||
| 5 | import ScreenCaptureKit | ||
| 6 | |||
| 7 | let recorderVersion = "0.1.0" | ||
| 8 | |||
| 9 | // MARK: - Logging | ||
| 10 | |||
| 11 | // Optional log file so diagnostics survive launch methods that discard | ||
| 12 | // stdout/stderr (e.g. `open` / LaunchServices). | ||
| 13 | var logFile: FileHandle? | ||
| 14 | |||
| 15 | func openLogFile(_ path: String) { | ||
| 16 | FileManager.default.createFile(atPath: path, contents: nil) | ||
| 17 | logFile = FileHandle(forWritingAtPath: path) | ||
| 18 | } | ||
| 19 | |||
| 20 | private func emit(_ message: String) { | ||
| 21 | let line = "[recorder] " + message + "\n" | ||
| 22 | FileHandle.standardError.write(Data(line.utf8)) | ||
| 23 | if let logFile { | ||
| 24 | logFile.write(Data(line.utf8)) | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | func logErr(_ message: String) { emit(message) } | ||
| 29 | func logInfo(_ message: String) { emit(message) } | ||
| 30 | |||
| 31 | // MARK: - Host clock | ||
| 32 | |||
| 33 | /// Seconds on the mach host clock — the same clock ScreenCaptureKit and | ||
| 34 | /// AVCapture stamp their sample buffers with, so values are directly comparable | ||
| 35 | /// across every stream. | ||
| 36 | func hostSeconds() -> Double { | ||
| 37 | CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock())) | ||
| 38 | } | ||
| 39 | |||
| 40 | // MARK: - Device discovery | ||
| 41 | |||
| 42 | struct DisplayInfo: Codable { | ||
| 43 | let id: UInt32 | ||
| 44 | let width: Int | ||
| 45 | let height: Int | ||
| 46 | let x: Int | ||
| 47 | let y: Int | ||
| 48 | } | ||
| 49 | |||
| 50 | struct DeviceInfo: Codable { | ||
| 51 | let uid: String | ||
| 52 | let name: String | ||
| 53 | } | ||
| 54 | |||
| 55 | struct DiscoveredDevices: Codable { | ||
| 56 | let displays: [DisplayInfo] | ||
| 57 | let cameras: [DeviceInfo] | ||
| 58 | let audioInputs: [DeviceInfo] | ||
| 59 | } | ||
| 60 | |||
| 61 | enum Devices { | ||
| 62 | static func discover() async throws -> DiscoveredDevices { | ||
| 63 | let content = try await SCShareableContent.excludingDesktopWindows( | ||
| 64 | false, onScreenWindowsOnly: false) | ||
| 65 | |||
| 66 | let displays = | ||
| 67 | content.displays | ||
| 68 | .sorted { $0.frame.origin.x < $1.frame.origin.x } | ||
| 69 | .map { | ||
| 70 | DisplayInfo( | ||
| 71 | id: $0.displayID, | ||
| 72 | width: Int($0.frame.width), | ||
| 73 | height: Int($0.frame.height), | ||
| 74 | x: Int($0.frame.origin.x), | ||
| 75 | y: Int($0.frame.origin.y)) | ||
| 76 | } | ||
| 77 | |||
| 78 | let cameras = AVCaptureDevice.DiscoverySession( | ||
| 79 | deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], | ||
| 80 | mediaType: .video, position: .unspecified | ||
| 81 | ).devices.map { DeviceInfo(uid: $0.uniqueID, name: $0.localizedName) } | ||
| 82 | |||
| 83 | let audioInputs = AVCaptureDevice.DiscoverySession( | ||
| 84 | deviceTypes: [.microphone, .external], | ||
| 85 | mediaType: .audio, position: .unspecified | ||
| 86 | ).devices.map { DeviceInfo(uid: $0.uniqueID, name: $0.localizedName) } | ||
| 87 | |||
| 88 | return DiscoveredDevices(displays: displays, cameras: cameras, audioInputs: audioInputs) | ||
| 89 | } | ||
| 90 | |||
| 91 | static func audioDevice(matching wanted: String) -> AVCaptureDevice? { | ||
| 92 | let devices = AVCaptureDevice.DiscoverySession( | ||
| 93 | deviceTypes: [.microphone, .external], | ||
| 94 | mediaType: .audio, position: .unspecified | ||
| 95 | ).devices | ||
| 96 | if wanted == "default" { | ||
| 97 | return AVCaptureDevice.default(for: .audio) ?? devices.first | ||
| 98 | } | ||
| 99 | return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted } | ||
| 100 | } | ||
| 101 | |||
| 102 | static func videoDevice(matching wanted: String) -> AVCaptureDevice? { | ||
| 103 | let devices = AVCaptureDevice.DiscoverySession( | ||
| 104 | deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], | ||
| 105 | mediaType: .video, position: .unspecified | ||
| 106 | ).devices | ||
| 107 | if wanted == "default" { | ||
| 108 | return AVCaptureDevice.default(for: .video) ?? devices.first | ||
| 109 | } | ||
| 110 | return devices.first { $0.uniqueID == wanted || $0.localizedName == wanted } | ||
| 111 | } | ||
| 112 | } | ||
| 113 | |||
| 114 | // MARK: - Manifest | ||
| 115 | |||
| 116 | struct StreamManifest: Codable { | ||
| 117 | let name: String | ||
| 118 | let file: String | ||
| 119 | let kind: String | ||
| 120 | var displayID: UInt32? | ||
| 121 | var deviceUID: String? | ||
| 122 | var width: Int? | ||
| 123 | var height: Int? | ||
| 124 | var fps: Int? | ||
| 125 | let firstSampleHostSeconds: Double | ||
| 126 | let lastSampleHostSeconds: Double | ||
| 127 | let durationSeconds: Double | ||
| 128 | let frames: Int | ||
| 129 | let dropped: Int // real drops (input not ready / append failed) | ||
| 130 | let repeated: Int // CFR frames re-emitted to hold the rate on a static screen | ||
| 131 | var offsetSeconds: Double | ||
| 132 | } | ||
| 133 | |||
| 134 | struct SessionManifest: Codable { | ||
| 135 | let recorderVersion: String | ||
| 136 | let label: String | ||
| 137 | let createdEpoch: Double | ||
| 138 | let hostClockAtStart: Double | ||
| 139 | let tStartHostSeconds: Double | ||
| 140 | let streams: [StreamManifest] | ||
| 141 | } | ||
| 142 | |||
| 143 | // MARK: - Stream sink protocol | ||
| 144 | |||
| 145 | /// Anything that records one stream to one file and reports where it sat on the | ||
| 146 | /// shared host clock, so the engine can align and summarize them uniformly. | ||
| 147 | protocol RecordingStream: AnyObject { | ||
| 148 | var firstPTS: Double { get } | ||
| 149 | func finish() async | ||
| 150 | func manifest() -> StreamManifest | ||
| 151 | } | ||
| 152 | |||
| 153 | // MARK: - Stream writer (passthrough: audio + camera) | ||
| 154 | |||
| 155 | /// Owns one AVAssetWriter + input and turns a flow of CMSampleBuffers into one | ||
| 156 | /// file, lazily starting the writer session on the first buffer and recording | ||
| 157 | /// that buffer's host-clock timestamp for later alignment. | ||
| 158 | final class StreamWriter: RecordingStream { | ||
| 159 | let name: String | ||
| 160 | let kind: String | ||
| 161 | let url: URL | ||
| 162 | var displayID: UInt32? | ||
| 163 | var deviceUID: String? | ||
| 164 | var width: Int? | ||
| 165 | var height: Int? | ||
| 166 | var fps: Int? | ||
| 167 | |||
| 168 | private let writer: AVAssetWriter | ||
| 169 | private let input: AVAssetWriterInput | ||
| 170 | private let lock = NSLock() | ||
| 171 | |||
| 172 | private(set) var firstPTS: Double = .nan | ||
| 173 | private(set) var lastPTS: Double = .nan | ||
| 174 | private(set) var frames = 0 | ||
| 175 | private(set) var dropped = 0 | ||
| 176 | private var started = false | ||
| 177 | private var failed = false | ||
| 178 | |||
| 179 | init( | ||
| 180 | url: URL, name: String, kind: String, fileType: AVFileType, | ||
| 181 | settings: [String: Any], mediaType: AVMediaType | ||
| 182 | ) throws { | ||
| 183 | self.url = url | ||
| 184 | self.name = name | ||
| 185 | self.kind = kind | ||
| 186 | self.writer = try AVAssetWriter(outputURL: url, fileType: fileType) | ||
| 187 | self.input = AVAssetWriterInput(mediaType: mediaType, outputSettings: settings) | ||
| 188 | self.input.expectsMediaDataInRealTime = true | ||
| 189 | if writer.canAdd(input) { | ||
| 190 | writer.add(input) | ||
| 191 | } else { | ||
| 192 | throw RecorderError("cannot add \(mediaType.rawValue) input for \(name)") | ||
| 193 | } | ||
| 194 | } | ||
| 195 | |||
| 196 | func append(_ sb: CMSampleBuffer) { | ||
| 197 | lock.lock() | ||
| 198 | defer { lock.unlock() } | ||
| 199 | if failed { return } | ||
| 200 | |||
| 201 | let pts = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sb)) | ||
| 202 | |||
| 203 | if !started { | ||
| 204 | guard writer.startWriting() else { | ||
| 205 | failed = true | ||
| 206 | logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")") | ||
| 207 | return | ||
| 208 | } | ||
| 209 | writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(sb)) | ||
| 210 | firstPTS = pts | ||
| 211 | started = true | ||
| 212 | } | ||
| 213 | |||
| 214 | if input.isReadyForMoreMediaData { | ||
| 215 | if input.append(sb) { | ||
| 216 | frames += 1 | ||
| 217 | lastPTS = pts | ||
| 218 | } else { | ||
| 219 | dropped += 1 | ||
| 220 | if writer.status == .failed { | ||
| 221 | failed = true | ||
| 222 | logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")") | ||
| 223 | } | ||
| 224 | } | ||
| 225 | } else { | ||
| 226 | dropped += 1 | ||
| 227 | } | ||
| 228 | } | ||
| 229 | |||
| 230 | func finish() async { | ||
| 231 | lock.lock() | ||
| 232 | let didStart = started | ||
| 233 | if didStart { input.markAsFinished() } | ||
| 234 | lock.unlock() | ||
| 235 | |||
| 236 | guard didStart else { | ||
| 237 | logErr("\(name): no samples captured, nothing written") | ||
| 238 | return | ||
| 239 | } | ||
| 240 | await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | ||
| 241 | writer.finishWriting { cont.resume() } | ||
| 242 | } | ||
| 243 | if writer.status == .failed { | ||
| 244 | logErr("\(name): finishWriting failed: \(writer.error?.localizedDescription ?? "?")") | ||
| 245 | } | ||
| 246 | } | ||
| 247 | |||
| 248 | func manifest() -> StreamManifest { | ||
| 249 | StreamManifest( | ||
| 250 | name: name, | ||
| 251 | file: url.lastPathComponent, | ||
| 252 | kind: kind, | ||
| 253 | displayID: displayID, | ||
| 254 | deviceUID: deviceUID, | ||
| 255 | width: width, | ||
| 256 | height: height, | ||
| 257 | fps: fps, | ||
| 258 | firstSampleHostSeconds: firstPTS, | ||
| 259 | lastSampleHostSeconds: lastPTS, | ||
| 260 | durationSeconds: (firstPTS.isNaN || lastPTS.isNaN) ? 0 : (lastPTS - firstPTS), | ||
| 261 | frames: frames, | ||
| 262 | dropped: dropped, | ||
| 263 | repeated: 0, | ||
| 264 | offsetSeconds: 0) | ||
| 265 | } | ||
| 266 | } | ||
| 267 | |||
| 268 | struct RecorderError: Error, CustomStringConvertible, LocalizedError { | ||
| 269 | let description: String | ||
| 270 | init(_ description: String) { self.description = description } | ||
| 271 | var errorDescription: String? { description } | ||
| 272 | } | ||
| 273 | |||
| 274 | // MARK: - Constant-frame-rate video writer (screens) | ||
| 275 | |||
| 276 | /// ScreenCaptureKit only delivers a frame when the screen changes, so a raw | ||
| 277 | /// passthrough yields a variable, sparse frame rate whose duration ends at the | ||
| 278 | /// last change rather than at the stop time — which would desync against audio. | ||
| 279 | /// | ||
| 280 | /// This writer decouples capture from encoding: SCOutput hands every fresh | ||
| 281 | /// frame to `update(_:)`, and an independent timer emits the most-recent frame | ||
| 282 | /// at the target rate, timestamped on the host clock. The result stays dense, | ||
| 283 | /// its duration matches wall-clock, and it never drifts against the audio. | ||
| 284 | final class CFRVideoWriter: RecordingStream { | ||
| 285 | let name: String | ||
| 286 | let kind = "screen" | ||
| 287 | let url: URL | ||
| 288 | var displayID: UInt32? | ||
| 289 | let width: Int | ||
| 290 | let height: Int | ||
| 291 | let fps: Int | ||
| 292 | |||
| 293 | private let writer: AVAssetWriter | ||
| 294 | private let input: AVAssetWriterInput | ||
| 295 | private let adaptor: AVAssetWriterInputPixelBufferAdaptor | ||
| 296 | private let queue = DispatchQueue(label: "clover.cfr") | ||
| 297 | private let lock = NSLock() | ||
| 298 | |||
| 299 | private var latest: CVPixelBuffer? | ||
| 300 | private var lastEmitted: CVPixelBuffer? | ||
| 301 | private var timer: DispatchSourceTimer? | ||
| 302 | |||
| 303 | private(set) var firstPTS = Double.nan | ||
| 304 | private var lastPTS = Double.nan | ||
| 305 | private(set) var frames = 0 | ||
| 306 | private(set) var repeated = 0 | ||
| 307 | private(set) var dropped = 0 | ||
| 308 | private var started = false | ||
| 309 | private var failed = false | ||
| 310 | |||
| 311 | init(url: URL, name: String, width: Int, height: Int, fps: Int, bitrate: Int) throws { | ||
| 312 | self.url = url | ||
| 313 | self.name = name | ||
| 314 | self.width = width | ||
| 315 | self.height = height | ||
| 316 | self.fps = fps | ||
| 317 | |||
| 318 | let settings: [String: Any] = [ | ||
| 319 | AVVideoCodecKey: AVVideoCodecType.hevc, | ||
| 320 | AVVideoWidthKey: width, | ||
| 321 | AVVideoHeightKey: height, | ||
| 322 | AVVideoCompressionPropertiesKey: [ | ||
| 323 | AVVideoAverageBitRateKey: bitrate, | ||
| 324 | AVVideoExpectedSourceFrameRateKey: fps, | ||
| 325 | AVVideoMaxKeyFrameIntervalKey: fps * 2, | ||
| 326 | ], | ||
| 327 | ] | ||
| 328 | writer = try AVAssetWriter(outputURL: url, fileType: .mov) | ||
| 329 | input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) | ||
| 330 | input.expectsMediaDataInRealTime = true | ||
| 331 | adaptor = AVAssetWriterInputPixelBufferAdaptor( | ||
| 332 | assetWriterInput: input, | ||
| 333 | sourcePixelBufferAttributes: [ | ||
| 334 | kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, | ||
| 335 | kCVPixelBufferWidthKey as String: width, | ||
| 336 | kCVPixelBufferHeightKey as String: height, | ||
| 337 | ]) | ||
| 338 | guard writer.canAdd(input) else { throw RecorderError("cannot add video input for \(name)") } | ||
| 339 | writer.add(input) | ||
| 340 | } | ||
| 341 | |||
| 342 | /// Latest frame from ScreenCaptureKit; retained until replaced (SCK won't | ||
| 343 | /// recycle a buffer we still hold, so no copy is needed). | ||
| 344 | func update(_ sb: CMSampleBuffer) { | ||
| 345 | guard let pb = CMSampleBufferGetImageBuffer(sb) else { return } | ||
| 346 | lock.lock() | ||
| 347 | latest = pb | ||
| 348 | lock.unlock() | ||
| 349 | } | ||
| 350 | |||
| 351 | func start() { | ||
| 352 | let interval = 1.0 / Double(fps) | ||
| 353 | let t = DispatchSource.makeTimerSource(queue: queue) | ||
| 354 | t.schedule(deadline: .now() + interval, repeating: interval, leeway: .milliseconds(2)) | ||
| 355 | t.setEventHandler { [weak self] in self?.tick() } | ||
| 356 | timer = t | ||
| 357 | t.resume() | ||
| 358 | } | ||
| 359 | |||
| 360 | private func tick() { | ||
| 361 | lock.lock() | ||
| 362 | let pb = latest | ||
| 363 | lock.unlock() | ||
| 364 | guard let pb else { return } // no frame captured yet | ||
| 365 | |||
| 366 | let pts = hostSeconds() | ||
| 367 | if !started { | ||
| 368 | guard writer.startWriting() else { | ||
| 369 | failed = true | ||
| 370 | logErr("\(name): startWriting failed: \(writer.error?.localizedDescription ?? "?")") | ||
| 371 | return | ||
| 372 | } | ||
| 373 | writer.startSession(atSourceTime: CMTime(seconds: pts, preferredTimescale: 1_000_000)) | ||
| 374 | firstPTS = pts | ||
| 375 | started = true | ||
| 376 | } | ||
| 377 | guard !failed else { return } | ||
| 378 | guard input.isReadyForMoreMediaData else { | ||
| 379 | dropped += 1 | ||
| 380 | return | ||
| 381 | } | ||
| 382 | let time = CMTime(seconds: pts, preferredTimescale: 1_000_000) | ||
| 383 | if adaptor.append(pb, withPresentationTime: time) { | ||
| 384 | frames += 1 | ||
| 385 | lastPTS = pts | ||
| 386 | if pb === lastEmitted { repeated += 1 } | ||
| 387 | lastEmitted = pb | ||
| 388 | } else { | ||
| 389 | dropped += 1 | ||
| 390 | if writer.status == .failed { | ||
| 391 | failed = true | ||
| 392 | logErr("\(name): append failed: \(writer.error?.localizedDescription ?? "?")") | ||
| 393 | } | ||
| 394 | } | ||
| 395 | } | ||
| 396 | |||
| 397 | func finish() async { | ||
| 398 | timer?.cancel() | ||
| 399 | timer = nil | ||
| 400 | // Drain the timer queue so no tick races with finalization. | ||
| 401 | await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | ||
| 402 | queue.async { cont.resume() } | ||
| 403 | } | ||
| 404 | guard started else { | ||
| 405 | logErr("\(name): no frames captured, nothing written") | ||
| 406 | return | ||
| 407 | } | ||
| 408 | input.markAsFinished() | ||
| 409 | await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in | ||
| 410 | writer.finishWriting { cont.resume() } | ||
| 411 | } | ||
| 412 | if writer.status == .failed { | ||
| 413 | logErr("\(name): finishWriting failed: \(writer.error?.localizedDescription ?? "?")") | ||
| 414 | } | ||
| 415 | } | ||
| 416 | |||
| 417 | func manifest() -> StreamManifest { | ||
| 418 | StreamManifest( | ||
| 419 | name: name, | ||
| 420 | file: url.lastPathComponent, | ||
| 421 | kind: kind, | ||
| 422 | displayID: displayID, | ||
| 423 | deviceUID: nil, | ||
| 424 | width: width, | ||
| 425 | height: height, | ||
| 426 | fps: fps, | ||
| 427 | firstSampleHostSeconds: firstPTS, | ||
| 428 | lastSampleHostSeconds: lastPTS, | ||
| 429 | durationSeconds: (firstPTS.isNaN || lastPTS.isNaN) ? 0 : (lastPTS - firstPTS), | ||
| 430 | frames: frames, | ||
| 431 | dropped: dropped, | ||
| 432 | repeated: repeated, | ||
| 433 | offsetSeconds: 0) | ||
| 434 | } | ||
| 435 | } | ||
| 436 | |||
| 437 | // MARK: - Sample delegates | ||
| 438 | |||
| 439 | final class SCOutput: NSObject, SCStreamOutput, SCStreamDelegate { | ||
| 440 | let label: String | ||
| 441 | private let onScreen: (CMSampleBuffer) -> Void | ||
| 442 | private let onAudio: ((CMSampleBuffer) -> Void)? | ||
| 443 | |||
| 444 | private let counterLock = NSLock() | ||
| 445 | private(set) var screenSeen = 0 | ||
| 446 | private(set) var screenComplete = 0 | ||
| 447 | private(set) var audioSeen = 0 | ||
| 448 | |||
| 449 | init( | ||
| 450 | label: String, onScreen: @escaping (CMSampleBuffer) -> Void, | ||
| 451 | onAudio: ((CMSampleBuffer) -> Void)? | ||
| 452 | ) { | ||
| 453 | self.label = label | ||
| 454 | self.onScreen = onScreen | ||
| 455 | self.onAudio = onAudio | ||
| 456 | } | ||
| 457 | |||
| 458 | func stream( | ||
| 459 | _ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, | ||
| 460 | of type: SCStreamOutputType | ||
| 461 | ) { | ||
| 462 | switch type { | ||
| 463 | case .screen: | ||
| 464 | counterLock.lock() | ||
| 465 | screenSeen += 1 | ||
| 466 | counterLock.unlock() | ||
| 467 | guard CMSampleBufferGetImageBuffer(sampleBuffer) != nil, Self.isComplete(sampleBuffer) else { | ||
| 468 | return | ||
| 469 | } | ||
| 470 | counterLock.lock() | ||
| 471 | screenComplete += 1 | ||
| 472 | counterLock.unlock() | ||
| 473 | onScreen(sampleBuffer) | ||
| 474 | case .audio: | ||
| 475 | counterLock.lock() | ||
| 476 | audioSeen += 1 | ||
| 477 | counterLock.unlock() | ||
| 478 | onAudio?(sampleBuffer) | ||
| 479 | default: | ||
| 480 | break | ||
| 481 | } | ||
| 482 | } | ||
| 483 | |||
| 484 | func stream(_ stream: SCStream, didStopWithError error: Error) { | ||
| 485 | logErr("SCStream stopped with error: \(error.localizedDescription)") | ||
| 486 | } | ||
| 487 | |||
| 488 | static func isComplete(_ sb: CMSampleBuffer) -> Bool { | ||
| 489 | guard | ||
| 490 | let arr = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: false) | ||
| 491 | as? [[SCStreamFrameInfo: Any]], | ||
| 492 | let info = arr.first, | ||
| 493 | let raw = info[.status] as? Int, | ||
| 494 | let status = SCFrameStatus(rawValue: raw) | ||
| 495 | else { return false } | ||
| 496 | return status == .complete | ||
| 497 | } | ||
| 498 | } | ||
| 499 | |||
| 500 | final class AVOutput: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, | ||
| 501 | AVCaptureAudioDataOutputSampleBufferDelegate | ||
| 502 | { | ||
| 503 | private let cb: (CMSampleBuffer) -> Void | ||
| 504 | init(_ cb: @escaping (CMSampleBuffer) -> Void) { self.cb = cb } | ||
| 505 | |||
| 506 | func captureOutput( | ||
| 507 | _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, | ||
| 508 | from connection: AVCaptureConnection | ||
| 509 | ) { | ||
| 510 | cb(sampleBuffer) | ||
| 511 | } | ||
| 512 | } | ||
src/Recorder/engine/Sources/recorder/main.swift created+167| ... | @@ -0,0 +1,167 @@ | ||
| 1 | import AVFoundation | ||
| 2 | import CoreGraphics | ||
| 3 | import Dispatch | ||
| 4 | import Foundation | ||
| 5 | |||
| 6 | // Clover Recorder capture core. | ||
| 7 | // | ||
| 8 | // recorder list | ||
| 9 | // recorder record --out DIR [options] | ||
| 10 | // | ||
| 11 | // record options: | ||
| 12 | // --label NAME session label (default "session") | ||
| 13 | // --screen ID capture this display (repeatable; default: all) | ||
| 14 | // --system-audio capture the system-audio mix into desktop.m4a | ||
| 15 | // --mic UID|default capture this audio input into mic.m4a | ||
| 16 | // --camera UID|default capture this camera into cam.mov | ||
| 17 | // --fps N frame rate (default 30) | ||
| 18 | // --max-width N clamp the longest screen side, px (default 3840; 0 = native) | ||
| 19 | // --bpp F HEVC bits per pixel-frame (default 0.05) | ||
| 20 | // --duration S auto-stop after S seconds (otherwise runs until SIGINT/SIGTERM) | ||
| 21 | |||
| 22 | func fail(_ message: String) -> Never { | ||
| 23 | logErr(message) | ||
| 24 | exit(1) | ||
| 25 | } | ||
| 26 | |||
| 27 | func runList() { | ||
| 28 | let sem = DispatchSemaphore(value: 0) | ||
| 29 | var exitCode: Int32 = 0 | ||
| 30 | Task { | ||
| 31 | do { | ||
| 32 | let devices = try await Devices.discover() | ||
| 33 | let encoder = JSONEncoder() | ||
| 34 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | ||
| 35 | let data = try encoder.encode(devices) | ||
| 36 | FileHandle.standardOutput.write(data) | ||
| 37 | FileHandle.standardOutput.write(Data("\n".utf8)) | ||
| 38 | } catch { | ||
| 39 | logErr("list failed: \(error.localizedDescription)") | ||
| 40 | logErr("(Screen Recording permission may not be granted to this binary yet.)") | ||
| 41 | exitCode = 1 | ||
| 42 | } | ||
| 43 | sem.signal() | ||
| 44 | } | ||
| 45 | sem.wait() | ||
| 46 | exit(exitCode) | ||
| 47 | } | ||
| 48 | |||
| 49 | func parseRecordConfig(_ args: [String]) -> RecordConfig { | ||
| 50 | var outDir: URL? | ||
| 51 | var label = "session" | ||
| 52 | var displayIDs: [CGDirectDisplayID] = [] | ||
| 53 | var systemAudio = false | ||
| 54 | var micUID: String? | ||
| 55 | var cameraUID: String? | ||
| 56 | var fps = 30 | ||
| 57 | var maxWidth = 3840 | ||
| 58 | var bpp = 0.05 | ||
| 59 | var duration: Double? | ||
| 60 | var logPath: String? | ||
| 61 | |||
| 62 | var i = 0 | ||
| 63 | func next(_ flag: String) -> String { | ||
| 64 | i += 1 | ||
| 65 | guard i < args.count else { fail("missing value for \(flag)") } | ||
| 66 | return args[i] | ||
| 67 | } | ||
| 68 | |||
| 69 | while i < args.count { | ||
| 70 | let arg = args[i] | ||
| 71 | switch arg { | ||
| 72 | case "--out": outDir = URL(fileURLWithPath: next(arg)) | ||
| 73 | case "--label": label = next(arg) | ||
| 74 | case "--screen": | ||
| 75 | guard let id = UInt32(next(arg)) else { fail("--screen expects a numeric display id") } | ||
| 76 | displayIDs.append(id) | ||
| 77 | case "--system-audio": systemAudio = true | ||
| 78 | case "--mic": micUID = next(arg) | ||
| 79 | case "--camera": cameraUID = next(arg) | ||
| 80 | case "--fps": fps = Int(next(arg)) ?? 30 | ||
| 81 | case "--max-width": maxWidth = Int(next(arg)) ?? 3840 | ||
| 82 | case "--bpp": bpp = Double(next(arg)) ?? 0.05 | ||
| 83 | case "--duration": duration = Double(next(arg)) | ||
| 84 | case "--log": logPath = next(arg) | ||
| 85 | default: fail("unknown option: \(arg)") | ||
| 86 | } | ||
| 87 | i += 1 | ||
| 88 | } | ||
| 89 | |||
| 90 | guard let outDir else { fail("--out DIR is required") } | ||
| 91 | return RecordConfig( | ||
| 92 | outDir: outDir, label: label, displayIDs: displayIDs, systemAudio: systemAudio, | ||
| 93 | micUID: micUID, cameraUID: cameraUID, fps: fps, maxWidth: maxWidth, bitsPerPixel: bpp, | ||
| 94 | duration: duration, logPath: logPath) | ||
| 95 | } | ||
| 96 | |||
| 97 | func runRecord(_ args: [String]) { | ||
| 98 | let cfg = parseRecordConfig(args) | ||
| 99 | if let logPath = cfg.logPath { openLogFile(logPath) } | ||
| 100 | logInfo("recorder \(recorderVersion) starting: \(cfg.outDir.path)") | ||
| 101 | let engine = CaptureEngine(cfg) | ||
| 102 | |||
| 103 | // Never block the main thread: ScreenCaptureKit delivers start/stop | ||
| 104 | // completions on the main queue, so we keep main free via dispatchMain() | ||
| 105 | // and drive everything from background queues / Tasks. | ||
| 106 | let stopGuard = StopOnce() | ||
| 107 | func triggerStop() { | ||
| 108 | guard stopGuard.begin() else { return } | ||
| 109 | logInfo("stopping…") | ||
| 110 | Task { | ||
| 111 | await engine.stop() | ||
| 112 | exit(0) | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | Task { | ||
| 117 | do { | ||
| 118 | try await engine.start() | ||
| 119 | } catch { | ||
| 120 | logErr("start failed: \(error.localizedDescription)") | ||
| 121 | exit(1) | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | // Stop on SIGINT / SIGTERM, or after --duration. | ||
| 126 | let sigQueue = DispatchQueue(label: "clover.signals") | ||
| 127 | var sources: [DispatchSourceSignal] = [] | ||
| 128 | for sig in [SIGINT, SIGTERM] { | ||
| 129 | signal(sig, SIG_IGN) | ||
| 130 | let src = DispatchSource.makeSignalSource(signal: sig, queue: sigQueue) | ||
| 131 | src.setEventHandler { triggerStop() } | ||
| 132 | src.resume() | ||
| 133 | sources.append(src) | ||
| 134 | } | ||
| 135 | if let duration = cfg.duration { | ||
| 136 | sigQueue.asyncAfter(deadline: .now() + duration) { triggerStop() } | ||
| 137 | } | ||
| 138 | signalSources = sources // keep alive | ||
| 139 | |||
| 140 | dispatchMain() | ||
| 141 | } | ||
| 142 | |||
| 143 | /// One-shot guard so duration + signal can't both run finalize. | ||
| 144 | final class StopOnce { | ||
| 145 | private let lock = NSLock() | ||
| 146 | private var started = false | ||
| 147 | func begin() -> Bool { | ||
| 148 | lock.lock() | ||
| 149 | defer { lock.unlock() } | ||
| 150 | if started { return false } | ||
| 151 | started = true | ||
| 152 | return true | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | var signalSources: [DispatchSourceSignal] = [] | ||
| 157 | |||
| 158 | // Entry point. With no args (or `menubar`) it runs as a menubar app; with | ||
| 159 | // `record`/`list` it runs headless for SSH/scripting. Same signed binary either | ||
| 160 | // way, so the one Screen Recording grant covers both. | ||
| 161 | let argv = Array(CommandLine.arguments.dropFirst()) | ||
| 162 | switch argv.first { | ||
| 163 | case "list": runList() | ||
| 164 | case "record": runRecord(Array(argv.dropFirst())) | ||
| 165 | case nil, "menubar": MainActor.assumeIsolated { runMenubar() } | ||
| 166 | default: fail("unknown command: \(argv.first ?? "")") | ||
| 167 | } | ||
src/Recorder/setup-signing.sh created+82| ... | @@ -0,0 +1,82 @@ | ||
| 1 | #!/usr/bin/env bash | ||
| 2 | # One-time: create a stable self-signed code-signing identity in a dedicated | ||
| 3 | # keychain so the Screen Recording grant survives rebuilds. | ||
| 4 | # | ||
| 5 | # Why a dedicated keychain (not login): it can be created, unlocked, and | ||
| 6 | # imported into entirely over SSH with a known password — no GUI, no touching | ||
| 7 | # your login keychain. TCC keys the Screen Recording grant on the app's | ||
| 8 | # *designated requirement* (bundle id + cert leaf), which stays identical across | ||
| 9 | # rebuilds, so you grant once and never get re-prompted. | ||
| 10 | # | ||
| 11 | # The cert is self-signed and untrusted; that's fine — Gatekeeper is bypassed | ||
| 12 | # for locally-built, non-quarantined apps, and TCC matching doesn't need trust. | ||
| 13 | # | ||
| 14 | # Safe to re-run; it's idempotent. The keychain password is local-only and has | ||
| 15 | # nothing to do with your macOS login password. | ||
| 16 | set -euo pipefail | ||
| 17 | |||
| 18 | CN="Clover Code Signing" | ||
| 19 | KC="$HOME/Library/Keychains/clover-signing.keychain-db" | ||
| 20 | KCPW="${CLOVER_KEYCHAIN_PW:-clover}" | ||
| 21 | P12="$HOME/.clover-code-signing.p12" # backup so the identity survives keychain loss | ||
| 22 | |||
| 23 | ensure_searchlist() { | ||
| 24 | local existing | ||
| 25 | existing=$(security list-keychains -d user | sed -e 's/^ *//' -e 's/"//g') | ||
| 26 | case "$existing" in | ||
| 27 | *clover-signing*) ;; | ||
| 28 | *) security list-keychains -d user -s "$KC" $existing ;; | ||
| 29 | esac | ||
| 30 | } | ||
| 31 | |||
| 32 | if [[ -f "$KC" ]] && security find-identity -p codesigning "$KC" 2>/dev/null | grep -q "$CN"; then | ||
| 33 | security unlock-keychain -p "$KCPW" "$KC" 2>/dev/null || true | ||
| 34 | ensure_searchlist | ||
| 35 | echo "✅ '$CN' already present in $KC" | ||
| 36 | exit 0 | ||
| 37 | fi | ||
| 38 | |||
| 39 | TMP="$(mktemp -d)" | ||
| 40 | trap 'rm -rf "$TMP"' EXIT | ||
| 41 | |||
| 42 | if [[ -f "$P12" ]]; then | ||
| 43 | echo "==> reusing saved identity from $P12 (keeps the same TCC requirement)" | ||
| 44 | cp "$P12" "$TMP/cs.p12" | ||
| 45 | else | ||
| 46 | echo "==> generating new self-signed code-signing certificate" | ||
| 47 | cat > "$TMP/cs.conf" <<EOF | ||
| 48 | [ req ] | ||
| 49 | distinguished_name = dn | ||
| 50 | x509_extensions = v3 | ||
| 51 | prompt = no | ||
| 52 | [ dn ] | ||
| 53 | CN = $CN | ||
| 54 | [ v3 ] | ||
| 55 | keyUsage = critical, digitalSignature | ||
| 56 | extendedKeyUsage = critical, codeSigning | ||
| 57 | basicConstraints = critical, CA:false | ||
| 58 | EOF | ||
| 59 | openssl req -x509 -newkey rsa:2048 -keyout "$TMP/cs.key" -out "$TMP/cs.crt" \ | ||
| 60 | -days 3650 -nodes -config "$TMP/cs.conf" >/dev/null 2>&1 | ||
| 61 | openssl pkcs12 -export -inkey "$TMP/cs.key" -in "$TMP/cs.crt" -out "$TMP/cs.p12" \ | ||
| 62 | -passout pass:clover -name "$CN" >/dev/null 2>&1 | ||
| 63 | cp "$TMP/cs.p12" "$P12" | ||
| 64 | chmod 600 "$P12" | ||
| 65 | fi | ||
| 66 | |||
| 67 | echo "==> (re)creating dedicated keychain $KC" | ||
| 68 | security delete-keychain "$KC" 2>/dev/null || true | ||
| 69 | security create-keychain -p "$KCPW" "$KC" | ||
| 70 | security set-keychain-settings "$KC" # no auto-lock timeout | ||
| 71 | security unlock-keychain -p "$KCPW" "$KC" | ||
| 72 | security import "$TMP/cs.p12" -k "$KC" -P clover -A -T /usr/bin/codesign | ||
| 73 | security set-key-partition-list -S apple-tool:,apple:,unsigned: -s -k "$KCPW" "$KC" >/dev/null 2>&1 || true | ||
| 74 | ensure_searchlist | ||
| 75 | |||
| 76 | echo | ||
| 77 | if security find-identity -p codesigning "$KC" | grep -q "$CN"; then | ||
| 78 | echo "✅ '$CN' ready in $KC. build.sh will sign with it automatically." | ||
| 79 | else | ||
| 80 | echo "⚠️ identity not found after setup — check the output above." | ||
| 81 | exit 1 | ||
| 82 | fi | ||