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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
type: fixed
date: 2026-08-19
---

Stopping a long desktop recording no longer loses its last minutes when macOS finishes writing the file slowly, and a clip's saved duration no longer includes the time spent finalizing it.
154 changes: 137 additions & 17 deletions templates/clips/desktop/src-tauri/src/native_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1734,6 +1734,9 @@ pub async fn native_fullscreen_recording_stop_and_upload(
"ms": stop_started.elapsed().as_millis() as u64,
"multiSegment": multi_segment,
"stopOk": stop_outcome.is_ok(),
"durationMs": duration_ms as u64,
"lostSegments": session.lost_segment_count,
"lostMs": session.lost_segment_duration.as_millis() as u64,
}),
);
let _ = crate::clips::close_bubble(app.clone()).await;
Expand Down Expand Up @@ -2334,12 +2337,21 @@ pub async fn native_fullscreen_recording_pause(
session.paused_at = Some(Instant::now());
return Ok(());
}
// The pause starts when it was requested: the finalize wait below is
// not recorded content, so it must not count against the clip.
let current_segment_recorded = session.current_segment_started_at.elapsed();
session.paused_at = Some(Instant::now());
let stop_outcome = finalize_active_backend(session, true);
if let Err(err) = &stop_outcome {
eprintln!("[clips-tray] pause finalize reported an error: {err}");
}
recover_from_unusable_current_segment(session, "pause", true);
session.paused_at = Some(Instant::now());
recover_from_unusable_current_segment(
session,
"pause",
true,
current_segment_recorded,
SEGMENT_MOOV_GRACE,
);
let current_segment_bytes = session
.segments
.last()
Expand Down Expand Up @@ -2647,14 +2659,43 @@ fn take_and_finalize_active_session(
if let Some(stop) = &session.disk_monitor_stop {
stop.store(true, Ordering::Relaxed);
}
// Measure the clip at the moment Stop was requested. Everything below —
// the finalize wait (up to SCK_FINALIZE_TIMEOUT), parked writers, the
// segment merge — is stop latency, not recorded content. Measured at the
// end, `duration_ms` ran 10–17s long on slow finalizes and the pre-upload
// duration gate then reported complete clips as truncated.
let paused_now = session
.paused_at
.take()
.map(|paused_at| paused_at.elapsed());
if let Some(paused_for) = paused_now {
session.paused_total = session
.paused_total
.checked_add(paused_for)
.unwrap_or(session.paused_total);
}
let recorded = session
.started_at
.elapsed()
.saturating_sub(session.paused_total);
let current_segment_recorded = session
.current_segment_started_at
.elapsed()
.saturating_sub(paused_now.unwrap_or(Duration::ZERO));
// Try to finalize capture, but don't early-return on failure: the
// underlying MP4 file is already on disk after stop_capture(), and
// ScreenCaptureKit's StreamError("invalid parameter") on
// remove_recording_output occasionally fires even though the file is
// playable. The caller persists recovery metadata so a finalize
// failure doesn't orphan the file.
let stop_outcome = finalize_active_backend(&mut session, true);
recover_from_unusable_current_segment(&mut session, "final stop", false);
recover_from_unusable_current_segment(
&mut session,
"final stop",
false,
current_segment_recorded,
SEGMENT_MOOV_GRACE,
);
#[cfg(target_os = "macos")]
resolve_deferred_finalizes(&mut session);
println!(
Expand All @@ -2672,8 +2713,8 @@ fn take_and_finalize_active_session(
// With one segment this is a cheap rename. With multiple segments a
// failure would silently lose everything after the first pause, so
// callers check `multi_segment` and surface the merge error.
let consolidate_outcome = consolidate_segments_into_path(&mut session);
let multi_segment = session.segments.len() > 1;
let consolidate_outcome = consolidate_segments_into_path(&mut session);
if let Err(err) = &consolidate_outcome {
eprintln!("[clips-tray] segment consolidation failed: {err}");
}
Expand All @@ -2691,16 +2732,7 @@ fn take_and_finalize_active_session(
);
}

if let Some(paused_at) = session.paused_at.take() {
session.paused_total = session
.paused_total
.checked_add(paused_at.elapsed())
.unwrap_or(session.paused_total);
}
let duration_ms = session
.started_at
.elapsed()
.saturating_sub(session.paused_total)
let duration_ms = recorded
.saturating_sub(session.lost_segment_duration)
.as_millis();
Ok(StoppedSession {
Expand Down Expand Up @@ -2735,15 +2767,43 @@ fn playable_recording_file(path: &Path, mime_type: &str) -> bool {
true
}

/// How long a just-stopped segment may keep finalizing before it is judged
/// unusable. `SCK_FINALIZE_TIMEOUT` bounds the delegate callback, but the
/// writer keeps flushing the moov after that wait gives up — every segment
/// quarantined by an instant check at that point turned out fully playable,
/// with minutes of tail silently cut from the clip. The grace is the same
/// budget parked rotation writers get in `resolve_deferred_finalizes`.
const SEGMENT_MOOV_GRACE: Duration = Duration::from_secs(20);

/// Whether a segment is playable, giving a still-flushing writer up to
/// `moov_grace` to land its moov before answering no.
fn segment_playable_within(path: &Path, mime_type: &str, moov_grace: Duration) -> bool {
let deadline = Instant::now() + moov_grace;
loop {
if playable_recording_file(path, mime_type) {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(500));
}
}

/// `segment_recorded` is how long the current segment captured, measured
/// when the stop or pause was requested — not after the finalize wait, which
/// would overstate the loss.
fn recover_from_unusable_current_segment(
session: &mut NativeFullscreenSession,
reason: &str,
allow_empty: bool,
segment_recorded: Duration,
moov_grace: Duration,
) -> bool {
let Some(current) = session.segments.last().cloned() else {
return false;
};
if playable_recording_file(&current, session.mime_type) {
if segment_playable_within(&current, session.mime_type, moov_grace) {
return false;
}
if !allow_empty && session.segments.len() <= 1 {
Expand All @@ -2759,7 +2819,7 @@ fn recover_from_unusable_current_segment(
session.lost_segment_count = session.lost_segment_count.saturating_add(1);
session.lost_segment_duration = session
.lost_segment_duration
.checked_add(session.current_segment_started_at.elapsed())
.checked_add(segment_recorded)
.unwrap_or(session.lost_segment_duration);
eprintln!(
"[clips-tray] dropped unusable recording segment after {reason}; recovered {} earlier segment(s)",
Expand Down Expand Up @@ -8261,10 +8321,12 @@ mod segment_recovery_tests {
&mut session,
"test pause",
true,
Duration::from_millis(250),
Duration::ZERO,
));
assert_eq!(session.segments, vec![good.clone()]);
assert_eq!(session.lost_segment_count, 1);
assert!(session.lost_segment_duration > Duration::ZERO);
assert_eq!(session.lost_segment_duration, Duration::from_millis(250));
assert!(!bad.exists());

let _ = std::fs::remove_file(good);
Expand All @@ -8280,6 +8342,8 @@ mod segment_recovery_tests {
&mut session,
"final stop",
false,
Duration::from_millis(250),
Duration::ZERO,
));
assert_eq!(session.segments, vec![bad.clone()]);
assert_eq!(session.lost_segment_count, 0);
Expand All @@ -8300,6 +8364,8 @@ mod segment_recovery_tests {
&mut session,
"test pause",
true,
Duration::from_millis(250),
Duration::ZERO,
));
assert_eq!(session.segments, vec![first.clone(), second.clone()]);
assert_eq!(session.lost_segment_count, 0);
Expand All @@ -8308,6 +8374,60 @@ mod segment_recovery_tests {
let _ = std::fs::remove_file(second);
}

#[test]
fn keeps_last_segment_whose_moov_lands_within_grace() {
let first = temp_path("first-graced");
let late = temp_path("late-moov");
write_mp4(&first, true);
write_mp4(&late, false);

let writer_path = late.clone();
let writer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(700));
write_mp4(&writer_path, true);
});

let mut session = test_session(vec![first.clone(), late.clone()]);
assert!(!recover_from_unusable_current_segment(
&mut session,
"final stop",
false,
Duration::from_secs(1),
Duration::from_secs(5),
));
writer.join().unwrap();
assert_eq!(session.segments, vec![first.clone(), late.clone()]);
assert_eq!(session.lost_segment_count, 0);
assert!(late.exists());

let _ = std::fs::remove_file(first);
let _ = std::fs::remove_file(late);
}

#[test]
fn drops_last_segment_whose_moov_never_lands_after_grace() {
let first = temp_path("first-expired");
let bad = temp_path("never-moov");
write_mp4(&first, true);
write_mp4(&bad, false);

let mut session = test_session(vec![first.clone(), bad.clone()]);
let started = Instant::now();
assert!(recover_from_unusable_current_segment(
&mut session,
"final stop",
false,
Duration::from_secs(3),
Duration::from_millis(600),
));
assert!(started.elapsed() >= Duration::from_millis(600));
assert_eq!(session.segments, vec![first.clone()]);
assert_eq!(session.lost_segment_duration, Duration::from_secs(3));
assert!(!bad.exists());

let _ = std::fs::remove_file(first);
}

#[test]
fn concat_validation_rejects_bad_middle_segment() {
let first = temp_path("middle-first");
Expand Down
16 changes: 13 additions & 3 deletions templates/clips/desktop/src/lib/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ const LIVE_UPLOAD_CHUNK_MS = 2_000;
const NATIVE_FULLSCREEN_SEGMENT_MS = 5 * 60_000;
const NATIVE_FULLSCREEN_MIME_TYPE = "video/mp4";
const MEDIA_RECORDER_STOP_TIMEOUT_MS = 15_000;
// Lost-event guard for `clips:native-recording-finalized`. Rust waits
// SCK_FINALIZE_TIMEOUT (10s) for the finalize callback, then up to
// SEGMENT_MOOV_GRACE (20s) for a still-flushing moov, then up to 30s for
// parked rotation writers; this must outlast that path or the transcription
// teardown interrupts the writer mid-flush.
const NATIVE_FINALIZE_EVENT_TIMEOUT_MS = 65_000;
// GCS resumable uploads require every non-final chunk to be a multiple of
// 256 KiB. MediaRecorder emits arbitrary blob sizes, so on the streaming path
// we buffer raw blobs and only PUT aligned slices; the unaligned remainder is
Expand Down Expand Up @@ -3517,8 +3523,10 @@ async function startNativeFullscreenRecording(
// capture immediately; wait for Rust to emit that the recorder has
// finalized (moov written); only then tear the transcription stream
// down. A timeout longer than the Rust finalize ceiling
// (SCK_FINALIZE_TIMEOUT) guards against a lost event so Stop can never
// hang.
// (SCK_FINALIZE_TIMEOUT, then SEGMENT_MOOV_GRACE while the writer is
// still flushing) guards against a lost event so Stop can never hang.
// It must stay above that ceiling: firing while the moov is still
// being written is exactly the -5814 interruption described above.
let signalRecorderFinalized: () => void = () => {};
const recorderFinalized = new Promise<void>((resolve) => {
signalRecorderFinalized = resolve;
Expand Down Expand Up @@ -3555,7 +3563,9 @@ async function startNativeFullscreenRecording(
try {
await Promise.race([
recorderFinalized,
new Promise<void>((resolve) => window.setTimeout(resolve, 15000)),
new Promise<void>((resolve) =>
window.setTimeout(resolve, NATIVE_FINALIZE_EVENT_TIMEOUT_MS),
),
]);
unlistenFinalized();

Expand Down
Loading