Skip to content
Merged
Show file tree
Hide file tree
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
38 changes: 29 additions & 9 deletions electron/native/ScreenCaptureKitRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
}
}
Expand Down
17 changes: 17 additions & 0 deletions electron/native/ScreenCaptureKitRecorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
});
Binary file not shown.
Binary file not shown.
9 changes: 7 additions & 2 deletions src/components/launch/LaunchWindow.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,6 @@
color: #4eeeb0;
}



.menuCard {
width: 300px;
max-height: 400px;
Expand Down Expand Up @@ -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;
}
Expand Down
10 changes: 4 additions & 6 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={{
Expand All @@ -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
Expand Down
21 changes: 13 additions & 8 deletions src/components/launch/RecordingControls.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/components/launch/hooks/useLaunchHudInteractionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function useLaunchHudInteractionState({
) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
}, 300);
}, 0);
}
};

Expand Down Expand Up @@ -93,7 +93,7 @@ export function useLaunchHudInteractionState({
) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
}, 300);
}, 0);
},
[openId, isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef],
);
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

Expand Down
Loading