diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index d4c05a3ae..ed3649ece 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -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 + } } } } diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index 0e269bd4f..852641009 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -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", + ); + }); +}); diff --git a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper index 242bf20ef..f5dd25b40 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper differ diff --git a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper index 7a4ce3128..7349cdefd 100755 Binary files a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper differ diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 3638e7563..196f86ec6 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -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; } diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 9bc34c85b..cb43ead1b 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -452,11 +452,7 @@ function LaunchWindowContent() { ref={hudContentRef} className="flex items-center overflow-visible flex-col-reverse pointer-events-none" > -