diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index 42e764e3..c5e19105 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -141,6 +141,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private var audioMixer: AudioTrackMixer? private var didStartWriting = false private var didEmitRecordingStarted = false + private var didReportWriterFailure = false private var isStopping = false private var isPaused = false private var pauseStartedAt: CMTime? @@ -309,7 +310,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } if videoInput.isReadyForMoreMediaData { - if videoInput.append(sampleBuffer), !didEmitRecordingStarted { + let appended = videoInput.append(sampleBuffer) + if appended, !didEmitRecordingStarted { didEmitRecordingStarted = true emit([ "event": "recording-started", @@ -318,10 +320,40 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { "height": outputHeight, "captureBounds": captureBoundsPayload(), ]) + } else if !appended { + reportWriterFailure("video append") } } } + /// A failed AVAssetWriter keeps accepting appends and keeps answering false, so + /// a recorder that discards that Bool records nothing while the HUD counts on. + /// That is how a two-minute take was already lost by its fourth second and only + /// said so at finishWriting(). The Windows helper checks every WriteSample + /// HRESULT and escalates; this is the macOS half of the same contract -- report + /// once, at the append that actually failed, carrying the live writer.error. + /// + /// Deliberately not the code finishWriter() emits, and the difference is load + /// bearing. That one is the terminal result of stopping, and the Electron side + /// settles its stop on exactly one of `recording-stopped` or `writer-failed`. + /// Give both sites the same code behind this one-shot guard and a writer that + /// died mid-capture emits nothing at all at stop, so the stop promise never + /// settles and every failure becomes the "Saving..." hang instead of an error. + /// This event answers "when did the writer die"; that one answers "did stopping + /// work". Two questions, two codes. + private func reportWriterFailure(_ stage: String) { + guard !didReportWriterFailure, let writer else { + return + } + didReportWriterFailure = true + emitError( + code: "writer-failed-during-capture", + message: "\(stage): " + + (writer.error.map { "\($0)" } + ?? "AVAssetWriter status \(writer.status.rawValue)"), + ) + } + private func ensureRequestedPermissions() throws { if !CGPreflightScreenCaptureAccess() { let granted = CGRequestScreenCaptureAccess() @@ -456,6 +488,33 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { AVVideoCompressionPropertiesKey: [ AVVideoAverageBitRateKey: request.video.bitrate ?? 18_000_000, AVVideoExpectedSourceFrameRateKey: request.video.fps, + // Without this the encoder defaults to B-frames, and a reordered + // stream needs a composition offset per sample. AVAssetWriter emits + // those in a version 0 `trun`, where ISO/IEC 14496-12 8.8.8.2 defines + // the field as UNSIGNED -- so a negative offset goes out as + // 0xFFFFFFF6 and the fragment writer refuses the fragment it is + // about to emit. That refusal is -11800 / -16341, raised from the + // single site in MediaToolbox that writes moof/traf/trun, which is + // why it appears if and only if movieFragmentInterval is set and + // lands exactly on a fragment boundary. + // + // Turning reordering off makes every offset zero and PTS == DTS, so + // the fragment stays representable. A screen recorder gives up + // nothing for it: B-frames buy compression on lookahead-friendly + // content and cost encode latency, which is the wrong trade for + // real-time capture. + // + // Measured on macOS 26.5 / M1, 1080p with system audio. How reliably + // the bug bites scales with append rate, so quote the rate with the + // result: at ~57 fps, the rate the app actually drives, reordering + // on dies at 13.0s while reordering off stops clean at 31.6s; at + // 30 fps it is intermittent, dying at 1.0s and 2.0s but once + // surviving 22.2s. That intermittency is why the byte-level evidence + // leads here and the run counts only corroborate: the offsets are + // out of spec in every fragmented file whether or not that + // particular run happened to die. Reordering off is 3/3 clean across + // both rates, and a SIGKILL at 25s still leaves 27 readable `moof`. + AVVideoAllowFrameReorderingKey: false, ], ] let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)