Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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",
Expand All @@ -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)"),
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private func ensureRequestedPermissions() throws {
if !CGPreflightScreenCaptureAccess() {
let granted = CGRequestScreenCaptureAccess()
Expand Down Expand Up @@ -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)
Expand Down
Loading