323 lines
17 KiB
Swift
323 lines
17 KiB
Swift
// Record only the owned Emacs frame. Build with swiftc -parse-as-library.
|
|
import Foundation
|
|
import CoreGraphics
|
|
import ScreenCaptureKit
|
|
import AVFoundation
|
|
|
|
struct CaptureFailure: Error, CustomStringConvertible {
|
|
let description: String
|
|
init(_ description: String) { self.description = description }
|
|
}
|
|
|
|
// Delegate callbacks and signal handlers do not share an execution queue.
|
|
final class CaptureEvents: NSObject, SCStreamDelegate, SCRecordingOutputDelegate,
|
|
SCStreamOutput, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var started = false
|
|
private var finished = false
|
|
private var stopping = false
|
|
private var frames = 0
|
|
private var failure: String?
|
|
private var nativeSize: (width: Int, height: Int)?
|
|
private var pixelMapping: Double?
|
|
private var firstPTS: Double?
|
|
private var lastPTS: Double?
|
|
private let requireNative: Bool
|
|
private let expectedPixelScale: Double
|
|
|
|
init(pixelScale: Double, requireNative: Bool = true) {
|
|
self.expectedPixelScale = pixelScale
|
|
self.requireNative = requireNative
|
|
}
|
|
|
|
func state() -> (started: Bool, finished: Bool, stopping: Bool,
|
|
frames: Int, failure: String?, nativeSize: (width: Int, height: Int)?,
|
|
pixelMapping: Double?, firstPTS: Double?, lastPTS: Double?) {
|
|
lock.withLock {
|
|
(started, finished, stopping, frames, failure, nativeSize,
|
|
pixelMapping, firstPTS, lastPTS)
|
|
}
|
|
}
|
|
func stop() { lock.withLock { stopping = true } }
|
|
func fail(_ message: String) { lock.withLock { failure = message } }
|
|
func recordingOutputDidStartRecording(_ output: SCRecordingOutput) {
|
|
lock.withLock { started = true }
|
|
}
|
|
func recordingOutputDidFinishRecording(_ output: SCRecordingOutput) {
|
|
lock.withLock { finished = true }
|
|
}
|
|
func recordingOutput(_ output: SCRecordingOutput, didFailWithError error: Error) {
|
|
fail("Recording failed: \(error)")
|
|
}
|
|
func stream(_ stream: SCStream, didStopWithError error: Error) {
|
|
fail("Capture stream stopped: \(error)")
|
|
}
|
|
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
|
|
of type: SCStreamOutputType) {
|
|
guard type == .screen else { return }
|
|
inspectScreenSample(sampleBuffer)
|
|
}
|
|
func inspectScreenSample(_ sampleBuffer: CMSampleBuffer) {
|
|
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(
|
|
sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
|
|
let info = attachments.first,
|
|
let status = info[.status] as? Int,
|
|
status == SCFrameStatus.complete.rawValue else { return }
|
|
guard let scale = info[.contentScale] as? Double, scale.isFinite, scale > 0,
|
|
let backing = info[.scaleFactor] as? Double, backing.isFinite, backing > 0,
|
|
let rectDictionary = info[.contentRect] as? [String: Any],
|
|
let rect = CGRect(dictionaryRepresentation: rectDictionary as CFDictionary),
|
|
rect.origin.x.isFinite, rect.origin.y.isFinite,
|
|
rect.width.isFinite, rect.height.isFinite,
|
|
rect.width > 0, rect.height > 0 else {
|
|
fail("Captured frame has no usable native geometry")
|
|
return
|
|
}
|
|
// SCK can render at a higher resolution than the window's display.
|
|
// Its source-to-surface scale and source backing scale together define
|
|
// output pixels per logical point; compare that with the real display.
|
|
let mapping = scale * backing / expectedPixelScale
|
|
if requireNative && abs(mapping - 1) >= 0.0001 {
|
|
let image = CMSampleBufferGetImageBuffer(sampleBuffer)
|
|
let dimensions = image.map { "\(CVPixelBufferGetWidth($0))x\(CVPixelBufferGetHeight($0))" } ?? "missing"
|
|
fail("Captured frame was scaled; native pixels are required. Buffer=\(dimensions), attachments=\(info)")
|
|
return
|
|
}
|
|
let sourceWidth = rect.width / scale * expectedPixelScale
|
|
let sourceHeight = rect.height / scale * expectedPixelScale
|
|
if requireNative {
|
|
guard let image = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
|
fail("Captured frame has no pixel buffer")
|
|
return
|
|
}
|
|
let width = Double(CVPixelBufferGetWidth(image))
|
|
let height = Double(CVPixelBufferGetHeight(image))
|
|
guard sourceWidth <= width, sourceHeight <= height,
|
|
rect.minX >= 0, rect.minY >= 0,
|
|
rect.maxX * backing <= width, rect.maxY * backing <= height else {
|
|
fail("Captured frame exceeds original native-pixel canvas or is clipped")
|
|
return
|
|
}
|
|
}
|
|
let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
|
|
guard timestamp.isFinite else { fail("Captured frame has no valid timestamp"); return }
|
|
lock.withLock {
|
|
nativeSize = (Int(ceil(sourceWidth)), Int(ceil(sourceHeight)))
|
|
pixelMapping = mapping
|
|
if abs(mapping - 1) < 0.0001 {
|
|
frames += 1
|
|
if firstPTS == nil { firstPTS = timestamp }
|
|
lastPTS = timestamp
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@main
|
|
struct RecordEmacsWindow {
|
|
static func windowID(pid: pid_t) throws -> CGWindowID {
|
|
guard kill(pid, 0) == 0,
|
|
let inventory = CGWindowListCopyWindowInfo(.optionAll, kCGNullWindowID)
|
|
as? [[String: Any]] else {
|
|
throw CaptureFailure("Owned Emacs process is unavailable: \(pid)")
|
|
}
|
|
let candidates = inventory.compactMap { item -> (CGWindowID, Double)? in
|
|
guard item[kCGWindowOwnerPID as String] as? Int == Int(pid),
|
|
item[kCGWindowOwnerName as String] as? String == "Emacs",
|
|
item[kCGWindowLayer as String] as? Int == 0,
|
|
let alpha = item[kCGWindowAlpha as String] as? Double, alpha > 0,
|
|
let title = item[kCGWindowName as String] as? String, !title.isEmpty,
|
|
let bounds = item[kCGWindowBounds as String] as? [String: Double],
|
|
let width = bounds["Width"], let height = bounds["Height"],
|
|
width > 0, height > 0,
|
|
let number = item[kCGWindowNumber as String] as? UInt32 else { return nil }
|
|
return (number, width * height)
|
|
}.sorted { $0.1 > $1.1 }
|
|
guard let first = candidates.first else {
|
|
throw CaptureFailure("No named Emacs frame owned by PID \(pid)")
|
|
}
|
|
guard candidates.count == 1 || first.1 != candidates[1].1 else {
|
|
throw CaptureFailure("Ambiguous largest Emacs frame for PID \(pid)")
|
|
}
|
|
return first.0
|
|
}
|
|
|
|
static func filter(pid: pid_t, id: CGWindowID) async throws -> SCContentFilter {
|
|
let content = try await SCShareableContent.excludingDesktopWindows(
|
|
true, onScreenWindowsOnly: false)
|
|
guard let window = content.windows.first(where: {
|
|
$0.windowID == id && $0.owningApplication?.processID == pid
|
|
&& $0.windowLayer == 0
|
|
}) else { throw CaptureFailure("Owned frame is not shareable: \(id)") }
|
|
return SCContentFilter(desktopIndependentWindow: window)
|
|
}
|
|
|
|
static func emit(_ values: [String: Any]) throws {
|
|
let data = try JSONSerialization.data(withJSONObject: values, options: [.sortedKeys])
|
|
FileHandle.standardOutput.write(data + Data([10]))
|
|
}
|
|
|
|
static func configureNativeCanvas(filter: SCContentFilter,
|
|
configuration: SCStreamConfiguration) async throws {
|
|
// Fullscreen WindowServer bounds can exclude native window decoration.
|
|
// Discover the full source extent from one unrecorded sample; never
|
|
// guess offsets or grow the canvas iteratively after recording starts.
|
|
let probe = CaptureEvents(pixelScale: Double(filter.pointPixelScale), requireNative: false)
|
|
let stream = SCStream(filter: filter, configuration: configuration, delegate: probe)
|
|
try stream.addStreamOutput(probe, type: .screen,
|
|
sampleHandlerQueue: DispatchQueue(label: "capture-probe"))
|
|
try await stream.startCapture()
|
|
let deadline = Date().addingTimeInterval(10)
|
|
do {
|
|
while probe.state().nativeSize == nil {
|
|
if let failure = probe.state().failure { throw CaptureFailure(failure) }
|
|
guard Date() < deadline else { throw CaptureFailure("No native geometry sample") }
|
|
try await Task.sleep(nanoseconds: 20_000_000)
|
|
}
|
|
let size = probe.state().nativeSize!
|
|
configuration.width = size.width + size.width % 2
|
|
configuration.height = size.height + size.height % 2
|
|
try await stream.updateConfiguration(configuration)
|
|
while probe.state().pixelMapping.map({ abs($0 - 1) >= 0.0001 }) ?? true {
|
|
if let failure = probe.state().failure { throw CaptureFailure(failure) }
|
|
guard Date() < deadline else {
|
|
throw CaptureFailure("Measured canvas did not preserve native pixels")
|
|
}
|
|
try await Task.sleep(nanoseconds: 20_000_000)
|
|
}
|
|
try await stream.stopCapture()
|
|
} catch {
|
|
try? await stream.stopCapture()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
static func record(pid: pid_t, output: URL, ready: URL) async throws {
|
|
guard !FileManager.default.fileExists(atPath: output.path),
|
|
!FileManager.default.fileExists(atPath: ready.path) else {
|
|
throw CaptureFailure("Recording output or readiness file already exists")
|
|
}
|
|
var id = try windowID(pid: pid)
|
|
var currentFilter = try await filter(pid: pid, id: id)
|
|
let scale = Double(currentFilter.pointPixelScale)
|
|
let width = Int(ceil(currentFilter.contentRect.width * scale))
|
|
let height = Int(ceil(currentFilter.contentRect.height * scale))
|
|
guard width > 0, height > 0 else { throw CaptureFailure("Empty frame") }
|
|
let configuration = SCStreamConfiguration()
|
|
// Fixed canvas; smaller windows are padded, never enlarged. The frame
|
|
// callback rejects any change to the display's native pixel mapping.
|
|
configuration.width = width + width % 2
|
|
configuration.height = height + height % 2
|
|
configuration.scalesToFit = false
|
|
configuration.captureResolution = .automatic
|
|
configuration.preservesAspectRatio = true
|
|
configuration.ignoreShadowsSingleWindow = true
|
|
configuration.showsCursor = false
|
|
let background = CGColor(gray: 0, alpha: 1)
|
|
defer { withExtendedLifetime(background) {} }
|
|
configuration.backgroundColor = background
|
|
configuration.minimumFrameInterval = CMTime(value: 1, timescale: 60)
|
|
configuration.capturesAudio = false
|
|
try await configureNativeCanvas(filter: currentFilter, configuration: configuration)
|
|
let events = CaptureEvents(pixelScale: scale)
|
|
try emit(["event": "configuration", "window_id": id, "pid": pid,
|
|
"width": configuration.width, "height": configuration.height,
|
|
"pixel_scale": scale, "content_rect": NSStringFromRect(currentFilter.contentRect)])
|
|
let stream = SCStream(filter: currentFilter, configuration: configuration,
|
|
delegate: events)
|
|
try stream.addStreamOutput(events, type: .screen,
|
|
sampleHandlerQueue: DispatchQueue(label: "capture-frames"))
|
|
let recordingConfiguration = SCRecordingOutputConfiguration()
|
|
recordingConfiguration.outputURL = output
|
|
recordingConfiguration.outputFileType = .mov
|
|
let recording = SCRecordingOutput(configuration: recordingConfiguration, delegate: events)
|
|
try stream.addRecordingOutput(recording)
|
|
let signals = [SIGTERM, SIGINT].map { number -> DispatchSourceSignal in
|
|
signal(number, SIG_IGN)
|
|
let source = DispatchSource.makeSignalSource(signal: number, queue: .global())
|
|
source.setEventHandler { events.stop() }
|
|
source.resume()
|
|
return source
|
|
}
|
|
defer { signals.forEach { $0.cancel() } }
|
|
try await stream.startCapture()
|
|
let startedAt = Date()
|
|
var announced = false
|
|
do {
|
|
while true {
|
|
let nextID = try windowID(pid: pid)
|
|
// SCContentFilter is a snapshot: refresh even for the same ID,
|
|
// whose source can resize independently of window replacement.
|
|
currentFilter = try await filter(pid: pid, id: nextID)
|
|
let nextScale = Double(currentFilter.pointPixelScale)
|
|
guard nextScale.isFinite, nextScale > 0,
|
|
currentFilter.contentRect.width > 0,
|
|
currentFilter.contentRect.height > 0,
|
|
currentFilter.contentRect.width * nextScale <= Double(configuration.width),
|
|
currentFilter.contentRect.height * nextScale <= Double(configuration.height) else {
|
|
throw CaptureFailure("Owned frame exceeds original native-pixel canvas or has invalid geometry")
|
|
}
|
|
if nextID != id {
|
|
try await stream.updateContentFilter(currentFilter)
|
|
id = nextID
|
|
try emit(["event": "window-replaced", "window_id": id, "pid": pid])
|
|
}
|
|
// Check the current source before readiness and requested stop;
|
|
// also re-read callback failures after the asynchronous refresh.
|
|
let state = events.state()
|
|
if let failure = state.failure { throw CaptureFailure(failure) }
|
|
if state.stopping { break }
|
|
if state.finished { throw CaptureFailure("Recording ended before requested stop") }
|
|
let elapsed = Date().timeIntervalSince(startedAt)
|
|
if elapsed > 600 { throw CaptureFailure("Recording exceeded 600 seconds") }
|
|
if !announced && elapsed > 10 { throw CaptureFailure("No verified recording frames") }
|
|
if !announced && state.started && state.frames > 0 {
|
|
let metadata: [String: Any] = ["pid": pid, "window_id": id,
|
|
"width": configuration.width, "height": configuration.height,
|
|
"pixel_scale": scale, "capture": "desktop-independent-window",
|
|
"ready_wall_time": Date().timeIntervalSince1970,
|
|
"source_first_pts": state.firstPTS!]
|
|
try JSONSerialization.data(withJSONObject: metadata).write(to: ready, options: .atomic)
|
|
try emit(metadata)
|
|
announced = true
|
|
}
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
}
|
|
try await stream.stopCapture()
|
|
let stopDeadline = Date().addingTimeInterval(4)
|
|
while !events.state().finished {
|
|
if let failure = events.state().failure { throw CaptureFailure(failure) }
|
|
guard Date() < stopDeadline else { throw CaptureFailure("Recording did not finalize") }
|
|
try await Task.sleep(nanoseconds: 20_000_000)
|
|
}
|
|
if let failure = events.state().failure { throw CaptureFailure(failure) }
|
|
guard announced, events.state().frames > 0 else {
|
|
throw CaptureFailure("Recording stopped without verified frames")
|
|
}
|
|
let final = events.state()
|
|
try emit(["event": "finished", "frames": final.frames,
|
|
"source_first_pts": final.firstPTS!, "source_last_pts": final.lastPTS!,
|
|
"source_span_seconds": final.lastPTS! - final.firstPTS!,
|
|
"recorded_duration_seconds": CMTimeGetSeconds(recording.recordedDuration),
|
|
"wall_duration_seconds": Date().timeIntervalSince(startedAt)])
|
|
} catch {
|
|
try? await stream.stopCapture()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
static func main() async {
|
|
do {
|
|
let arguments = CommandLine.arguments
|
|
guard arguments.count == 4, let pid = Int32(arguments[1]), pid > 1 else {
|
|
throw CaptureFailure("usage: record-emacs-window PID OUTPUT.mov READY_FILE")
|
|
}
|
|
try await record(pid: pid, output: URL(fileURLWithPath: arguments[2]),
|
|
ready: URL(fileURLWithPath: arguments[3]))
|
|
} catch {
|
|
FileHandle.standardError.write(Data("\(error)\n".utf8))
|
|
exit(1)
|
|
}
|
|
}
|
|
}
|