From c428179fa1239f516d8d8ee437a7693fa8602218 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 14:19:52 +0200 Subject: [PATCH 1/2] fix(recording): stop macOS fragments carrying an offset the box cannot hold a6795d23 gave macOS the same crash-resilience Windows got, in one line: movieFragmentInterval. On macOS that line destroyed every recording it touched. Capture stopped after a few seconds while the HUD counted on, and stop answered AVFoundationErrorDomain -11800 / -16341, so the take was discarded: no sidecars, no editor. Six takes on the shipped rc.1 lost ~530 MB of perfectly decodable video between them. The container was never the problem, and neither were the timestamps -- every sample file has strictly monotonic DTS. What is wrong is in the fragment bytes: each `trun` goes out version 0 carrying composition offsets like 0xFFFFFFF6, which is -10 reinterpreted, because ISO/IEC 14496-12 8.8.8.2 defines that field as unsigned in version 0 and signed only in version 1. Offsets are negative only because the encoder reorders frames, and it reorders because AVVideoAllowFrameReorderingKey is never set, so it runs High profile with has_b_frames=2. MediaToolbox raises -16341 from exactly one site -- inside the function that writes moof/mfhd/traf/trun -- which is why the failure needs movieFragmentInterval to exist at all and always lands on a fragment boundary: the two audio failures hit at 1.0s and 2.0s against a 1s interval. Turning reordering off makes every offset zero and PTS == DTS, and the fragment becomes representable. A screen recorder pays nothing for it -- B-frames buy compression on lookahead-friendly content and cost encode latency, the wrong trade for real-time capture. Measured on macOS 26.5 / M1, 1080p30 with system audio, the configuration that kills the current build in 1-2s: clean stop at 43.66s, has_b_frames 2 -> 0, 0 of 819 packets with pts != dts. SIGKILL at 25s leaves 27 moof, decodes clean (ffmpeg -v error -f null - exit 0) and recovers 28.01s with both tracks. So the recording survives AND the crash-resilience the commit existed for now actually works on macOS, which it never did. The second change is why this cost a whole recording to learn one bit. A failed AVAssetWriter keeps accepting appends and keeps answering false; the helper discarded that Bool after the first frame and read writer.status only in finishWriter(). That is the entire reason the HUD counted to 02:02 over a writer that died at 00:04. The Windows helper checks every WriteSample HRESULT and escalates; this reports once, at the append that failed, carrying the live writer.error. It does not abort the capture -- handlers.ts tears its error listener down once recording-started arrives, so acting on this mid-recording is a TypeScript change and belongs in its own commit. --- .../ScreenCaptureRecorder.swift | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index 42e764e3..eaca432e 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,31 @@ 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. + private func reportWriterFailure(_ stage: String) { + guard !didReportWriterFailure, let writer else { + return + } + didReportWriterFailure = true + emitError( + code: "writer-failed", + message: "\(stage): " + + (writer.error.map { "\($0)" } + ?? "AVAssetWriter status \(writer.status.rawValue)"), + ) + } + private func ensureRequestedPermissions() throws { if !CGPreflightScreenCaptureAccess() { let granted = CGRequestScreenCaptureAccess() @@ -456,6 +479,25 @@ 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, 1080p30 with + // system audio: with reordering the writer dies after 1-2s, without + // it a 43.6s take stops clean and a SIGKILL at 25s still leaves 27 + // readable `moof` fragments. + AVVideoAllowFrameReorderingKey: false, ], ] let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) From 53e34a79791cf48ba8927e58d483c39afdfad0de Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 14 Aug 2026 15:32:21 +0200 Subject: [PATCH 2/2] fix(recording): separate the two writer-failure events, and quote the rate Review caught that reportWriterFailure and finishWriter both emitted `writer-failed`, and proposed routing finalization through the one-time reporter. That would break stopping. handlers.ts settles the stop promise on exactly one of `recording-stopped` or `writer-failed`, so suppressing the terminal event whenever an append already fired turns every writer failure into the "Saving..." hang instead of an error -- the exact symptom this branch exists to remove. The two sites answer different questions, so they now carry different codes: `writer-failed-during-capture` says when the writer died, `writer-failed` says whether stopping worked. Verified by putting the bug back and watching a failing run emit exactly one of each. Rebuilding that broken variant also corrected the evidence. It survived 22.2s at 30 fps, where the same configuration had failed twice at 1-2s, so the failure is probabilistic and my "2/2 versus 3/3" was a sample, not a law. It is rate-dependent: at ~57 fps, the rate the app drives and the rate at which the shipped binary failed 6/6, reordering on dies at 13.0s and reordering off stops clean at 31.6s. The comment now quotes the frame rate beside every number, because a reproduction that is only sometimes reproducible is exactly the kind a future reader will try once, fail to trigger, and conclude was never real. The case for the fix does not rest on those counts. It rests on the bytes: the composition offsets are unrepresentable in a version 0 trun in every fragmented file, whether or not that particular run happened to die. --- .../ScreenCaptureRecorder.swift | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index eaca432e..c5e19105 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -332,13 +332,22 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { /// 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", + code: "writer-failed-during-capture", message: "\(stage): " + (writer.error.map { "\($0)" } ?? "AVAssetWriter status \(writer.status.rawValue)"), @@ -493,10 +502,18 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { // 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, 1080p30 with - // system audio: with reordering the writer dies after 1-2s, without - // it a 43.6s take stops clean and a SIGKILL at 25s still leaves 27 - // readable `moof` fragments. + // 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, ], ]