Merge pull request #829 from webadderallorg/codex/macos-recording-resume-fix

Fix macOS warm-start recording finalization
This commit is contained in:
webadderall
2026-08-24 18:20:53 +10:00
committed by GitHub
9 changed files with 73 additions and 28 deletions
+29 -9
View File
@@ -40,6 +40,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
private var firstSampleTime: CMTime = .zero
private var firstSystemAudioSampleTime: CMTime?
private var firstMicrophoneSampleTime: CMTime?
private var lastSystemAudioPresentationTime: CMTime = .invalid
private var lastMicrophonePresentationTime: CMTime = .invalid
private var lastSampleBuffer: CMSampleBuffer?
private var lastVideoPresentationTime: CMTime = .zero
private var lastVideoDuration: CMTime = .zero
@@ -159,6 +161,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
microphoneOutputURL = nil
firstSystemAudioSampleTime = nil
firstMicrophoneSampleTime = nil
lastSystemAudioPresentationTime = .invalid
lastMicrophonePresentationTime = .invalid
guard let assistant = AVOutputSettingsAssistant(preset: .preset3840x2160) else {
throw NSError(domain: "RecordlyCapture", code: 5, userInfo: [NSLocalizedDescriptionKey: "Unable to create output settings assistant"])
@@ -324,6 +328,10 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
guard let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return }
if outputType == .screen {
if frameCount > 0 && CMTimeCompare(presentationTime, lastVideoPresentationTime) <= 0 {
return
}
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
let attachment = attachments.first,
let statusRawValue = attachment[SCStreamFrameInfo.status] as? Int,
@@ -360,21 +368,21 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
if outputType == .audio {
guard let systemAudioInput else { return }
appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput, of: systemAudioWriter, firstSampleTime: &firstSystemAudioSampleTime, presentationTime: presentationTime)
appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput, of: systemAudioWriter, firstSampleTime: &firstSystemAudioSampleTime, lastPresentationTime: &lastSystemAudioPresentationTime, presentationTime: presentationTime)
// Also write system audio to the inline video track
if let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData {
appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, of: assetWriter, firstSampleTime: &firstInlineAudioSampleTime, presentationTime: presentationTime)
appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, of: assetWriter, firstSampleTime: &firstInlineAudioSampleTime, lastPresentationTime: &lastInlineAudioPresentationTime, presentationTime: presentationTime)
}
return
}
if outputType.rawValue == microphoneOutputTypeRawValue {
if let microphoneOnlyInput {
appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, of: microphoneOnlyWriter, firstSampleTime: &firstMicrophoneSampleTime, presentationTime: presentationTime)
appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, of: microphoneOnlyWriter, firstSampleTime: &firstMicrophoneSampleTime, lastPresentationTime: &lastMicrophonePresentationTime, presentationTime: presentationTime)
}
// Write mic to inline video track only if there's no system audio (avoids double-writing)
if !capturesSystemAudio, let inlineAudioInput, inlineAudioInput.isReadyForMoreMediaData {
appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, of: assetWriter, firstSampleTime: &firstInlineAudioSampleTime, presentationTime: presentationTime)
appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, of: assetWriter, firstSampleTime: &firstInlineAudioSampleTime, lastPresentationTime: &lastInlineAudioPresentationTime, presentationTime: presentationTime)
}
return
}
@@ -516,6 +524,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
firstSampleTime = .zero
firstSystemAudioSampleTime = nil
firstMicrophoneSampleTime = nil
lastSystemAudioPresentationTime = .invalid
lastMicrophonePresentationTime = .invalid
firstInlineAudioSampleTime = nil
lastSampleBuffer = nil
lastVideoPresentationTime = .zero
@@ -579,7 +589,14 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
}
let sampleTime = sampleBuffer.presentationTimeStamp
if pendingResumeAdjustment, let pauseStartedHostTime {
if pendingResumeAdjustment {
// Audio and video callbacks share this queue but their timestamps can be
// offset slightly. Anchor the post-countdown adjustment to video and drop
// audio until that anchor exists; otherwise the first audio callback can
// make the following video timestamp move backwards and fail the writer.
guard outputType == .screen, let pauseStartedHostTime else {
return nil
}
let pauseGap = sampleTime - pauseStartedHostTime
if pauseGap > .zero {
accumulatedPausedDuration = accumulatedPausedDuration + pauseGap
@@ -647,10 +664,11 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
return videoEndTime + CMTimeMinimum(tailExtension, maxInlineAudioTailExtension)
}
private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, of writer: AVAssetWriter?, firstSampleTime: inout CMTime?, presentationTime: CMTime) {
private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, of writer: AVAssetWriter?, firstSampleTime: inout CMTime?, lastPresentationTime: inout CMTime, presentationTime: CMTime) {
// A writer that failed mid-capture (a full disk, say) raises on every
// further append, which would abort the helper and lose the whole file.
guard writer?.status == .writing, input.isReadyForMoreMediaData else { return }
guard !lastPresentationTime.isValid || CMTimeCompare(presentationTime, lastPresentationTime) > 0 else { return }
if firstSampleTime == nil {
firstSampleTime = presentationTime
@@ -661,9 +679,11 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp)
if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) {
let appended = input.append(retimedSampleBuffer)
if appended, input === inlineAudioInput {
lastInlineAudioPresentationTime = presentationTime
lastInlineAudioDuration = sampleBuffer.duration
if appended {
lastPresentationTime = presentationTime
if input === inlineAudioInput {
lastInlineAudioDuration = sampleBuffer.duration
}
}
}
}
@@ -23,3 +23,20 @@ describe("ScreenCaptureKitRecorder finalization coordination", () => {
);
});
});
describe("ScreenCaptureKitRecorder resume timing", () => {
it("anchors warm-start resume timing to video before accepting audio", () => {
expect(recorderSource).toContain(
"guard outputType == .screen, let pauseStartedHostTime else",
);
});
it("drops non-monotonic video and audio samples", () => {
expect(recorderSource).toContain(
"CMTimeCompare(presentationTime, lastVideoPresentationTime) <= 0",
);
expect(recorderSource).toContain(
"CMTimeCompare(presentationTime, lastPresentationTime) > 0",
);
});
});
@@ -95,8 +95,6 @@
color: #4eeeb0;
}
.menuCard {
width: 300px;
max-height: 400px;
@@ -252,6 +250,13 @@
transition: all 0.2s ease;
}
.stopSquare {
width: 15px;
height: 15px;
border-radius: 3px;
background: #fff;
}
.recDotBlink {
animation: blink 1.2s ease-in-out infinite;
}
+4 -6
View File
@@ -452,11 +452,7 @@ function LaunchWindowContent() {
ref={hudContentRef}
className="flex items-center overflow-visible flex-col-reverse pointer-events-none"
>
<div
className="flex flex-col items-center pointer-events-auto p-2"
onMouseEnter={handleHudMouseEnter}
onMouseLeave={handleHudMouseLeave}
>
<div className="flex flex-col items-center pointer-events-none p-2">
<div
ref={hudBarTransformRef}
style={{
@@ -467,7 +463,9 @@ function LaunchWindowContent() {
ref={hudBarRef}
layout={shouldAnimateHudLayout}
transition={hudStateTransition}
className={`${styles.bar} launch-theme mb-2`}
className={`${styles.bar} launch-theme mb-2 pointer-events-auto`}
onMouseEnter={handleHudMouseEnter}
onMouseLeave={handleHudMouseLeave}
>
<div
// Linux compositors and non-passthrough Windows fallback windows
+13 -8
View File
@@ -1,4 +1,11 @@
import { MicrophoneIcon, MicrophoneSlashIcon, MinusIcon, PauseIcon, PlayIcon, SquareIcon, XIcon } from "@phosphor-icons/react";
import {
MicrophoneIcon,
MicrophoneSlashIcon,
MinusIcon,
PauseIcon,
PlayIcon,
XIcon,
} from "@phosphor-icons/react";
import { useMemo } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import { Button } from "@/components/ui/button";
@@ -94,17 +101,15 @@ export const RecordingControls = ({
)}
</Button>
<Button
variant="ghost"
size="icon"
iconSize="lg"
<button
type="button"
onClick={onStopRecording}
title={t("recording.stop")}
aria-label={t("recording.stop")}
className={styles.ibRed}
className={`${styles.recBtn} ${styles.electronNoDrag}`}
>
<SquareIcon size={16} fill="currentColor" strokeWidth={0} />
</Button>
<span className={styles.stopSquare} />
</button>
<Button
variant="ghost"
@@ -53,7 +53,7 @@ export function useLaunchHudInteractionState({
) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
}, 300);
}, 0);
}
};
@@ -93,7 +93,7 @@ export function useLaunchHudInteractionState({
) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
}, 300);
}, 0);
},
[openId, isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef],
);
+1 -1
View File
@@ -473,7 +473,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (diagnostics.error) {
details.push(diagnostics.error);
}
if (diagnostics.outputPath) {
if (diagnostics.outputPath && (diagnostics.fileSizeBytes ?? 0) > 0) {
details.push(`Saved file: ${diagnostics.outputPath}`);
}