From 2fca0fe936dfb4832002655ff49f795b9c965769 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:35:52 +0100 Subject: [PATCH 1/4] improve: desktop recording and editor responsiveness --- Cargo.lock | 12 + apps/desktop-gpui/Cargo.lock | 6 + apps/desktop-gpui/Cargo.toml | 6 + apps/desktop-gpui/dev.sh | 9 +- .../examples/audio-readiness-benchmark.rs | 302 ++++ .../examples/media-readiness-benchmark.rs | 71 + apps/desktop-gpui/src/app_windows.rs | 466 ++++- apps/desktop-gpui/src/editor_crop.rs | 392 ++++- .../desktop-gpui/src/editor_crop/alignment.rs | 257 +++ apps/desktop-gpui/src/editor_export.rs | 114 +- apps/desktop-gpui/src/editor_preparing.rs | 988 +++++++++++ .../src/editor_preparing/audio.rs | 362 ++++ .../src/editor_preparing/presentation.rs | 275 +++ apps/desktop-gpui/src/editor_timeline.rs | 199 ++- .../src/editor_timeline/playback_follow.rs | 103 +- apps/desktop-gpui/src/editor_window.rs | 637 ++++++- apps/desktop-gpui/src/main.rs | 12 +- apps/desktop-gpui/src/platform/windows.rs | 5 + .../src/platform/windows/hidden_frame.rs | 134 ++ apps/desktop-gpui/src/recording.rs | 585 +++++- apps/desktop-gpui/src/session.rs | 178 +- apps/desktop-gpui/src/upload.rs | 25 +- .../scripts/stop-editor-performance.md | 712 ++++++++ apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/api.rs | 3 + apps/desktop/src-tauri/src/audio.rs | 33 +- .../desktop/src-tauri/src/editor_preparing.rs | 1395 +++++++++++++++ .../src-tauri/src/editor_preparing/audio.rs | 226 +++ apps/desktop/src-tauri/src/editor_window.rs | 256 ++- apps/desktop/src-tauri/src/frame_ws.rs | 224 ++- apps/desktop/src-tauri/src/gpui_app.rs | 4 + apps/desktop/src-tauri/src/lib.rs | 236 ++- apps/desktop/src-tauri/src/main.rs | 153 ++ .../src-tauri/src/preparing_finalization.rs | 588 +++++++ apps/desktop/src-tauri/src/recording.rs | 425 ++++- .../src-tauri/src/stop-editor-benchmark.js | 30 + .../src-tauri/src/stop-editor-parity-dom.js | 214 +++ .../src-tauri/src/stop_editor_benchmark.rs | 472 +++++ apps/desktop/src-tauri/src/upload.rs | 101 +- .../desktop/src-tauri/src/upload/lifecycle.rs | 3 +- apps/desktop/src-tauri/src/windows.rs | 18 +- apps/desktop/src/components/Cropper.tsx | 107 +- .../src/components/crop-alignment.test.ts | 74 + apps/desktop/src/components/crop-alignment.ts | 76 + apps/desktop/src/components/cropper.css | 62 + .../src/routes/editor/ClipsSidebar.tsx | 33 +- apps/desktop/src/routes/editor/Editor.tsx | 85 +- apps/desktop/src/routes/editor/ExportPage.tsx | 68 +- apps/desktop/src/routes/editor/Header.tsx | 5 +- apps/desktop/src/routes/editor/Player.tsx | 158 +- .../desktop/src/routes/editor/ShareButton.tsx | 286 +-- .../src/routes/editor/Timeline/index.tsx | 11 +- .../editor/Timeline/playback-follow.test.ts | 44 + .../routes/editor/Timeline/playback-follow.ts | 16 +- .../src/routes/editor/TranscriptPage.tsx | 81 +- apps/desktop/src/routes/editor/context.ts | 204 ++- .../src/routes/editor/editor-skeleton.tsx | 306 ++-- apps/desktop/src/routes/editor/index.tsx | 9 +- .../editor/playback-intent-routing.test.ts | 163 ++ .../routes/editor/playback-intent-routing.ts | 14 + .../editor/preparing-editor-context.tsx | 264 +++ .../editor/preparing-editor-model.test.ts | 294 ++++ .../routes/editor/preparing-editor-model.ts | 250 +++ .../preparing-editor-subscription.test.ts | 198 +++ .../editor/preparing-editor-subscription.ts | 90 + .../editor/preparing-frame-lease.test.ts | 131 ++ .../routes/editor/preparing-frame-lease.ts | 60 + .../editor/preparing-frame-transport.test.ts | 285 +++ .../editor/preparing-frame-transport.ts | 47 + .../src/routes/editor/preparing-frame.tsx | 35 + .../routes/editor/preparing-handoff.test.ts | 494 ++++++ .../src/routes/editor/preparing-handoff.ts | 180 ++ .../editor/preparing-playback-handoff.test.ts | 184 ++ .../editor/preparing-playback-handoff.ts | 119 ++ .../src/routes/editor/preparing-timeline.tsx | 123 ++ .../src/routes/editor/preview-bounds.test.ts | 264 +++ .../src/routes/editor/preview-bounds.ts | 94 + apps/desktop/src/utils/frame-identity.ts | 11 + apps/desktop/src/utils/frame-worker.ts | 6 + apps/desktop/src/utils/socket.ts | 60 +- .../src/utils/stride-correction-worker.ts | 11 +- apps/desktop/src/utils/tauri.ts | 33 +- ...sktop-recording-output-replacement.test.ts | 40 + .../__tests__/unit/multipart-presign.test.ts | 197 ++- .../app/api/upload/[...route]/multipart.ts | 91 +- apps/web/lib/desktop-reupload.ts | 85 + crates/audio/Cargo.toml | 3 + .../audio/examples/relocatable-audio-probe.rs | 139 ++ crates/audio/src/audio_data.rs | 199 ++- crates/audio/src/lib.rs | 2 + crates/audio/src/progressive.rs | 1162 ++++++++++++ crates/audio/src/progressive/managed.rs | 758 ++++++++ crates/audio/src/progressive/test_support.rs | 91 + crates/audio/src/renderer.rs | 221 ++- crates/audio/src/streaming.rs | 402 ++++- crates/cap-muxer/src/main.rs | 65 + crates/editor/Cargo.toml | 5 +- .../examples/editor-startup-benchmark.rs | 26 +- crates/editor/src/audio.rs | 84 +- crates/editor/src/audio_output.rs | 626 ++++++- .../editor/src/audio_output/native_tests.rs | 714 ++++++++ crates/editor/src/completed_audio.rs | 302 ++++ crates/editor/src/editor_instance.rs | 717 ++++++-- crates/editor/src/lib.rs | 34 +- crates/editor/src/playback.rs | 161 +- crates/editor/src/preparing_audio/mixer.rs | 669 +++++++ crates/editor/src/preparing_audio/mod.rs | 7 + crates/editor/src/preparing_audio/output.rs | 590 +++++++ .../src/preparing_audio/output_tests.rs | 624 +++++++ crates/editor/src/preparing_editor.rs | 29 + crates/editor/src/preparing_handoff.rs | 478 +++++ crates/editor/src/preparing_handoff_tests.rs | 261 +++ crates/editor/src/preparing_playback.rs | 1498 ++++++++++++++++ crates/editor/src/preparing_preview.rs | 800 +++++++++ .../src/preparing_preview/native_tests.rs | 1423 +++++++++++++++ crates/editor/src/preparing_preview/tests.rs | 269 +++ crates/editor/src/segments.rs | 120 +- crates/enc-ffmpeg/Cargo.toml | 1 + crates/enc-ffmpeg/src/lib.rs | 4 + crates/enc-ffmpeg/src/relocatable_source.rs | 1172 ++++++++++++ crates/enc-ffmpeg/src/remux.rs | 34 + crates/enc-ffmpeg/src/segmented_input.rs | 1028 +++++++++++ crates/project/src/cursor.rs | 56 +- crates/project/src/keyboard.rs | 61 +- .../examples/studio-finalization-benchmark.rs | 46 + crates/recording/src/recovery.rs | 1471 +++++++++++++++- .../src/recovery/preparing_canonical_probe.rs | 1259 +++++++++++++ .../src/recovery/preparing_observer.rs | 738 ++++++++ .../src/recovery/preparing_projection.rs | 1391 +++++++++++++++ crates/recording/src/studio_recording.rs | 424 ++++- crates/recording/tests/oop_muxer.rs | 195 +- crates/rendering/Cargo.toml | 3 + crates/rendering/src/decoder/ffmpeg.rs | 1566 ++++++++++------- crates/rendering/src/decoder/mod.rs | 1396 ++++++++++++++- crates/rendering/src/frame_pipeline.rs | 52 +- crates/rendering/src/layers/captions.rs | 2 + crates/rendering/src/layers/cursor.rs | 89 +- crates/rendering/src/layers/keyboard.rs | 2 + crates/rendering/src/layers/mod.rs | 16 +- crates/rendering/src/layers/text.rs | 2 + crates/rendering/src/lib.rs | 457 +++-- crates/rendering/src/managed_segment.rs | 587 ++++++ crates/rendering/src/overlay_layers.rs | 152 ++ .../src/overlay_layers/native_tests.rs | 368 ++++ crates/rendering/src/project_recordings.rs | 156 +- crates/rendering/src/readiness.rs | 70 + .../rendering/src/recorded_cursor_assets.rs | 307 ++++ crates/rendering/src/segment_timing.rs | 132 ++ crates/video-decode/Cargo.toml | 6 + .../examples/fragmented-readiness.rs | 95 + .../examples/relocatable-video-probe.rs | 310 ++++ crates/video-decode/src/ffmpeg.rs | 478 ++++- 152 files changed, 39950 insertions(+), 2335 deletions(-) create mode 100644 apps/desktop-gpui/examples/audio-readiness-benchmark.rs create mode 100644 apps/desktop-gpui/examples/media-readiness-benchmark.rs create mode 100644 apps/desktop-gpui/src/editor_crop/alignment.rs create mode 100644 apps/desktop-gpui/src/editor_preparing.rs create mode 100644 apps/desktop-gpui/src/editor_preparing/audio.rs create mode 100644 apps/desktop-gpui/src/editor_preparing/presentation.rs create mode 100644 apps/desktop-gpui/src/platform/windows/hidden_frame.rs create mode 100644 apps/desktop/scripts/stop-editor-performance.md create mode 100644 apps/desktop/src-tauri/src/editor_preparing.rs create mode 100644 apps/desktop/src-tauri/src/editor_preparing/audio.rs create mode 100644 apps/desktop/src-tauri/src/preparing_finalization.rs create mode 100644 apps/desktop/src-tauri/src/stop-editor-benchmark.js create mode 100644 apps/desktop/src-tauri/src/stop-editor-parity-dom.js create mode 100644 apps/desktop/src-tauri/src/stop_editor_benchmark.rs create mode 100644 apps/desktop/src/components/crop-alignment.test.ts create mode 100644 apps/desktop/src/components/crop-alignment.ts create mode 100644 apps/desktop/src/components/cropper.css create mode 100644 apps/desktop/src/routes/editor/playback-intent-routing.test.ts create mode 100644 apps/desktop/src/routes/editor/playback-intent-routing.ts create mode 100644 apps/desktop/src/routes/editor/preparing-editor-context.tsx create mode 100644 apps/desktop/src/routes/editor/preparing-editor-model.test.ts create mode 100644 apps/desktop/src/routes/editor/preparing-editor-model.ts create mode 100644 apps/desktop/src/routes/editor/preparing-editor-subscription.test.ts create mode 100644 apps/desktop/src/routes/editor/preparing-editor-subscription.ts create mode 100644 apps/desktop/src/routes/editor/preparing-frame-lease.test.ts create mode 100644 apps/desktop/src/routes/editor/preparing-frame-lease.ts create mode 100644 apps/desktop/src/routes/editor/preparing-frame-transport.test.ts create mode 100644 apps/desktop/src/routes/editor/preparing-frame-transport.ts create mode 100644 apps/desktop/src/routes/editor/preparing-frame.tsx create mode 100644 apps/desktop/src/routes/editor/preparing-handoff.test.ts create mode 100644 apps/desktop/src/routes/editor/preparing-handoff.ts create mode 100644 apps/desktop/src/routes/editor/preparing-playback-handoff.test.ts create mode 100644 apps/desktop/src/routes/editor/preparing-playback-handoff.ts create mode 100644 apps/desktop/src/routes/editor/preparing-timeline.tsx create mode 100644 apps/desktop/src/routes/editor/preview-bounds.test.ts create mode 100644 apps/desktop/src/routes/editor/preview-bounds.ts create mode 100644 apps/desktop/src/utils/frame-identity.ts create mode 100644 apps/web/lib/desktop-reupload.ts create mode 100644 crates/audio/examples/relocatable-audio-probe.rs create mode 100644 crates/audio/src/progressive.rs create mode 100644 crates/audio/src/progressive/managed.rs create mode 100644 crates/audio/src/progressive/test_support.rs create mode 100644 crates/editor/src/audio_output/native_tests.rs create mode 100644 crates/editor/src/completed_audio.rs create mode 100644 crates/editor/src/preparing_audio/mixer.rs create mode 100644 crates/editor/src/preparing_audio/mod.rs create mode 100644 crates/editor/src/preparing_audio/output.rs create mode 100644 crates/editor/src/preparing_audio/output_tests.rs create mode 100644 crates/editor/src/preparing_editor.rs create mode 100644 crates/editor/src/preparing_handoff.rs create mode 100644 crates/editor/src/preparing_handoff_tests.rs create mode 100644 crates/editor/src/preparing_playback.rs create mode 100644 crates/editor/src/preparing_preview.rs create mode 100644 crates/editor/src/preparing_preview/native_tests.rs create mode 100644 crates/editor/src/preparing_preview/tests.rs create mode 100644 crates/enc-ffmpeg/src/relocatable_source.rs create mode 100644 crates/enc-ffmpeg/src/segmented_input.rs create mode 100644 crates/recording/examples/studio-finalization-benchmark.rs create mode 100644 crates/recording/src/recovery/preparing_canonical_probe.rs create mode 100644 crates/recording/src/recovery/preparing_observer.rs create mode 100644 crates/recording/src/recovery/preparing_projection.rs create mode 100644 crates/rendering/src/managed_segment.rs create mode 100644 crates/rendering/src/overlay_layers.rs create mode 100644 crates/rendering/src/overlay_layers/native_tests.rs create mode 100644 crates/rendering/src/readiness.rs create mode 100644 crates/rendering/src/recorded_cursor_assets.rs create mode 100644 crates/rendering/src/segment_timing.rs create mode 100644 crates/video-decode/examples/fragmented-readiness.rs create mode 100644 crates/video-decode/examples/relocatable-video-probe.rs diff --git a/Cargo.lock b/Cargo.lock index c395bf64716..9197d093840 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1268,6 +1268,7 @@ dependencies = [ name = "cap-audio" version = "0.1.0" dependencies = [ + "cap-enc-ffmpeg", "cidre", "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=6013cb5f8bd3)", "ffmpeg-next", @@ -1560,6 +1561,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-stream", + "tokio-tungstenite", "tokio-util", "tracing", "tracing-appender", @@ -1584,6 +1586,7 @@ version = "0.1.0" dependencies = [ "axum", "cap-audio", + "cap-enc-ffmpeg", "cap-media", "cap-media-info", "cap-project", @@ -1593,6 +1596,7 @@ dependencies = [ "ffmpeg-next", "flume", "futures", + "image 0.25.8", "lru", "ringbuf", "sentry", @@ -1635,6 +1639,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tracing", + "windows 0.60.0", "workspace-hack", ] @@ -1926,7 +1931,9 @@ dependencies = [ "bytemuck", "cap-camera-effects", "cap-cursor-info", + "cap-enc-ffmpeg", "cap-flags", + "cap-media-info", "cap-project", "cap-video-decode", "cidre", @@ -1950,6 +1957,7 @@ dependencies = [ "serde", "serde_json", "specta", + "tempfile", "thiserror 1.0.69", "tiny-skia", "tokio", @@ -2054,10 +2062,14 @@ name = "cap-video-decode" version = "0.1.0" dependencies = [ "cap-d3d-adapter", + "cap-enc-ffmpeg", + "cap-media-info", "cidre", "ffmpeg-hw-device", "ffmpeg-next", "num_cpus", + "serde_json", + "sha2", "tempfile", "tokio", "tracing", diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index d36888cc560..686399628c5 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -1396,6 +1396,7 @@ dependencies = [ name = "cap-audio" version = "0.1.0" dependencies = [ + "cap-enc-ffmpeg", "cidre", "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=6013cb5f8bd3)", "ffmpeg-next", @@ -1538,6 +1539,7 @@ dependencies = [ "anyhow", "ashpd 0.11.1", "base64 0.22.1", + "cap-audio", "cap-camera", "cap-camera-effects", "cap-cursor-info", @@ -1619,6 +1621,7 @@ dependencies = [ "ringbuf", "sentry", "serde", + "serde_json", "specta", "tokio", "tokio-util", @@ -1654,6 +1657,7 @@ dependencies = [ "sysinfo 0.32.1", "thiserror 1.0.69", "tracing", + "windows 0.60.0", "workspace-hack", ] @@ -1909,6 +1913,7 @@ dependencies = [ "bytemuck", "cap-camera-effects", "cap-cursor-info", + "cap-enc-ffmpeg", "cap-flags", "cap-project", "cap-video-decode", @@ -1979,6 +1984,7 @@ name = "cap-video-decode" version = "0.1.0" dependencies = [ "cap-d3d-adapter", + "cap-enc-ffmpeg", "cidre", "ffmpeg-hw-device", "ffmpeg-next", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 83aa68e187b..b2411ea5a9f 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -18,6 +18,10 @@ publish = false name = "cap-gpui" path = "src/main.rs" +[[example]] +name = "studio-finalization-benchmark" +path = "../../crates/recording/examples/studio-finalization-benchmark.rs" + [package.metadata.bundle] name = "Cap GPUI" identifier = "so.cap.desktop.gpui" @@ -28,6 +32,7 @@ osx_info_plist_exts = ["resources/Info.plist"] osx_url_schemes = ["cap-desktop"] [dependencies] +cap-audio = { path = "../../crates/audio" } # gpui — the wingleeio/zed fork Comet ships. The pinned rev carries f596cde # ("destination alpha on transparent windows: Porter-Duff OVER, not additive"), # which our transparent rounded main-window shell depends on. @@ -183,6 +188,7 @@ windows-sys = { version = "0.59", features = [ "Win32_Security", "Win32_System_SystemInformation", "Win32_System_Threading", + "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } diff --git a/apps/desktop-gpui/dev.sh b/apps/desktop-gpui/dev.sh index 8dff6251270..cd92c3edd60 100755 --- a/apps/desktop-gpui/dev.sh +++ b/apps/desktop-gpui/dev.sh @@ -4,17 +4,12 @@ # app persists its window state through CAP_GPUI_DEV_RESTORE and reopens where # it was, and the swap waits out an in-flight recording (see dev_restore.rs). set -uo pipefail -cd "$(dirname "$0")" +cd "$(dirname "$0")" || exit 1 STATE_FILE="$PWD/target/dev-restore.json" BIN="$PWD/target/debug/cap-gpui" BUILD=cargo -# Cargo.toml turns incremental off to keep the dep tree's caches off a full -# disk, but deps never rebuild in this loop -- the cache this creates is the -# app crate's only, and it takes a warm rebuild from ~41s to 3-23s. -export CARGO_INCREMENTAL=1 - WATCH_PATHS=(src assets Cargo.toml) [ -d resources ] && WATCH_PATHS+=(resources) for crate in camera scap-targets recording timestamp utils project rendering editor export; do @@ -84,7 +79,7 @@ while true; do if [ "$CURRENT" != "$LAST" ]; then LAST="$CURRENT" echo "[dev] building..." - if "$BUILD" build; then + if "$BUILD" build --config profile.dev.package.cap-desktop-gpui.incremental=true; then if [ -n "$APP_PID" ] || gpui_owns_session || instance_live; then stop_app start_app diff --git a/apps/desktop-gpui/examples/audio-readiness-benchmark.rs b/apps/desktop-gpui/examples/audio-readiness-benchmark.rs new file mode 100644 index 00000000000..e0fcf623125 --- /dev/null +++ b/apps/desktop-gpui/examples/audio-readiness-benchmark.rs @@ -0,0 +1,302 @@ +use std::{ + path::PathBuf, + sync::{Arc, atomic::AtomicBool}, + time::Instant, +}; + +use cap_audio::{AudioData, AudioStream, ChunkRead}; +use sha2::{Digest, Sha256}; +use tokio::sync::watch; + +fn main() -> anyhow::Result<()> { + let path = std::env::args_os() + .nth(1) + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("Pass an audio path"))?; + if std::env::var_os("CAP_BENCH_PROGRESSIVE_AUDIO").is_some() { + return progressive_audio(path); + } + background_audio(path) +} + +#[derive(Clone)] +struct PreviousAudioLoader { + rx: watch::Receiver>, String>>>, +} + +impl PreviousAudioLoader { + fn spawn(path: PathBuf, label: String) -> Self { + let (tx, rx) = watch::channel(None); + tokio::task::spawn_blocking(move || { + let result = AudioData::from_file(&path) + .map(|data| Some(Arc::new(data))) + .map_err(|e| format!("{label} / {e}")); + let _ = tx.send(Some(result)); + }); + Self { rx } + } + + async fn get(&self) -> Result>, String> { + let mut rx = self.rx.clone(); + loop { + if let Some(result) = rx.borrow_and_update().clone() { + return result; + } + if rx.changed().await.is_err() { + return Err("Audio load task was dropped".to_string()); + } + } + } +} + +fn background_audio(path: PathBuf) -> anyhow::Result<()> { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(async move { + let started = Instant::now(); + let loader = PreviousAudioLoader::spawn(path.clone(), "benchmark".into()); + let full = loader + .get() + .await + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("Missing decoded audio"))?; + let full_ms = started.elapsed().as_secs_f64() * 1000.0; + background_audio_report(path, full.as_ref(), full_ms) + }) +} + +fn background_audio_report(path: PathBuf, full: &AudioData, full_ms: f64) -> anyhow::Result<()> { + let full_hash = format!( + "{:x}", + Sha256::digest(unsafe { cap_audio::cast_f32_slice_to_bytes(full.samples()) }) + ); + let mix = mix_benchmark(full); + let channels = usize::from(full.channels()); + let sample_frames = full.samples().len() / channels; + let window_frames = AudioData::SAMPLE_RATE as usize * 2; + let mut windows = Vec::new(); + let mut matches_reference = true; + let streaming = if std::env::var_os("CAP_BENCH_STREAM_AUDIO").is_some() { + let started = Instant::now(); + let mut stream = AudioStream::open(&path, Arc::new(AtomicBool::new(false)))?; + let open_ms = started.elapsed().as_secs_f64() * 1000.0; + let mut decode_ms = open_ms; + let mut first_chunk_ms = None; + let mut next_sample = 0usize; + let mut maximum_chunk_bytes = 0usize; + let mut streamed_hash = Sha256::new(); + loop { + let read_started = Instant::now(); + let result = stream.read_chunk(12_000)?; + decode_ms += read_started.elapsed().as_secs_f64() * 1000.0; + match result { + ChunkRead::Chunk(chunk) => { + first_chunk_ms.get_or_insert(decode_ms); + anyhow::ensure!(chunk.channels == full.channels(), "Channel count changed"); + anyhow::ensure!( + chunk.source_start_sample == (next_sample / channels) as u64, + "Noncontiguous audio chunk" + ); + let end = next_sample + chunk.samples.len(); + let reference = full.samples().get(next_sample..end).ok_or_else(|| { + anyhow::anyhow!("Streaming decode exceeded full-track length") + })?; + anyhow::ensure!( + chunk + .samples + .iter() + .zip(reference) + .all(|(actual, expected)| { actual.to_bits() == expected.to_bits() }), + "Streaming samples differ at source sample {next_sample}" + ); + let bytes = unsafe { cap_audio::cast_f32_slice_to_bytes(&chunk.samples) }; + maximum_chunk_bytes = maximum_chunk_bytes.max(bytes.len()); + streamed_hash.update(bytes); + next_sample = end; + } + ChunkRead::Eof { next_sample: end } => { + anyhow::ensure!(end == sample_frames as u64, "Streaming EOF differs"); + anyhow::ensure!( + next_sample == full.samples().len(), + "Streaming audio is short" + ); + break; + } + } + } + Some(serde_json::json!({ + "openMs": open_ms, + "firstChunkMs": first_chunk_ms, + "chunkDurationSeconds": 0.25, + "decodeMsExcludingComparison": decode_ms, + "maximumChunkBytes": maximum_chunk_bytes, + "samplesSha256": format!("{:x}", streamed_hash.finalize()), + "bitExact": true, + })) + } else { + None + }; + for start in [ + 0, + sample_frames / 2, + sample_frames.saturating_sub(window_frames), + ] { + if std::env::var_os("CAP_BENCH_FULL_AUDIO_ONLY").is_some() { + break; + } + let end = start.saturating_add(window_frames).min(sample_frames); + let started = Instant::now(); + let range = AudioData::from_file_range(&path, start, end).map_err(anyhow::Error::msg)?; + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; + let reference = &full.samples()[start * channels..end * channels]; + let sample_count_matches = range.samples().len() == reference.len(); + let maximum_difference = range + .samples() + .iter() + .zip(reference) + .map(|(actual, expected)| (actual - expected).abs()) + .fold(0.0_f32, f32::max); + matches_reference &= sample_count_matches && maximum_difference <= 0.000_001; + windows.push(serde_json::json!({ + "startSeconds": start as f64 / f64::from(AudioData::SAMPLE_RATE), + "elapsedMs": elapsed_ms, + "decodedBytes": std::mem::size_of_val(range.samples()), + "expectedSamples": reference.len(), + "actualSamples": range.samples().len(), + "sampleCountMatches": sample_count_matches, + "maximumSampleDifference": maximum_difference, + })); + } + println!( + "{}", + serde_json::json!({ + "path": path, + "mode": "background-baseline", + "baselineMode": "spawn-blocking-watch-arc", + "methodology": methodology(), + "fullDecodeMs": full_ms, + "fullDecodedBytes": std::mem::size_of_val(full.samples()), + "fullSamplesSha256": full_hash, + "durationSeconds": sample_frames as f64 / f64::from(AudioData::SAMPLE_RATE), + "channels": full.channels(), + "sampleFrames": sample_frames, + "windows": windows, + "streaming": streaming, + "mix": mix, + }) + ); + anyhow::ensure!( + matches_reference, + "Window decoding differs from full-track decoding" + ); + Ok(()) +} + +fn progressive_audio(path: PathBuf) -> anyhow::Result<()> { + use cap_audio::AudioSampleSource; + + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(async move { + let started = Instant::now(); + let loader = cap_audio::ProgressiveAudio::spawn(path.clone(), "benchmark".into()); + let window = loader + .window(0..12_000) + .await + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("Missing decoded audio"))?; + let first_window_ms = started.elapsed().as_secs_f64() * 1000.0; + let progress = loader.progress(); + let audio = loader + .get() + .await + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("Missing decoded audio"))?; + let full_ms = started.elapsed().as_secs_f64() * 1000.0; + let mut first_hash = Sha256::new(); + for index in 0..12_000.min(window.sample_count()) * usize::from(window.channels()) { + first_hash.update(window.sample(index).unwrap().to_le_bytes()); + } + let mut hash = Sha256::new(); + let mut decoded_bytes = 0; + for samples in audio.sample_slices() { + decoded_bytes += std::mem::size_of_val(samples); + hash.update(unsafe { cap_audio::cast_f32_slice_to_bytes(samples) }); + } + println!( + "{}", + serde_json::json!({ + "path": path, + "mode": "progressive", + "methodology": methodology(), + "firstWindowMs": first_window_ms, + "readyFramesAtFirstWindow": progress.ready_frames, + "completeAtFirstWindow": progress.complete, + "fullDecodedAudioMs": full_ms, + "fullDecodedBytes": decoded_bytes, + "channels": audio.channels(), + "sampleFrames": audio.sample_count(), + "decodedSha256": format!("{:x}", hash.finalize()), + "firstWindowSha256": format!("{:x}", first_hash.finalize()), + "mix": mix_benchmark(audio.as_ref()), + }) + ); + Ok(()) + }) +} + +fn methodology() -> serde_json::Value { + serde_json::json!({ + "runtime": "tokio-current-thread", + "decodeExecution": "spawn-blocking", + "fullDecodeTimer": "before-loader-spawn-through-completed-get", + "runtimeSetupIncluded": false, + "hashingIncluded": false, + "mixingIncluded": false, + "renderer": "current-cap-audio", + "mixTiming": "after-full-pcm-hash-before-stream-or-range-diagnostics", + }) +} + +fn mix_benchmark(audio: &impl cap_audio::AudioSampleSource) -> Option { + if std::env::var_os("CAP_BENCH_MIX_AUDIO").is_none() || audio.sample_count() < 48_000 { + return None; + } + let tracks = [cap_audio::AudioRendererTrack { + data: audio, + gain: -4.0, + stereo_mode: cap_audio::StereoMode::Stereo, + offset: 0, + }]; + let mut output = vec![0.0_f32; 48_000 * 2 * 3]; + let started = Instant::now(); + for _ in 0..20 { + for (range, start) in [0, audio.sample_count() / 2, audio.sample_count() - 48_000] + .into_iter() + .enumerate() + { + for offset in (0..48_000).step_by(1_024) { + let count = 1_024.min(48_000 - offset); + let written = cap_audio::render_audio( + &tracks, + start + offset, + count, + (range * 48_000 + offset) * 2, + &mut output, + ); + assert_eq!(written, count); + } + } + std::hint::black_box(output.as_slice()); + } + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; + Some(serde_json::json!({ + "elapsedMs": elapsed_ms, + "renderedSeconds": 60, + "repetitions": 20, + "outputBarrier": "after-each-repetition", + "sha256": format!("{:x}", Sha256::digest(unsafe { cap_audio::cast_f32_slice_to_bytes(&output) })), + })) +} diff --git a/apps/desktop-gpui/examples/media-readiness-benchmark.rs b/apps/desktop-gpui/examples/media-readiness-benchmark.rs new file mode 100644 index 00000000000..ee496e4553d --- /dev/null +++ b/apps/desktop-gpui/examples/media-readiness-benchmark.rs @@ -0,0 +1,71 @@ +use std::{path::PathBuf, time::Instant}; + +use cap_rendering::{Video, decoder::spawn_decoder}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + let mut arguments = std::env::args_os().skip(1); + let path = arguments + .next() + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("Pass a video path and optional audio path"))?; + let audio_path = arguments.next().map(PathBuf::from); + let force_ffmpeg = std::env::var_os("CAP_BENCH_FORCE_FFMPEG").is_some(); + let started = Instant::now(); + let video = Video::new(&path, 0.0).map_err(anyhow::Error::msg)?; + let probe_ms = started.elapsed().as_secs_f64() * 1000.0; + let decoder = spawn_decoder( + "readiness-benchmark", + path.clone(), + video.fps, + 0.0, + force_ffmpeg, + ) + .await + .map_err(anyhow::Error::msg)?; + let initialized_ms = started.elapsed().as_secs_f64() * 1000.0; + let mut frames = Vec::new(); + for time in [0.0, video.duration * 0.5, video.duration - 1.0, 1.0] { + let requested = Instant::now(); + let frame = decoder + .get_frame_initial(time.max(0.0) as f32) + .await + .ok_or_else(|| anyhow::anyhow!("No frame at {time}"))?; + frames.push(serde_json::json!({ + "timeSeconds": time, + "elapsedMs": requested.elapsed().as_secs_f64() * 1000.0, + "sinceOpenMs": started.elapsed().as_secs_f64() * 1000.0, + "width": frame.width(), + "height": frame.height(), + })); + } + let audio_ms = if let Some(audio_path) = audio_path { + let started = Instant::now(); + cap_editor::AudioLoader::spawn(audio_path, "readiness-benchmark".into()) + .get() + .await + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("No decoded audio"))?; + Some(started.elapsed().as_secs_f64() * 1000.0) + } else { + None + }; + println!( + "{}", + serde_json::json!({ + "path": path, + "durationSeconds": video.duration, + "probeMs": probe_ms, + "initializedMs": initialized_ms, + "decoder": decoder.decoder_type().to_string(), + "fallbackReason": decoder.fallback_reason(), + "frames": frames, + "audioReadyMs": audio_ms, + }) + ); + Ok(()) +} diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 5665a04bc29..fc8067b1b3a 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -29,9 +29,9 @@ use crate::{ mode_select_window::{self, ModeSelectWindow}, onboarding_window::{self, OnboardingWindow}, platform, - recording::{RecordingMode, StartConfig}, + recording::{RecordingMode, StartConfig, StudioFinalization}, screenshot_editor::{self, ScreenshotEditorWindow}, - session::{Phase, RecordingSession}, + session::{Phase, RecordingSession, StudioEditorPresentation}, settings_window::{self, Page, SettingsWindow}, target_overlay::{AreaRect, HoveredWindow, OverlayWindow, TargetSelect}, teleprompter_window::{self, TeleprompterWindow}, @@ -206,6 +206,7 @@ pub struct AppWindows { /// incrementing id; the path is the identity in both. pub editors: Vec<(PathBuf, WindowHandle)>, deleting_editors: HashSet, + preparing_cleanup: crate::editor_preparing::PreparingCleanupRegistry, /// One screenshot editor per `.cap` bundle -- the gpui spelling of /// `ScreenshotEditorWindowIds`, keyed by the bundle directory. pub screenshot_editors: Vec<(PathBuf, WindowHandle)>, @@ -486,6 +487,7 @@ pub fn init(main: WindowHandle, session: Entity, c overlays: Vec::new(), editors: Vec::new(), deleting_editors: HashSet::new(), + preparing_cleanup: crate::editor_preparing::PreparingCleanupRegistry::default(), screenshot_editors: Vec::new(), main_hidden_for_picker: false, editor_hidden_for_picker: None, @@ -521,6 +523,9 @@ pub fn init(main: WindowHandle, session: Entity, c // status item becomes a stop button while a capture runs. crate::tray::set_recording(recording, cx); } + if phase == Phase::Stopping { + open_preparing_studio_editor(&session, cx); + } if phase == Phase::Idle && last_phase != Phase::Idle { #[cfg(target_os = "linux")] if !session.read(cx).instant_cleanup_safe() { @@ -532,7 +537,12 @@ pub fn init(main: WindowHandle, session: Entity, c if !restore_clean_capture_ui(cx) { return; } - let finished_studio = session.update(cx, |session, _| session.finished_studio.take()); + let (finished_studio, preparing_editor) = session.update(cx, |session, _| { + ( + session.finished_studio.take(), + session.take_preparing_studio_editor(), + ) + }); // The in-editor re-record target is consumed first, before // `postStudioRecordingBehaviour` is even consulted -- the order // `apply_post_studio_editor_behaviour` checks them @@ -549,6 +559,20 @@ pub fn init(main: WindowHandle, session: Entity, c session.update(cx, |session, _| session.take_editor_recording_target()); if let Some(editor_path) = editor_target { editor_recording_finished(editor_path, finished_studio, cx); + } else if let Some(preparing) = + preparing_editor.filter(|pending| pending.presentation.suppresses_completion_open()) + { + let key = editor_key(&preparing.project_path); + if !cx + .global::() + .editors + .iter() + .any(|(path, _)| path == &key) + { + restore_after_editor_close(&key, cx); + } else { + park_idle_camera_preview(cx); + } } else { // `postStudioRecordingBehaviour` (`openEditor` is the // default): a cleanly-stopped studio recording goes straight @@ -593,6 +617,40 @@ pub fn init(main: WindowHandle, session: Entity, c crate::deeplink::init(cx); } +fn open_preparing_studio_editor(session: &Entity, cx: &mut App) { + let Some(pending) = session.read(cx).preparing_studio_editor().cloned() else { + return; + }; + if !pending.capture_stopped + || pending.presentation != StudioEditorPresentation::Pending + || !pending.finalization.is_finalizing() + || crate::store::GeneralSettings::load().post_studio_recording_behaviour + != crate::store::PostStudioBehaviour::OpenEditor + { + return; + } + #[cfg(target_os = "linux")] + if !restore_clean_capture_ui(cx) { + return; + } + session.update(cx, |session, _| { + if let Some(current) = session.preparing_studio_editor_mut() { + current.presentation = StudioEditorPresentation::Attempted; + } + }); + open_editor(pending.project_path, cx); + if session + .read(cx) + .preparing_studio_editor() + .is_some_and(|pending| matches!(pending.presentation, StudioEditorPresentation::Opened(_))) + { + close_controls(session, cx); + close_target_overlays(cx); + close_camera_window(cx); + restore_content_protection(cx); + } +} + /// `createThemeListener` + `commands.setTheme`: persist is already done; this /// forces native appearance on every open window and invalidates so each /// `sync_appearance` rebuilds the palette. @@ -875,7 +933,10 @@ pub fn hide_main_window(cx: &mut App) { fn hide_main_and_park_camera_preview(cx: &mut App) { hide_main_window(cx); + park_idle_camera_preview(cx); +} +fn park_idle_camera_preview(cx: &mut App) { if !camera_preview_can_be_parked(RecordingSession::global(cx).read(cx).phase) { return; } @@ -2579,6 +2640,7 @@ fn own_windows(cx: &mut App) -> Vec { overlays, editors, deleting_editors: _, + preparing_cleanup: _, screenshot_editors, main_hidden_for_picker: _, editor_hidden_for_picker: _, @@ -4495,21 +4557,52 @@ fn editor_key(path: &Path) -> PathBuf { /// Must be reached through `cx.defer` from anything inside an entity update: /// opening a window paints it synchronously and would double-lease the caller. pub fn open_editor(project_path: PathBuf, cx: &mut App) { + let key = editor_key(&project_path); + let session = RecordingSession::global(cx); + let preparing = session + .read(cx) + .preparing_studio_editor() + .filter(|pending| editor_key(&pending.project_path) == key) + .cloned(); + if preparing + .as_ref() + .is_some_and(|pending| !pending.capture_stopped) + { + return; + } + let finalization = preparing.map(|pending| pending.finalization); + if let Some(window_id) = open_editor_window(project_path, finalization, cx) { + session.update(cx, |session, _| { + if let Some(pending) = session.preparing_studio_editor_mut() + && editor_key(&pending.project_path) == key + { + pending.presentation = StudioEditorPresentation::Opened(window_id); + } + }); + } +} + +fn open_editor_window( + project_path: PathBuf, + finalization: Option, + cx: &mut App, +) -> Option { #[cfg(target_os = "linux")] if defer_window_until_capture_safe(cx) { - return; + return None; } let key = editor_key(&project_path); if cx.global::().deleting_editors.contains(&key) { tracing::info!(path = %key.display(), "recording deletion is still settling; editor remains closed"); - return; + return None; } - if cx + if let Some(window_id) = cx .global::() .editors .iter() - .any(|(path, _)| path == &key) + .find(|(path, _)| path == &key) + .map(|(_, handle)| handle.window_id()) { tracing::info!( path = %key.display(), @@ -4517,7 +4610,7 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { ); hide_main_and_park_camera_preview(cx); reveal_editor_window(&key, cx); - return; + return Some(window_id); } let bounds = opening_window_bounds( @@ -4571,7 +4664,7 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { Ok(handle) => handle, Err(error) => { tracing::error!("editor window failed to open: {error:#}"); - return; + return None; } }; @@ -4595,7 +4688,137 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { hide_main_and_park_camera_preview(cx); reveal_editor_window(&key, cx); - load_editor_project(key, handle, cx); + if finalization.is_some() { + tracing::info!(path = %key.display(), "preparing editor shell created"); + let frame_path = key.clone(); + handle.update(cx, |_, window, _| { + window.on_next_frame(move |window, _| { + window.on_next_frame(move |_, _| { + tracing::info!(path = %frame_path.display(), "preparing editor first frame cycle completed"); + }); + }); + window.refresh(); + }).ok(); + } + load_editor_project(key, handle, finalization, cx); + Some(handle.window_id()) +} + +fn editor_audio_output() -> Arc { + if std::env::var("CAP_GPUI_MUTE_AUDIO").is_ok_and(|value| value == "1") { + Arc::new(cap_editor::AudioOutput::new_headless(Box::new(|_, _| {}))) + } else { + Arc::new(cap_editor::AudioOutput::new()) + } +} + +fn start_preparing_editor( + path: PathBuf, + handle: WindowHandle, + finalization: StudioFinalization, + audio_output: Arc, + cx: &mut App, +) -> crate::editor_preparing::PreparingJoin { + let resolution = editor_window::preview_resolution( + crate::store::GeneralSettings::load().editor_preview_quality, + ); + let (consumer, joined, frames) = crate::editor_preparing::spawn( + finalization, + resolution, + audio_output, + &gpui_tokio::Tokio::handle(cx), + ); + cx.global_mut::() + .preparing_cleanup + .register(path.clone(), joined.clone()); + let mut updates = consumer.updates(); + handle + .update(cx, |view, window, cx| { + view.begin_preparing(consumer); + cx.notify(); + window.refresh(); + }) + .ok(); + cx.spawn({ + let path = path.clone(); + async move |cx| { + loop { + let update = updates.borrow_and_update().clone(); + if let Some(update) = update { + let current_window = cx.update(|cx| { + cx.global::() + .editors + .iter() + .any(|(key, current)| { + key == &path && current.window_id() == handle.window_id() + }) + }); + if !current_window + || handle + .update(cx, |view, window, cx| { + view.preparing_progress_arrived(update, window, cx) + }) + .is_err() + { + return; + } + } + if updates.changed().await.is_err() { + return; + } + } + } + }) + .detach(); + cx.spawn(async move |cx| { + while let Ok(preparing) = frames.recv_async().await { + let frame = match preparing.output { + cap_editor::EditorFrameOutput::Rgba(frame) => { + let image = cx + .background_executor() + .spawn(async move { editor_window::frame_image(&frame) }) + .await; + let Some(image) = image else { continue }; + editor_window::EditorPreviewFrame::Image(image) + } + #[cfg(target_os = "macos")] + cap_editor::EditorFrameOutput::Surface(surface) => { + editor_window::surface_preview_frame(surface) + } + cap_editor::EditorFrameOutput::Nv12(_) => continue, + }; + let current_window = cx.update(|cx| { + cx.global::() + .editors + .iter() + .any(|(key, current)| key == &path && current.window_id() == handle.window_id()) + }); + if !current_window { + return; + } + if handle + .update(cx, |view, window, cx| { + view.preparing_frame_arrived( + &preparing.epoch, + &preparing.identity, + preparing.request.sequence, + editor_window::EditorFrame { + frame, + layout: preparing.layout, + number: preparing.request.frame_number, + }, + window, + cx, + ); + }) + .is_err() + { + return; + } + } + }) + .detach(); + joined } /// Build the `EditorInstance` and get frame 0 on screen. @@ -4605,8 +4828,119 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { /// preview renderer, all of which are tokio-spawned -- is constructed on the /// `gpui_tokio` runtime. `EditorInstance::new` on the main thread would block /// it for however long the first segment takes to open. -fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &mut App) { +fn load_editor_project( + path: PathBuf, + handle: WindowHandle, + finalization: Option, + cx: &mut App, +) { + load_editor_project_candidate(path, handle, finalization, None, cx); +} + +pub(crate) fn retry_preparing_editor( + path: PathBuf, + handle: WindowHandle, + previous: Arc, + cx: &mut App, +) { + load_editor_project_candidate(path, handle, None, Some(previous), cx); +} + +fn load_editor_project_candidate( + path: PathBuf, + handle: WindowHandle, + finalization: Option, + previous: Option>, + cx: &mut App, +) { + let preparing_output = finalization.as_ref().map(|_| editor_audio_output()); + let current_join = + finalization + .as_ref() + .zip(preparing_output.as_ref()) + .map(|(finalization, output)| { + start_preparing_editor( + path.clone(), + handle, + finalization.clone(), + output.clone(), + cx, + ) + }); + let continuing_join = current_join.clone(); + let mut preparing_joins = if previous.is_some() { + Vec::new() + } else { + cx.global_mut::() + .preparing_cleanup + .pending(&path) + }; + if let Some(joined) = current_join + && !preparing_joins.iter().any(|pending| pending.same(&joined)) + { + preparing_joins.push(joined); + } cx.spawn(async move |cx| { + let finalized = match finalization { + Some(finalization) => finalization.wait().await, + None => Ok(()), + }; + let mut completed_audio = None; + let mut live_handoff = None; + for joined in &preparing_joins { + if finalized.is_ok() + && continuing_join + .as_ref() + .is_some_and(|current| current.same(joined)) + && let Some(handoff) = joined.continuing_handoff().await + { + match handoff.take_completed_audio().await { + Ok(audio) => { + completed_audio = Some(audio); + live_handoff = Some(handoff); + continue; + } + Err(_) => { + handoff.cancel(); + } + } + } + let completed = match joined.wait().await { + Ok(completed) => completed, + Err(message) => { + handle + .update(cx, |view, window, cx| view.set_error(message, window, cx)) + .ok(); + return; + } + }; + let audio = completed.take_audio(); + let accepted = handle + .update(cx, |view, window, cx| { + let accepted = view.finish_preparing(&completed); + cx.notify(); + window.refresh(); + accepted + }) + .unwrap_or(false); + if accepted && finalized.is_ok() { + completed_audio = audio; + } + } + cx.update(|cx| { + cx.global_mut::() + .preparing_cleanup + .prune_completed() + }); + if let Err(message) = finalized { + handle + .update(cx, |view, window, cx| view.set_error(message, window, cx)) + .ok(); + return; + } + if handle.update(cx, |_, _, _| ()).is_err() { + return; + } let preflight_path = path.clone(); let summary = cx .background_executor() @@ -4671,23 +5005,28 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m let frame_format = cap_editor::EditorFrameFormat::BgraSurface; #[cfg(not(target_os = "macos"))] let frame_format = cap_editor::EditorFrameFormat::Rgba; - let audio_output = if std::env::var("CAP_GPUI_MUTE_AUDIO").is_ok_and(|v| v == "1") { - std::sync::Arc::new(cap_editor::AudioOutput::new_headless(Box::new( - |_samples, _at| {}, - ))) - } else { - std::sync::Arc::new(cap_editor::AudioOutput::new()) - }; - cap_editor::EditorInstance::new_with_preloaded_recordings( + if let Some(previous) = previous { + return previous + .recreate_preparing_candidate(state_cb, frame_cb, frame_format) + .await; + } + let instance = cap_editor::EditorInstance::new_with_startup_inputs( instance_path, state_cb, frame_cb, None, frame_format, - audio_output, - recordings, + preparing_output.unwrap_or_else(editor_audio_output), + cap_editor::EditorStartupInputs { + recordings: Some(recordings), + completed_audio, + }, ) - .await + .await?; + if let Some(handoff) = live_handoff { + instance.install_preparing_handoff(&handoff).await?; + } + Ok::<_, String>(instance) }) }); @@ -4753,6 +5092,7 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m // BGRA swap is a few megabytes per frame), deliver on the main one. cx.spawn({ let stats = stats.clone(); + let pump_instance = instance.clone(); async move |cx| { while let Ok((output, layout)) = frame_rx.recv_async().await { let (frame, number) = match output { @@ -4788,6 +5128,9 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m }; if handle .update(cx, |view, window, cx| { + if !view.is_instance(&pump_instance) { + return; + } view.frame_arrived( editor_window::EditorFrame { frame, @@ -4810,12 +5153,15 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m // The playhead drain. The signal is latest-wins, so this reads the // atomic rather than a queue: at 60Hz a backlog would only ever // describe the past. + let playhead_instance = instance.clone(); cx.spawn(async move |cx| { while playhead_rx.recv_async().await.is_ok() { let frame = playhead.position(); if handle .update(cx, |view, window, cx| { - view.playhead_changed(frame, window, cx) + if view.is_instance(&playhead_instance) { + view.playhead_changed(frame, window, cx); + } }) .is_err() { @@ -4840,10 +5186,15 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m // The engine-stop drain: the driver's message that the engine died on // its own (end of timeline under a live seek, warmup abort, error), // delivered on the main thread like every other foreign-thread seam. + let stopped_instance = instance.clone(); cx.spawn(async move |cx| { while engine_stopped_rx.recv_async().await.is_ok() { if handle - .update(cx, |view, window, cx| view.engine_stopped(window, cx)) + .update(cx, |view, window, cx| { + if view.is_instance(&stopped_instance) { + view.engine_stopped(window, cx); + } + }) .is_err() { return; @@ -4863,17 +5214,6 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m return; } - // The initial kick, exactly as `lib.rs:6617-6618` does it after - // creating an instance. Without this the canvas stays black: `seek_to` - // and `set_playhead_position` render nothing. - editor_window::request_frame( - &instance, - 0, - editor_window::preview_resolution( - crate::store::GeneralSettings::load().editor_preview_quality, - ), - ); - drive_auto_sidebar(handle, cx).await; drive_auto_playback(path, handle, cx).await; drive_auto_export(handle, cx).await; @@ -4978,7 +5318,7 @@ fn load_editor_waveforms( .map(|audio| { Arc::new(match audio { Some(audio) => editor_timeline::waveform_peaks( - audio.samples(), + audio.sample_slices().flatten(), audio.channels(), ), None => Vec::new(), @@ -5082,6 +5422,10 @@ async fn drive_auto_playback( let seek = std::env::var("CAP_GPUI_AUTO_SEEK") .ok() .and_then(|value| value.parse::().ok()); + let seek_time = std::env::var("CAP_GPUI_AUTO_SEEK_TIME") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|seconds| seconds.is_finite() && *seconds >= 0.0); // `CAP_GPUI_AUTO_SCRUB_PLAYING=`: a synthetic ruler drag during // playback -- n seeks at 33ms intervals sweeping 20% to 70% of the // timeline. This is the live-seek path's perf gate: every seek lands on a @@ -5089,7 +5433,12 @@ async fn drive_auto_playback( let scrub_playing = std::env::var("CAP_GPUI_AUTO_SCRUB_PLAYING") .ok() .and_then(|value| value.parse::().ok()); - if play_secs.is_none() && torture.is_none() && seek.is_none() && scrub_playing.is_none() { + if play_secs.is_none() + && torture.is_none() + && seek.is_none() + && seek_time.is_none() + && scrub_playing.is_none() + { return; } @@ -5144,6 +5493,16 @@ async fn drive_auto_playback( tracing::info!(fraction, "auto seek"); } + if let Some(seconds) = seek_time { + handle + .update(cx, |view, _window, cx| { + let seconds = seconds.min(view.total_duration()); + view.seek_to_time(seconds, cx); + tracing::info!(seconds, "auto seek to recording time"); + }) + .ok(); + } + if let Some(n) = scrub_playing { if handle .update(cx, |view, window, cx| view.toggle_play(window, cx)) @@ -5261,6 +5620,34 @@ pub fn editor_closed(project_path: &Path, window_id: gpui::WindowId, cx: &mut Ap return; }; + RecordingSession::global(cx).update(cx, |session, _| { + if let Some(pending) = session.preparing_studio_editor_mut() + && editor_key(&pending.project_path) == key + { + pending.presentation.closed(window_id); + } + }); + + handle.update(cx, |view, _, _| view.cancel_preparing()).ok(); + let preparing_joins = cx + .global_mut::() + .preparing_cleanup + .pending(&key); + cx.spawn(async move |cx| { + for joined in preparing_joins { + if let Err(error) = joined.wait().await { + tracing::warn!(%error, "Closed preparing editor cleanup failed"); + return; + } + } + cx.update(|cx| { + cx.global_mut::() + .preparing_cleanup + .prune_completed() + }); + }) + .detach(); + // `onCleanup(() => { clearTimeout(saveTimer); flushProjectConfig() })` // (`ED/context.ts:1246-1252`): a `.cap` closed inside the 250ms save // debounce still gets its last edit written. @@ -5313,8 +5700,9 @@ fn restore_after_editor_close(key: &Path, cx: &mut App) { settings_open, "editor window closed" ); - let idle = RecordingSession::global(cx).read(cx).phase == Phase::Idle; - if reveal_main_after_editor_close(editors_left, settings_open, idle) { + let session = session.read(cx); + let capture_safe = session.phase == Phase::Idle || session.capture_stopped_for_editor(); + if reveal_main_after_editor_close(editors_left, settings_open, capture_safe) { show_main_window(cx); } else { // A dock-activating window closed; the policy has to be recomputed diff --git a/apps/desktop-gpui/src/editor_crop.rs b/apps/desktop-gpui/src/editor_crop.rs index 84b8ff276d2..7f8a58fe047 100644 --- a/apps/desktop-gpui/src/editor_crop.rs +++ b/apps/desktop-gpui/src/editor_crop.rs @@ -26,11 +26,6 @@ //! raw = max(0, real / scale) (`boundsToRaw`, :422-430) //! ``` //! -//! The container is [`crop_box_size`]: the display's aspect fitted into -//! `min(vw * 0.4, 520) x min(vh * 0.5, 520)` (`Editor.tsx:1010-1023`). At the -//! editor's 1275x800 that is 510x400, so a 3024x1964 recording gets a 510x331 -//! box and one container pixel is `3024 / 510 = 5.929` target pixels. -//! //! Every number the user sees in the header -- Size and Position -- is //! `realBounds`, i.e. target space. Every number the pointer maths works in is //! `rawBounds`. @@ -43,6 +38,9 @@ //! what `constrainBoundsToSize` does and the capture-area cropper does pass //! them. +mod alignment; + +use alignment::{CropGuides, ResizeAlignment, align_crop}; use std::collections::BTreeSet; use crate::{ @@ -725,12 +723,10 @@ pub fn bounds_to_raw(real: CropBounds, scale: Vec2) -> CropBounds { } } -/// `boxSize` (`Editor.tsx:1010-1023`): the display's aspect fitted into -/// `min(vw * 0.4, 520) x min(vh * 0.5, 520)`, rounded. pub fn crop_box_size(viewport: (f32, f32), display: (u32, u32)) -> (f32, f32) { let ratio = f64::from(display.0) / f64::from(display.1).max(1.); - let max_w = f64::from(viewport.0 * 0.4).min(520.); - let max_h = f64::from(viewport.1 * 0.5).min(520.); + let max_w = f64::from((viewport.0 - 164.).clamp(120., 1280.) * 0.68); + let max_h = f64::from((viewport.1 - 280.).clamp(100., 760.)); let mut w = max_w; let mut h = w / ratio; if h > max_h { @@ -1067,6 +1063,7 @@ pub struct CropState { pub aspect: Option, /// `aspectState.snapped` -- the ratio a free drag landed on. pub snapped: Option, + pub alignment_guides: CropGuides, pub drag: Option, /// `mouseState.hoveringHandle`, for the corner-cursor rule. pub hovering: Option, @@ -1101,6 +1098,7 @@ impl CropState { initial, aspect: None, snapped: None, + alignment_guides: CropGuides::default(), drag: None, hovering: None, frame: None, @@ -1307,6 +1305,17 @@ impl CropState { /// `handleResizePointerMove` (`:798-868`). pub fn resize_move(&mut self, point: Vec2, alt: bool, shift: bool, snap_enabled: bool) { + self.resize_move_with_alignment(point, alt, shift, snap_enabled, false); + } + + fn resize_move_with_alignment( + &mut self, + point: Vec2, + alt: bool, + shift: bool, + snap_enabled: bool, + alignment_enabled: bool, + ) { let Some(session) = self.session_mut() else { return; }; @@ -1349,6 +1358,44 @@ impl CropState { let container = self.container_vec(); let final_bounds = slide_bounds_into_container(next, container.x, container.y); + self.alignment_guides = CropGuides::default(); + let final_bounds = if alignment_enabled && !shift { + let movable = session.active_handle.movable; + let start = session.start_bounds; + let centered = session.is_alt && options.ratio.is_none(); + let origin = Vec2::new( + if centered { + 0.5 + } else if point.x < start.x + if movable.left { start.width } else { 0. } { + 1. + } else { + 0. + }, + if centered { + 0.5 + } else if point.y < start.y + if movable.top { start.height } else { 0. } { + 1. + } else { + 0. + }, + ); + let (aligned, guides) = align_crop( + final_bounds, + container, + Some(ResizeAlignment { + origin, + axes: (movable.left || movable.right, movable.top || movable.bottom), + ratio: options.ratio, + }), + ); + self.alignment_guides = guides; + if guides.x.is_some() || guides.y.is_some() { + self.snapped = None; + } + aligned + } else { + final_bounds + }; self.set_raw(final_bounds); } @@ -1672,7 +1719,7 @@ impl EditorWindow { // lands outside the region -- but a **handle**'s double-click expands // that side (`:1097, 1143, 1200, 1286`). if event.click_count == 2 { - let hit = hit_test(point, state.raw); + let hit = editor_hit_test(point, state.raw); match hit { CropHit::Handle(handle) => { state.stop_animation(); @@ -1690,7 +1737,13 @@ impl EditorWindow { } state.stop_animation(); - let hit = hit_test(point, state.raw); + let hit = editor_hit_test(point, state.raw); + let container = state.container_vec(); + if matches!(hit, CropHit::Draw) + && (point.x < 0. || point.y < 0. || point.x > container.x || point.y > container.y) + { + return; + } // The press log every crop probe calibrates against: where the box is // on screen, where the pointer landed inside it, and what it grabbed. tracing::info!( @@ -1761,7 +1814,7 @@ impl EditorWindow { let Some(drag) = state.drag else { // `onMouseEnter` on the handles is what seeds `hoveringHandle`, // which the corner-cursor rule reads. - let hovering = match hit_test(point, state.raw) { + let hovering = match editor_hit_test(point, state.raw) { CropHit::Handle(handle) => Some(handle), _ => None, }; @@ -1781,10 +1834,22 @@ impl EditorWindow { let new_x = clamp(point.x - start_offset.x, 0., container.x - bounds.width); let new_y = clamp(point.y - start_offset.y, 0., container.y - bounds.height); let moved = move_bounds(bounds, Some(new_x), Some(new_y)); + let (moved, guides) = if event.modifiers.shift { + (moved, CropGuides::default()) + } else { + align_crop(moved, container, None) + }; + state.alignment_guides = guides; state.set_raw(moved); } CropDrag::Handle(_) | CropDrag::Overlay { .. } => { - state.resize_move(point, event.modifiers.alt, event.modifiers.shift, snap); + state.resize_move_with_alignment( + point, + event.modifiers.alt, + event.modifiers.shift, + snap, + true, + ); } } self.crop_changed(cx); @@ -1797,6 +1862,7 @@ impl EditorWindow { let Some(drag) = state.drag.take() else { return; }; + state.alignment_guides = CropGuides::default(); // `onOverlayPointerDown`'s end (`:934-941`): a drag that never grew // past 5px in either axis is a stray click, and the previous region // comes back. @@ -2246,7 +2312,7 @@ async fn decode_display_frame( // --------------------------------------------------------------------------- /// `Dialog.Root`'s `max-w-[1180px]` (`Editor.tsx:809`). -const CROP_DIALOG_MAX_WIDTH: f32 = 1180.; +const CROP_DIALOG_MAX_WIDTH: f32 = 1440.; /// `h-14 px-4` (`ui.tsx:231`). const DIALOG_HEADER_HEIGHT: f32 = 56.; /// `h-16 px-4 gap-3` (`ui.tsx:213-215`). @@ -2483,7 +2549,7 @@ impl EditorWindow { .gap(px(12.)) .justify_center() .items_stretch() - .child(labelled("Crop area", self.render_crop_area(state, cx))) + .child(labelled("Crop area", self.render_crop_stage(state, cx))) .child( div() .flex() @@ -2498,14 +2564,44 @@ impl EditorWindow { .text_color(Hsla::from(theme.gray_8)), ), ) - .child(labelled("Preview", self.render_crop_preview(px(w), px(h)))), + .child(labelled("Preview", self.render_crop_preview(px((w * 0.44).round()), px(h + 32.)))), ) + .child(div().flex().items_center().justify_between().gap(px(12.)).pt(px(16.)).text_size(px(12.)).text_color(Hsla::from(theme.gray_11)) + .child("Drag to move · Snap to center, halves and quarters · Hold Shift to skip snapping") + .child(ui::EditorButton::plain(&theme, "crop-center").label("Center crop").on_click(cx.listener(|this, _, window, cx| { + if let Some(state) = this.crop.as_mut() { + let bounds = state.raw; + let container = state.container_vec(); + state.set_raw_and_animate(CropBounds::new((container.x - bounds.width) / 2., (container.y - bounds.height) / 2., bounds.width, bounds.height), ORIGIN_CENTER, CropAnim::DEFAULT); + } + this.start_crop_ticker(window, cx); + this.crop_changed(cx); + })))) .into_any_element() } /// The cropper itself: the raw frame, the occluder, the region and the /// eight handles. - fn render_crop_area(&self, state: &CropState, cx: &mut Context) -> AnyElement { + fn render_crop_stage(&self, state: &CropState, cx: &mut Context) -> AnyElement { + div() + .id("crop-stage") + .p(px(16.)) + .rounded(px(12.)) + .bg(Hsla::from(self.theme.gray_3)) + .child(self.render_crop_area(state)) + .on_mouse_down(MouseButton::Left, cx.listener(Self::crop_mouse_down)) + // `onContextMenu={(e) => showCropOptionsMenu(e, true)}` -- anchored + // at the cursor (`Editor.tsx:1333-1335`). + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + this.open_crop_menu(event.position, window, cx); + }), + ) + .into_any_element() + } + + fn render_crop_area(&self, state: &CropState) -> AnyElement { let theme = self.theme; let (w, h) = state.container; let (box_w, box_h) = state.box_size; @@ -2528,12 +2624,16 @@ impl EditorWindow { .w(px(bounds.width as f32)) .h(px(bounds.height as f32)) .border_1() - // `border border-white/50` (`:1139`). - .border_color(gpui::hsla(0., 0., 1., 0.5)) + .border_color(gpui::rgb(0xffffff)) + .child( + div() + .absolute() + .inset(px(1.)) + .border_1() + .border_color(gpui::rgb(0x111827)), + ) .cursor(drag_cursor.unwrap_or(CursorStyle::OpenHand)) - // The rule-of-thirds grid, shown while any drag is live - // (`:1168-1174`): `border-white/40`. - .children(dragging.then(|| thirds_grid(bounds))) + .children(dragging.then(|| editor_thirds_grid(bounds))) // The snapped-ratio badge (`:1295-1321`). Only while free. .children( (state.aspect.is_none() && !too_small) @@ -2569,24 +2669,13 @@ impl EditorWindow { ) }), ) - // The corner glyphs: an L of two bars, `stroke="white"` with a - // drop shadow, shrinking to nothing when the region is tiny - // (`:1205-1238`). .children( HANDLES .iter() - .filter(|handle| handle.is_corner) - .flat_map(|handle| corner_glyph(*handle, bounds, too_small)), + .map(|handle| editor_handle_glyph(*handle, bounds)), ); - // The eight hit zones, in the source's paint order: edges first (which - // is why the move layer occludes their inner halves), then the move - // layer, then the corners. - let mut layers = div().absolute().inset_0(); - for handle in HANDLES.iter().filter(|handle| !handle.is_corner) { - layers = layers.child(handle_zone(*handle, bounds, drag_cursor)); - } - layers = layers.child( + let mut layers = div().absolute().inset_0().child( div() .absolute() .left(px(bounds.x as f32)) @@ -2595,15 +2684,17 @@ impl EditorWindow { .h(px(bounds.height as f32)) .cursor(drag_cursor.unwrap_or(CursorStyle::OpenHand)), ); - for handle in HANDLES.iter().filter(|handle| handle.is_corner) { - let cursor = match (drag_cursor, state.hovering) { - // `mouseState.drag === "handle" && - // mouseState.hoveringHandle?.isCorner` (`:1186-1189`). - (Some(_), Some(hovering)) if hovering.is_corner => hovering.direction.cursor(), - (Some(cursor), _) => cursor, - (None, _) => handle.direction.cursor(), - }; - layers = layers.child(handle_zone_with_cursor(*handle, bounds, cursor)); + for handle in HANDLES.iter().rev() { + let rect = editor_handle_rect(*handle, bounds); + layers = layers.child( + div() + .absolute() + .left(px((bounds.x + rect.x) as f32)) + .top(px((bounds.y + rect.y) as f32)) + .w(px(rect.width as f32)) + .h(px(rect.height as f32)) + .cursor(drag_cursor.unwrap_or(handle.direction.cursor())), + ); } div() @@ -2615,7 +2706,6 @@ impl EditorWindow { .border_1() .border_color(Hsla::from(theme.gray_3)) .bg(Hsla::from(theme.gray_3)) - .overflow_hidden() .cursor(base_cursor) // The box's painted origin, so a pointer position can be made // container-local. gpui has no `getBoundingClientRect`; this is @@ -2653,6 +2743,7 @@ impl EditorWindow { // The four occluder quads (`:1124-1133`). .children(occluders(bounds, w, h)) .child(region) + .child(editor_alignment_guides(state.alignment_guides, w, h)) .child(layers) // `Loading frame…` (`Editor.tsx:1356-1364`). .children((state.frame.is_none() && !state.frame_failed).then(|| { @@ -2668,15 +2759,6 @@ impl EditorWindow { .text_color(Hsla::from(theme.gray_10)) .child("Loading frame…") })) - .on_mouse_down(MouseButton::Left, cx.listener(Self::crop_mouse_down)) - // `onContextMenu={(e) => showCropOptionsMenu(e, true)}` -- anchored - // at the cursor (`Editor.tsx:1333-1335`). - .on_mouse_down( - MouseButton::Right, - cx.listener(|this, event: &MouseDownEvent, window, cx| { - this.open_crop_menu(event.position, window, cx); - }), - ) .into_any_element() } @@ -2935,24 +3017,204 @@ pub(crate) fn handle_zone_with_cursor( .cursor(cursor) } +fn editor_handle_rect(handle: Handle, bounds: CropBounds) -> LocalRect { + let mut rect = handle_rect(handle, bounds); + if !handle.is_corner { + if handle.x == Axis::Center { + rect.y -= 4.; + rect.height = 18.; + } else { + rect.x -= 4.; + rect.width = 18.; + } + } + rect +} + +fn editor_hit_test(point: Vec2, bounds: CropBounds) -> CropHit { + for handle in HANDLES { + if editor_handle_rect(handle, bounds).contains(point.x - bounds.x, point.y - bounds.y) { + return CropHit::Handle(handle); + } + } + hit_test(point, bounds) +} + +fn editor_handle_glyph(handle: Handle, bounds: CropBounds) -> gpui::Div { + let outline = gpui::rgb(0x111827); + if handle.is_corner { + return div().absolute().inset_0().children( + corner_glyph(handle, bounds, false) + .into_iter() + .map(|glyph| glyph.border_1().border_color(outline)), + ); + } + let horizontal = handle.x == Axis::Center; + let x = match handle.x { + Axis::Low => -1., + Axis::High => bounds.width + 1., + Axis::Center => bounds.width / 2., + }; + let y = match handle.y { + Axis::Low => -1., + Axis::High => bounds.height + 1., + Axis::Center => bounds.height / 2., + }; + let (w, h) = if horizontal { (26., 7.) } else { (7., 26.) }; + div() + .absolute() + .left(px(x as f32 - w / 2.)) + .top(px(y as f32 - h / 2.)) + .w(px(w)) + .h(px(h)) + .rounded(px(3.)) + .border_1() + .border_color(outline) + .bg(gpui::rgb(0xffffff)) +} + +fn editor_thirds_grid(bounds: CropBounds) -> gpui::Div { + let mut grid = div().absolute().inset_0(); + for fraction in [1. / 3., 2. / 3.] { + grid = grid.child( + div() + .absolute() + .left_0() + .top(px((bounds.height * fraction) as f32)) + .w_full() + .h(px(2.)) + .bg(gpui::rgb(0xffffff)) + .child( + div() + .absolute() + .inset_0() + .h(px(1.)) + .bg(gpui::rgba(0x111827cc)), + ), + ); + grid = grid.child( + div() + .absolute() + .top_0() + .left(px((bounds.width * fraction) as f32)) + .h_full() + .w(px(2.)) + .bg(gpui::rgb(0xffffff)) + .child( + div() + .absolute() + .inset_0() + .w(px(1.)) + .bg(gpui::rgba(0x111827cc)), + ), + ); + } + grid +} + +fn editor_alignment_guides(guides: CropGuides, w: f32, h: f32) -> gpui::Div { + let mut layer = div().absolute().inset_0(); + for (horizontal, guide) in [(false, guides.x), (true, guides.y)] { + if let Some(guide) = guide { + layer = layer.child( + div() + .absolute() + .left(px(if horizontal { 0. } else { guide as f32 - 1. })) + .top(px(if horizontal { guide as f32 - 1. } else { 0. })) + .w(px(if horizontal { w } else { 3. })) + .h(px(if horizontal { 3. } else { h })) + .bg(gpui::rgba(0xffffff99)) + .child( + div() + .absolute() + .left(px(if horizontal { 0. } else { 1. })) + .top(px(if horizontal { 1. } else { 0. })) + .w(px(if horizontal { w } else { 1. })) + .h(px(if horizontal { 1. } else { h })) + .bg(gpui::rgb(0xd946ef)), + ), + ); + } + } + layer +} + #[cfg(test)] mod tests { use super::*; // -- The coordinate spaces ---------------------------------------------- - /// The box the editor's 1275x800 window gives a 3024x1964 recording, and - /// the scale that follows from it. #[test] - fn crop_box_fits_the_display_aspect_into_the_source_caps() { - // maxW = min(1275 * 0.4, 520) = 510; maxH = min(800 * 0.5, 520) = 400. - // 510 / (3024/1964) = 331.2 -> under 400, so the width wins. - assert_eq!(crop_box_size((1275., 800.), (3024, 1964)), (510., 331.)); - // An ultrawide is capped by width too, but a tall display is capped by - // height: 510 / (1080/1920) = 906 > 400, so h = 400, w = 225. - assert_eq!(crop_box_size((1275., 800.), (1080, 1920)), (225., 400.)); - // A large viewport is still capped at 520. - assert_eq!(crop_box_size((2000., 1400.), (1000, 1000)), (520., 520.)); + fn crop_box_leaves_room_for_handles_controls_and_preview() { + assert_eq!(crop_box_size((1275., 800.), (3024, 1964)), (755., 491.)); + assert_eq!(crop_box_size((1275., 800.), (1080, 1920)), (293., 520.)); + assert_eq!(crop_box_size((2000., 1400.), (1000, 1000)), (760., 760.)); + } + + #[test] + fn editor_edge_handles_are_live_on_both_sides_of_the_border() { + let bounds = CropBounds::new(100., 80., 300., 200.); + for (point, direction) in [ + (Vec2::new(106., 180.), Direction::W), + (Vec2::new(93., 180.), Direction::W), + (Vec2::new(394., 180.), Direction::E), + (Vec2::new(406., 180.), Direction::E), + (Vec2::new(250., 86.), Direction::N), + (Vec2::new(250., 74.), Direction::N), + (Vec2::new(250., 274.), Direction::S), + (Vec2::new(250., 286.), Direction::S), + ] { + assert!( + matches!(editor_hit_test(point, bounds), CropHit::Handle(handle) if handle.direction == direction) + ); + } + assert!(matches!( + editor_hit_test(Vec2::new(250., 180.), bounds), + CropHit::Move + )); + assert!(matches!( + hit_test(Vec2::new(106., 180.), bounds), + CropHit::Move + )); + } + + #[test] + fn editor_resize_snaps_in_source_space_and_shift_releases_it() { + let mut state = CropState::new((2400, 1800), (802., 602.), CROP_ZERO); + state.set_container((802., 602.), (800., 600.)); + let start = CropBounds::new(80., 70., 300., 180.); + state.set_raw(start); + state.drag = Some(CropDrag::Handle(ResizeSession { + start_bounds: start, + is_alt: false, + active_handle: HANDLES[7], + original_handle: HANDLES[7], + })); + state.resize_move_with_alignment(Vec2::new(397., 150.), false, false, false, true); + assert_eq!(state.raw, CropBounds::new(80., 70., 320., 180.)); + assert_eq!(state.real(), CropBounds::new(240., 210., 960., 540.)); + assert_eq!(state.alignment_guides.x, Some(400.)); + state.resize_move_with_alignment(Vec2::new(397., 150.), false, true, false, true); + assert_eq!(state.raw, CropBounds::new(80., 70., 317., 180.)); + assert_eq!(state.alignment_guides, CropGuides::default()); + } + + #[test] + fn editor_resize_keeps_the_anchor_after_crossing_it() { + let mut state = CropState::new((800, 600), (802., 602.), CROP_ZERO); + state.set_container((802., 602.), (800., 600.)); + let start = CropBounds::new(100., 70., 200., 180.); + state.set_raw(start); + state.drag = Some(CropDrag::Handle(ResizeSession { + start_bounds: start, + is_alt: false, + active_handle: HANDLES[6], + original_handle: HANDLES[6], + })); + state.resize_move_with_alignment(Vec2::new(397., 150.), false, false, false, true); + assert_eq!(state.raw, CropBounds::new(300., 70., 100., 180.)); + assert_eq!(state.alignment_guides.x, Some(400.)); } #[test] diff --git a/apps/desktop-gpui/src/editor_crop/alignment.rs b/apps/desktop-gpui/src/editor_crop/alignment.rs new file mode 100644 index 00000000000..873f6d98adb --- /dev/null +++ b/apps/desktop-gpui/src/editor_crop/alignment.rs @@ -0,0 +1,257 @@ +use super::{CropBounds, Vec2}; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct CropGuides { + pub x: Option, + pub y: Option, +} + +#[derive(Clone, Copy)] +pub struct ResizeAlignment { + pub origin: Vec2, + pub axes: (bool, bool), + pub ratio: Option, +} + +pub fn align_crop( + bounds: CropBounds, + container: Vec2, + resize: Option, +) -> (CropBounds, CropGuides) { + let mut next = bounds; + let mut guides = CropGuides::default(); + let mut best_ratio_distance = f64::INFINITY; + for horizontal in [true, false] { + if resize.is_some_and(|r| if horizontal { !r.axes.0 } else { !r.axes.1 }) { + continue; + } + let base = if resize.is_some_and(|r| r.ratio.is_some()) { + bounds + } else { + next + }; + let (position, size, extent) = if horizontal { + (base.x, base.width, container.x) + } else { + (base.y, base.height, container.y) + }; + let mut best_distance = 7.; + let mut candidate = base; + let mut guide = None; + let anchors: &[f64] = if resize.is_some() { + &[0., 1.] + } else { + &[0.5, 0., 1.] + }; + for target in [0.5, 0., 1., 0.25, 0.75] { + for &anchor in anchors { + let origin = + resize.map_or(0., |r| if horizontal { r.origin.x } else { r.origin.y }); + if resize.is_some() && anchor == origin { + continue; + } + let line = target * extent; + let delta = line - (position + size * anchor); + let distance = delta.abs(); + if distance > 6. || distance >= best_distance { + continue; + } + let mut proposed = base; + if let Some(resize) = resize { + if horizontal { + proposed.width += delta / (anchor - origin); + if let Some(ratio) = resize.ratio { + proposed.height = proposed.width / ratio; + } + } else { + proposed.height += delta / (anchor - origin); + if let Some(ratio) = resize.ratio { + proposed.width = proposed.height * ratio; + } + } + proposed.x += (base.width - proposed.width) * resize.origin.x; + proposed.y += (base.height - proposed.height) * resize.origin.y; + } else if horizontal { + proposed.x += delta; + } else { + proposed.y += delta; + } + if proposed.width < 1. + || proposed.height < 1. + || proposed.x < -0.001 + || proposed.y < -0.001 + || proposed.x + proposed.width > container.x + 0.001 + || proposed.y + proposed.height > container.y + 0.001 + { + continue; + } + candidate = proposed; + guide = Some(line); + best_distance = distance; + } + } + if resize.is_some_and(|r| r.ratio.is_some()) { + if guide.is_none() || best_distance >= best_ratio_distance { + continue; + } + best_ratio_distance = best_distance; + guides = CropGuides::default(); + } + next = candidate; + if horizontal { + guides.x = guide; + } else { + guides.y = guide; + } + } + (next, guides) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn centers_a_moved_crop_on_both_axes() { + let (bounds, guides) = align_crop( + CropBounds::new(253.0, 197.0, 300.0, 200.0), + Vec2::new(800., 600.), + None, + ); + assert_eq!(bounds, CropBounds::new(250.0, 200.0, 300.0, 200.0)); + assert_eq!( + guides, + CropGuides { + x: Some(400.0), + y: Some(300.0) + } + ); + } + + #[test] + fn snaps_a_resize_to_the_halfway_line() { + let (bounds, guides) = align_crop( + CropBounds::new(80.0, 70.0, 317.0, 180.0), + Vec2::new(800., 600.), + Some(ResizeAlignment { + origin: Vec2::new(0.0, 0.0), + axes: (true, false), + ratio: None, + }), + ); + assert_eq!(bounds, CropBounds::new(80.0, 70.0, 320.0, 180.0)); + assert_eq!( + guides, + CropGuides { + x: Some(400.0), + y: None + } + ); + } + + #[test] + fn keeps_the_opposite_corner_fixed() { + let (bounds, guides) = align_crop( + CropBounds::new(197.0, 153.0, 343.0, 237.0), + Vec2::new(800., 600.), + Some(ResizeAlignment { + origin: Vec2::new(1.0, 1.0), + axes: (true, true), + ratio: None, + }), + ); + assert_eq!(bounds, CropBounds::new(200.0, 150.0, 340.0, 240.0)); + assert_eq!( + guides, + CropGuides { + x: Some(200.0), + y: Some(150.0) + } + ); + } + + #[test] + fn keeps_alt_resizing_centered() { + let (bounds, guides) = align_crop( + CropBounds::new(163.0, 85.0, 234.0, 190.0), + Vec2::new(800., 600.), + Some(ResizeAlignment { + origin: Vec2::new(0.5, 0.5), + axes: (true, false), + ratio: None, + }), + ); + assert_eq!(bounds, CropBounds::new(160.0, 85.0, 240.0, 190.0)); + assert_eq!( + guides, + CropGuides { + x: Some(400.0), + y: None + } + ); + } + + #[test] + fn preserves_a_locked_ratio() { + let (bounds, guides) = align_crop( + CropBounds::new(80.0, 70.0, 317.0, 158.5), + Vec2::new(800., 600.), + Some(ResizeAlignment { + origin: Vec2::new(0.0, 0.0), + axes: (true, true), + ratio: Some(2.0), + }), + ); + assert_eq!(bounds, CropBounds::new(80.0, 70.0, 320.0, 160.0)); + assert_eq!( + guides, + CropGuides { + x: Some(400.0), + y: None + } + ); + } + + #[test] + fn releases_beyond_six_display_pixels() { + let (bounds, guides) = align_crop( + CropBounds::new(263.0, 217.0, 300.0, 200.0), + Vec2::new(800., 600.), + None, + ); + assert_eq!(bounds, CropBounds::new(263.0, 217.0, 300.0, 200.0)); + assert_eq!(guides, CropGuides { x: None, y: None }); + } + + #[test] + fn never_collapses_a_tiny_crop() { + let (bounds, guides) = align_crop( + CropBounds::new(400.0, 300.0, 3.0, 3.0), + Vec2::new(800., 600.), + Some(ResizeAlignment { + origin: Vec2::new(0.0, 0.0), + axes: (true, true), + ratio: None, + }), + ); + assert_eq!(bounds, CropBounds::new(400.0, 300.0, 3.0, 3.0)); + assert_eq!(guides, CropGuides { x: None, y: None }); + } + + #[test] + fn snaps_to_the_image_boundary() { + let (bounds, guides) = align_crop( + CropBounds::new(3.0, 91.0, 318.0, 217.0), + Vec2::new(800., 600.), + None, + ); + assert_eq!(bounds, CropBounds::new(0.0, 91.0, 318.0, 217.0)); + assert_eq!( + guides, + CropGuides { + x: Some(0.0), + y: None + } + ); + } +} diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 677caf82558..12e7f58376e 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -189,6 +189,7 @@ pub struct ExportUi { pub sign_in_cancel: Arc, pub organization_id: Option, pub share_link: Option, + pub reuploading: bool, pub upload_progress: f32, pub copy_link_pressed: bool, } @@ -245,6 +246,7 @@ impl ExportUi { sign_in_cancel: Arc::new(AtomicBool::new(false)), organization_id: prefs.organization_id, share_link: None, + reuploading: false, upload_progress: 0.0, copy_link_pressed: false, } @@ -357,11 +359,52 @@ fn decode_jpeg_bytes(bytes: &[u8]) -> Option> { } impl EditorWindow { + pub(crate) fn render_reupload_button(&self, cx: &mut Context) -> impl IntoElement { + div() + .rounded(px(8.)) + .bg(Hsla::from(self.theme.blue_3)) + .border_1() + .border_color(Hsla::from(self.theme.blue_5)) + .child( + ui::EditorButton::plain(&self.theme, "editor-reupload") + .left_icon("icons/cloud-upload.svg") + .label("Reupload") + .tooltip( + &self.theme, + if has_transparent_background(&self.project) { + "Share links require a background without transparency" + } else { + "Upload your latest edit to the same link" + }, + ) + .disabled(!self.project_ready() || has_transparent_background(&self.project)) + .on_click(cx.listener(|this, _, window, cx| { + if !this.project_ready() { + return; + } + this.open_export(window, cx); + if !has_transparent_background(&this.project) + && let Some(ui) = this.export.as_mut() + { + ui.destination = ExportDestination::Link; + ui.format = ExportFormatKind::Mp4; + ui.cursor_only = false; + } + this.normalize_loaded_export_fps(); + this.refresh_export_preview(window, cx); + cx.notify(); + })), + ) + } + pub(crate) fn open_export(&mut self, window: &mut Window, cx: &mut Context) { if self.playing { self.toggle_play_from_crop(cx); } let mut ui = ExportUi::load(); + if let Ok(meta) = RecordingMeta::load_for_project(&self.project_path) { + self.sharing = meta.sharing; + } if has_transparent_background(&self.project) { ui.format = ExportFormatKind::Gif; if ui.resolution == ExportResolution::P4k { @@ -880,6 +923,7 @@ impl EditorWindow { ui.cancel = Arc::new(AtomicBool::new(false)); let cancel = ui.cancel.clone(); ui.phase = ExportPhase::Starting; + ui.reuploading = self.sharing.is_some(); ui.error = None; ui.share_link = None; ui.upload_progress = 0.0; @@ -993,6 +1037,11 @@ impl EditorWindow { match upload.await { Ok(Ok(crate::upload::UploadResult::Success(link))) => { let _ = this.update(cx, |this, cx| { + if let Ok(meta) = + RecordingMeta::load_for_project(&this.project_path) + { + this.sharing = meta.sharing; + } if let Some(ui) = this.export.as_mut() { ui.phase = ExportPhase::Done; ui.share_link = Some(link.clone()); @@ -1387,7 +1436,11 @@ impl EditorWindow { ), ExportDestination::Link => ( ui::ButtonVariant::Primary, - "Export to Link", + if self.sharing.is_some() { + "Reupload to same link" + } else { + "Create shareable link" + }, Some("icons/link.svg"), ), }; @@ -1426,8 +1479,15 @@ impl EditorWindow { ExportDestination::ALL .iter() .map(|dest| { + let label = if *dest == ExportDestination::Link + && self.sharing.is_some() + { + "Reupload" + } else { + dest.label() + }; let mut option = - ui::SegmentOption::new(dest.label(), ui.destination == *dest) + ui::SegmentOption::new(label, ui.destination == *dest) .disabled(*dest == ExportDestination::Link && link_disabled); option.icon = Some(dest.icon().into()); option @@ -1454,8 +1514,48 @@ impl EditorWindow { cx.notify(); })), ) + .when( + ui.destination == ExportDestination::Link && self.sharing.is_some(), + |field| { + let link = self.sharing.as_ref() + .map(|sharing| sharing.link.clone()) + .unwrap_or_default(); + field.child( + div() + .p(px(12.)) + .rounded(px(8.)) + .bg(Hsla::from(theme.blue_3)) + .flex() + .flex_col() + .gap(px(6.)) + .child( + div() + .text_size(px(13.)) + .font_weight(FontWeight::MEDIUM) + .child("Update your existing link"), + ) + .child( + div() + .text_size(px(12.)) + .text_color(Hsla::from(theme.gray_11)) + .child("Reupload replaces the video at this link with your latest edit. Everyone with the link will see the updated version."), + ) + .child( + div() + .id("reupload-existing-link") + .text_size(px(12.)) + .text_color(Hsla::from(theme.blue_11)) + .truncate() + .cursor_pointer() + .child(link.clone()) + .on_click(move |_, _, cx| cx.open_url(&link)), + ), + ) + }, + ) .when( ui.destination == ExportDestination::Link + && self.sharing.is_none() && store::auth_snapshot().organizations.len() > 1, |this| { let orgs = store::auth_snapshot().organizations; @@ -1922,10 +2022,14 @@ impl EditorWindow { "Copying to clipboard" } ExportPhase::Copying => "Saving to file", + ExportPhase::Uploading if ui.reuploading => "Reuploading to your link", ExportPhase::Uploading => "Creating shareable link", ExportPhase::Done if ui.destination == ExportDestination::Clipboard => { "Copied to clipboard" } + ExportPhase::Done if ui.destination == ExportDestination::Link && ui.reuploading => { + "Reupload complete" + } ExportPhase::Done if ui.destination == ExportDestination::Link => "Upload complete", ExportPhase::Done => "Export complete", ExportPhase::Failed if ui.clipboard_retry_path().is_some() => { @@ -1982,7 +2086,11 @@ impl EditorWindow { div() .text_size(px(12.)) .text_color(Hsla::from(theme.gray_11)) - .child("Your Cap has been uploaded successfully"), + .child(if ui.reuploading { + "Your latest edit is ready at the same link" + } else { + "Your Cap has been uploaded successfully" + }), ) }, ), diff --git a/apps/desktop-gpui/src/editor_preparing.rs b/apps/desktop-gpui/src/editor_preparing.rs new file mode 100644 index 00000000000..9c938990e91 --- /dev/null +++ b/apps/desktop-gpui/src/editor_preparing.rs @@ -0,0 +1,988 @@ +use std::{ + io::{self, BufReader, Read}, + panic::AssertUnwindSafe, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Instant, +}; + +use cap_editor::{ + EditorFrameFormat, EditorFrameOutput, PreparingFrameRequest, PreparingPlaybackExit, + PreparingPlaybackOptions, PreparingPlaybackSession, PreparingPlaybackStopHandle, + PreparingPreviewInput, PreparingPreviewOptions, PreparingPreviewSegment, +}; +use cap_project::{CursorEvents, XY}; + +mod audio; +pub(crate) mod presentation; + +use cap_recording::recovery::{ + PreparingSidecarKind, PreparingStudioIdentity, PreparingStudioSources, PreparingStudioState, + PreparingVideoTrack, +}; +use cap_rendering::{ + FrameLayout, FrozenRecordedCursorAssets, ManagedSegmentVideoInput, ManagedVideoTrackInput, +}; +use futures_util::{ + FutureExt, + future::{BoxFuture, Shared}, +}; +use presentation::PreparingTimelineSeed; +use tokio::sync::watch; + +use crate::recording::StudioFinalization; + +const MAX_CURSOR_BYTES: u64 = 256 * 1024 * 1024; + +#[derive(Clone)] +pub(crate) struct PreparingEpoch(Arc<()>); + +impl PreparingEpoch { + fn same(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +struct ConsumerControl { + epoch: PreparingEpoch, + finalization: StudioFinalization, + cancelled: watch::Sender, + accepts_frames: AtomicBool, + started: Instant, + updates: watch::Sender>, + commands: tokio::sync::mpsc::Sender<(PreparingCommand, cap_editor::PreparingPlaybackIntent)>, + completed: Mutex>, + discarded: AtomicBool, + admitted_identity: Mutex>, + handoff: watch::Sender>, +} + +impl ConsumerControl { + fn cancel(&self) { + if let Some(handoff) = &*self.handoff.borrow() { + handoff.cancel(); + } + self.accepts_frames.store(false, Ordering::Release); + self.cancelled.send_replace(true); + } + + fn discard(&self) { + self.discarded.store(true, Ordering::Release); + self.cancel(); + if let Some(exit) = self + .completed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + drop(exit.take_completed_audio()); + } + } + + fn accepts_status(&self, epoch: &PreparingEpoch, identity: &PreparingStudioIdentity) -> bool { + self.epoch.same(epoch) + && !*self.cancelled.borrow() + && ((self.finalization.is_finalizing() + && self.finalization.matches_preparing_identity(identity)) + || self.finalization.allows_preparing_continuation(identity)) + } + + fn accepts(&self, epoch: &PreparingEpoch, identity: &PreparingStudioIdentity) -> bool { + self.accepts_frames.load(Ordering::Acquire) && self.accepts_status(epoch, identity) + } +} + +pub(crate) struct PreparingConsumer { + control: Arc, +} + +#[derive(Clone)] +pub(crate) struct PreparingUpdate { + pub(crate) epoch: PreparingEpoch, + pub(crate) identity: PreparingStudioIdentity, + pub(crate) sequence: u64, + pub(crate) seed: Arc, + pub(crate) progress: cap_editor::PreparingEditorProgress, + pub(crate) playback: cap_editor::PreparingPlaybackState, +} + +#[derive(Clone, Copy)] +pub(crate) struct PreparingCommand { + pub(crate) seek: Option, + pub(crate) playing: Option, +} + +impl PreparingConsumer { + pub(crate) fn can_command(&self) -> bool { + !*self.control.cancelled.borrow() + && (self.control.finalization.is_finalizing() + || self + .control + .admitted_identity + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|identity| { + self.control + .finalization + .allows_preparing_continuation(identity) + })) + && !self.control.commands.is_closed() + } + + pub(crate) fn command(&self, command: PreparingCommand) -> bool { + if !self.can_command() { + return false; + } + let Some(handoff) = self.control.handoff.borrow().clone() else { + return false; + }; + let Ok(intent) = handoff.reserve_command() else { + return false; + }; + self.control.commands.try_send((command, intent)).is_ok() + } + + pub(crate) fn updates(&self) -> watch::Receiver> { + self.control.updates.subscribe() + } + + pub(crate) fn accepts_status(&self, update: &PreparingUpdate) -> bool { + self.control.accepts_status(&update.epoch, &update.identity) + || (update.progress.phase == cap_editor::PreparingEditorPhase::Unavailable + && !self.control.discarded.load(Ordering::Acquire) + && self.control.epoch.same(&update.epoch) + && self + .control + .admitted_identity + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|identity| identity.same_job(&update.identity))) + } + + pub(crate) fn accepts( + &self, + epoch: &PreparingEpoch, + identity: &PreparingStudioIdentity, + ) -> bool { + self.control.accepts(epoch, identity) + } +} + +impl Drop for PreparingConsumer { + fn drop(&mut self) { + if !self + .control + .handoff + .borrow() + .as_ref() + .is_some_and(|handoff| handoff.committed()) + { + self.control.discard(); + } + } +} + +#[derive(Clone)] +pub(crate) struct PreparingJoined { + epoch: PreparingEpoch, + pub(crate) exit: Option, +} + +impl PreparingJoined { + pub(crate) fn matches(&self, consumer: &PreparingConsumer) -> bool { + self.epoch.same(&consumer.control.epoch) + } + + pub(crate) fn take_audio(&self) -> Option { + self.exit + .as_ref() + .and_then(PreparingPlaybackExit::take_completed_audio) + } +} + +#[derive(Clone)] +pub(crate) struct PreparingJoin { + completion: Shared>>, + identity: Arc<()>, + control: std::sync::Weak, +} + +impl PreparingJoin { + fn from_task( + runtime: &tokio::runtime::Handle, + task: tokio::task::JoinHandle>, + ) -> Self { + let completion = async move { + task.await + .map_err(|error| format!("Preparing preview task cleanup failed: {error}"))? + } + .boxed() + .shared(); + let retained = completion.clone(); + drop(runtime.spawn(async move { + let _ = retained.await; + })); + Self { + completion, + identity: Arc::new(()), + control: Default::default(), + } + } + + pub(crate) async fn continuing_handoff(&self) -> Option { + let control = self.control.upgrade()?; + let mut handoff = control.handoff.subscribe(); + loop { + let value = handoff.borrow_and_update().clone(); + if let Some(value) = value { + let admitted = control + .admitted_identity + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone()?; + return control + .finalization + .allows_preparing_continuation(&admitted) + .then_some(value); + } + tokio::select! { + _ = self.completion.clone() => return None, + changed = handoff.changed() => if changed.is_err() { return None; }, + } + } + } + + pub(crate) async fn wait(&self) -> Result { + self.completion.clone().await + } + + pub(crate) fn same(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.identity, &other.identity) + } + + fn completed_successfully(&self) -> bool { + self.completion.peek().is_some_and(Result::is_ok) + } +} + +#[derive(Default)] +pub(crate) struct PreparingCleanupRegistry { + entries: Vec<(PathBuf, PreparingJoin)>, +} + +impl PreparingCleanupRegistry { + pub(crate) fn prune_completed(&mut self) { + self.entries + .retain(|(_, joined)| !joined.completed_successfully()); + } + + pub(crate) fn register(&mut self, path: PathBuf, joined: PreparingJoin) { + self.entries + .retain(|(_, joined)| !joined.completed_successfully()); + self.entries.push((path, joined)); + } + + pub(crate) fn pending(&mut self, path: &Path) -> Vec { + self.entries + .retain(|(_, joined)| !joined.completed_successfully()); + self.entries + .iter() + .filter(|(key, _)| key == path) + .map(|(_, joined)| joined.clone()) + .collect() + } +} + +pub(crate) struct PreparingFrame { + pub(crate) epoch: PreparingEpoch, + pub(crate) identity: PreparingStudioIdentity, + pub(crate) request: PreparingFrameRequest, + pub(crate) output: EditorFrameOutput, + pub(crate) layout: FrameLayout, +} + +struct PreparingRunner { + control: Arc, + frames: flume::Sender, + stop: Option, + resolution: XY, + audio_output: Arc, + commands: Option< + tokio::sync::mpsc::Receiver<(PreparingCommand, cap_editor::PreparingPlaybackIntent)>, + >, + command_task: Option>, + frame_discard: flume::Receiver, +} + +pub(crate) fn spawn( + finalization: StudioFinalization, + resolution: XY, + audio_output: Arc, + runtime: &tokio::runtime::Handle, +) -> ( + PreparingConsumer, + PreparingJoin, + flume::Receiver, +) { + let (cancelled, _) = watch::channel(false); + let (updates, _) = watch::channel(None); + let (commands, command_rx) = tokio::sync::mpsc::channel(8); + let control = Arc::new(ConsumerControl { + epoch: PreparingEpoch(Arc::new(())), + finalization, + cancelled, + accepts_frames: AtomicBool::new(false), + started: Instant::now(), + updates, + commands, + completed: Mutex::new(None), + discarded: AtomicBool::new(false), + admitted_identity: Mutex::new(None), + handoff: watch::channel(None).0, + }); + let (frames, frame_rx) = flume::bounded(1); + let runner = PreparingRunner { + control: control.clone(), + frames, + stop: None, + resolution, + audio_output, + commands: Some(command_rx), + command_task: None, + frame_discard: frame_rx.clone(), + }; + let mut joined = PreparingJoin::from_task(runtime, runtime.spawn(runner.run())); + joined.control = Arc::downgrade(&control); + (PreparingConsumer { control }, joined, frame_rx) +} + +impl PreparingRunner { + async fn run(mut self) -> Result { + let result = AssertUnwindSafe(self.run_inner()).catch_unwind().await; + let adopted = matches!(&result, Ok(Ok(()))) + && self + .control + .handoff + .borrow() + .as_ref() + .is_some_and(|handoff| handoff.committed()); + if adopted { + self.control.accepts_frames.store(false, Ordering::Release); + self.control.cancelled.send_replace(true); + self.stop = None; + } else { + self.control.cancel(); + } + match result { + Ok(Err(error)) => tracing::debug!(%error, "Preparing playback declined"), + Err(_) => tracing::warn!("Preparing playback adapter panicked"), + Ok(Ok(())) => {} + } + let command_error = if let Some(task) = self.command_task.take() { + task.await.err().map(|error| error.to_string()) + } else { + None + }; + let exit = if let Some(stop) = self.stop.take() { + Some(stop.stop_and_wait().await) + } else { + None + }; + if let Some(exit) = &exit { + if exit.snapshot.progress.phase == cap_editor::PreparingEditorPhase::Unavailable { + let latest = self.control.updates.borrow().clone(); + if let Some(mut update) = latest { + update.sequence = progress_sequence(exit.snapshot.sequence)?; + update.progress = exit.snapshot.progress.clone(); + update.playback = exit.snapshot.playback; + self.control.updates.send_replace(Some(update)); + } + } + let mut completed = self + .control + .completed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.control.discarded.load(Ordering::Acquire) + || exit.cleanup_failed + || command_error.is_some() + { + drop(exit.take_completed_audio()); + } else { + *completed = Some(exit.clone()); + } + } + if exit.as_ref().is_some_and(|exit| exit.cleanup_failed) { + return Err("Preparing playback workers could not be joined".into()); + } + if let Some(error) = command_error { + return Err(format!("Preparing command task cleanup failed: {error}")); + } + if adopted { + self.control.handoff.send_replace(None); + } + Ok(PreparingJoined { + epoch: self.control.epoch.clone(), + exit, + }) + } + + async fn run_inner(&mut self) -> Result<(), String> { + let mut cancel = self.control.cancelled.subscribe(); + if *cancel.borrow() { + return Ok(()); + } + let observer = tokio::select! { + biased; + _ = cancel.changed() => return Ok(()), + observer = self.control.finalization.wait_for_preparing() => observer, + }; + let Some(mut observer) = observer else { + return Ok(()); + }; + let sources = loop { + match observer.latest() { + PreparingStudioState::Available(sources) => break sources, + PreparingStudioState::Unavailable(reason) => return Err(reason), + PreparingStudioState::Ended => return Ok(()), + PreparingStudioState::Waiting => {} + } + tokio::select! { + biased; + _ = cancel.changed() => return Ok(()), + _ = observer.changed() => {} + } + }; + if !self + .control + .accepts_status(&self.control.epoch, observer.identity()) + { + return Ok(()); + } + *self + .control + .admitted_identity + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(observer.identity().clone()); + tracing::info!( + elapsed_ms = self.control.started.elapsed().as_secs_f64() * 1000.0, + "GPUI preparing sources available" + ); + let adapter_control = self.control.clone(); + let (input, audio) = tokio::task::spawn_blocking(move || adapt(sources, &adapter_control)) + .await + .map_err(|error| error.to_string())??; + if *cancel.borrow() || !matches!(observer.latest(), PreparingStudioState::Available(_)) { + return Ok(()); + } + tracing::info!( + elapsed_ms = self.control.started.elapsed().as_secs_f64() * 1000.0, + "GPUI preparing adapter ready" + ); + let seed = Arc::new(PreparingTimelineSeed { + project: input.project.clone(), + pretty_name: input.recording_meta.pretty_name.clone(), + has_camera: input + .recording_meta + .studio_meta() + .is_some_and(|studio| match studio { + cap_project::StudioRecordingMeta::SingleSegment { segment } => { + segment.camera.is_some() + } + cap_project::StudioRecordingMeta::MultipleSegments { inner, .. } => inner + .segments + .iter() + .any(|segment| segment.camera.is_some()), + }), + multiple_clips: input.segments.len() > 1, + }); + let total_duration = input + .project + .timeline + .as_ref() + .map(|timeline| timeline.duration()) + .filter(|duration| duration.is_finite() && *duration > 0.0); + self.control.updates.send_replace(Some(PreparingUpdate { + epoch: self.control.epoch.clone(), + identity: observer.identity().clone(), + sequence: 1, + seed: seed.clone(), + progress: cap_editor::PreparingEditorProgress { + total_duration, + ..Default::default() + }, + playback: cap_editor::PreparingPlaybackState::default(), + })); + let control = self.control.clone(); + let identity = observer.identity().clone(); + let frame_observer = observer.clone(); + let frames = self.frames.clone(); + let discard = self.frame_discard.clone(); + #[cfg(target_os = "macos")] + let frame_format = EditorFrameFormat::BgraSurface; + #[cfg(not(target_os = "macos"))] + let frame_format = EditorFrameFormat::Rgba; + self.control.accepts_frames.store(true, Ordering::Release); + let playback = PreparingPlaybackSession::spawn_with_expected_metadata( + input, + audio.tracks, + PreparingPlaybackOptions { + preview: PreparingPreviewOptions { + use_hardware_decoding: preparing_hardware_decode(), + frame_format, + }, + fps: crate::editor_window::EDITOR_PREVIEW_FPS, + resolution: self.resolution, + }, + self.audio_output.clone(), + Box::new(move |request, output, layout| { + if accepts_composed_frame( + control.accepts(&control.epoch, &identity), + frame_observer.latest(), + control + .finalization + .allows_preparing_continuation(&identity), + ) { + tracing::debug!( + elapsed_ms = control.started.elapsed().as_secs_f64() * 1000.0, + "GPUI preparing composed frame produced" + ); + let frame = PreparingFrame { + epoch: control.epoch.clone(), + identity: identity.clone(), + request, + output, + layout, + }; + if let Err(flume::TrySendError::Full(frame)) = frames.try_send(frame) { + drop(discard.try_recv()); + let _ = frames.try_send(frame); + } + } + }), + audio.expected_metadata, + )?; + self.stop = Some(playback.stop_handle()); + let handoff = playback.handoff_handle(); + self.control.handoff.send_replace(Some(handoff.clone())); + let mut updates = playback.updates(); + let controller = playback.controller(); + let mut commands = self + .commands + .take() + .ok_or("Preparing commands already started")?; + let mut command_cancel = self.control.cancelled.subscribe(); + self.command_task = Some(tokio::spawn(async move { + loop { + if *command_cancel.borrow() { + return; + } + let (command, _intent) = tokio::select! { + biased; + _ = command_cancel.changed() => return, + command = commands.recv() => match command { Some(command) => command, None => return }, + }; + let applied = async { + if command.playing == Some(false) { + controller.set_playing(false).await?; + } + if let Some(seek) = command.seek { + controller.seek(seek).await?; + } + if command.playing == Some(true) { + controller.set_playing(true).await?; + } + Ok::<(), String>(()) + }; + tokio::select! { + biased; + _ = command_cancel.changed() => return, + result = applied => { + if let Err(error) = result { + tracing::debug!(%error, "Preparing playback command declined"); + } + } + } + } + })); + let mut observing = true; + loop { + let snapshot = updates.borrow_and_update().clone(); + self.control.updates.send_replace(Some(PreparingUpdate { + epoch: self.control.epoch.clone(), + identity: observer.identity().clone(), + sequence: progress_sequence(snapshot.sequence)?, + seed: seed.clone(), + progress: snapshot.progress, + playback: snapshot.playback, + })); + tokio::select! { + biased; + _ = cancel.changed() => return Ok(()), + _ = handoff.wait_committed() => return Ok(()), + state = observer.changed(), if observing => { + if !matches!(state, PreparingStudioState::Available(_)) { + if !self.control.finalization.allows_preparing_continuation(observer.identity()) { return Ok(()); } + observing = false; + } + } + result = updates.changed() => { if result.is_err() { return Ok(()); } } + } + } + } +} + +fn accepts_composed_frame( + consumer_accepts: bool, + state: PreparingStudioState, + publication_continues: bool, +) -> bool { + consumer_accepts + && (matches!(state, PreparingStudioState::Available(_)) || publication_continues) +} + +fn progress_sequence(sequence: u64) -> Result { + sequence + .checked_add(2) + .ok_or_else(|| "Preparing progress sequence exhausted".into()) +} + +fn preparing_hardware_decode() -> bool { + !cfg!(target_os = "windows") +} + +fn adapt( + sources: Arc, + control: &Arc, +) -> Result<(PreparingPreviewInput, audio::AudioAdaptation), String> { + let ended = || "Preparing sources ended".to_string(); + let live = sources.live().ok_or_else(ended)?; + let recording_meta = live.metadata().ok_or_else(ended)?.clone(); + let studio = recording_meta.studio_meta().ok_or_else(ended)?; + let mut project = + crate::recording::preparing_presentation(live.configuration().ok_or_else(ended)?)?; + if project.clips.is_empty() { + project.clips = + cap_editor::initial_clip_configuration(&recording_meta.project_path, studio); + } + let descriptors = live.segments().ok_or_else(ended)?; + let pointer_ids = studio.pointer_cursor_ids(); + let mut segments = Vec::with_capacity(descriptors.len()); + let mut cursor_budget = MAX_CURSOR_BYTES; + for descriptor in descriptors { + if *control.cancelled.borrow() || sources.live().is_none() { + return Err(ended()); + } + let display = live + .video(descriptor.index(), PreparingVideoTrack::Display) + .ok_or_else(ended)?; + let (source, paths) = display.input().ok_or_else(ended)?; + let display = ManagedVideoTrackInput::new(source.clone(), paths.to_vec()) + .map_err(|error| error.to_string())?; + let camera = if descriptor.camera().is_some() { + let camera = live + .video(descriptor.index(), PreparingVideoTrack::Camera) + .ok_or_else(ended)?; + let (source, paths) = camera.input().ok_or_else(ended)?; + Some( + ManagedVideoTrackInput::new(source.clone(), paths.to_vec()) + .map_err(|error| error.to_string())?, + ) + } else { + None + }; + let mut cursor = if descriptor.cursor_path().is_some() { + let cursor = live + .sidecar(descriptor.index(), PreparingSidecarKind::Cursor) + .ok_or_else(ended)?; + let (source, path) = cursor.input().ok_or_else(ended)?; + let reader = source.reader(path).map_err(|error| error.to_string())?; + let mut reader = BufReader::new(CheckedCursorReader { + reader, + is_live: || !*control.cancelled.borrow() && sources.live().is_some(), + remaining: cursor_budget, + }); + let cursor = CursorEvents::load_from_reader(&mut reader)?; + cursor_budget = reader.get_ref().remaining; + cursor + } else { + CursorEvents::default() + }; + cursor.stabilize_short_lived_cursor_shapes( + (!pointer_ids.is_empty()).then_some(&pointer_ids), + cap_project::cursor::SHORT_CURSOR_SHAPE_DEBOUNCE_MS, + ); + segments.push(PreparingPreviewSegment { + video: ManagedSegmentVideoInput::new( + descriptor.index() as usize, + studio, + display, + camera, + ) + .map_err(|error| error.to_string())?, + cursor: Arc::new(cursor), + }); + } + let images = descriptors + .first() + .ok_or_else(ended)? + .cursor_images() + .iter() + .map(|asset| { + ( + asset.id().to_string(), + asset.metadata().clone(), + Arc::<[u8]>::from(asset.bytes()), + ) + }); + let cursor_assets = + FrozenRecordedCursorAssets::new(images).map_err(|error| error.to_string())?; + if *control.cancelled.borrow() || sources.live().is_none() { + return Err(ended()); + } + let audio = audio::adapt_audio(&sources, &recording_meta, control)?; + Ok(( + PreparingPreviewInput { + recording_meta, + project, + segments, + cursor_assets, + }, + audio, + )) +} + +struct CheckedCursorReader { + reader: R, + is_live: F, + remaining: u64, +} + +impl bool> Read for CheckedCursorReader { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if output.is_empty() { + return Ok(0); + } + if !(self.is_live)() { + return Err(io::Error::other("Preparing sources ended")); + } + if self.remaining == 0 { + let mut next = [0]; + return if self.reader.read(&mut next)? == 0 { + Ok(0) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "Cursor sidecar exceeds preparing limit", + )) + }; + } + let length = output.len().min(self.remaining as usize); + let count = self.reader.read(&mut output[..length])?; + self.remaining -= count as u64; + Ok(count) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn joined() -> PreparingJoined { + PreparingJoined { + epoch: PreparingEpoch(Arc::new(())), + exit: None, + } + } + + #[test] + fn composed_frame_gate_keeps_published_footage_and_rejects_inactive_consumers() { + let (state, observer) = watch::channel(PreparingStudioState::Waiting); + assert!(!accepts_composed_frame( + true, + observer.borrow().clone(), + false + )); + state.send_replace(PreparingStudioState::Ended); + assert!(!accepts_composed_frame( + true, + observer.borrow().clone(), + false + )); + assert!(accepts_composed_frame( + true, + observer.borrow().clone(), + true + )); + assert!( + !accepts_composed_frame(false, observer.borrow().clone(), true), + "publication must not bypass cancellation or supersession" + ); + state.send_replace(PreparingStudioState::Unavailable( + "finalization failed".into(), + )); + assert!(!accepts_composed_frame( + true, + observer.borrow().clone(), + false + )); + } + + #[test] + fn preparing_epochs_do_not_alias_reopened_windows() { + let first = PreparingEpoch(Arc::new(())); + let reopened = PreparingEpoch(Arc::new(())); + assert!(first.same(&first.clone())); + assert!(!first.same(&reopened)); + } + + #[test] + fn cursor_reader_preserves_exact_limit_and_rejects_an_extra_byte() { + let bytes = br#"{"clicks":[],"moves":[]}"#; + for remaining in [bytes.len() as u64, bytes.len() as u64 - 1] { + let reader = CheckedCursorReader { + reader: Cursor::new(bytes), + is_live: || true, + remaining, + }; + let result = CursorEvents::load_from_reader(BufReader::new(reader)); + assert_eq!(result.is_ok(), remaining == bytes.len() as u64); + } + } + + #[test] + fn cursor_cancellation_is_terminal_instead_of_retryable_interrupted() { + let mut reader = CheckedCursorReader { + reader: Cursor::new(b"retained"), + is_live: || false, + remaining: 100, + }; + assert_eq!( + reader.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::Other + ); + assert_eq!(reader.reader.position(), 0); + assert_eq!(reader.read(&mut []).unwrap(), 0); + } + + #[tokio::test] + async fn close_before_admission_joins_even_when_first_waiter_is_dropped() { + let (_publisher, finalization) = StudioFinalization::channel("test.cap".into(), 7); + let (consumer, joined, _frames) = spawn( + finalization, + XY::new(320, 240), + Arc::new(cap_editor::AudioOutput::new_headless(Box::new(|_, _| {}))), + &tokio::runtime::Handle::current(), + ); + let retained = joined.clone(); + drop(joined); + drop(consumer); + retained.wait().await.unwrap(); + } + + #[tokio::test] + async fn panicked_cleanup_task_never_allows_ordinary_loading() { + let runtime = tokio::runtime::Handle::current(); + let joined = + PreparingJoin::from_task(&runtime, runtime.spawn(async { panic!("cleanup failed") })); + assert!(joined.wait().await.is_err()); + assert!(!joined.completed_successfully()); + } + + #[tokio::test] + async fn reopened_window_waits_the_old_windows_actual_task_exit() { + let runtime = tokio::runtime::Handle::current(); + let (release, stopped) = tokio::sync::oneshot::channel(); + let old = PreparingJoin::from_task( + &runtime, + runtime.spawn(async move { + stopped.await.unwrap(); + Ok(joined()) + }), + ); + let new = PreparingJoin::from_task(&runtime, runtime.spawn(async { Ok(joined()) })); + let mut registry = PreparingCleanupRegistry::default(); + registry.register("recording.cap".into(), old.clone()); + registry.register("recording.cap".into(), new.clone()); + new.wait().await.unwrap(); + let pending = registry.pending(Path::new("recording.cap")); + assert_eq!(pending.len(), 1); + assert!(pending[0].wait().now_or_never().is_none()); + release.send(()).unwrap(); + pending[0].wait().await.unwrap(); + assert!(registry.pending(Path::new("recording.cap")).is_empty()); + } + + #[tokio::test] + async fn failed_join_is_retained_for_its_project_only() { + let runtime = tokio::runtime::Handle::current(); + let failed = PreparingJoin::from_task( + &runtime, + runtime.spawn(async { Err("missing join".into()) }), + ); + assert!(failed.wait().await.is_err()); + let mut registry = PreparingCleanupRegistry::default(); + registry.register("failed.cap".into(), failed); + assert!(registry.pending(Path::new("other.cap")).is_empty()); + let pending = registry.pending(Path::new("failed.cap")); + assert_eq!(pending.len(), 1); + assert!(pending[0].wait().await.is_err()); + } + + #[test] + fn initial_core_progress_follows_seed_and_cannot_wrap() { + let mut presentation = presentation::PreparingPresentation::default(); + assert!(presentation.apply(1, Default::default(), Default::default())); + assert!(presentation.apply( + progress_sequence(0).unwrap(), + Default::default(), + Default::default() + )); + assert!(!presentation.apply(1, Default::default(), Default::default())); + assert_eq!(progress_sequence(u64::MAX - 2).unwrap(), u64::MAX); + assert!(progress_sequence(u64::MAX - 1).is_err()); + } + + #[tokio::test] + async fn queued_commands_are_bounded_and_close_joins_without_admission() { + let (_publisher, finalization) = StudioFinalization::channel("test.cap".into(), 9); + let (consumer, joined, _frames) = spawn( + finalization, + XY::new(320, 240), + Arc::new(cap_editor::AudioOutput::new_headless(Box::new(|_, _| {}))), + &tokio::runtime::Handle::current(), + ); + assert!(!consumer.command(PreparingCommand { + seek: Some(0.0), + playing: Some(true) + })); + let control = consumer.control.clone(); + let permits = (0..8) + .map(|_| control.commands.try_reserve().unwrap()) + .collect::>(); + assert!(control.commands.try_reserve().is_err()); + drop(consumer); + tokio::time::timeout(std::time::Duration::from_secs(1), joined.wait()) + .await + .unwrap() + .unwrap(); + assert!(*control.cancelled.borrow()); + assert!(control.discarded.load(Ordering::Acquire)); + assert!(control.commands.is_closed()); + drop(permits); + } + + #[test] + fn preparing_decode_preference_matches_the_ordinary_platform_path() { + #[cfg(target_os = "windows")] + assert!(!preparing_hardware_decode()); + #[cfg(not(target_os = "windows"))] + assert!(preparing_hardware_decode()); + } +} diff --git a/apps/desktop-gpui/src/editor_preparing/audio.rs b/apps/desktop-gpui/src/editor_preparing/audio.rs new file mode 100644 index 00000000000..62929f287b1 --- /dev/null +++ b/apps/desktop-gpui/src/editor_preparing/audio.rs @@ -0,0 +1,362 @@ +use super::*; +use cap_editor::PreparingAudioSegmentInput; +use cap_project::{AudioMeta, RecordingMeta, StudioRecordingMeta}; +use cap_recording::recovery::PreparingAudioTrack; + +const MAX_AUDIO_TIMING_LOG_BYTES: u64 = 4 * 1024 * 1024; + +struct AudioTiming { + expected_metadata: RecordingMeta, + repairs: Option>, +} + +pub(super) struct AudioAdaptation { + pub(super) tracks: Vec, + pub(super) expected_metadata: RecordingMeta, +} + +pub(super) fn adapt_audio( + sources: &Arc, + metadata: &RecordingMeta, + control: &Arc, +) -> Result { + let timing = audio_timing(metadata)?; + let live = sources.live().ok_or("Preparing audio sources ended")?; + let descriptors = live.segments().ok_or("Preparing audio sources ended")?; + let mut tracks = Vec::with_capacity(descriptors.len()); + for (index, descriptor) in descriptors.iter().enumerate() { + let repair = match &timing.repairs { + Some(repairs) => *repairs + .get(index) + .ok_or("Preparing audio timing layout changed")?, + None => cap_editor::SegmentAudioTimingRepair::default(), + }; + let track = |kind, stem| { + timing.repairs.as_ref()?; + let lease = live.audio(descriptor.index(), kind)?; + let metadata = lease.metadata()?; + let (source, path) = lease.input()?; + if !preserved_audio_path(metadata, path, stem) { + return None; + } + if path.extension().is_some_and(|extension| extension == "m4a") { + let control = control.clone(); + let sources = sources.clone(); + let input = cap_enc_ffmpeg::SegmentedInput::open_relocatable_interruptible( + source, + [path], + Arc::new(move || *control.cancelled.borrow() || sources.live().is_none()), + ) + .ok()?; + if input + .input() + .streams() + .best(ffmpeg::media::Type::Video) + .is_some() + || !input + .input() + .streams() + .best(ffmpeg::media::Type::Audio) + .is_some_and(|stream| stream.parameters().id() == ffmpeg::codec::Id::AAC) + { + return None; + } + } + cap_audio::ManagedAudioInput::new(source.clone(), path.to_path_buf()).ok() + }; + tracks.push(PreparingAudioSegmentInput { + mic: track(PreparingAudioTrack::Mic, "audio-input"), + system_audio: track(PreparingAudioTrack::SystemAudio, "system_audio"), + timing_repair: repair, + }); + if *control.cancelled.borrow() || sources.live().is_none() { + return Err("Preparing audio sources ended".into()); + } + } + Ok(AudioAdaptation { + tracks, + expected_metadata: timing.expected_metadata, + }) +} + +fn audio_timing(metadata: &RecordingMeta) -> Result { + let mut expected_metadata = metadata.clone(); + let cap_project::RecordingMetaInner::Studio(studio) = &mut expected_metadata.inner else { + return Err("Preparing audio requires Studio metadata".into()); + }; + let StudioRecordingMeta::MultipleSegments { inner } = studio.as_mut() else { + return Err("Preparing audio requires stopped segments".into()); + }; + for segment in &mut inner.segments { + for audio in [&mut segment.mic, &mut segment.system_audio] + .into_iter() + .flatten() + { + audio.gap_summary = None; + } + } + // Clean Stop joins timing-log writers; finalization can still append unrelated events. + let timing_log = read_timing_log( + &metadata.project_path.join("recording-logs.log"), + MAX_AUDIO_TIMING_LOG_BYTES, + ); + let repairs = match timing_log { + Ok(log) => Some(cap_editor::segment_audio_timing_repairs( + expected_metadata + .studio_meta() + .ok_or("Preparing Studio metadata ended")?, + log.as_deref(), + )), + Err(error) => { + tracing::debug!(%error, "Preparing audio timing requires ordinary loading"); + None + } + }; + Ok(AudioTiming { + expected_metadata, + repairs, + }) +} + +fn read_timing_log(path: &Path, limit: u64) -> Result, String> { + let initial = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("Preparing audio timing log metadata: {error}")), + }; + if !initial.is_file() || initial.len() > limit { + return Err("Preparing audio timing log is not a bounded regular file".into()); + } + let file = std::fs::File::open(path) + .map_err(|error| format!("Preparing audio timing log open: {error}"))?; + let opened = file.metadata().map_err(|error| error.to_string())?; + if !opened.is_file() + || opened.len() != initial.len() + || opened.modified().ok() != initial.modified().ok() + { + return Err("Preparing audio timing log changed before reading".into()); + } + let mut bytes = String::with_capacity(opened.len() as usize); + let mut bounded = file.take(limit.saturating_add(1)); + bounded + .read_to_string(&mut bytes) + .map_err(|error| format!("Preparing audio timing log read: {error}"))?; + let finished = bounded + .get_ref() + .metadata() + .map_err(|error| error.to_string())?; + if bytes.len() as u64 != opened.len() + || finished.len() != opened.len() + || finished.modified().ok() != opened.modified().ok() + { + return Err("Preparing audio timing log changed while reading".into()); + } + Ok(Some(bytes)) +} + +fn preserved_audio_path(metadata: &AudioMeta, relative: &Path, stem: &str) -> bool { + let expected = Path::new("content/segments").join(relative); + if Path::new(metadata.path.as_str()) != expected { + return false; + } + ["m4a", "ogg"].into_iter().any(|extension| { + relative + .file_name() + .is_some_and(|name| name == format!("{stem}.{extension}").as_str()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestDirectory(PathBuf); + + impl TestDirectory { + fn new() -> Self { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "cap-gpui-preparing-audio-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + fn metadata(&self) -> RecordingMeta { + let mut metadata: RecordingMeta = serde_json::from_value(serde_json::json!({ + "pretty_name": "Timing snapshot", + "segments": [{ + "display": { "path": "content/segments/segment-0/display", "fps": 30 }, + "mic": { + "path": "content/segments/segment-0/audio-input.m4a", + "start_time": 0.125, + "gap_summary": { + "total_overlap_trimmed_ms": 867, + "startup_overlap_trimmed_ms": 867, + "overlap_dropped_frames": 3, + "startup_overlap_drops": 3 + } + }, + "system_audio": { + "path": "content/segments/segment-0/system_audio.m4a", + "start_time": 0.25 + } + }] + })) + .unwrap(); + metadata.project_path = self.0.clone(); + metadata + } + + fn log(&self) -> PathBuf { + self.0.join("recording-logs.log") + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn repair_log() -> String { + "segment{index=0}:mic-out: Dropping overlapping audio frame frame_count=1 overlap_ms=100\nsegment{index=0}:system-audio-out: Dropping overlapping audio frame frame_count=1 overlap_ms=50\n".repeat(3) + } + + #[test] + fn present_and_missing_timing_logs_admit_ordinary_zero_offsets() { + let directory = TestDirectory::new(); + let metadata = directory.metadata(); + let absent = audio_timing(&metadata).unwrap(); + assert_eq!(absent.repairs.unwrap(), vec![Default::default()]); + for text in [ + "", + "Finalization finished 微phone\n", + "overlap_ms=not-a-number\n", + ] { + std::fs::write(directory.log(), text).unwrap(); + let present = audio_timing(&metadata).unwrap(); + assert_eq!(present.repairs.unwrap(), vec![Default::default()]); + } + } + + #[test] + fn stopped_timing_matches_ordinary_gap_clearing_and_nonzero_log_repairs() { + let directory = TestDirectory::new(); + let metadata = directory.metadata(); + let before = serde_json::to_value(&metadata).unwrap(); + assert_eq!( + cap_editor::segment_audio_timing_repairs(metadata.studio_meta().unwrap(), None)[0] + .mic_offset_secs, + -0.867 + ); + std::fs::write(directory.log(), repair_log()).unwrap(); + let snapshot = audio_timing(&metadata).unwrap(); + let mut finalized_value = before.clone(); + finalized_value["segments"][0]["mic"] + .as_object_mut() + .unwrap() + .remove("gap_summary"); + let finalized: RecordingMeta = serde_json::from_value(finalized_value.clone()).unwrap(); + let ordinary_log = std::fs::read_to_string(directory.log()).unwrap(); + let ordinary = cap_editor::segment_audio_timing_repairs( + finalized.studio_meta().unwrap(), + Some(&ordinary_log), + ); + assert_eq!(snapshot.repairs.as_ref().unwrap(), &ordinary); + assert_eq!(ordinary[0].mic_offset_secs, -0.3); + assert_eq!(ordinary[0].system_audio_offset_secs, -0.15); + assert_eq!( + serde_json::to_value(&snapshot.expected_metadata).unwrap(), + finalized_value + ); + assert_eq!( + snapshot.expected_metadata.project_path, + metadata.project_path + ); + assert_eq!(serde_json::to_value(&metadata).unwrap(), before); + } + + #[test] + fn malformed_oversized_and_nonregular_logs_decline_audio_timing() { + let directory = TestDirectory::new(); + let metadata = directory.metadata(); + std::fs::write(directory.log(), [0xff, 0xfe]).unwrap(); + assert!(audio_timing(&metadata).unwrap().repairs.is_none()); + let file = std::fs::File::create(directory.log()).unwrap(); + file.set_len(MAX_AUDIO_TIMING_LOG_BYTES + 1).unwrap(); + drop(file); + assert!(audio_timing(&metadata).unwrap().repairs.is_none()); + std::fs::remove_file(directory.log()).unwrap(); + std::fs::create_dir(directory.log()).unwrap(); + assert!(audio_timing(&metadata).unwrap().repairs.is_none()); + } + + #[test] + fn captured_timing_is_immutable_across_later_log_updates() { + let directory = TestDirectory::new(); + let metadata = directory.metadata(); + let log = repair_log(); + std::fs::write(directory.log(), &log).unwrap(); + let snapshot = audio_timing(&metadata).unwrap(); + std::fs::write(directory.log(), format!("{log}Finalization published\n")).unwrap(); + assert_eq!(audio_timing(&metadata).unwrap().repairs, snapshot.repairs); + std::fs::write(directory.log(), "different later contents").unwrap(); + assert_eq!( + audio_timing(&metadata).unwrap().repairs.unwrap(), + vec![Default::default()] + ); + assert_eq!(snapshot.repairs.unwrap()[0].mic_offset_secs, -0.3); + } + + #[cfg(unix)] + #[test] + fn symlink_timing_log_is_not_admitted() { + let directory = TestDirectory::new(); + let target = directory.0.join("target.log"); + std::fs::write(&target, repair_log()).unwrap(); + std::os::unix::fs::symlink(target, directory.log()).unwrap(); + assert!( + audio_timing(&directory.metadata()) + .unwrap() + .repairs + .is_none() + ); + } + + #[test] + fn audio_admission_requires_the_unchanged_final_path_and_container_name() { + let metadata = |path: &str| AudioMeta { + path: path.into(), + start_time: Some(0.0), + device_id: None, + gap_summary: None, + }; + for extension in ["m4a", "ogg"] { + let relative = PathBuf::from(format!("segment-0/audio-input.{extension}")); + let meta = metadata(&format!("content/segments/{}", relative.display())); + assert!(preserved_audio_path(&meta, &relative, "audio-input")); + assert!(!preserved_audio_path(&meta, &relative, "system_audio")); + } + for path in [ + "segment-0/audio-input.mp3", + "segment-0/audio-input/audio.m4a", + "segment-0/audio-input.M4A", + ] { + assert!(!preserved_audio_path( + &metadata(&format!("content/segments/{path}")), + Path::new(path), + "audio-input" + )); + } + assert!(!preserved_audio_path( + &metadata("content/segments/other/audio-input.m4a"), + Path::new("segment-0/audio-input.m4a"), + "audio-input" + )); + } +} diff --git a/apps/desktop-gpui/src/editor_preparing/presentation.rs b/apps/desktop-gpui/src/editor_preparing/presentation.rs new file mode 100644 index 00000000000..ebfa06eeb6b --- /dev/null +++ b/apps/desktop-gpui/src/editor_preparing/presentation.rs @@ -0,0 +1,275 @@ +use cap_editor::{PreparingEditorPhase, PreparingEditorProgress, PreparingPlaybackState}; +use cap_project::ProjectConfiguration; + +#[derive(Clone)] +pub(crate) struct PreparingTimelineSeed { + pub(crate) project: ProjectConfiguration, + pub(crate) pretty_name: String, + pub(crate) has_camera: bool, + pub(crate) multiple_clips: bool, +} + +#[derive(Default)] +pub(crate) struct PreparingPresentation { + pub(crate) progress: PreparingEditorProgress, + pub(crate) playback: PreparingPlaybackState, + sequence: u64, +} + +impl PreparingPresentation { + pub(crate) fn apply( + &mut self, + sequence: u64, + progress: PreparingEditorProgress, + playback: PreparingPlaybackState, + ) -> bool { + if sequence <= self.sequence + || !progress.playable_until.is_finite() + || progress.playable_until < 0.0 + || progress.total_duration.is_some_and(|total| { + !total.is_finite() || total <= 0.0 || progress.playable_until > total + }) + || (progress.total_duration.is_none() && progress.playable_until != 0.0) + || !playback.playhead_seconds.is_finite() + || playback.playhead_seconds < 0.0 + || progress + .total_duration + .is_some_and(|total| playback.playhead_seconds > total) + || (progress.phase == PreparingEditorPhase::Unavailable + && progress.playable_until != 0.0) + || (playback.playing + && (!progress.preview_available + || progress.phase == PreparingEditorPhase::Unavailable + || playback.playhead_seconds > progress.playable_until + || (playback.playhead_seconds == progress.playable_until + && !playback.buffering))) + { + return false; + } + self.sequence = sequence; + self.progress = progress; + self.playback = playback; + true + } + + pub(crate) fn controls_ready(&self) -> bool { + self.progress.phase == PreparingEditorPhase::Preparing + && self.progress.preview_available + && self.progress.playable_until > 0.0 + } + + pub(crate) fn last_playable_frame_time(&self, fps: u32) -> Option { + (self.controls_ready() && fps > 0).then(|| { + let frame = + ((self.progress.playable_until * f64::from(fps)).ceil() as u64).saturating_sub(1); + frame as f64 / f64::from(fps) + }) + } + + pub(crate) fn seek_target(&self, seconds: f64, fps: u32) -> Option { + if !self.controls_ready() + || !seconds.is_finite() + || seconds < 0.0 + || seconds > self.progress.playable_until + { + return None; + } + self.last_playable_frame_time(fps) + .map(|last| seconds.min(last)) + } + + pub(crate) fn handoff(&mut self) { + self.progress.phase = PreparingEditorPhase::Handoff; + self.playback.buffering = false; + } + + pub(crate) fn toggle_handoff_playback(&mut self, at_end: bool) -> Option { + if at_end { + self.playback.playing = true; + self.playback.playhead_seconds = 0.0; + Some(0.0) + } else { + self.playback.playing = !self.playback.playing; + None + } + } + + pub(crate) fn handoff_frame(&self, total: f64, fps: u32) -> u32 { + let target = (self.playback.playhead_seconds * f64::from(fps)).round() as u32; + if total.is_finite() && total > 0.0 { + target.min(((total * f64::from(fps)).ceil() as u32).saturating_sub(1)) + } else { + target + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn progress() -> PreparingEditorProgress { + PreparingEditorProgress { + total_duration: Some(60.0), + playable_until: 20.0, + preview_available: true, + ..Default::default() + } + } + + #[test] + fn preview_alone_has_no_playable_prefix() { + let state = PreparingPresentation::default(); + assert_eq!(state.progress.playable_until, 0.0); + assert!(!state.playback.playing); + assert_eq!(state.progress.total_duration, None); + } + + #[test] + fn invalid_or_stale_readiness_preserves_last_confirmed_state() { + let mut state = PreparingPresentation::default(); + assert!(state.apply(2, progress(), PreparingPlaybackState::default())); + assert!(!state.apply(1, PreparingEditorProgress::default(), Default::default())); + for invalid in [f64::NAN, f64::INFINITY, -1.0, 61.0] { + let mut next = progress(); + next.playable_until = invalid; + assert!(!state.apply(3, next, Default::default())); + } + assert_eq!(state.progress, progress()); + } + + #[test] + fn unknown_duration_never_admits_a_playable_prefix() { + let mut state = PreparingPresentation::default(); + let next = PreparingEditorProgress { + playable_until: 1.0, + ..Default::default() + }; + assert!(!state.apply(1, next, Default::default())); + } + + #[test] + fn playing_requires_a_real_preview_and_confirmed_prefix() { + let mut state = PreparingPresentation::default(); + let playback = PreparingPlaybackState { + playhead_seconds: 20.0, + playing: true, + buffering: false, + }; + assert!(!state.apply(1, progress(), playback)); + let mut next = progress(); + next.preview_available = false; + assert!(!state.apply( + 1, + next, + PreparingPlaybackState { + playhead_seconds: 10.0, + ..playback + } + )); + assert!(state.apply( + 1, + progress(), + PreparingPlaybackState { + playhead_seconds: 10.0, + ..playback + } + )); + } + + #[test] + fn seeking_never_crosses_the_confirmed_frontier_or_exact_end_frame() { + let mut state = PreparingPresentation::default(); + assert_eq!(state.seek_target(0.0, 30), None); + assert!(state.apply(1, progress(), Default::default())); + assert_eq!(state.seek_target(12.25, 30), Some(12.25)); + assert_eq!(state.seek_target(20.0, 30), Some(599.0 / 30.0)); + for invalid in [20.001, -0.1, f64::NAN, f64::INFINITY] { + assert_eq!(state.seek_target(invalid, 30), None); + } + assert_eq!(state.seek_target(0.0, 0), None); + state.handoff(); + assert!(!state.controls_ready()); + assert_eq!(state.seek_target(0.0, 30), None); + } + + #[test] + fn terminal_failure_disables_controls_and_preserves_a_retained_image_position() { + let mut state = PreparingPresentation::default(); + assert!(state.apply( + 1, + progress(), + PreparingPlaybackState { + playhead_seconds: 12.25, + ..Default::default() + } + )); + assert!(state.controls_ready()); + let failed = PreparingEditorProgress { + phase: PreparingEditorPhase::Unavailable, + playable_until: 0.0, + ..progress() + }; + assert!(state.apply(2, failed, state.playback)); + assert!(!state.controls_ready()); + assert_eq!(state.playback.playhead_seconds, 12.25); + assert!(!state.apply(1, progress(), Default::default())); + } + + #[test] + fn natural_end_retains_exact_playhead_with_a_valid_last_image() { + let mut state = PreparingPresentation::default(); + let ready = PreparingEditorProgress { + total_duration: Some(10.0), + playable_until: 10.0, + ..progress() + }; + assert!(state.apply( + 1, + ready, + PreparingPlaybackState { + playhead_seconds: 10.0, + ..Default::default() + } + )); + state.handoff(); + assert_eq!(state.handoff_frame(10.0, 30), 299); + assert_eq!(state.playback.playhead_seconds, 10.0); + assert!(!state.playback.playing); + } + + #[test] + fn newer_handoff_play_intent_restarts_at_end_and_can_be_paused_again() { + let mut state = PreparingPresentation::default(); + state.playback.playhead_seconds = 10.0; + state.handoff(); + assert_eq!(state.toggle_handoff_playback(true), Some(0.0)); + assert_eq!(state.handoff_frame(10.0, 30), 0); + assert!(state.playback.playing); + assert_eq!(state.toggle_handoff_playback(false), None); + assert!(!state.playback.playing); + assert_eq!(state.playback.playhead_seconds, 0.0); + } + + #[test] + fn handoff_retains_position_and_playing_intent() { + let mut state = PreparingPresentation::default(); + assert!(state.apply( + 1, + progress(), + PreparingPlaybackState { + playhead_seconds: 12.25, + playing: true, + buffering: false, + } + )); + state.handoff(); + assert_eq!(state.progress.phase, PreparingEditorPhase::Handoff); + assert_eq!(state.handoff_frame(60.0, 30), 368); + assert_eq!(state.handoff_frame(10.0, 30), 299); + assert_eq!(state.playback.playhead_seconds, 12.25); + assert_eq!(state.handoff_frame(0.01, 30), 0); + assert_eq!(state.handoff_frame(10.01, 30), 300); + assert!(state.playback.playing); + } +} diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs index 14ff44c4754..ced9a0cb490 100644 --- a/apps/desktop-gpui/src/editor_timeline.rs +++ b/apps/desktop-gpui/src/editor_timeline.rs @@ -503,44 +503,8 @@ fn visible_box(start: f64, end: f64, transform: Transform, secs_per_pixel: f64) // Waveform peaks // --------------------------------------------------------------------------- -/// `AudioData::SAMPLE_RATE` (`crates/audio/src/audio_data.rs:20`). Spelled out -/// rather than imported because `cap-audio` is not a direct dependency here -- -/// the decoded track arrives through `cap_editor::AudioLoader`, and its -/// inherent methods are all this needs. -const AUDIO_SAMPLE_RATE: usize = 48_000; - -/// `get_waveform` (`apps/desktop/src-tauri/src/audio.rs:42-73`), transcribed: -/// one absolute-dBFS value per ~100 ms chunk of the decoded track, with digital -/// silence pinned to -60 dBFS rather than -inf. -/// -/// It lives in the Tauri *app*, not in a crate, which is the only reason it is -/// copied here rather than called. The data path itself needs nothing new: -/// `EditorInstance::segment_medias[i].audio` is an `AudioLoader` whose `get()` -/// resolves once the background decode finishes, exactly as -/// `get_mic_waveforms` (`lib.rs:4395-4412`) awaits it. -pub fn waveform_peaks(samples: &[f32], channels: u16) -> Vec { - const CHUNK_SIZE: usize = AUDIO_SAMPLE_RATE / 10; // ~100ms - - let channels = (channels as usize).max(1); - let mut waveform = Vec::new(); - - let mut i = 0; - while i < samples.len() { - let end = (i + CHUNK_SIZE * channels).min(samples.len()); - let mut sum = 0.0f32; - for s in &samples[i..end] { - sum += s.abs(); - } - let avg = if end > i { sum / (end - i) as f32 } else { 0.0 }; - waveform.push(avg); - i += CHUNK_SIZE * channels; - } - - for v in waveform.iter_mut() { - *v = if *v > 0.0 { 20.0 * v.log10() } else { -60.0 }; - } - - waveform +pub fn waveform_peaks<'a>(samples: impl IntoIterator, channels: u16) -> Vec { + cap_editor::waveform_peaks(samples.into_iter(), channels) } /// `WAVEFORM_MIN_DB` / `WAVEFORM_SAMPLE_STEP` / `WAVEFORM_MUTE_DB` @@ -3290,6 +3254,8 @@ pub fn wheel_zoom_delta(dom_delta_y: f64, zoom: f64) -> f64 { mod tests { use super::*; + const AUDIO_SAMPLE_RATE: usize = 48_000; + // -- The ruler ---------------------------------------------------------- #[test] @@ -4182,3 +4148,160 @@ mod style_image_tests { assert!(!scene_available(&project, false)); } } + +pub(crate) fn preparing_frontier_offset( + playable_until: f64, + view: TimelineView, + width: f32, +) -> f32 { + if !playable_until.is_finite() || !width.is_finite() || width <= 0.0 { + return 0.0; + } + let seconds_per_pixel = view.transform.secs_per_pixel(width); + if !seconds_per_pixel.is_finite() || seconds_per_pixel <= 0.0 { + return 0.0; + } + ((playable_until - view.transform.position) / seconds_per_pixel).clamp(0.0, f64::from(width)) + as f32 +} + +pub(crate) fn render_preparing_timeline( + theme: &Theme, + model: &TimelineModel, + view: TimelineView, + viewport_width: f32, + progress: Option<&cap_editor::PreparingEditorProgress>, +) -> AnyElement { + let known_duration = progress.and_then(|progress| progress.total_duration); + let playable_until = progress.map_or(0.0, |progress| progress.playable_until); + let content_width = content_width(viewport_width); + let frontier = preparing_frontier_offset(playable_until, view, content_width); + let mut rows = div() + .flex() + .flex_col() + .gap(px(TRACK_ROW_GAP)) + .w_full() + .pr(px(SCROLL_BODY_PADDING_RIGHT)); + if known_duration.is_some() { + for row in &model.rows { + rows = rows.child(render_row( + theme, + model, + *row, + view, + viewport_width, + SegmentUi::default(), + )); + } + } else { + rows = rows.child( + div() + .flex() + .flex_row() + .h(px(TRACK_HEIGHT)) + .rounded(px(TRACK_BAND_RADIUS)) + .bg(Hsla::from(theme.editor.ctl)) + .child( + div() + .w(px(TRACK_GUTTER)) + .flex_none() + .flex() + .items_center() + .pl(px(10.)) + .text_size(px(12.)) + .child("Clip"), + ) + .child( + div() + .flex_1() + .m(px(8.)) + .rounded(px(6.)) + .bg(Hsla::from(theme.editor.ctl_hover)), + ), + ); + } + let mut veil = theme.editor.card; + veil.a = 0.88; + let mut edge = theme.editor.card; + edge.a = 0.35; + let mut clear = theme.editor.card; + clear.a = 0.0; + div() + .size_full() + .min_h_0() + .flex() + .flex_col() + .relative() + .overflow_hidden() + .pt(px(TIMELINE_TOP_PADDING)) + .px(px(TIMELINE_PADDING)) + .pb(px(TIMELINE_BOTTOM_PADDING)) + .gap(px(TIMELINE_HEADER_GAP)) + .child( + div() + .relative() + .h(px(TIMELINE_HEADER_HEIGHT)) + .flex_none() + .children(known_duration.map(|_| render_ruler(theme, view, viewport_width))) + .child( + div() + .absolute() + .left(px(4.)) + .bottom_0() + .text_size(px(12.)) + .text_color(Hsla::from(theme.editor.text_3)) + .child("Timeline"), + ), + ) + .child( + div() + .relative() + .flex_1() + .min_h_0() + .overflow_hidden() + .child(rows) + .children((frontier < content_width).then(|| { + div() + .absolute() + .top_0() + .bottom_0() + .left(px(TRACK_GUTTER + frontier)) + .right_0() + .bg(gpui::linear_gradient( + 90., + gpui::linear_color_stop(edge, 0.), + gpui::linear_color_stop(veil, 1.), + )) + .child(div().absolute().left_0().top_0().bottom_0().w(px(20.)).bg( + gpui::linear_gradient( + 90., + gpui::linear_color_stop(clear, 0.), + gpui::linear_color_stop(veil, 1.), + ), + )) + })), + ) + .child(render_playhead( + theme, + playhead_offset(view, content_width), + 0.65, + )) + .into_any_element() +} + +#[cfg(test)] +mod preparing_frontier_tests { + use super::*; + + #[test] + fn confirmed_boundary_stays_inside_visible_timeline() { + let mut view = TimelineView::default(); + view.transform.zoom = 20.0; + view.transform.position = 10.0; + assert_eq!(preparing_frontier_offset(5.0, view, 400.0), 0.0); + assert_eq!(preparing_frontier_offset(20.0, view, 400.0), 200.0); + assert_eq!(preparing_frontier_offset(35.0, view, 400.0), 400.0); + assert_eq!(preparing_frontier_offset(f64::NAN, view, 400.0), 0.0); + assert_eq!(preparing_frontier_offset(20.0, view, 0.0), 0.0); + } +} diff --git a/apps/desktop-gpui/src/editor_timeline/playback_follow.rs b/apps/desktop-gpui/src/editor_timeline/playback_follow.rs index e587f60356f..9fca91066e3 100644 --- a/apps/desktop-gpui/src/editor_timeline/playback_follow.rs +++ b/apps/desktop-gpui/src/editor_timeline/playback_follow.rs @@ -6,12 +6,14 @@ use super::Transform; pub struct PlaybackFollow { previous: Option, resume_at: Option, + follow_offset: Option, } impl PlaybackFollow { pub fn reset(&mut self) { self.previous = None; self.resume_at = None; + self.follow_offset = None; } pub fn update( @@ -24,6 +26,7 @@ impl PlaybackFollow { ) { if interacting || self.previous.is_some_and(|previous| previous != *transform) { self.resume_at = Some(now + Duration::from_secs(1)); + self.follow_offset = None; } if self.resume_at.is_none_or(|resume_at| now >= resume_at) && transform.position.is_finite() @@ -33,14 +36,25 @@ impl PlaybackFollow { && transform.zoom > 0. && duration > 0. { + let follow_offset = *self.follow_offset.get_or_insert_with(|| { + if playhead >= transform.position && playhead <= transform.position + transform.zoom + { + (transform.zoom * 0.8).max(playhead - transform.position) + } else { + transform.zoom * 0.8 + } + }); let position = if playhead < transform.position { playhead - transform.zoom * 0.2 - } else if playhead > transform.position + transform.zoom * 0.8 { - playhead - transform.zoom * 0.8 + } else if playhead > transform.position + follow_offset { + playhead - follow_offset } else { transform.position }; - transform.position = position.clamp(0., (duration - transform.zoom).max(0.)); + transform.position = position.clamp( + 0., + (duration - transform.zoom).max(transform.position).max(0.), + ); } self.previous = Some(*transform); } @@ -50,6 +64,89 @@ impl PlaybackFollow { mod tests { use super::*; + #[test] + fn playback_follow_starts_from_a_visible_playhead_without_jumping() { + for start in [28.1, 29., 30.] { + let mut follow = PlaybackFollow::default(); + let mut transform = Transform { + position: 20., + zoom: 10., + }; + let now = Instant::now(); + for frame in 0..=120 { + let elapsed = f64::from(frame) / 60.; + follow.update( + &mut transform, + start + elapsed, + 60., + now + Duration::from_secs_f64(elapsed), + false, + ); + assert!((transform.position - (20. + elapsed)).abs() < 1e-9); + } + } + } + + #[test] + fn playback_follow_preserves_a_visible_playhead_after_pause_and_seek() { + let mut follow = PlaybackFollow::default(); + let mut transform = Transform { + position: 0., + zoom: 10., + }; + let now = Instant::now(); + follow.update(&mut transform, 8., 60., now, false); + follow.reset(); + transform.position = 20.; + follow.update(&mut transform, 29., 60., now, false); + assert_eq!(transform.position, 20.); + follow.update(&mut transform, 29.1, 60., now, false); + assert!((transform.position - 20.1).abs() < 1e-9); + } + + #[test] + fn playback_follow_preserves_manual_trailing_space() { + let mut follow = PlaybackFollow::default(); + let mut transform = Transform { + position: 54., + zoom: 10., + }; + for playhead in [55., 58., 60.] { + follow.update(&mut transform, playhead, 60., Instant::now(), false); + assert_eq!(transform.position, 54.); + } + } + + #[test] + fn playback_follow_resumes_after_panning_without_jumping() { + let mut follow = PlaybackFollow::default(); + let mut transform = Transform { + position: 0., + zoom: 10., + }; + let now = Instant::now(); + follow.update(&mut transform, 8., 60., now, false); + transform.position = 1.; + follow.update(&mut transform, 9., 60., now, false); + assert_eq!(transform.position, 1.); + follow.update( + &mut transform, + 10., + 60., + now + Duration::from_secs(1), + false, + ); + assert_eq!(transform.position, 1.); + follow.update( + &mut transform, + 10.1, + 60., + now + Duration::from_millis(1100), + false, + ); + assert!((transform.position - 1.1).abs() < 1e-9); + } + #[test] fn playback_follow_stays_still_then_tracks_at_playback_speed() { let mut follow = PlaybackFollow::default(); diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 1c652b712ab..c430f97670a 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -757,9 +757,6 @@ impl Render for EditorSectionView { return div().into_any_element(); }; editor.update(cx, |editor, cx| { - if !editor.project_ready() && !matches!(self.section, EditorSection::Header) { - return div().size_full().into_any_element(); - } match self.section { EditorSection::Header => editor.render_header(window, cx).into_any_element(), EditorSection::Toolbar => { @@ -770,6 +767,9 @@ impl Render for EditorSectionView { // clips sidebar; the config sidebar is hidden, not destroyed // (`Editor.tsx:728-747`). EditorSection::Sidebar => { + if !editor.project_ready() { + return editor.render_preparing_sidebar().into_any_element(); + } if editor.clips.open { editor.render_clips_sidebar(cx).into_any_element() } else { @@ -1116,6 +1116,9 @@ pub async fn run_transport(instance: Arc, driver: TransportDrive break; } let active = *active_rx.borrow_and_update(); + if active && instance.preparing_adoption().is_some() { + applied_playing = true; + } // A false while this driver believes it is playing is an // engine-initiated stop: the driver marks the engine gone and // tells the window, which answers through its ordinary pause @@ -1191,8 +1194,17 @@ pub async fn run_transport(instance: Arc, driver: TransportDrive } if want.playing && !applied_playing { - instance.start_playback(fps, want.resolution).await; - applied_playing = true; + match instance + .start_playback_with_handle(fps, want.resolution, None) + .await + { + Ok(_) => applied_playing = true, + Err(error) => { + tracing::warn!(?error, "Editor playback could not start"); + applied_playing = false; + let _ = driver.engine_stopped.try_send(()); + } + } } } } @@ -1372,6 +1384,17 @@ pub struct EditorWindow { pub(crate) theme: Theme, pub(crate) project_path: PathBuf, state: LoadState, + preparing_consumer: Option, + preparing_candidate_frame: Option, + preparing_retry_pending: bool, + preparing_sequence: u64, + preparing_presentation: Option, + ordinary_handoff_frame: Option, + #[cfg(debug_assertions)] + preparing_auto_played: bool, + #[cfg(debug_assertions)] + preparing_frame_presented: bool, + preparing_seed: Option>, pub(crate) latest_frame: Option, /// The bundle's `screenshots/display.jpg`, letterboxed into the canvas /// until the first composed frame lands -- decoded in parallel with @@ -1592,6 +1615,7 @@ pub struct EditorWindow { preset_dialog: Option, caption_sync_signature: Option, pub(crate) export: Option, + pub(crate) sharing: Option, /// The Clips layout mode (`ClipsSidebar.tsx`): while open, the config /// sidebar's column draws the clips sidebar instead. pub(crate) clips: crate::editor_clips::ClipsState, @@ -1816,8 +1840,22 @@ impl EditorWindow { // routes, and `is_transparent()` (`windows.rs:1069-1082`) does not // list Editor. The root paints `bg-gray-2 dark:bg-gray-1`. theme: Theme::for_window(window, cx, false), + sharing: RecordingMeta::load_for_project(&project_path) + .ok() + .and_then(|meta| meta.sharing), project_path, state: LoadState::Loading, + preparing_consumer: None, + preparing_candidate_frame: None, + preparing_retry_pending: false, + preparing_sequence: 0, + preparing_presentation: None, + ordinary_handoff_frame: None, + #[cfg(debug_assertions)] + preparing_auto_played: false, + #[cfg(debug_assertions)] + preparing_frame_presented: false, + preparing_seed: None, latest_frame: None, preview, header, @@ -1980,7 +2018,9 @@ impl EditorWindow { // (`ED/context.ts:1455`), so it is set the moment a duration exists -- // the on-mount 80px fit then narrows it on the first render that knows // the timeline's width. - self.view.transform = Transform::initial(summary.duration); + if self.preparing_presentation.is_none() { + self.view.transform = Transform::initial(summary.duration); + } self.name_input.update(cx, |input, cx| { input.set_text(summary.pretty_name.clone(), cx); input.set_disabled(false, cx); @@ -2346,6 +2386,7 @@ impl EditorWindow { pub fn set_error(&mut self, message: String, window: &mut Window, cx: &mut Context) { tracing::error!(path = %self.project_path.display(), "editor project failed to open: {message}"); + self.cancel_preparing(); self.state = LoadState::Failed(message); cx.notify(); window.refresh(); @@ -2373,7 +2414,257 @@ impl EditorWindow { &self.project } + pub(crate) fn begin_preparing(&mut self, consumer: crate::editor_preparing::PreparingConsumer) { + self.preparing_sequence = 0; + #[cfg(debug_assertions)] + { + self.preparing_auto_played = false; + self.preparing_frame_presented = false; + } + self.preparing_presentation = Some(Default::default()); + self.preparing_consumer = Some(consumer); + } + + pub(crate) fn cancel_preparing(&mut self) { + drop(self.preparing_consumer.take()); + if self.preparing_presentation.take().is_some() { + self.playing = false; + self.view.playing = false; + } + self.preparing_seed = None; + self.ordinary_handoff_frame = None; + } + + pub(crate) fn finish_preparing( + &mut self, + joined: &crate::editor_preparing::PreparingJoined, + ) -> bool { + if !self + .preparing_consumer + .as_ref() + .is_some_and(|consumer| joined.matches(consumer)) + { + return false; + } + if let Some(presentation) = &mut self.preparing_presentation { + if let Some(exit) = &joined.exit + && let Some(sequence) = exit.snapshot.sequence.checked_add(2) + { + presentation.apply( + sequence, + exit.snapshot.progress.clone(), + exit.snapshot.playback, + ); + } + presentation.handoff(); + self.playhead = presentation.playback.playhead_seconds; + self.view.playhead = self.playhead; + self.playing = false; + self.view.playing = false; + } + drop(self.preparing_consumer.take()); + true + } + + fn preparing_transport_active(&self) -> bool { + self.preparing_consumer.is_some() + && self + .instance + .as_ref() + .and_then(|instance| instance.preparing_adoption()) + .is_none_or(|adoption| !adoption.is_owner()) + } + + fn preparing_controls_ready(&self) -> bool { + self.preparing_consumer + .as_ref() + .is_some_and(|consumer| consumer.can_command()) + && self + .preparing_presentation + .as_ref() + .is_some_and(|presentation| presentation.controls_ready()) + } + + fn preparing_command(&self, seek: Option, playing: Option) -> bool { + self.preparing_consumer.as_ref().is_some_and(|consumer| { + consumer.command(crate::editor_preparing::PreparingCommand { seek, playing }) + }) + } + + pub(crate) fn preparing_progress_arrived( + &mut self, + update: crate::editor_preparing::PreparingUpdate, + window: &mut Window, + cx: &mut Context, + ) { + if (self.project_ready() && !self.preparing_transport_active()) + || !self + .preparing_consumer + .as_ref() + .is_some_and(|consumer| consumer.accepts_status(&update)) + { + return; + } + let Some(presentation) = &mut self.preparing_presentation else { + return; + }; + let previous_playback = presentation.playback; + if !presentation.apply(update.sequence, update.progress, update.playback) { + return; + } + if previous_playback.playing != presentation.playback.playing + || previous_playback.buffering != presentation.playback.buffering + { + tracing::debug!( + playing = presentation.playback.playing, + buffering = presentation.playback.buffering, + playhead = presentation.playback.playhead_seconds, + playable_until = presentation.progress.playable_until, + "GPUI preparing playback state" + ); + } + let initial_duration = self.total <= 0.0; + self.total = presentation.progress.total_duration.unwrap_or(0.0); + self.playhead = presentation.playback.playhead_seconds; + self.playing = presentation.playback.playing; + self.view.playhead = self.playhead; + self.view.playing = self.playing; + if !self + .preparing_seed + .as_ref() + .is_some_and(|seed| Arc::ptr_eq(seed, &update.seed)) + { + self.has_camera = update.seed.has_camera; + self.multiple_clips = update.seed.multiple_clips; + self.timeline = + TimelineModel::build(&update.seed.project, self.has_camera, self.multiple_clips); + self.name_input.update(cx, |input, cx| { + input.set_text(update.seed.pretty_name.clone(), cx) + }); + self.preparing_seed = Some(update.seed); + } + if initial_duration && self.total > 0.0 { + self.view.transform = Transform::initial(self.total); + self.fitted = false; + } + if self.preparing_sequence > 0 { + presentation.progress.preview_available = true; + } + #[cfg(debug_assertions)] + self.drive_auto_preparing_playback(window, cx); + self.retry_preparing_candidate(window, cx); + cx.notify(); + window.refresh(); + } + + pub(crate) fn initial_frame_number(&self) -> u32 { + self.preparing_presentation.as_ref().map_or( + (self.playhead * f64::from(EDITOR_PREVIEW_FPS)).round() as u32, + |presentation| presentation.handoff_frame(self.total_duration(), EDITOR_PREVIEW_FPS), + ) + } + + pub(crate) fn preparing_frame_arrived( + &mut self, + epoch: &crate::editor_preparing::PreparingEpoch, + identity: &cap_recording::recovery::PreparingStudioIdentity, + sequence: u64, + frame: EditorFrame, + window: &mut Window, + cx: &mut Context, + ) { + if (self.project_ready() && !self.preparing_transport_active()) + || self.preparing_candidate_frame.is_some() + || sequence <= self.preparing_sequence + || !self + .preparing_consumer + .as_ref() + .is_some_and(|consumer| consumer.accepts(epoch, identity)) + { + return; + } + self.preparing_sequence = sequence; + if let Some(presentation) = &mut self.preparing_presentation { + presentation.progress.preview_available = true; + } + self.display_frame(frame, false, window, cx); + let epoch = epoch.clone(); + let identity = identity.clone(); + cx.on_next_frame(window, move |this, window, cx| { + tracing::info!("Preparing composed frame reached presentation cycle"); + if !this + .preparing_consumer + .as_ref() + .is_some_and(|consumer| consumer.accepts(&epoch, &identity)) + { + return; + } + #[cfg(debug_assertions)] + { + this.preparing_frame_presented = true; + this.drive_auto_preparing_playback(window, cx); + } + #[cfg(not(debug_assertions))] + let _ = (window, cx); + }); + window.refresh(); + } + + #[cfg(debug_assertions)] + fn drive_auto_preparing_playback(&mut self, window: &mut Window, cx: &mut Context) { + if self.preparing_auto_played + || !self.preparing_frame_presented + || !self.preparing_controls_ready() + || !std::env::var("CAP_GPUI_AUTO_PREPARING_PLAY").is_ok_and(|value| value == "1") + { + return; + } + self.preparing_auto_played = true; + tracing::info!("auto preparing playback requested"); + self.toggle_play(window, cx); + } + + pub(crate) fn is_instance(&self, instance: &Arc) -> bool { + self.instance + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, instance)) + } + + fn retry_preparing_candidate(&mut self, window: &mut Window, cx: &mut Context) { + if self.preparing_retry_pending { + return; + } + let Some(instance) = self + .instance + .as_ref() + .filter(|instance| { + instance + .preparing_adoption() + .is_some_and(|adoption| adoption.invalidated()) + }) + .cloned() + else { + return; + }; + let Some(handle) = window.window_handle().downcast::() else { + return; + }; + self.preparing_retry_pending = true; + self.preparing_candidate_frame = None; + let path = self.project_path.clone(); + cx.defer(move |cx| crate::app_windows::retry_preparing_editor(path, handle, instance, cx)); + } + pub fn set_instance(&mut self, instance: Arc) { + self.preparing_retry_pending = false; + self.preparing_candidate_frame = None; + self.ordinary_handoff_frame = self + .preparing_presentation + .as_ref() + .map(|_| self.initial_frame_number()); + if instance.preparing_adoption().is_some() { + self.ordinary_handoff_frame = None; + } self.instance = Some(instance); } @@ -2397,8 +2688,36 @@ impl EditorWindow { if total > 0.0 { self.total = total; } + let initial_frame = self.initial_frame_number(); + self.ordinary_handoff_frame = self.preparing_presentation.as_ref().map(|_| initial_frame); + self.playhead = self.preparing_presentation.as_ref().map_or( + f64::from(initial_frame) / f64::from(EDITOR_PREVIEW_FPS), + |presentation| { + presentation + .playback + .playhead_seconds + .clamp(0.0, self.total_duration()) + }, + ); + self.view.playhead = self.playhead; if let Some(instance) = &self.instance { - request_frame(instance, 0, self.preview_resolution()); + if instance.preparing_adoption().is_some() { + self.ordinary_handoff_frame = None; + let instance = instance.clone(); + let resolution = self.preview_resolution(); + gpui_tokio::Tokio::spawn(cx, async move { + match instance + .start_preparing_handoff(EDITOR_PREVIEW_FPS, resolution) + .await + { + Ok(true) => {} + _ => request_frame(&instance, initial_frame, resolution), + } + }) + .detach(); + } else { + request_frame(instance, initial_frame, self.preview_resolution()); + } } cx.notify(); window.refresh(); @@ -2463,8 +2782,15 @@ impl EditorWindow { /// (`ClipsSidebar.tsx:508-511`). pub(crate) fn stop_playback(&mut self, cx: &mut Context) { self.playback_follow.reset(); + if self.preparing_transport_active() { + self.preparing_command(None, Some(false)); + return; + } if let Some(transport) = &self.transport { transport.pause(); + if let Some(presentation) = &mut self.preparing_presentation { + presentation.playback.playing = false; + } } if self.playing { self.playing = false; @@ -2480,6 +2806,10 @@ impl EditorWindow { /// pressed it twice. Runs the ordinary pause path so `Desired` stays /// UI-owned and the run's stats still get reported. pub fn engine_stopped(&mut self, window: &mut Window, cx: &mut Context) { + if self.preparing_transport_active() { + self.retry_preparing_candidate(window, cx); + return; + } if !self.playing { return; } @@ -2492,6 +2822,9 @@ impl EditorWindow { let Some(transport) = &self.transport else { return; }; + self.ordinary_handoff_frame = None; + self.preparing_presentation = None; + self.preparing_seed = None; self.playback_follow.reset(); // `Math.floor(editorState.playbackTime * FPS)`. let frame = (from.max(0.0) * EDITOR_PREVIEW_FPS as f64).floor() as u32; @@ -2562,7 +2895,23 @@ impl EditorWindow { /// `handlePlayPauseClick` (`Player.tsx:212-233`), verbatim: at the end, /// restart from 0; playing, stop; otherwise seek to the playhead and go. pub fn toggle_play(&mut self, _window: &mut Window, cx: &mut Context) { - if self.transport.is_none() { + if self.preparing_transport_active() { + if self.preparing_controls_ready() { + let seek = self.is_at_end().then_some(0.0); + self.preparing_command(seek, Some(!self.playing)); + } + return; + } + if self.ordinary_handoff_frame.is_some() { + let at_end = self.is_at_end(); + let target = self + .preparing_presentation + .as_mut() + .and_then(|presentation| presentation.toggle_handoff_playback(at_end)); + if let Some(target) = target { + self.seek_to_time(target, cx); + } + cx.notify(); return; } if self.is_at_end() { @@ -2581,13 +2930,34 @@ impl EditorWindow { /// (`PlaybackHandle::seek`), so a scrub during playback holds the last /// picture for one decode instead of paying a warmup per tick. pub fn seek_to_time(&mut self, time: f64, cx: &mut Context) { + if self.preparing_transport_active() { + self.preparing_command(Some(time), None); + return; + } let Some(transport) = &self.transport else { + if let Some(target) = self + .preparing_presentation + .as_ref() + .and_then(|presentation| presentation.seek_target(time, EDITOR_PREVIEW_FPS)) + { + self.preparing_command(Some(target), None); + } return; }; let time = time.clamp(0.0, self.total_duration()); // `Math.round(newTime * FPS)` -- "round to nearest frame to prevent // off-by-one drift". - let frame = (time * EDITOR_PREVIEW_FPS as f64).round() as u32; + let mut frame = (time * EDITOR_PREVIEW_FPS as f64).round() as u32; + if self.ordinary_handoff_frame.is_some() { + frame = frame.min( + ((self.total_duration() * f64::from(EDITOR_PREVIEW_FPS)).ceil() as u32) + .saturating_sub(1), + ); + self.ordinary_handoff_frame = Some(frame); + if let Some(presentation) = &mut self.preparing_presentation { + presentation.playback.playhead_seconds = time; + } + } if self.playing { // A live seek re-anchors the engine's clock at the target, and // the drawn line re-anchors with it: the engine's immediate @@ -2610,12 +2980,26 @@ impl EditorWindow { /// timeline transform back to the start. **Not** a frame step -- neither /// transport button is one in the Tauri editor. pub fn jump_to_start(&mut self, cx: &mut Context) { + if self.preparing_transport_active() && self.preparing_controls_ready() { + self.preparing_command(Some(0.0), Some(false)); + return; + } self.stop_playback(cx); self.seek_to_time(0.0, cx); } /// The next button (`Player.tsx:395-405`): stop, playhead to the end. pub fn jump_to_end(&mut self, cx: &mut Context) { + if self.preparing_transport_active() && self.preparing_controls_ready() { + if let Some(target) = self + .preparing_presentation + .as_ref() + .and_then(|presentation| presentation.last_playable_frame_time(EDITOR_PREVIEW_FPS)) + { + self.preparing_command(Some(target), Some(false)); + } + return; + } self.stop_playback(cx); let total = self.total_duration(); self.seek_to_time(total, cx); @@ -5386,6 +5770,48 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) { + self.display_frame(frame, true, window, cx); + } + + fn display_frame( + &mut self, + frame: EditorFrame, + ordinary: bool, + window: &mut Window, + cx: &mut Context, + ) { + let adoption = ordinary + .then(|| { + self.instance + .as_ref() + .and_then(|instance| instance.preparing_adoption()) + }) + .flatten() + .filter(|adoption| !adoption.is_owner()); + if let Some(adoption) = &adoption { + if adoption.invalidated() { + self.retry_preparing_candidate(window, cx); + return; + } + let Some(snapshot) = adoption.snapshot() else { + return; + }; + let current = + (snapshot.playback.playhead_seconds * f64::from(EDITOR_PREVIEW_FPS)).floor() as u32; + if frame.number < current.saturating_sub(2) || frame.number > current.saturating_add(1) + { + return; + } + self.preparing_candidate_frame = Some(frame.number); + } + let candidate_number = frame.number; + if self + .ordinary_handoff_frame + .is_some_and(|target| frame.number != target) + { + return; + } + let handoff_target = self.transport.as_ref().and(self.ordinary_handoff_frame); let first_frame = self.latest_frame.is_none(); let layout_changed = self.frame_layout != Some(frame.layout); if self.frame_layout.map(|layout| layout.output_size) != Some(frame.layout.output_size) { @@ -5439,6 +5865,43 @@ impl EditorWindow { if !window.is_window_active() && (first_frame || !self.playing) { window.refresh(); } + if let Some(adoption) = adoption { + cx.on_next_frame(window, move |this, window, cx| { + if this.preparing_candidate_frame != Some(candidate_number) { + return; + } + this.preparing_candidate_frame = None; + if adoption.try_commit(candidate_number, EDITOR_PREVIEW_FPS) { + this.preparing_presentation = None; + this.preparing_seed = None; + this.ordinary_handoff_frame = None; + drop(this.preparing_consumer.take()); + } else { + this.retry_preparing_candidate(window, cx); + } + cx.notify(); + window.refresh(); + }); + window.refresh(); + } else if let Some(target) = handoff_target { + cx.on_next_frame(window, move |this, window, cx| { + if this.ordinary_handoff_frame != Some(target) { + return; + } + this.ordinary_handoff_frame = None; + let resume = this + .preparing_presentation + .take() + .is_some_and(|presentation| presentation.playback.playing); + this.preparing_seed = None; + if resume { + this.start_playback(this.playhead, cx); + } + cx.notify(); + window.refresh(); + }); + window.refresh(); + } } fn sync_appearance(&mut self, window: &Window, cx: &gpui::App) { @@ -5482,6 +5945,9 @@ impl EditorWindow { } fn commit_pretty_name(&mut self, cx: &mut Context) -> Result<(), String> { + if !self.project_ready() { + return Ok(()); + } let Some(stored) = self.summary().map(|summary| summary.pretty_name.clone()) else { return Ok(()); }; @@ -8086,6 +8552,9 @@ impl EditorWindow { .then(|| self.header_pill("icons/captions.svg", "Captions", compact)), ) .child(div().w(px(6.)).flex_none()) + .when(self.sharing.is_some(), |group| { + group.child(self.render_reupload_button(cx)) + }) .child(self.render_export_button(cx)), ); @@ -8223,6 +8692,78 @@ impl EditorWindow { ) } + fn render_preparing_sidebar(&self) -> impl IntoElement { + let theme = self.theme; + let project = self + .preparing_seed + .as_ref() + .map_or(&self.project, |seed| &seed.project); + div() + .size_full() + .flex() + .flex_col() + .rounded(px(12.)) + .border_1() + .border_color(self.card_line()) + .bg(self.panel_bg()) + .overflow_hidden() + .child( + div() + .flex() + .flex_row() + .items_center() + .justify_between() + .px(px(10.)) + .h(px(44.)) + .flex_none() + .children( + crate::editor_sidebar::SidebarTab::ALL + .into_iter() + .map(|tab| { + div() + .size(px(28.)) + .flex() + .items_center() + .justify_center() + .opacity(0.45) + .child( + svg() + .path(tab.icon()) + .size(px(15.)) + .text_color(Hsla::from(theme.editor.text_2)), + ) + }), + ), + ) + .child( + div() + .flex() + .flex_col() + .p(px(16.)) + .gap(px(16.)) + .text_size(px(12.)) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(Hsla::from(theme.editor.text_1)) + .child("Background"), + ) + .child( + div() + .flex() + .justify_between() + .text_color(Hsla::from(theme.editor.text_3)) + .child("Aspect ratio") + .child(Self::aspect_ratio_label(project.aspect_ratio.as_ref())), + ) + .child( + div() + .text_color(Hsla::from(theme.editor.text_3)) + .child("Editing is available when your recording is ready."), + ), + ) + } + /// The player's top row: the stage's own triggers on the left, the /// preview-resolution control on the right. fn render_player_toolbar(&self, window: &Window, cx: &mut Context) -> impl IntoElement { @@ -8271,9 +8812,16 @@ impl EditorWindow { .text(&theme) .left_icon("icons/layout.svg") .when(!narrow, |button| { - button.label(Self::aspect_ratio_label(self.project.aspect_ratio.as_ref())) + button.label(Self::aspect_ratio_label( + self.preparing_seed + .as_ref() + .map_or(&self.project, |seed| &seed.project) + .aspect_ratio + .as_ref(), + )) }) .tooltip(&theme, "Aspect Ratio") + .disabled(!self.project_ready()) .pressed( self.toolbar_menu .as_ref() @@ -8291,6 +8839,7 @@ impl EditorWindow { .left_icon("icons/crop.svg") .when(!narrow, |button| button.label("Crop")) .tooltip(&theme, "Crop Video") + .disabled(!self.project_ready()) .pressed(self.crop.is_some()) .on_click(cx.listener(|this, _, window, cx| { this.open_crop(window, cx); @@ -8349,9 +8898,11 @@ impl EditorWindow { } }) .child(quality.label()) - .on_click(cx.listener(move |this, _, _window, cx| { - this.set_preview_quality(quality, cx); - })) + .when(self.project_ready(), |button| { + button.on_click(cx.listener(move |this, _, _window, cx| { + this.set_preview_quality(quality, cx); + })) + }) })) } @@ -8413,10 +8964,33 @@ impl EditorWindow { .overflow_hidden() .bg(Hsla::from(theme.editor.card)) .child(body) + .children( + (!self.project_ready() && self.latest_frame.is_some()).then(|| { + div() + .absolute() + .bottom(px(12.)) + .left(px(12.)) + .px(px(10.)) + .py(px(6.)) + .rounded(px(6.)) + .bg(Hsla::from(theme.editor.card)) + .text_size(px(12.)) + .text_color(Hsla::from(theme.editor.text_2)) + .child(if self.preparing_consumer.is_some() { + "Preparing recording…" + } else { + "Opening editor…" + }) + }), + ) // `CanvasElementsOverlay` + `SnapGuidesOverlay` // (`Player.tsx:636-643`), both mounted inside the letterbox // wrapper and only while a frame exists. - .children(self.render_canvas_overlay(cx)) + .children(if self.project_ready() { + self.render_canvas_overlay(cx) + } else { + None + }) } /// `EditorErrorScreen` -- what a bundle that will not open shows instead of @@ -8546,7 +9120,7 @@ impl EditorWindow { // (`Player.tsx:359-365`) -- the clock reads the *hover* time when there // is one, which is what makes it a readout for the ghost playhead. let current = self.view.preview_time.unwrap_or(self.playhead).max(0.0); - let live = self.transport.is_some(); + let live = self.transport.is_some() || self.preparing_controls_ready(); // `{!editorState.playing || isAtEnd() ? : // }` (`Player.tsx:388-392`). let icon = if !self.playing || self.is_at_end() { @@ -8606,7 +9180,11 @@ impl EditorWindow { .text_color(Hsla::from(editor.text_3)) .child(SharedString::from(format!( " / {}", - timeline::format_time(total) + if total > 0.0 { + timeline::format_time(total) + } else { + "--:--".into() + } ))) })), ) @@ -8649,6 +9227,7 @@ impl EditorWindow { .icon_size(px(12.)) .color(Hsla::from(editor.card)) .filled(Hsla::from(editor.text_1), None) + .disabled(!live) .hover_bg(Hsla::from(crate::theme::mix( editor.text_1, editor.card, @@ -8679,6 +9258,7 @@ impl EditorWindow { ui::EditorButton::plain(&theme, "transport-split") .left_icon("icons/scissors.svg") .tooltip(&theme, "Toggle Split") + .disabled(!self.project_ready()) .pressed(self.split_mode) .on_click(cx.listener(|this, _, window, cx| { this.toggle_split_mode(cx); @@ -8734,6 +9314,30 @@ impl EditorWindow { cx: &mut Context, ) -> impl IntoElement { let theme = self.theme; + if !self.project_ready() { + return div() + .size_full() + .child(timeline::render_preparing_timeline( + &theme, + &self.timeline, + self.view, + viewport_width, + self.preparing_presentation + .as_ref() + .map(|presentation| &presentation.progress), + )) + .when(self.preparing_controls_ready(), |timeline| { + timeline.on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + let time = this.time_at(f32::from(event.position.x), viewport_width); + this.seek_to_time(time, cx); + cx.stop_propagation(); + }), + ) + }) + .into_any_element(); + } let content_width = timeline::content_width(viewport_width); let live = self.transport.is_some(); @@ -8895,6 +9499,7 @@ impl EditorWindow { as f32; x.is_finite().then(|| render_split_preview(&theme, x, true)) })) + .into_any_element() } /// The 26px header strip (`TL/index.tsx:1220-1245`): the ruler, the "Add diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index a3b6abe9611..6771d8b128b 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -30,6 +30,7 @@ mod editor_export; #[cfg(target_os = "linux")] mod editor_modal; mod editor_panels; +mod editor_preparing; mod editor_sidebar; mod editor_tabs; mod editor_timeline; @@ -505,7 +506,16 @@ fn main() { }); }) .detach(); - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "windows")] + cx.spawn(async move |_| { + if let Some(native) = native_main + && let Err(error) = platform::install_main_window_frame_policy(&native) + { + tracing::warn!(%error, "could not install Main window frame policy"); + } + }) + .detach(); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] let _ = native_main; // `CAP_GPUI_DEBUG_LIGHTS=1`: poll the main window's style mask and diff --git a/apps/desktop-gpui/src/platform/windows.rs b/apps/desktop-gpui/src/platform/windows.rs index d7e81fbffc3..9e3bed11b0a 100644 --- a/apps/desktop-gpui/src/platform/windows.rs +++ b/apps/desktop-gpui/src/platform/windows.rs @@ -16,6 +16,7 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ use super::{ForcedAppearance, MaterialKind, PanelBehavior}; mod capture_exclusion; +mod hidden_frame; #[derive(Clone, Copy)] pub struct NativeWindow(isize); @@ -63,6 +64,10 @@ pub fn native_window(window: &Window) -> Option { native_handle(window).map(NativeWindow) } +pub fn install_main_window_frame_policy(native: &NativeWindow) -> std::io::Result<()> { + hidden_frame::install(native.hwnd()) +} + #[link(name = "dwmapi")] unsafe extern "system" { fn DwmSetWindowAttribute(hwnd: HWND, attribute: u32, value: *const c_void, size: u32) -> i32; diff --git a/apps/desktop-gpui/src/platform/windows/hidden_frame.rs b/apps/desktop-gpui/src/platform/windows/hidden_frame.rs new file mode 100644 index 00000000000..22585035c7b --- /dev/null +++ b/apps/desktop-gpui/src/platform/windows/hidden_frame.rs @@ -0,0 +1,134 @@ +use std::ffi::c_void; +use std::io::{self, Write}; + +use windows_sys::Win32::Foundation::{GetLastError, HWND, LPARAM, LRESULT, SetLastError, WPARAM}; +use windows_sys::Win32::System::Threading::GetCurrentThreadId; +use windows_sys::Win32::UI::Shell::{ + DefSubclassProc, GetWindowSubclass, RemoveWindowSubclass, SetWindowSubclass, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetWindowThreadProcessId, IsWindowVisible, SWP_SHOWWINDOW, WINDOWPOS, WM_NCDESTROY, + WM_SHOWWINDOW, WM_WINDOWPOSCHANGED, WM_WINDOWPOSCHANGING, +}; + +const SUBCLASS_ID: usize = 0x4341_5046; +const KNOWN: usize = 1; +const DISABLED: usize = 2; +const UPDATING: usize = 4; +const DWMWA_NCRENDERING_POLICY: u32 = 2; +const DWMNCRP_USEWINDOWSTYLE: u32 = 0; +const DWMNCRP_DISABLED: u32 = 1; + +#[link(name = "dwmapi")] +unsafe extern "system" { + fn DwmSetWindowAttribute(hwnd: HWND, attribute: u32, value: *const c_void, size: u32) -> i32; +} + +pub fn install(hwnd: HWND) -> io::Result<()> { + let owner = unsafe { GetWindowThreadProcessId(hwnd, std::ptr::null_mut()) }; + if owner == 0 || owner != unsafe { GetCurrentThreadId() } { + return Err(io::Error::other( + "Main frame policy must be installed on the window's owning thread", + )); + } + if state(hwnd).is_none() { + write_state(hwnd, 0)?; + } + reconcile(hwnd) +} + +fn state(hwnd: HWND) -> Option { + let mut state = 0; + let found = unsafe { GetWindowSubclass(hwnd, Some(subclass), SUBCLASS_ID, &mut state) }; + (found != 0).then_some(state) +} + +fn write_state(hwnd: HWND, state: usize) -> io::Result<()> { + if unsafe { SetWindowSubclass(hwnd, Some(subclass), SUBCLASS_ID, state) } == 0 { + return Err(io::Error::other("could not update Main frame subclass")); + } + Ok(()) +} + +fn apply_policy(hwnd: HWND, disabled: bool) -> io::Result<()> { + let Some(previous) = state(hwnd) else { + return Ok(()); + }; + let desired = KNOWN | if disabled { DISABLED } else { 0 }; + if previous & UPDATING != 0 || previous == desired { + return Ok(()); + } + let updating = previous | UPDATING; + write_state(hwnd, updating)?; + let policy = if disabled { + DWMNCRP_DISABLED + } else { + DWMNCRP_USEWINDOWSTYLE + }; + // A native frame can remain visible after Main hides; visible windows retain normal DWM policy. + let result = unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_NCRENDERING_POLICY, + std::ptr::addr_of!(policy).cast(), + std::mem::size_of_val(&policy) as u32, + ) + }; + if state(hwnd) == Some(updating) { + write_state(hwnd, if result >= 0 { desired } else { previous })?; + } + if result < 0 { + return Err(io::Error::other(format!( + "Main frame DWM policy {policy} failed: HRESULT {result:#010x}" + ))); + } + Ok(()) +} + +fn reconcile(hwnd: HWND) -> io::Result<()> { + if state(hwnd).is_none() { + return Ok(()); + } + apply_policy(hwnd, unsafe { IsWindowVisible(hwnd) } == 0) +} + +fn report(result: io::Result<()>) { + if let Err(error) = result { + let _ = writeln!(io::stderr().lock(), "Cap Main frame policy: {error}"); + } +} + +unsafe extern "system" fn subclass( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + _subclass_id: usize, + _reference_data: usize, +) -> LRESULT { + let incoming_error = unsafe { GetLastError() }; + if message == WM_NCDESTROY { + if unsafe { RemoveWindowSubclass(hwnd, Some(subclass), SUBCLASS_ID) } == 0 { + report(Err(io::Error::other( + "could not remove Main frame subclass", + ))); + } + unsafe { SetLastError(incoming_error) }; + return unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }; + } + let showing = message == WM_SHOWWINDOW && wparam != 0 + || matches!(message, WM_WINDOWPOSCHANGING | WM_WINDOWPOSCHANGED) + && lparam != 0 + && unsafe { (*(lparam as *const WINDOWPOS)).flags } & SWP_SHOWWINDOW != 0; + if showing { + report(apply_policy(hwnd, false)); + } + unsafe { SetLastError(incoming_error) }; + let result = unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }; + let forwarded_error = unsafe { GetLastError() }; + if message == WM_WINDOWPOSCHANGED { + report(reconcile(hwnd)); + } + unsafe { SetLastError(forwarded_error) }; + result +} diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index f840fe0546f..120d7e9ff5f 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -15,6 +15,7 @@ use anyhow::{Context as _, anyhow}; use cap_recording::{ feeds::{camera, camera::CameraFeed, microphone, microphone::MicrophoneFeed}, instant_recording, + recovery::{PreparingStudioJob, PreparingStudioObserver, RecoveryManager}, sources::screen_capture::ScreenCaptureTarget, studio_recording, }; @@ -409,22 +410,34 @@ async fn run_instant_operation( async fn finalize_studio( completed: studio_recording::CompletedRecording, capture_target: ScreenCaptureTarget, + progress: Option, ) -> anyhow::Result { + let started = std::time::Instant::now(); let project_path = completed.project_path.clone(); let needs_remux = matches!( completed.meta.status(), cap_project::StudioRecordingStatus::NeedsRemux ); - tokio::task::spawn_blocking(move || { + let completed = tokio::task::spawn_blocking(move || { if needs_remux { ensure_finalization_storage(&project_path)?; } - cap_recording::recovery::RecoveryManager::remux_if_needed(&project_path) - .map_err(anyhow::Error::from) + let preparing = progress + .as_ref() + .and_then(|progress| progress.claim_preparing(&completed)); + let result = match preparing { + Some(job) => RecoveryManager::remux_stopped_with_preparing(&completed, job), + None => RecoveryManager::remux_if_needed(&project_path), + }; + result.map(|_| completed).map_err(anyhow::Error::from) }) .await .context("studio finalize task")? .context("studio finalize")?; + tracing::info!( + elapsed_ms = started.elapsed().as_secs_f64() * 1000.0, + "Studio stop remux complete" + ); // Everything `handle_recording_finish` does after the remux, // in its order: the first-frame JPEG the library's card is @@ -433,9 +446,14 @@ async fn finalize_studio( // recording that is already on disk, so both only warn. let project_path = completed.project_path.clone(); tokio::task::spawn_blocking(move || { + let thumbnail_started = std::time::Instant::now(); if let Some(display_path) = studio_display_path(&project_path) { write_bundle_thumbnail(&project_path, &display_path); } + tracing::info!( + elapsed_ms = thumbnail_started.elapsed().as_secs_f64() * 1000.0, + "Studio stop thumbnail complete" + ); apply_camera_blur_to_project_config(&project_path, current_camera_blur()); let library = serde_json::from_value(serde_json::Value::Object( crate::store::store_section("animated_gradients"), @@ -517,6 +535,182 @@ where pub(crate) type CaptureStopFuture = std::pin::Pin)> + Send>>; +#[derive(Clone, Default)] +struct StudioFinalizationState { + capture_stopped: bool, + preparing_decided: bool, + preparing: Option, + result: Option>, +} + +#[derive(Clone)] +pub(crate) struct StudioFinalization { + state: tokio::sync::watch::Receiver, +} + +#[derive(Clone)] +pub(crate) struct StudioFinalizationPublisher { + state: tokio::sync::watch::Sender, + target: Arc, +} + +struct StudioFinalizationTarget { + project_path: PathBuf, + generation: u64, +} + +impl StudioFinalization { + pub(crate) fn channel( + project_path: PathBuf, + generation: u64, + ) -> (StudioFinalizationPublisher, Self) { + let (state, receiver) = tokio::sync::watch::channel(StudioFinalizationState::default()); + ( + StudioFinalizationPublisher { + state, + target: Arc::new(StudioFinalizationTarget { + project_path, + generation, + }), + }, + Self { state: receiver }, + ) + } + + pub(crate) fn same_job(&self, other: &Self) -> bool { + self.state.same_channel(&other.state) + } + + pub(crate) fn matches_preparing_identity( + &self, + identity: &cap_recording::recovery::PreparingStudioIdentity, + ) -> bool { + let observer = self.state.borrow().preparing.clone(); + observer.is_some_and(|observer| { + observer.identity().same_job(identity) + && matches!( + observer.latest(), + cap_recording::recovery::PreparingStudioState::Available(_) + ) + }) + } + + pub(crate) fn is_finalizing(&self) -> bool { + let state = self.state.borrow(); + state.capture_stopped && state.result.is_none() + } + + pub(crate) fn allows_preparing_continuation( + &self, + identity: &cap_recording::recovery::PreparingStudioIdentity, + ) -> bool { + let state = self.state.borrow(); + state.capture_stopped + && state.result.as_ref().is_none_or(Result::is_ok) + && identity.publication_succeeded() + && state + .preparing + .as_ref() + .is_some_and(|observer| observer.identity().same_job(identity)) + } + + pub(crate) async fn wait_for_capture(&self) -> bool { + let mut state = self.state.clone(); + loop { + { + let current = state.borrow_and_update(); + if current.capture_stopped { + return true; + } + if current.result.is_some() { + return false; + } + } + if state.changed().await.is_err() { + return false; + } + } + } + + pub(crate) async fn wait_for_preparing(&self) -> Option { + let mut state = self.state.clone(); + loop { + { + let current = state.borrow_and_update(); + if current.preparing_decided || current.result.is_some() { + return current.preparing.clone(); + } + } + if state.changed().await.is_err() { + return None; + } + } + } + + pub(crate) async fn wait(&self) -> Result<(), String> { + let mut state = self.state.clone(); + loop { + if let Some(result) = state.borrow_and_update().result.clone() { + return result; + } + state.changed().await.map_err(|_| { + "Recording finalization ended without a result. Your files were retained." + .to_string() + })?; + } + } +} + +impl StudioFinalizationPublisher { + fn claim_preparing( + &self, + completed: &studio_recording::CompletedRecording, + ) -> Option { + self.claim_preparing_with(completed, PreparingStudioJob::claim) + } + + fn claim_preparing_with( + &self, + completed: &studio_recording::CompletedRecording, + claim: impl FnOnce( + &studio_recording::CompletedRecording, + u64, + ) -> Option<(PreparingStudioJob, PreparingStudioObserver)>, + ) -> Option { + let mut job = None; + self.state.send_if_modified(|state| { + if !state.capture_stopped || state.preparing_decided || state.result.is_some() { + return false; + } + state.preparing_decided = true; + if completed.project_path == self.target.project_path + && let Some((claimed, observer)) = claim(completed, self.target.generation) + { + state.preparing = Some(observer); + job = Some(claimed); + } + true + }); + job + } + + fn capture_stopped(&self) { + tracing::info!("Studio capture shutdown acknowledged; finalization starting"); + self.state.send_modify(|state| state.capture_stopped = true); + } + + fn complete(&self, result: &anyhow::Result) { + self.state.send_modify(|state| { + state.result = Some( + result + .as_ref() + .map(|_| ()) + .map_err(|error| format!("{error:#}")), + ); + }); + } +} + #[cfg(any(windows, test))] async fn finish_windows_startup_setup( setup: anyhow::Result, @@ -626,29 +820,42 @@ impl ActiveRecording { } #[cfg(target_os = "linux")] - pub fn clean_studio_stop_handle(&self) -> Option { + pub(crate) fn clean_studio_stop_handle( + &self, + progress: Option, + ) -> Option { let Handle::Studio(handle) = &self.handle else { return None; }; let handle = handle.clone(); Some(Box::pin(async move { let capture_target = handle.capture_target.clone(); - finish_studio_after_join(handle.stop_with_report(), |completed| { - finalize_studio(completed, capture_target) + let result = finish_studio_after_join(handle.stop_with_report(), |completed| { + if let Some(progress) = &progress { + progress.capture_stopped(); + } + finalize_studio(completed, capture_target, progress.clone()) }) - .await + .await; + if let Some(progress) = progress { + progress.complete(&result.1); + } + result })) } #[cfg(any(target_os = "macos", windows))] - pub fn clean_studio_stop_handle(&self) -> Option { + pub(crate) fn clean_studio_stop_handle( + &self, + progress: Option, + ) -> Option { let Handle::Studio(handle) = &self.handle else { return None; }; let handle = handle.clone(); Some(Box::pin(async move { let capture_target = handle.capture_target.clone(); - finish_after_capture_stop( + let result = finish_after_capture_stop( async move { let outcome = handle.stop_with_outcome().await; ( @@ -656,9 +863,18 @@ impl ActiveRecording { outcome.result.map_err(anyhow::Error::msg), ) }, - |completed| finalize_studio(completed, capture_target), + |completed| { + if let Some(progress) = &progress { + progress.capture_stopped(); + } + finalize_studio(completed, capture_target, progress.clone()) + }, ) - .await + .await; + if let Some(progress) = progress { + progress.complete(&result.1); + } + result })) } @@ -917,7 +1133,7 @@ impl ActiveRecording { Handle::Studio(handle) => { let capture_target = handle.capture_target.clone(); let completed = handle.stop().await?; - finalize_studio(completed, capture_target).await + finalize_studio(completed, capture_target, None).await } Handle::Instant(handle) => { let result = async { @@ -1196,6 +1412,30 @@ fn write_bundle_thumbnail(project_dir: &std::path::Path, source_video: &std::pat } } +pub(crate) fn preparing_presentation( + project: &cap_project::ProjectConfiguration, +) -> Result { + let library = serde_json::from_value(serde_json::Value::Object(crate::store::store_section( + "animated_gradients", + ))) + .map_err(|error| format!("Preparing appearance preferences did not parse: {error}"))?; + preparing_presentation_for(project, current_camera_blur(), &library) +} + +fn preparing_presentation_for( + project: &cap_project::ProjectConfiguration, + blur: crate::store::BlurMode, + library: &cap_project::AnimatedGradientLibrary, +) -> Result { + if blur != crate::store::BlurMode::Off || project.camera.background_blur.is_active() { + return Err("Camera blur requires ordinary editor loading".into()); + } + if library.selected && library.last_used.is_some() { + return Err("Animated-gradient preferences require ordinary editor loading".into()); + } + Ok(project.clone()) +} + /// The camera preview bubble's current blur mode. /// /// `handle_recording_finish` reads the *live* preview state @@ -3171,6 +3411,282 @@ mod capture_stop_contract_tests { } } +#[cfg(test)] +mod studio_finalization_tests { + use super::*; + + fn channel() -> (StudioFinalizationPublisher, StudioFinalization) { + StudioFinalization::channel(PathBuf::from("project.cap"), 42) + } + + fn completed_without_receipt(path: &str) -> studio_recording::CompletedRecording { + studio_recording::CompletedRecording { + project_path: PathBuf::from(path), + meta: serde_json::from_value(serde_json::json!({ + "segments": [{ + "display": { "path": "content/segments/segment-0/display", "fps": 30, "start_time": 0.0 } + }], + "status": { "status": "NeedsRemux" } + })) + .unwrap(), + cursor_data: Default::default(), + clean_stopped: None, + } + } + + #[tokio::test] + async fn editor_waits_for_complete_finalization_after_capture_acknowledgement() { + let (publisher, finalization) = channel(); + assert!(!finalization.is_finalizing()); + assert!(finalization.wait_for_capture().now_or_never().is_none()); + assert!(finalization.wait().now_or_never().is_none()); + publisher.capture_stopped(); + assert!(finalization.wait_for_capture().await); + assert!(finalization.is_finalizing()); + assert!(finalization.wait().now_or_never().is_none()); + publisher.complete(&Ok(PathBuf::from("project.cap"))); + finalization.wait().await.unwrap(); + assert!(!finalization.is_finalizing()); + } + + #[tokio::test] + async fn completion_before_subscription_is_retained_for_close_and_reopen() { + let (publisher, finalization) = channel(); + publisher.capture_stopped(); + publisher.complete(&Ok(PathBuf::from("project.cap"))); + drop(publisher); + assert!(finalization.wait_for_capture().await); + finalization.wait().await.unwrap(); + finalization.clone().wait().await.unwrap(); + } + + #[tokio::test] + async fn stop_failure_never_opens_a_preparing_editor() { + let (publisher, finalization) = channel(); + publisher.complete(&Err(anyhow!("capture stop unconfirmed"))); + assert!(!finalization.wait_for_capture().await); + assert!(!finalization.is_finalizing()); + assert!( + finalization + .wait() + .await + .unwrap_err() + .contains("unconfirmed") + ); + } + + #[tokio::test] + async fn finalization_failure_remains_an_error_after_capture_stops() { + let (publisher, finalization) = channel(); + publisher.capture_stopped(); + publisher.complete(&Err(anyhow!("media validation failed"))); + assert!(finalization.wait_for_capture().await); + assert!(!finalization.is_finalizing()); + assert_eq!( + finalization.wait().await.unwrap_err(), + "media validation failed" + ); + } + + #[tokio::test] + async fn lost_finalizer_cannot_leave_the_loading_shell_waiting_forever() { + for capture_stopped in [false, true] { + let (publisher, finalization) = channel(); + if capture_stopped { + publisher.capture_stopped(); + } + drop(publisher); + assert_eq!(finalization.wait_for_capture().await, capture_stopped); + assert!( + finalization + .wait() + .await + .unwrap_err() + .contains("files were retained") + ); + } + } + + #[tokio::test] + async fn closing_every_editor_waiter_does_not_cancel_retained_finalization() { + let (publisher, finalization) = channel(); + let (release, released) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let result = finish_after_capture_stop(async { (true, Ok(())) }, |()| async { + publisher.capture_stopped(); + released.await.unwrap(); + Ok(PathBuf::from("project.cap")) + }) + .await; + publisher.complete(&result.1); + result + }); + assert!(finalization.wait_for_capture().await); + drop(finalization); + release.send(()).unwrap(); + let (stopped, result) = task.await.unwrap(); + assert!(stopped); + assert_eq!(result.unwrap(), PathBuf::from("project.cap")); + } + + #[tokio::test] + async fn finalizer_panic_publishes_failure_without_losing_capture_acknowledgement() { + let (publisher, finalization) = channel(); + let result = finish_after_capture_stop(async { (true, Ok(())) }, |()| async { + publisher.capture_stopped(); + panic!("finalizer failed"); + }) + .await; + publisher.complete(&result.1); + assert!(result.0); + assert!(finalization.wait_for_capture().await); + assert!( + finalization + .wait() + .await + .unwrap_err() + .contains("after capture stopped") + ); + } + + #[tokio::test] + async fn capture_acknowledgement_does_not_imply_preparing_sources() { + let (publisher, finalization) = channel(); + publisher.capture_stopped(); + assert!(finalization.wait_for_capture().await); + assert!(finalization.wait_for_preparing().now_or_never().is_none()); + publisher.complete(&Ok(PathBuf::from("project.cap"))); + assert!(finalization.wait_for_preparing().await.is_none()); + finalization.wait().await.unwrap(); + } + + #[tokio::test] + async fn declined_receipt_does_not_finish_post_finalize_work() { + let (publisher, finalization) = channel(); + publisher.capture_stopped(); + assert!( + publisher + .claim_preparing(&completed_without_receipt("project.cap")) + .is_none() + ); + assert!(finalization.wait_for_preparing().await.is_none()); + assert!(finalization.is_finalizing()); + assert!(finalization.wait().now_or_never().is_none()); + publisher.complete(&Err(anyhow!("post-finalize task failed"))); + assert_eq!( + finalization.wait().await.unwrap_err(), + "post-finalize task failed" + ); + } + + #[tokio::test] + async fn preparing_admission_requires_capture_acknowledgement() { + let (publisher, finalization) = channel(); + assert!( + publisher + .claim_preparing_with(&completed_without_receipt("project.cap"), |_, _| panic!( + "Capture must be acknowledged before the receipt is claimed" + ),) + .is_none() + ); + assert!(!publisher.state.borrow().preparing_decided); + assert!(finalization.wait_for_preparing().now_or_never().is_none()); + publisher.complete(&Err(anyhow!("capture stop failed"))); + assert!(finalization.wait_for_preparing().await.is_none()); + assert!(!finalization.wait_for_capture().await); + } + + #[tokio::test] + async fn wrong_project_declines_preview_without_changing_finalization_result() { + let (publisher, finalization) = channel(); + publisher.capture_stopped(); + assert!( + publisher + .claim_preparing_with(&completed_without_receipt("other.cap"), |_, _| panic!( + "A different project cannot claim this publisher's receipt" + ),) + .is_none() + ); + assert!(publisher.state.borrow().preparing_decided); + assert!(finalization.wait_for_preparing().await.is_none()); + assert!(finalization.is_finalizing()); + publisher.complete(&Ok(PathBuf::from("project.cap"))); + finalization.wait().await.unwrap(); + } + + #[test] + fn completed_and_duplicate_attempts_cannot_reopen_preparing_admission() { + let (publisher, _) = channel(); + publisher.capture_stopped(); + let observed_generation = std::cell::Cell::new(None); + assert!( + publisher + .claim_preparing_with( + &completed_without_receipt("project.cap"), + |_, generation| { + observed_generation.set(Some(generation)); + None + }, + ) + .is_none() + ); + assert_eq!(observed_generation.get(), Some(42)); + assert!(publisher.state.borrow().preparing_decided); + let cloned = publisher.clone(); + assert!( + cloned + .claim_preparing_with(&completed_without_receipt("project.cap"), |_, _| panic!( + "A duplicate finalizer cannot claim a second receipt" + ),) + .is_none() + ); + publisher.complete(&Ok(PathBuf::from("project.cap"))); + assert!( + cloned + .claim_preparing_with(&completed_without_receipt("project.cap"), |_, _| panic!( + "A completed finalizer cannot reopen preview admission" + ),) + .is_none() + ); + assert_eq!(publisher.target.generation, 42); + assert_eq!(publisher.target.project_path, PathBuf::from("project.cap")); + assert!(Arc::ptr_eq(&publisher.target, &cloned.target)); + } + + #[test] + fn completed_finalization_cannot_mint_a_late_preparing_job() { + let (publisher, _) = channel(); + publisher.capture_stopped(); + publisher.complete(&Ok(PathBuf::from("project.cap"))); + assert!(!publisher.state.borrow().preparing_decided); + assert!( + publisher + .claim_preparing_with(&completed_without_receipt("project.cap"), |_, _| panic!( + "Completed finalization cannot claim a first late receipt" + ),) + .is_none() + ); + assert!(!publisher.state.borrow().preparing_decided); + } + + #[tokio::test] + async fn lost_publisher_releases_preparing_waiters_without_success() { + let (publisher, finalization) = channel(); + publisher.capture_stopped(); + drop(publisher); + assert!(finalization.wait_for_preparing().await.is_none()); + assert!(finalization.wait().await.is_err()); + } + + #[test] + fn readiness_identity_does_not_confuse_retries_or_later_recordings() { + let (_, first) = channel(); + let (_, second) = channel(); + assert!(first.same_job(&first.clone())); + assert!(!first.same_job(&second)); + } +} + #[cfg(all(test, target_os = "linux"))] mod instant_lifecycle_tests { use super::*; @@ -3745,3 +4261,48 @@ mod recording_owned_feed_tests { assert!(app_stopped); } } + +#[cfg(test)] +mod preparing_presentation_tests { + use super::*; + + #[test] + fn supported_preparing_presentation_preserves_every_config_value() { + let project = cap_project::ProjectConfiguration::default(); + let shown = preparing_presentation_for( + &project, + crate::store::BlurMode::Off, + &cap_project::AnimatedGradientLibrary::default(), + ) + .unwrap(); + assert_eq!( + serde_json::to_value(shown).unwrap(), + serde_json::to_value(project).unwrap() + ); + } + + #[test] + fn live_blur_and_selected_gradient_decline_without_mutating_stopped_config() { + let project = cap_project::ProjectConfiguration::default(); + let original = serde_json::to_value(&project).unwrap(); + for blur in [crate::store::BlurMode::Light, crate::store::BlurMode::Heavy] { + assert!( + preparing_presentation_for( + &project, + blur, + &cap_project::AnimatedGradientLibrary::default(), + ) + .is_err() + ); + } + let library = cap_project::AnimatedGradientLibrary { + selected: true, + last_used: Some(cap_project::AnimatedGradientConfig::default()), + ..Default::default() + }; + assert!( + preparing_presentation_for(&project, crate::store::BlurMode::Off, &library).is_err() + ); + assert_eq!(serde_json::to_value(project).unwrap(), original); + } +} diff --git a/apps/desktop-gpui/src/session.rs b/apps/desktop-gpui/src/session.rs index 791fd0697b4..adae83be56c 100644 --- a/apps/desktop-gpui/src/session.rs +++ b/apps/desktop-gpui/src/session.rs @@ -23,6 +23,49 @@ pub enum Phase { Stopping, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StudioEditorPresentation { + Pending, + Attempted, + Opened(I), + Dismissed, +} + +impl StudioEditorPresentation { + pub(crate) fn suppresses_completion_open(&self) -> bool { + matches!(self, Self::Opened(_) | Self::Dismissed) + } + + pub(crate) fn closed(&mut self, window: I) { + if matches!(self, Self::Opened(current) if *current == window) { + *self = Self::Dismissed; + } + } +} + +#[derive(Clone)] +pub(crate) struct PreparingStudioEditor { + generation: u64, + pub(crate) project_path: std::path::PathBuf, + pub(crate) finalization: recording::StudioFinalization, + pub(crate) capture_stopped: bool, + pub(crate) presentation: StudioEditorPresentation, +} + +fn prepares_studio_editor( + mode: Option, + low_storage: bool, + recording_failed: bool, + editor_target: bool, + open_editor: bool, +) -> bool { + mode == Some(recording::RecordingMode::Studio) + && !low_storage + && !recording_failed + && !editor_target + && open_editor +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CountdownAdvance { Ignore, @@ -367,6 +410,7 @@ pub struct RecordingSession { /// Taken by the phase observer to honour `postStudioRecordingBehaviour` /// (`openEditor` by default) the way the Tauri app does. pub finished_studio: Option, + preparing_studio_editor: Option, /// `EditorRecordingTarget` (`src-tauri/src/windows.rs:3679-3697`): the /// open editor project a "Record a new clip" capture must land back in. /// Set by the editor's record modal (`setEditorRecordingTarget`, @@ -416,6 +460,7 @@ impl RecordingSession { controls_open: false, mic_muted: false, finished_studio: None, + preparing_studio_editor: None, editor_recording_target: None, started_at: None, paused_accum: Duration::ZERO, @@ -553,6 +598,29 @@ impl RecordingSession { self.countdown_remaining } + pub(crate) fn preparing_studio_editor(&self) -> Option<&PreparingStudioEditor> { + self.preparing_studio_editor + .as_ref() + .filter(|pending| pending.generation == self.recording_generation) + } + + pub(crate) fn preparing_studio_editor_mut(&mut self) -> Option<&mut PreparingStudioEditor> { + self.preparing_studio_editor + .as_mut() + .filter(|pending| pending.generation == self.recording_generation) + } + + pub(crate) fn take_preparing_studio_editor(&mut self) -> Option { + self.preparing_studio_editor.take() + } + + pub(crate) fn capture_stopped_for_editor(&self) -> bool { + self.phase == Phase::Stopping + && self + .preparing_studio_editor() + .is_some_and(|pending| pending.capture_stopped) + } + pub fn start(&mut self, config: StartConfig, cx: &mut Context) { if self.phase != Phase::Idle { return; @@ -564,6 +632,7 @@ impl RecordingSession { self.storage_monitor = None; self.failure_monitor = None; self.recording_generation = self.recording_generation.wrapping_add(1); + self.preparing_studio_editor = None; let generation = self.recording_generation; let countdown = crate::store::GeneralSettings::load() .recording_countdown @@ -947,18 +1016,35 @@ impl RecordingSession { let separator = if link.contains('?') { '&' } else { '?' }; cx.open_url(&format!("{link}{separator}recordingStopped=1")); } + let studio_project_path = active.project_dir.clone(); + let (studio_progress, studio_finalization) = if prepares_studio_editor( + self.mode(), + low_storage, + recording_failed, + self.editor_recording_target.is_some(), + crate::store::GeneralSettings::load().post_studio_recording_behaviour + == crate::store::PostStudioBehaviour::OpenEditor, + ) { + let (publisher, finalization) = recording::StudioFinalization::channel( + studio_project_path.clone(), + self.recording_generation, + ); + (Some(publisher), Some(finalization)) + } else { + (None, None) + }; #[cfg(target_os = "linux")] let retained_stop = active .instant_stop_handle(low_storage || recording_failed, recording_failed) - .or_else(|| active.clean_studio_stop_handle()); + .or_else(|| active.clean_studio_stop_handle(studio_progress)); #[cfg(windows)] let retained_stop = recording_failed .then(|| active.failed_stop_handle()) - .or_else(|| active.clean_studio_stop_handle()); + .or_else(|| active.clean_studio_stop_handle(studio_progress)); #[cfg(target_os = "macos")] let retained_stop = recording_failed .then(|| active.failed_stop_handle()) - .or_else(|| active.clean_studio_stop_handle()); + .or_else(|| active.clean_studio_stop_handle(studio_progress)); #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] let retained_stop: Option = None; let retains_active = retained_stop.is_some(); @@ -977,9 +1063,42 @@ impl RecordingSession { self.stopped_elapsed = Some(self.elapsed()); self.pause_control.cancel_for_stop(); self.phase = Phase::Stopping; + self.preparing_studio_editor = + studio_finalization.map(|finalization| PreparingStudioEditor { + generation: self.recording_generation, + project_path: studio_project_path, + finalization, + capture_stopped: false, + presentation: StudioEditorPresentation::Pending, + }); cx.notify(); let task = gpui_tokio::Tokio::spawn(cx, stop_future); + if let Some(pending) = self.preparing_studio_editor.clone() { + let finalization = pending.finalization.clone(); + let captured = + gpui_tokio::Tokio::spawn(cx, async move { finalization.wait_for_capture().await }); + #[cfg(target_os = "linux")] + let capture_ticket = stop_ticket.clone(); + cx.spawn(async move |this, cx| { + if !matches!(captured.await, Ok(true)) { + return; + } + this.update(cx, |this, cx| { + #[cfg(target_os = "linux")] + if !capture_ticket.is_current(this.phase, this.recording_generation, this.terminal_operation, this.recording_owner().as_ref(), retains_active) { return; } + #[cfg(not(target_os = "linux"))] + if this.recording_generation != pending.generation || this.phase != Phase::Stopping { return; } + if let Some(current) = this.preparing_studio_editor_mut() + && current.finalization.same_job(&pending.finalization) + { + current.capture_stopped = true; + tracing::info!(path = %current.project_path.display(), "Studio capture stopped; editor preparation can begin"); + cx.notify(); + } + }).ok(); + }).detach(); + } cx.spawn(async move |this, cx| { let result = task.await; this.update(cx, |this, cx| { @@ -992,6 +1111,7 @@ impl RecordingSession { Err(_) => !retains_active, }; if !capture_stopped { + this.preparing_studio_editor = None; let error = match result { Ok((_, Err(error))) => format!("{error:#}"), Err(error) => format!("Stop task failed: {error}"), @@ -2300,3 +2420,55 @@ mod pause_control_tests { assert!(!polled.load(std::sync::atomic::Ordering::SeqCst)); } } + +#[cfg(test)] +mod studio_editor_presentation_tests { + use super::*; + + #[test] + fn early_editor_only_applies_to_normal_studio_stops_with_open_editor_preference() { + for mode in [ + None, + Some(recording::RecordingMode::Instant), + Some(recording::RecordingMode::Studio), + ] { + for flags in 0..16 { + let low_storage = flags & 1 != 0; + let failed = flags & 2 != 0; + let editor_target = flags & 4 != 0; + let open_editor = flags & 8 != 0; + let expected = mode == Some(recording::RecordingMode::Studio) && flags == 8; + assert_eq!( + prepares_studio_editor(mode, low_storage, failed, editor_target, open_editor), + expected + ); + } + } + } + + #[test] + fn unsuccessful_or_raced_early_window_creation_keeps_normal_completion_open() { + assert!(!StudioEditorPresentation::::Pending.suppresses_completion_open()); + assert!(!StudioEditorPresentation::::Attempted.suppresses_completion_open()); + } + + #[test] + fn closing_a_preparing_editor_suppresses_completion_reopening() { + let mut state = StudioEditorPresentation::Opened(1); + assert!(state.suppresses_completion_open()); + state.closed(1); + assert_eq!(state, StudioEditorPresentation::Dismissed); + assert!(state.suppresses_completion_open()); + } + + #[test] + fn explicit_reopening_retains_the_new_window_when_the_old_close_arrives() { + let mut state = StudioEditorPresentation::Opened(1); + state.closed(1); + state = StudioEditorPresentation::Opened(2); + state.closed(1); + assert_eq!(state, StudioEditorPresentation::Opened(2)); + state.closed(2); + assert_eq!(state, StudioEditorPresentation::Dismissed); + } +} diff --git a/apps/desktop-gpui/src/upload.rs b/apps/desktop-gpui/src/upload.rs index f186f10c99d..95f4f2e1e69 100644 --- a/apps/desktop-gpui/src/upload.rs +++ b/apps/desktop-gpui/src/upload.rs @@ -1183,11 +1183,23 @@ async fn upload_exported_video_inner( .map_err(|error| format!("Failed to persist upload state: {error}"))?; match checked_upload_step(&cancel, || { - upload_video(&s3_config.id, &file_path, &metadata, progress, &cancel) + upload_video( + &s3_config.id, + &file_path, + &metadata, + progress, + &cancel, + meta.sharing.is_some(), + ) }) .await { Ok((link, object_identity)) => { + let link = meta + .sharing + .as_ref() + .map(|sharing| sharing.link.clone()) + .unwrap_or(link); meta.sharing = Some(SharingMeta { link: link.clone(), id: s3_config.id.clone(), @@ -1333,6 +1345,7 @@ async fn upload_video( metadata: &VideoMeta, progress: impl Fn(f64), cancel: &AtomicBool, + replace_existing: bool, ) -> Result<(String, Option), AuthApiError> { let initiate = checked_upload_step(cancel, || multipart_initiate(video_id)).await?; let is_drive = is_google_drive_upload(initiate.provider.as_deref(), &initiate.upload_id); @@ -1349,7 +1362,13 @@ async fn upload_video( return Err(AuthApiError::Other("Export cancelled".into())); } let completed_identity = checked_upload_step(cancel, || { - multipart_complete(video_id, &initiate.upload_id, &parts, Some(metadata)) + multipart_complete( + video_id, + &initiate.upload_id, + &parts, + Some(metadata), + replace_existing, + ) }) .await?; progress(1.0); @@ -1453,11 +1472,13 @@ async fn multipart_complete( upload_id: &str, parts: &[UploadedPart], meta: Option<&VideoMeta>, + replace_existing: bool, ) -> Result, AuthApiError> { let mut body = json!({ "videoId": video_id, "uploadId": upload_id, "parts": parts, + "replaceExisting": replace_existing, }); if let Some(meta) = meta && let Value::Object(object) = &mut body diff --git a/apps/desktop/scripts/stop-editor-performance.md b/apps/desktop/scripts/stop-editor-performance.md new file mode 100644 index 00000000000..48b51e0a83d --- /dev/null +++ b/apps/desktop/scripts/stop-editor-performance.md @@ -0,0 +1,712 @@ +# Stop recording to usable editor + +Status on 2026-09-10: the user stopped further testing and requested sandbox shutdown and PR publication with the remaining gaps recorded. This is **not** an all-tests-passed result. GPUI v4 native suites passed on macOS (993), Linux (1,052) and Windows (970), with two existing ignores each and passing compilation checks. macOS passed 161 editor tests, 597 recording/recovery tests, 470 Tauri tests plus binding generation; desktop frontend 473, web frontend 3,047 and tooling 64 tests passed, with both frontend TypeScript checks passing. Linux root library/integration scopes, 536 Tauri function tests and three canonical executions passed. Windows completed 1,314 scoped library tests across eight crates; its remaining protocol tests, Tauri, muxer, integration, explicit GPU, canonical, hardware and strict-lint scopes were not run after the capacity guard stopped the lane. Strict GPUI and Linux export Clippy retain unchanged baseline findings. Linux ordinary static-library packaging failed for lack of disk space despite passing direct main-binary function tests. Active test sandboxes were stopped after evidence retention. Cleanup of older stalled restore requests is recorded separately and is not claimed complete here. + +The candidate now keeps eligible preparing playback alive after successful publication while ordinary loading completes, then lets the ordinary editor adopt the existing audio generation and consumed-audio clock. Native function tests cover that ownership path, completed PCM reuse, cancellation and stale-candidate disposal. Current application Stop-to-first-audible timing and physically uninterrupted audio/image handoff remain unproven. The user will test the application manually after automated verification. Earlier screenshots and benchmarks below apply to their recorded source snapshots; no new UI run or expanded two-hour benchmark was performed for this final verification. + +## Acceptance criteria + +Measure from the actual Stop action to capture shutdown, the first presented editor frame, first audible playback, and a successful seek near the end. A window or spinner appearing is not editor readiness. + +The earlier under-500-ms p95 target is retained as historical context, not an achieved measurement or the current optimization priority. The current goal is immediate editor feedback with truthful media readiness, including multi-hour recordings. Track capture frame drops, A/V alignment, audio sample counts, output quality, CPU, memory, and disk activity alongside latency. No finalizer percentage or elapsed-time animation may stand in for a confirmed playable prefix. + +## Measurements on 2026-09-09 + +The experiments below record successive source snapshots from September 9–10. “Current” or “final” within a historical stage refers to that stage. Later implementation and verification updates are listed at the end; earlier counts and timings are not added to the final results. + +Evidence is retained locally in `.git/stop-editor-20260909`. Originals and disposable benchmark copies are separate. No customer recordings were modified. + +The GPUI runs used actual macOS screen capture at 3024 by 1964 and 60 fps. The native microphone was the MacBook Pro Microphone. The five-minute run also enabled system audio. + +| Native GPUI baseline | Capture pipelines stopped | Stop to first editor frame | +| ------------------------------------------------ | --------------------------: | -------------------------: | +| 15 seconds, screen | Not separately instrumented | 1,001.7 ms | +| 60 seconds, screen and microphone | 38.9 ms | 1,552.9 ms | +| 300 seconds, screen, microphone and system audio | 122.7 ms | 6,013.5 ms | + +A native 60-second candidate recording reached the first editor frame in 937.9 ms, with capture pipelines stopped in 59.1 ms. These native runs captured changing foreground content and are flow observations, not controlled paired percentage comparisons. GPUI's frame marker observes delivery to the editor, not a measured display scanout. + +The native Tauri screen-only driver measured 1,686.6 ms baseline and 1,684.7 ms candidate from Stop to a rendered, visible canvas followed by two animation frames. Stop command completion was 139.9 ms and 177.3 ms respectively; neither value represents editor readiness. These are separate 15-second recordings using the root workspace's unoptimized debug build, so they are not release timings and are not comparable to the GPUI build profile. + +A Tauri candidate run with both microphone and system audio also completed recording and opened a rendered editor. Its 15-second debug run measured 2,622.0 ms to the presented-frame observer. This exercises the preserved-AAC path in Tauri, but has no matched native audio baseline. + +The controlled comparison replays identical preserved recording bytes through `RecoveryManager::remux_if_needed`, alternating baseline and candidate order. Each table entry is the median of three runs. Both executable versions use the GPUI development workspace's optimized dependencies. These are component benchmarks, not packaged release or full Stop-to-editor benchmarks. + +| Shared finalization fixture | Baseline | Candidate | Reduction | +| --------------------------------------------- | ----------: | ----------: | --------: | +| Native screen recording, 14.9 seconds | 548.1 ms | 534.2 ms | 2.6% | +| Native screen and microphone, 60 seconds | 1,177.9 ms | 850.5 ms | 27.8% | +| Two-hour replay, video and AAC audio, 2.40 GB | 47,616.9 ms | 19,499.9 ms | 59.0% | + +The two-hour fixture contains actual encoded screen and microphone media replayed to two-hour timestamps. It is not a two-hour capture or hardware soak. Its video is 1280 by 832 at 30 fps. It excludes long cursor and keyboard histories. Three samples do not establish p95 or p99 performance. + +An earlier overlapping run reported 18.1 seconds inside finalization and 19.6 seconds for the whole component call. Use the later alternating comparison above for the candidate assessment. + +## Initial candidate changes, September 9 + +- Preserve single-track AAC media by remuxing it to M4A instead of decoding it and encoding it again as Ogg. Detect the actual codec; old files with misleading extensions retain the existing conversion path. +- On macOS, attempt an independent copy-on-write staging clone, then use the existing exclusive copy fallback. This is not a hard link. Other operating systems retain the copy path. +- Hash separate files with at most one additional scoped worker when the recovery inputs total at least 32 MiB and multiple CPUs are available. Keep full byte hashes, directory entries, symlink rejection, and the serial fallback if a worker cannot be created. +- Reuse the editor audio resampler's output allocation across packets. Restore the requested sample capacity for every conversion and flush; keep the same decoder, resampling configuration, samples, and range behaviour. +- Read fragmented video through a seekable concatenated input when creating a new finalized output. Avoid writing and rereading a recording-sized temporary aggregate. Existing destinations retain the original aggregate path because they can alias an input. +- Add stage timing and opt-in native benchmark drivers. Retain source snapshots, staged validation, source rechecks, synchronization, transactional publication, and preserved originals. + +The recovery unit suite passed 58 tests. The integration suite passed 50 tests, with one existing ignored test. Added coverage verifies decoded AAC samples, microphone/system-audio offsets and device metadata, preserved original bytes, independent cloned files, exclusive destination creation, and large-file changes with restored timestamps. The audio unit suite passed 42 tests, including bit-exact comparisons against fresh resampling buffers at 16, 44.1, 48 and 96 kHz with one, two and six source channels and varying packet sizes. These results do not establish platform or release parity. + +Scoped Rust checks passed for `cap-audio`, `cap-recording`, `cap-desktop`, and `cap-desktop-gpui`. Rust formatting and the benchmark JavaScript's Biome check passed. Biome did not process Markdown, so this document was checked manually. Native baseline/candidate Tauri builds and the GPUI candidate build completed. + +## Further matched optimization benchmarks + +The bounded hashing experiment compares the preceding AAC/clone candidate with the same candidate plus concurrent hashing. Each result is the median of three alternating runs on identical preserved bytes, with builds and capture runs stopped. Evidence is in `optimization-stage-2/results.json`. + +| Shared finalization fixture | Previous candidate | Concurrent hashing | Reduction | +| ---------------------------------------- | -----------------: | -----------------: | --------: | +| Native screen recording, 14.9 seconds | 559.6 ms | 528.0 ms | 5.6% | +| Native screen and microphone, 60 seconds | 950.4 ms | 843.5 ms | 11.2% | +| Two-hour replay, video and AAC audio | 19,943.9 ms | 15,329.0 ms | 23.1% | + +An instrumented long run still spent about 1.87 seconds validating staged inputs, 4.13 seconds finalizing video, and 1.81 seconds validating output tracks. Those validations remain. The entire component call took 15.58 seconds in that run. These results do not demonstrate duration-independent stopping or p95 performance. + +The resampler allocation experiment compares identical audio bytes through the full production audio loader, using five alternating runs per variant. Hashing the decoded samples happens after the timed load. Every before/after run matched both decoded byte count and SHA-256. Evidence is in `optimization-stage-3/results.json`. + +| Full audio loading | Fresh buffers | Reused buffers | Reduction | +| ---------------------- | ------------: | -------------: | --------: | +| Native AAC, 60 seconds | 19.47 ms | 17.97 ms | 7.7% | +| Legacy Ogg, 60 seconds | 56.29 ms | 55.02 ms | 2.2% | +| AAC replay, two hours | 2,063.26 ms | 1,988.66 ms | 3.6% | + +The long track still allocates 1,382,703,104 bytes for decoded samples. Buffer reuse reduces temporary allocation work; it does not implement bounded audio loading. The full decoded SHA-256 for that track was `5c5c67688b348b9e4d0e54a42e5021c630a7ab8c49c078c892d620e8f7fcd8c9` in both variants. + +The combined candidate completed fresh native screen, microphone and system-audio recordings in GPUI and Tauri. GPUI's 60-second run stopped its capture pipelines in 120.3 ms and delivered the first editor frame in 1,254.5 ms. Its five-second playback run reported 300 frames at 59.8 fps with zero dropped frames. Tauri's 15-second run reported stop-command completion in 329.2 ms and a rendered-editor marker at 2,081.6 ms. These are individual development-build flow checks, with changing capture content and different build profiles; they do not establish a matched native speedup or p95. + +## Avoiding the temporary combined video + +The finalized output remains the same progressive MP4. The clean finalization path now supplies the original fragment bytes through FFmpeg's seekable custom-input interface instead of writing an intermediate concatenated file. The reader uses a 64 KiB buffer and at most two source file handles during a file transition. It retains read failures even if the demuxer subsequently reports EOF. Custom I/O lifetime and buffer ownership follow the [FFmpeg example](https://ffmpeg.org/doxygen/trunk/avio_read_callback_8c-example.html). + +Existing output files retain the previous implementation. This preserves overwrites where the output aliases an input, including hard links; opening that destination directly while reading fragments could otherwise truncate its own input. Interrupted-recording recovery retains its established full-validation path. + +The final candidate, including the overwrite fallback, was compared with the previous concurrent-hashing candidate using three alternating runs per fixture. Temporary benchmark copies were removed after each completed subprocess; the original fixtures, logs and binaries remain. The last long candidate output was retained for native playback verification. Evidence is in `optimization-stage-4/guarded-results.json`. + +| Shared finalization fixture | Previous candidate | Seekable fragment input | Reduction | +| ---------------------------------------- | -----------------: | ----------------------: | --------: | +| Native screen recording, 14.9 seconds | 528.35 ms | 489.48 ms | 7.4% | +| Native screen and microphone, 60 seconds | 763.30 ms | 678.32 ms | 11.1% | +| Two-hour replay, video and AAC audio | 15,245.86 ms | 13,593.52 ms | 10.8% | + +These remain component medians in development builds, not p95 or full Stop-to-editor measurements. All three final media sets matched the preceding candidate byte for byte in a retained comparison, including the complete MP4 containers. Recording metadata and project configuration also matched. Evidence is in `optimization-stage-4/media-equivalence.json` and `metadata-equivalence.json`. + +The encoder suite passed 112 tests, including concatenated read/seek equivalence, large seek offsets, truncated and missing fragments, sticky I/O failures, exact packets/timestamps, and existing-output aliases. Scoped clippy passed with warnings denied. Recovery checks passed 58 unit tests and 50 integration tests, with one existing ignored integration test. Root and GPUI application checks and native builds passed. + +With the final candidate, a fresh native 60-second GPUI recording with microphone and system audio stopped its capture pipelines in 110.45 ms and delivered the first editor frame in 923.81 ms. Playback from an explicit 30-second seek reported 300 frames at 60.0 fps with zero dropped frames. A native Tauri recording with the same track types, lasting 15 seconds, reported stop-command completion in 285.06 ms and a rendered-editor marker at 1,686.22 ms in its unoptimized debug build. The retained two-hour output also reopened in GPUI, sought explicitly to 7,194.5 seconds, and played for five seconds at 60.0 fps with zero dropped frames. These are individual native flow observations, not controlled percentage comparisons or p95 measurements. Evidence is in `optimization-stage-4/native-results.json`. + +The guarded candidate's retained two-hour media files, recording metadata and project configuration also matched the prior candidate byte for byte. See `optimization-stage-4/guarded-equivalence.json`. + +One native driver run initially reused the stage 3 GPUI stdout filename. The completed new log was moved to stage 4, and the original stage 3 events were recovered from the independently written daily diagnostic log. This recovered excerpt is named `optimization-stage-3/gpui-mic-system-60.diagnostic.log`; provenance and the distinction from the overwritten stdout stream are recorded in `optimization-stage-4/log-recovery-provenance.json`. Subsequent native log creation is exclusive. Recording media was unaffected. + +## Direct media experiment + +A separate two-hour fragmented MP4 was created from the replay's encoded video without re-encoding. Compressed video payload SHA-256 matched the progressive input: + +`b194eecf662b211a05c0f55724ad31a2425cb1d1d3dca172e4760add6234a95a` + +The production decoder was then opened directly on that file: + +| Decoder on macOS | Probe and first frame | Seek at 3,600 seconds | Seek at 7,199 seconds | +| ---------------------- | --------------------: | --------------------: | --------------------: | +| AVAssetReader hardware | 440.4 ms | 46.6 ms | 25.6 ms | +| FFmpeg hardware | 163.9 ms | 51.7 ms | 21.8 ms | + +Neither required a post-recording remux for reading. This tests already-created media and existing decoders, not a new recorder, editor UI presentation, crash recovery, Windows Media Foundation, or Linux hardware/software decoding. + +The same fragmented two-hour file was also opened in the actual GPUI editor and played for five seconds. The app reported 301 rendered frames, 60.0 fps, and zero dropped frames. Audio output and the microphone waveform loaded. The initial `CAP_GPUI_AUTO_SEEK=0.999` run was incorrectly interpreted as a seek near the end of the recording: that driver targets the visible timeline viewport, which can show only the beginning of a long recording. It proves native playback but not native tail playback. The direct decoder and trim/export experiments use explicit timestamps and are unaffected by this correction. + +The corrected driver explicitly sought to 7,194.5 seconds, then played for five seconds in the native GPUI editor. Both the progressive output from the concurrent-hashing candidate and the fragmented media experiment reported 300 frames at 59.9 fps with zero dropped frames. Logs confirm the exact requested time. Evidence is in `optimization-stage-3/native-tail-results.json`; this verifies tail playback on macOS, not a two-hour native capture or a p95 latency. + +The current Rust exporter successfully rendered a three-second trim from the preserved-AAC candidate and another from 7,195–7,198 seconds of the fragmented two-hour project. Each output contained 90 H.264 frames and a three-second AAC track. Both outputs fully decoded without errors. This is short trim/export coverage, not a complete multi-hour export or visual/aural equivalence test. + +After the resampler change, both trims were exported again with the rebuilt exporter and identical settings. Full decoded video and audio SHA-256 hashes matched the preceding candidate's exports in both cases, with no decode errors. Evidence is in `optimization-stage-3/export-validation.json`. + +Full audio loading still took approximately 2.05 seconds and the process reached approximately 1.46 GB RSS. A trial using the existing range decoder failed a sample-count comparison near the end of the long replay. Range decoding is not enabled for recorded-track playback by this change. Timestamp discontinuities, AAC priming/padding, and resampling alignment must be resolved before introducing bounded audio loading. + +The two-second audio-window experiment took approximately 5 ms per window and used 384 KB of decoded samples, but middle and end windows did not match the full reference on the replay. The same experiment matched every sample at the beginning, middle and end of the original native 60-second AAC track, at 1.2–1.5 ms per window. This distinguishes feasibility from correctness across timestamp discontinuities; it is not a passing long-recording result. + +## Longer-term implementation direction + +These are design goals, not a claim that arbitrary indexed seeking, bounded total PCM storage or the full recording matrix has been implemented and verified. + +The accepted experience is to open the editor as soon as capture has safely stopped and make the beginning playable while remaining preparation continues. A subtle timeline boundary should represent the actual contiguous range ready across display, camera, microphone and system audio, accounting for track offsets. Progress must not be estimated from elapsed time. Playback reaching the boundary must wait without losing audio, advancing the media clock, or presenting incomplete output as complete. Seeking into an unprepared range should prioritize that range or show preparation while preserving the requested position. Full export and recovery requirements still apply. + +Produce durable, indexed, editor-readable media during capture. Keep the original encoded media in stable locations. On a clean stop, drain every requested track, flush the remaining media and event tails, commit the final index and project metadata, and open the editor directly on those originals. Whole-recording copying, hashing, transcoding, and remuxing must not remain prerequisites to editing. + +Use bounded audio decoding with a small look-ahead buffer and an index that preserves the current sample timeline. Seeking must work anywhere in a long recording without decoding everything before it. Failure to load requested audio must not become a silent successful playback result. + +Build cursor/keyboard indexes, thumbnails, and waveform data incrementally. The current stop path serializes complete cursor histories, so the media-only two-hour replay is insufficient to prove that Stop is independent of duration. GPU resources and the editor shell can be prepared during recording; this must be measured for capture contention and frame drops. + +Keep clean-stop publication separate from interrupted-recording recovery. A failed drain or incomplete tail must retain the existing failed/recoverable state. Old recording formats continue through their established reader and recovery paths. No crash-recovery validation is removed to obtain a faster timing. + +Background jobs must use immutable inputs and must not rename, replace, or delete media underneath an open editor or export. Closing or reopening the app must preserve both the recording and saved edits. Optional consolidation for external compatibility can operate on a separate output when required. + +## Progressive audio feasibility + +The existing sequential `AudioStream` decoder was compared with the current full `AudioData` decoder over the native 60-second AAC track, its legacy Ogg output, and the two-hour AAC replay. Every chunk's channel count, cumulative sample position and sample bits matched the full reference through EOF. The two-hour decoded SHA-256 remains `5c5c67688b348b9e4d0e54a42e5021c630a7ab8c49c078c892d620e8f7fcd8c9`. + +Across three runs, the median time to open and decode the first 250 ms was 0.61 ms for native AAC, 0.74 ms for legacy Ogg, and 4.43 ms for the two-hour AAC replay. The largest returned chunk was 48,000 bytes for these mono tracks. These are warm component observations: full decoding ran first in each process, and sample comparison/hash work is excluded from the accumulated streaming decode time. They do not measure the audio device, editor startup, cold storage, or a production progressive playback path. Evidence is in `progressive-audio-probe/results.json`. + +Use `CAP_BENCH_STREAM_AUDIO=1 CAP_BENCH_FULL_AUDIO_ONLY=1` with the audio readiness driver to reproduce this comparison. This sequential decoder avoids the timestamp-to-sample seek mismatch found by the earlier range experiment. It does not yet provide arbitrary seeking or a bounded editor cache. Existing export code also uses `AudioStream`; at this feasibility stage, editor playback still waited for whole-track `AudioLoader` completion. + +The same three fixtures were also tested on native Linux, three runs each. Every streamed sample matched the full decoder on that platform. Median first-chunk times were 0.82 ms for native AAC, 1.02 ms for legacy Ogg and 12.31 ms for the two-hour replay, with the same 48,000-byte maximum chunk size. These use the same warm component methodology and do not establish editor startup time. Decoded sample hashes differ between macOS and Linux builds; equivalence is checked against each platform's own full decoder. Linux evidence is in `daytona/long-audio-results.json`. + +## Direct fragmented-video decoding + +The FFmpeg directory reader previously read every fragment into one allocation, wrote a temporary combined file, and opened that file. It now owns the same seekable virtual input already used by remuxing. Opening retains paths, lengths and one active file handle instead of copying all encoded media. Custom I/O failures remain errors through packet reads and seeks, including when FFmpeg interprets a truncated input as EOF. The input context is destroyed before its custom I/O buffer and callback state. + +Shared video metadata loading also accepts fragment directories. On macOS, directories select the existing FFmpeg hardware decoder directly; ordinary files retain their existing AVAssetReader selection. Ordinary project readiness, failure and recovery guards are unchanged. At this stage, direct reading was available, but neither editor yet opened unfinished recordings or moved finalization into the background. + +Matched debug-profile decoder runs alternated old and new readers, three runs per variant and fixture. macOS tested both software and actual VideoToolbox decoding; Linux tested software decoding. Each run compared 30 decoded frames at the beginning, middle, near the end and after a backward seek. Visible pixel bytes, timestamps, frame counts and requested hardware selection matched in all 36 macOS runs and all 12 Linux runs. This is sampled frame equivalence, not a complete two-hour decode comparison. A separate generated multi-fragment recording test compares every returned frame and timestamp through EOF and after four seeks. + +| Platform and fixture | Decoder | Old first frame median | Direct first frame median | Old / direct peak RSS median | +| ----------------------------------- | ------------ | ---------------------: | ------------------------: | ---------------------------: | +| macOS, native screen 15 seconds | Software | 106.31 ms | 64.93 ms | 124.86 / 73.60 MB | +| macOS, native screen 15 seconds | VideoToolbox | 135.26 ms | 125.55 ms | 156.04 / 103.79 MB | +| macOS, native microphone 60 seconds | Software | 238.71 ms | 69.34 ms | 205.52 / 71.55 MB | +| macOS, native microphone 60 seconds | VideoToolbox | 269.64 ms | 130.43 ms | 236.54 / 104.12 MB | +| macOS, two-hour replay | Software | 1,916.36 ms | 123.47 ms | 2,307.05 / 57.29 MB | +| macOS, two-hour replay | VideoToolbox | 1,463.52 ms | 190.70 ms | 2,319.83 / 67.99 MB | +| Linux, native screen 15 seconds | Software | 136.11 ms | 105.14 ms | Not measured | +| Linux, native microphone 60 seconds | Software | 183.55 ms | 109.91 ms | Not measured | + +Timing starts before decoder construction and ends at the first decoded frame, excluding frame hashing. Inputs are warm and builds finish before measurement; three runs do not establish p95 or cold-storage performance. Memory uses decimal MB. Linux two-hour video decoding and Linux hardware decoding were not measured in this comparison. + +With the final shared metadata/decoder integration, the GPUI media-readiness driver also read the original fragment directories directly. Metadata loading plus decoder setup plus first frame took 171.80 ms for the 15-second recording, 171.79 ms for the 60-second recording and 264.44 ms for the two-hour replay. All four seek requests returned frames in each case, and durations matched the finalized-file controls. These are single component runs, without an editor window, GPU composition, audio readiness or Stop. Ordinary finalized-file controls continued selecting AVAssetReader hardware decoding. + +Evidence is retained in `optimization-stage-5/macos-results.json`, `optimization-stage-5/macos-shared-media-results.json`, and `daytona/stage5/`, with build logs, source manifests and binary hashes. The matched decoder binaries precede the helper's public visibility change and shared metadata integration; the shared-media driver and final checks include those additions. + +Final macOS validation passed 114 encoder/remux tests, two decoder tests and 235 rendering tests. Linux passed 118 encoder/remux tests, two decoder tests and 209 rendering tests. Each rendering suite retained five existing ignored tests. Scoped checks and all-targets Clippy with denied warnings passed on both platforms using the repository's Rust 1.88 toolchain; the GPUI dependency check and shared-media driver build also passed using stable Rust. Linux initially lacked the Clippy component; it was installed before the successful run. A separate stable-Rust Clippy attempt reported newly introduced lints in five unchanged encoder tests, so that toolchain result is retained separately from the passing pinned-toolchain checks. + +The final macOS binary also finalized another two-hour disposable project in 13,609 ms. Its complete video, AAC audio, recording metadata and project configuration were byte-identical to the preceding guarded candidate. This confirms the shared reader ownership change preserves those outputs; it is a single regression run, not evidence of further finalization speedup. Evidence is in `optimization-stage-5/finalization-replay-2h.json`. + +## Required regression matrix + +Run the same fixture and native-action drivers in Tauri and GPUI on macOS Intel/Apple Silicon, Windows hardware/software decoder configurations, and Linux X11/Wayland with the supported capture and decoder paths. + +Cover short, 5-minute, 1-hour, 2-hour and 8-hour recordings; screen-only, webcam, microphone and system audio; pause/resume, device changes and dropped-input gaps; first and last frames, A/V sync, sample counts, trim/export/reopen; and repeated stop/start cycles. + +Inject interruption at media flush, index persistence and metadata publication. Also cover encoder errors, truncated media, a full disk, unavailable output storage, and app close/reopen while ancillary work is running. Originals and diagnostics must survive every failure. Exercise Windows file-sharing restrictions on actual Windows. + +Use matched release-equivalent builds, fixed visible capture content, identical settings, repeated cold and warm runs, and concurrent capture-load measurements. Record exact revisions and binary hashes. Any quality, correctness, recovery, or supported-platform regression blocks enabling the new path. + +## Daytona function validation + +macOS validation ran alongside the Daytona work using the local Apple Silicon build and locked dependencies with Rust stable 1.98.1. The first eight suites passed 864 tests with zero failures: encoder/remux (112), audio (42), recording (477), recovery integration (50), Instant Mode scenarios (61), editor (70), export (32) and GPUI recording/stop (20). There were three existing ignored tests. The project suite added 126 passes, and the A/V matrix added four passing test functions. The first matrix run reported 41 passing scenarios, but one random scenario skipped its video timing check after a 1.19-second runner stall; its audio check passed. A same-seed repeat also skipped that video check after a 1.10-second runner stall, with all other checks passing. Thus 40 scenarios have complete checks and one has audio-only verification. These skipped checks are not counted as verified video behavior; both reports remain retained. Full logs and commands are retained in `macos-function-validation/`. Platform-conditional tests account for differing macOS and Linux suite counts. + +The Linux candidate ran on Debian 13 x86_64 in a Daytona container with 4 vCPUs, 8 GiB RAM and a 10 GiB writable filesystem. The source archive contains HEAD `0c403be8a57f7ba4de67140273708a392c8b4050` plus the local candidate; every source file was hashed before upload. Tests used Rust 1.88.0, the committed dependency lockfile, and the repository's FFmpeg/native dependencies installed by `scripts/setup.js`. Debug symbols and incremental compilation were disabled to fit the sandbox. These are native Linux function tests, not UI, physical capture, or release latency tests. + +| Suite | Passed | Failed | Ignored | +| ----------------------------------------- | -----: | -----: | ------: | +| Encoder/remux library | 116 | 0 | 0 | +| Audio library | 42 | 0 | 0 | +| Project library | 126 | 0 | 0 | +| Recording library on container filesystem | 629 | 3 | 2 | +| Recovery integration | 50 | 0 | 1 | +| Instant Mode scenarios | 61 | 0 | 0 | +| Editor library | 71 | 0 | 0 | +| Export library | 32 | 0 | 0 | +| A/V sync matrix tests | 4 | 0 | 0 | +| Transactional recovery subset on tmpfs | 28 | 0 | 0 | + +The A/V matrix reported 41 of 41 scenarios passing with no skipped scenarios. Do not add the tmpfs subset to the other rows as if it contained distinct tests. + +Three interrupted-publication/rollback cases failed in the candidate's initial recording-library run. Rebuilding with the five modified shared source files replaced by their unchanged HEAD versions also produced recovery failures, although the failing cases varied. Temporary diagnostic instrumentation captured a directory's reported size changing from 21 to 29 during rename; its entry names, file metadata and Unix identities matched. The existing receipt digest includes directory size, so reconciliation conservatively rejected the state and retained the files. The same candidate transactional group passed all 28 tests on tmpfs. No production guard was relaxed to obtain that pass. At that snapshot, the container-filesystem issue remained an inherited compatibility limitation, not a fully green suite or a reason to omit the failures. A later guarded recovery-stamp fix is recorded in the final verification section. Candidate source hashes were checked after both baseline comparison and temporary instrumentation were restored. + +The native 15-second screen and 60-second microphone recordings were then finalized on Linux using matched aggregate-input and virtual-input binaries. All other candidate optimizations stayed identical. Builds completed before measurement, the variant order alternated, and each fixture ran three times per variant. All 12 runs succeeded with byte-identical finalized video, audio, recording metadata and project configuration. Each retained original segment tree also matched its input hash manifest, and the source fixtures stayed unchanged. + +| Linux finalization fixture | Aggregate input median | Virtual input median | +| ----------------------------- | ---------------------: | -------------------: | +| Native screen, 15 seconds | 745.10 ms | 723.92 ms | +| Native microphone, 60 seconds | 1,089.72 ms | 1,068.34 ms | + +These are modest observed component improvements on a container with warm inputs, using debug-profile builds with debug symbols disabled. Three runs do not establish p95 or a statistically reliable speedup. They exclude capture stop, editor rendering and playback, and do not measure Linux two-hour finalization. Full output hashes, binary hashes, logs and restored source hashes are retained in `daytona/finalization/`. + +Windows creation initially returned a tier-access denial through the API and authenticated dashboard despite the earlier support-confirmed grant. Access subsequently became available on September 10, and the user requested parallel validation on all three systems. The earlier errors and Linux function evidence remain under `daytona/`; the new Windows results are recorded below. + +## Shared fixture matrix and progressive audio, September 10 + +Three platform agents ran concurrently. All used the same immutable unfinished `.cap` inputs, reconstructed from a common chunk archive and verified by path, byte length and SHA-256 before testing. Finalization operated on separate disposable copies. + +| Canonical input | Files | Bytes | +| ----------------------------- | ----: | ------------: | +| Native screen, 15 seconds | 19 | 46,099,193 | +| Native microphone, 60 seconds | 40 | 121,129,994 | +| Two-hour encoded replay | 3,388 | 2,396,017,929 | + +The final shared runtime candidate is `stage6-32768-track-mixer-v3`. Each platform verified all 4,208 source hashes in `platform-matrix-20260910/candidate-v3-manifest.json`. Its overlay archive SHA-256 is `4c6ca640b2879dbebde40d22cbee12c34e6b299345fbac2b9db600618969b61b`. Earlier v2 media runs remain applicable to the unchanged finalizer and decoder. V3 changes only defensive integer arithmetic in the mixer; affected suites and audio hash checks were rerun on every platform. Subsequent benchmark-driver, documentation and GPUI shell changes are separate from this frozen runtime identity. + +### Runtime changes and correctness + +Recorded audio now decodes sequentially into immutable blocks of 32,768 sample frames. Consumers can request a ready window while full-track consumers await a separate completion notification. Completion reuses the blocks without making another full PCM copy. Waveforms iterate those same samples in their original order. The normal playback path still awaits complete recorded audio, so these window results do not yet establish early audible playback. + +The common mixer now handles one track at a time, hoists unchanged gain/channel calculations out of the per-sample loop, and reads contiguous slices where available. It retains the original track accumulation order, offsets, stereo modes and clamp behavior. A comparison against the original algorithm covered 3,024 cases and 2,007,936 output bytes on macOS and Linux. All matched exactly. A separate extreme-cursor probe found an overflow in the slice fast path; v3 fixes it with checked arithmetic and retains the original silent result. The regression test runs on all three systems. + +The macOS codec-failure matrix compared empty files, header prefixes, half truncation, final-byte and final-1,024-byte truncation, and a payload-bit change for both native AAC and legacy Ogg. All 12 old/new cases agreed: six successful pairs with identical full decoded samples, and six matching failure pairs. Original inputs stayed unchanged. + +| Final v3 affected suites | Audio | Editor | Export | Failed | +| ------------------------ | ----: | -----: | -----: | -----: | +| macOS | 51 | 70 | 32 | 0 | +| Linux | 51 | 71 | 32 | 0 | +| Windows | 55 | 70 | 30 | 0 | + +Scoped checks and all-targets, no-dependency Clippy passed for the affected shared crates on all three systems. The Windows native matrix totals 1,187 distinct passing tests with zero failures and eight existing ignored tests, including unchanged v2 encoder, decoder, project, recording, recovery, Instant and rendering suites. Windows used Server 2025 x64, Rust 1.88, MSVC and the repository's native dependencies. These headless results do not test physical Windows capture, a GPU decoder or its desktop UI. The Linux dependency-lint and container-filesystem failures described above remain separate unresolved baseline limitations. + +### Matched audio benchmarks + +Both sides use the same current-thread Tokio runtime and include worker startup and completion notification in their decode timer. The baseline reproduces the old `spawn_blocking` plus `Arc`/watch loader. Runtime construction, full PCM hashing and mixing happen outside the decode timer. Each mixing repetition has a `black_box` barrier. The corrected shared evidence harness SHA-256 is `bf526600f10feccf2186d186309ffb6cea2d7b42ea0d96e7ef6e6c98c96082d1`. + +These are three alternating pairs using development builds with optimization level 2. macOS retains debug symbols; Linux and Windows omit them. Compare each platform against its own baseline. Older direct-main-thread baseline measurements and unoptimized Linux measurements are retained for provenance but are not substituted into this comparison. + +| Two-hour AAC component | macOS | Linux | Windows | +| ----------------------------------- | ----------: | ----------: | ----------: | +| Old background loader, entire track | 1,943.59 ms | 3,287.10 ms | 2,526.91 ms | +| Block loader, entire track | 1,956.39 ms | 3,241.20 ms | 2,432.86 ms | +| First 250 ms of audio available | 4.97 ms | 16.35 ms | 13.57 ms | + +Every paired full PCM count/hash and mixed-output hash matched within its platform. The entire macOS decode was 0.66% slower in this small sample. Peak process RSS was 1,422.43 MB before and 1,434.35 MB after, an observed increase of 11.93 MB. The roughly 1.38 GB decoded source still remains resident; this is not bounded-memory loading. A 48,000-frame block experiment increased RSS by about 45 MB and was rejected. The extra 11.93 MB peak RSS is an explicitly accepted tradeoff. The full-decode timing difference remains visible; neither result is presented as proof of zero regressions. + +On native AAC, the original renderer versus the new block renderer mixed 60 seconds in 7.20 versus 1.21 ms on macOS, over five alternating pairs. On Linux the equivalent two-hour-source experiment measured 28.25 versus 1.80 ms over three pairs. Both matched original PCM and mixed-output hashes. The macOS evidence embeds the original helper in the driver; Linux rebuilds the original function in the library, so their ratios are not cross-platform comparisons. With the same optimized renderer on both storage types, blocks have a small access cost: 1.14 versus 1.23 ms on macOS and 1.72 versus 1.83 ms on Linux. + +Windows ran all 18 audio comparisons over native AAC, legacy Ogg and the long AAC replay with exact full PCM and mixed-output matches. Its short full-load medians were 24.71 versus 24.11 ms for AAC and 68.53 versus 67.49 ms for Ogg; first decoded windows arrived at 3.42 and 2.96 ms. Its baseline library restores the original mixer body, matching the Linux original-renderer method. Mixing 60 seconds from the long track measured 25.67 versus 1.42 ms, outside the decode timer. Windows process peak memory and CPU were not collected; decoded-buffer size is not a substitute for those metrics. + +### Full project media checks + +The updated Linux matrix ran 18 paired decoder probes across all three inputs. Decoded frame pixels and timestamps, including middle and tail seeks, matched. First-frame medians for aggregate versus direct input were 137.30 versus 105.08 ms for 15 seconds, 193.86 versus 111.62 ms for 60 seconds, and 2,665.91 versus 112.35 ms for two hours. These builds used the root workspace's unoptimized development profile; they exclude stop, GPU rendering and presentation. + +Twelve paired Linux short-project finalizations succeeded with exact output and retained-source checks. The two-hour candidate finalized in 13,317.68 ms on Linux and 14,388.35 ms on macOS in single regression runs. Each retained all 3,386 original segment files exactly. Published video, audio, project configuration and recording metadata matched across these platforms. All public cursor images/events and keyboard files were included; unordered JSON object keys were compared semantically where serialization order differed. There is no new matched two-hour Linux finalization baseline in this matrix. + +The finalized two-hour recording also played in the native macOS GPUI editor after an explicit seek to 7,194.5 seconds: 300 frames over five seconds at 59.8 fps, with zero dropped, skipped or starved frames. Logs confirm the real MacBook Pro Speakers output and no mute override. Audible A/V offset was not independently measured. + +Windows passed all 18 paired software-decoder probes and all 14 finalization runs. It used a two-vCPU VM and the unoptimized root development profile. First-frame medians were 188.95 versus 93.14 ms for 15 seconds, 315.83 versus 96.61 ms for 60 seconds, and 7,188.98 versus 628.15 ms for two hours. The three long candidate observations ranged from 190.19 to 650.28 ms, so the median alone must not be used as a latency guarantee. + +Windows short finalization medians were 945.44 versus 926.54 ms for 15 seconds and 1,414.75 versus 1,247.67 ms for 60 seconds. Its single long pair measured 42,940.06 versus 38,389.64 ms. All paired published segment bytes and parsed metadata/configuration matched; original segment files and original metadata/configuration remained exact. Supplementary untimed checks also compared every public output against macOS, including cursor images and normalized metadata/configuration, for all three fixtures. The long result reinforces that finalization is still too slow to gate a responsive editor. + +Evidence is retained in `macos-validation-20260910/`, `daytona/linux-validation-20260910/` and `windows-validation-20260910/`. + +The next integration must preserve source reads across publication, gate editor writes during publication, and prevent the audio/video clock from advancing past unprepared samples. No source validation, finalization requirement or recovery guard has been removed to get the component timings above. + +### Rejected Windows preview ownership proposal + +An evidence-only source-directory lease synchronized file opens with directory relocation. All five standalone tests passed on macOS and Linux, but Windows passed four and rejected the held-handle relocation with `ERROR_ACCESS_DENIED (5)`. The current FFmpeg `AudioStream` likewise blocked parent-directory rename while open, continued to produce exact samples, and released the restriction when dropped. Explicit `FILE_SHARE_DELETE` did not fix the directory rename, although renaming an individual shared file worked. + +This agrees with Microsoft's documented restriction on renaming a directory containing open files. The replacement-file POSIX flag does not establish a workaround for that directory rule. See [FILE_RENAME_INFORMATION](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information). The prototype was never integrated into the application. Its failure rejects that proposed mechanism; it does not change the passing v3 media results. Production publication semantics remain unchanged. + +The next design must either keep preview sources in a stable location throughout publication or explicitly release every affected reader while preserving buffered playback and exact decoder state. A path redirect alone is insufficient. Evidence includes `optimization-stage-7/source-lease-probe.rs`, the per-platform results, and Windows `rename-probe.log`/`source-lease-tests.log`. + +A second standalone prototype registers cached file handles with the source owner. Before relocating the directory, it closes all registered handles under an exclusive source lock; subsequent reads reopen at the preserved byte position. The identical `revocable-source-probe.rs`, SHA-256 `d62a69df330dff6d1fba36bf37e453f429552422c7a5245efed85dc15f3f8008`, passed all seven tests on macOS, Windows and Linux with Rust 1.88, optimization level 2, overflow checks and denied unused results. Coverage includes four simultaneous readers through 600 moves, successful and failed publication, rollback, seek positions, and changed-length rejection. This supports the filesystem mechanism only. FFmpeg/AVIO integration, exact decoded-output comparisons, publication latency and playback buffering are still required. + +## GPUI early loading shell + +This early shell change opened the existing `Loading project...` editor after a successful, generation-matched capture acknowledgement. `Phase::Stopping` and its detached task still own finalization and shutdown. Linux first completes the existing capture-visibility restoration protocol. At this shell-only stage, preflight, media readers, editing and saving remained behind successful full finalization, including the existing thumbnail and project configuration work. + +Presentation is tracked by recording generation, finalization job and actual window ID. Closing a preparing editor suppresses automatic reopening; explicitly reopening it uses the same readiness gate. Completion races and window-creation failures retain the existing fallback. Failed tracks, low storage, Instant recordings, non-editor preferences and in-editor recording targets keep their existing paths. + +The review found and fixed a camera-preview cleanup omission in the initial shell change. Finalization completion now parks the feed even though it no longer reopens or refocuses the editor. GPUI's binary test target also exposed a Stage6 test compilation regression: the waveform refactor removed a constant still used by two tests. The constant is restored inside the test module; runtime audio remains unchanged. + +macOS GPUI package check and 128 targeted tests passed: recording 45, session 15, app windows 26, timeline 42. Native extraction harnesses ran the same relevant production function bodies with scaffolding on Linux (35 passed) and Windows (22 passed plus check). They cover readiness, dismissal/reopen, stop outcomes, terminal ownership and restoration acknowledgements. They do not execute a real GPUI observer, compositor or typed capture device. Full native GPUI checks outside macOS were still pending at this shell-only stage; later frozen app suites are recorded below. + +Strict stable-toolchain GPUI Clippy with tests reported 30 issues in other code regions, including new `chunks_exact` recommendations and existing documentation/argument-count issues. No warning was suppressed, and no passing full-application Clippy result is claimed. The scoped shared-runtime checks described above remain separate. + +The first native shell attempt caught a panic in newly added timing instrumentation: `request_animation_frame` is invalid outside GPUI's render phases. That run is rejected and its isolated source and logs retained. A disposable copy recovered successfully, preserving all 36 original source files exactly and producing a complete decodable video. The instrumentation now uses the supported `window.refresh` call. + +A fresh native macOS run captured a synthetic AppKit window for 60 seconds at 60 fps, then used the ordinary Stop and editor playback paths. Microphone and system audio capture were disabled. The binary SHA-256 is `c98f054a08334e029801e94d5de1e07915caf994434d2f820d69a54e0dee994b`. + +| Milestone after Stop | Elapsed time | +| ------------------------------ | -----------: | +| Capture shutdown acknowledged | 37.41 ms | +| Editor shell created | 49.68 ms | +| First frame-cycle upper bound | 62.60 ms | +| Finalization completed | 317.51 ms | +| Project validated | 323.68 ms | +| Editor instance created | 1,130.83 ms | +| First recording frame received | 1,180.02 ms | + +Playback delivered 301 frames over five seconds at 60.1 fps with zero drops. Full video decoding passed, metadata and configuration parsed, and all 36 originals totaling 13,576,177 bytes were retained. The screenshot shows the existing loading editor with editing/export gated by `project_ready`; recording controls have closed. + +This is one native flow, not a matched old/new comparison or a p95 result. The two-frame callback gives a frame-cycle upper bound, not a compositor scanout timestamp. Default audio output was enabled, but this screen-only recording does not verify audible A/V synchronization. The early shell is visible quickly; playable content still waits for finalization and editor setup. Evidence and exact source hashes are in `optimization-stage-7/gpui-shell/implementation-manifest.json`, `native-stop-fixed/verification.json` and `interrupted-recovery-verification.json`. + +## Managed source input and publication + +The Stage8 input keeps FFmpeg decoder state while the finalizer moves original segments. It closes registered file handles under the source owner lock, then reopens them at their preserved byte positions. Each file retains its identity, length and modification-time checks; ordinary file readers retain their existing paths. Cancellation callbacks run outside that lock. This mechanism is still an optional API; the preparing editor does not use it yet. + +Frozen `native-io-v1` passed 190 native media-library tests on macOS, 194 on Linux and 190 on Windows, with scoped check and strict Clippy. Native AAC60, legacy Ogg60 and AAC2h matched every decoded sample through publication, failed moves and rollback. All three canonical video fixtures matched decoded pixels and timestamps, including tail seeks, while the managed decoder stayed alive. macOS included actual VideoToolbox; Windows and Linux used software decoding. Original canonical files remained byte-exact. These component tests do not establish synchronized preparing-editor playback. + +The first video timings had a cache-preparation asymmetry: the reference was hash-read before construction, while the disposable candidate copy was only hash-read afterward. Those results remain in the evidence but cannot isolate API overhead. Corrected probes hash-read both inputs outside the timer, use the same probe with both library versions, and balance which decoder opens first. Native hardware cold-start and warm-start samples remain separate. + +The v2 reader registers at most 64 files per lock acquisition, sharing repeated parent checks inside each batch while preserving every file snapshot and all reopen checks. On Linux with optimization level 2 and four balanced repetitions, corrected two-hour managed constructor-to-first-frame median improved from 194.21 to 163.22 ms. The ordinary control stayed at 113.43 versus 115.67 ms. Short managed medians were 100.30 versus 101.01 ms for 15 seconds and 108.28 versus 106.29 ms for 60 seconds. These are warm-input component timings, not Stop latency or a p95 result. The measured cancellation response after a separate thread requested cancellation was below 0.72 ms with batching, versus below 0.03 ms before batching. + +The optional managed finalizer changes only the original-segment move and its rollback. Its exclusive recovery lock refuses a pending publication receipt without reconciling it behind active readers. Existing publication checks, original hashes, staging, configuration and metadata commit order remain unchanged. Six new tests cover a live 61-second audio decoder through actual finalization, successful publication, every failed publication rename, failed rollback, the wrong project source and interrupted-publication handoff. At this stage, all preview decoder workers had to acknowledge shutdown before ordinary receipt reconciliation or the ordinary editor took ownership. The later successful-publication handoff adds an explicit continuation path for already-admitted readers. Native v2 validation is complete, with the platform limitations below retained. + +macOS v2 passed 260 native tests (64 recovery, 54 audio, 139 encoder and 3 video), scoped checks and strict all-target Clippy. Its 48 balanced constructor trials and six full video lifecycles included software and actual VideoToolbox. Full audio samples, video pixels/timestamps and all 3,447 canonical fixture files matched. The two-hour software managed constructor-to-first-frame median improved from 195.29 to 166.78 ms. VideoToolbox opened first improved from 270.41 to 248.19 ms; opened second improved from 206.60 to 165.71 ms. These hardware strata must remain separate. + +Linux v2 passed its audio, encoder and video suites; recovery passed 64/64 on tmpfs and 61/64 on the sandbox root filesystem. The root-filesystem failures are triggered by a pre-existing publication receipt guard: a directory allocation size changes from 21 to 29 bytes during rename while its identity, times and recursive children remain unchanged. The original repository HEAD has byte-identical guard functions, and its retained baseline also fails interrupted-publication cases on that filesystem. The new managed handoff test exposes the same guard. No check was relaxed at that stage; this was then an unresolved filesystem-specific recovery limitation, not a clean all-filesystem sign-off. The subsequent guarded stamp fix has its own native results below. Detailed provenance is in `managed-recovery-linux/receipt-guard-provenance.json`. + +Windows v2 passed 255 native tests (60 recovery, 58 audio, 131 encoder and 6 video), all four scoped crate checks, media all-target Clippy and recording library Clippy. Full-target recording Clippy hits an unchanged `items_after_test_module` finding in `capture_pipeline.rs`, verified byte-identical to HEAD. The three full video lifecycles and three full audio comparisons passed; the two-hour audio contained 345,675,776 exact samples. All canonical inputs and 4,212 frozen source files remained unchanged. + +Windows used one prepared copy per fixture, with both complete input trees hash-verified before and after each batch of eight fresh processes. The same probe and root dev profile (Rust 1.88 MSVC, optimization level 0, debug info 0) ran both runtime versions, balancing library and decoder-opening order. Four repetitions per variant gave managed constructor-to-first-frame medians of 115.52 to 92.21 ms (15 seconds), 130.84 to 113.90 ms (60 seconds), and 479.31 to 363.15 ms (two hours). The two-hour ordinary control varied from 217.18 to 246.64 ms, so cache and small-sample qualifications remain material. These numbers cannot be compared directly with macOS/Linux optimization-level-2 timings. Windows cancellation request-to-return was 0.696–0.911 ms with batching versus 0.048–0.057 ms before; the maximum callback interval was 1.898 ms. This measures input-constructor cancellation, not full worker shutdown. Evidence: `windows-validation-20260910/stage8-managed-recovery-v2/{terminal,prepared-timing-summary,cancellation-results}.json`. + +Evidence: `optimization-stage-8/native-io-v1-manifest.json`, `native-io-macos/summary.json`, `native-linux-io/`, `enc-input-v2/`, `managed-recovery-v2-manifest.json`, `managed-recovery-v2-macos/summary.json`, `managed-recovery-linux/linux-stage8-summary.json`, and the native Windows results. At that snapshot, the separate early-editor and progressive-playback contracts described remaining integration work; later sections record their implementation. + +### Editor startup diagnostic + +The startup driver can now profile stages in a fresh process before normal startup (`--profile-stages-only`) and separately measure font preparation (`--prewarm-fonts`). A missing first preview frame now fails the driver instead of contributing an infinite value that was excluded from summaries. + +One initial stage profile took 882.3 ms, including a remaining renderer-layer wait of 663.5 ms. A subsequent ordinary process took 118.3 ms without font prewarming. Four balanced fresh-process comparisons on the same finalized 60-second project measured constructor-to-first-preview medians of 137.15 ms ordinarily and 134.45 ms with fonts prepared beforehand; that separate font preparation took about 12 ms. OS and driver caches were not reset. This does not establish fonts as the initial delay's cause, so no production font-prewarm change was accepted. These measurements exclude Stop, finalization, UI presentation and playback. Evidence: `optimization-stage-8/font-startup/summary.json`. + +## Managed decoder worker and lifetime + +Stage9 adds an optional managed decoder worker around the shared existing FFmpeg frame-selection loop. Startup returns an owner before waiting for readiness. Cancellation wakes the worker; `stop_and_wait` acknowledges an actual native thread join after its decoder, custom input and cached frames are dropped. Managed decode/seek errors are terminal, and cancelled readiness or shutdown waiters retain cleanup ownership. Ordinary decoder entrypoints preserve their existing frame, cache, offset, hardware fallback and VFR hold behavior. + +`RelocatableSource::new_with_owner` retains an external lifetime guard through every source clone and reader. Reader field drop order closes cached files before releasing the last guard. Seven tests include Windows exclusive file opens inside the guard destructor, which would fail if handles remained open. Recording-owned recovery-lock binding was still pending at this stage; this generic source API did not connect the preparing editor by itself. Metadata extraction now exposes the same existing `Video::from_input` and `Audio::from_input` calculations for an already-open input, preserving ordinary path behavior. + +The frozen seven-file overlay is `optimization-stage-9/managed-worker-v1-overlay.tar.gz`, SHA-256 `7845c712628f9ad99f8d9bc882bc14e3c1bf1d2539f9c1d4e863bd4b958a18a2`. Both Cargo locks were regenerated by Cargo with no package version, source or checksum drift; only rendering dependency edges changed. + +- macOS: 518 tests passed, five existing rendering tests ignored; formatting, six scoped crate checks and strict all-target Clippy passed. The actual worker matched ordinary software and VideoToolbox pixels through a three-second PTS gap, a 0.25-second handle offset, repeated publication and forward/backward/tail/cache requests. Logs confirm hardware was actually used. +- Windows: 486 tests passed, five existing rendering tests ignored; scoped checks, media/rendering all-target Clippy and recording library Clippy passed. All owner-lifetime, interruptible-constructor and managed-worker cases passed, including the Windows exclusive-sharing teardown assertion. The inherited recording all-target lint remains documented above. Source and canonical fixture hashes match; the owned VM was stopped with caches and evidence preserved. +- Linux: 431 media/worker tests passed, five existing rendering tests ignored; scoped check and strict all-target Clippy passed. Recovery remained 61/64 on the root filesystem and 64/64 on tmpfs, with the same three documented pre-existing receipt-guard cases. Native Rust formatting also passed after installing the pinned component. Final source and binary provenance is retained in `optimization-stage-9/native-linux/linux-stage9-summary.json`; both owned Linux sandboxes are stopped with their files and caches preserved. + +The first missing-fragment test used an incorrect hard-coded target: 9.5 seconds was intact media, while macOS's deleted last fragment began at 9.8 seconds. That failure is retained. The corrected test derives its target from the packet's physical fragment position, PTS and actual renderer frame mapping, and independently asserts the underlying ENOENT/EIO. It then checks terminal decode/seek failure, the same error on a later frame-zero request, and the shutdown acknowledgement. Linux's encoded fragment boundary differs, validating the need for packet-based targeting. + +Evidence: `optimization-stage-9/native-macos/summary.json`, `windows-validation-20260910/stage9-summary.json`, `optimization-stage-9/native-linux/`, and `optimization-stage-9/missing-fragment/`. This stage verifies functions and worker lifetimes; it adds no new Stop-to-editor latency or p95 claim. The recording-owned source observer, restricted editor projection and synchronized progressive playback remain to be connected and validated. + +## Recording-owned preparing sources + +Stage10 adds a private, single-use clean-stop receipt and a retained observer for read-only source access during finalization. Receipt eligibility is bounded and does not change persisted recording data or make a successful Stop fail. The projection checks the exact stopped metadata/configuration, fragment inventory, required tracks, diagnostics and cursor assets. Unsupported layouts fall back to ordinary finalization. Its source readers retain the real recovery lock, follow owned publication/rollback moves, and are revoked when finalization ends. In the Stage10 snapshot, already-running readers still had to be cancelled, joined and dropped before ordinary reconciliation. + +The production application did not consume this observer in the initial Stage10 snapshot. The full initial source snapshot, staging, validation, source rechecks, synchronization and transactional publication remain in their existing order. Moving availability ahead of the first snapshot would permit previewing one version of a source while finalization accepts a later version; that reorder was rejected during review. Existing renderer construction also has configuration-loading fallbacks that cannot be used by a restricted preparing preview. + +Frozen v1 native validation used identical source hashes and canonical recordings: + +| Platform | Recording tests | Native canonical preparing coverage | +| -------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| macOS | 90 recovery and 8 receipt tests passed | All four video requests on each short fixture; all 2,883,584 microphone frames on the 60-second fixture matched exactly | +| Windows | 85 recovery and 8 receipt tests passed | All four video requests on each short fixture; the same complete microphone sample count matched exactly | +| Linux | 90 recovery and 8 receipt tests passed on tmpfs | Two video requests on the 15-second fixture and three on the 60-second fixture before finalization ended; the complete microphone sample count matched exactly | + +Every observed video frame was compared against an independently finalized MP4 through the same software decoder. Final public media bytes, normalized metadata/configuration, retained originals and canonical inputs matched. The actual recovery lock could be reacquired after worker teardown. Linux's remaining video requests were cancelled at finalization completion and are recorded as unobserved, not passed early-preview coverage. These fixed-order functional probes do not measure Stop-to-editor latency, audible playback, speedup or p95. + +Scoped compiler and formatting checks passed. macOS passed strict recording Clippy across all targets and a standalone GPUI check. Windows retained its inherited all-target lint qualification while the scoped strict gates passed. Linux strict library/tests Clippy passed; its all-target example failure is byte-identical to HEAD. On the Linux sandbox's regular root filesystem, five existing interrupted-publication receipt cases failed, while the identical binary passed all 90 recovery tests on tmpfs. The exact test bodies and receipt guards are byte-identical to HEAD; this run does not establish a new five-failure baseline or remove that platform qualification. + +The original two-hour canonical recording finalized successfully on all three systems, preserving its sources and producing all four finalized-file reference frames. It then declined the test receipt before preparing playback began: its explicit empty `cursors` object deserializes as the legacy cursor representation. A live Stop holds the current representation and its writer omits that empty field. The v1 failure and zero long preparing coverage remain recorded. Test-only v2 removes that one empty field through the actual writer on both disposable comparison copies, verifies unchanged typed metadata/configuration and every media/sidecar byte, and retains a separate hash-linked legacy control. Four new tests passed natively on each platform; they verify the transformation and reject nonempty legacy cursors or unknown fields without writes. No production validation predicate changed. + +The normalized two-hour v2 probes passed on all three platforms. Each compared all four requested video seeks (0, 3600, 7194.5 and 1 seconds) against an independently finalized MP4 and all 345,675,776 audio frames bit-for-bit through EOF. Published media, normalized documents, retained originals and canonical hashes matched. Actual worker joins, source release and recovery-lock reacquisition passed. macOS and Linux also produced identical visible pixel hashes and published media/document bytes. + +These unoptimized, ordinary-first functional runs are not performance comparisons: source availability was observed at 15,592.17 ms on macOS, 948.55 ms on Linux and 1,546.92 ms on Windows; first returned video frames were at 16,141.58, 1,145.54 and 2,580.97 ms respectively. Cache state, hardware and concurrent preview work differ. No rendered editor frame, audible synchronization, product Stop latency or p95 is established. The full source snapshot still precedes availability; removing that protection has not been accepted. + +V2 evidence is retained in `optimization-stage-10/native-macos/assessment-v2-long.json`, `optimization-stage-10/native-linux-v2/assessment-v2-long.json`, and `windows-validation-20260910/stage10/summary-v2.json`. Both Windows test executables and the macOS/Linux v2 executables are retained. The Windows archive contains 1,032 size/hash-verified members, and its owned sandbox is stopped with source, fixtures, outputs and cache preserved. + +Evidence: `optimization-stage-10/native-macos/assessment-v1.json`, `windows-validation-20260910/stage10/`, `optimization-stage-10/native-linux/linux-stage10-v1-summary.json`, `optimization-stage-10/canonical-probe-v2/`, and `optimization-stage-10/early-availability-review/`. The original macOS v1 test executable was overwritten by its test-only rebuild; its binary hash, dependency hashes, source snapshot and raw results remain, but that exact executable was not separately retained. + +## Owned preparing frame controller + +Stage11 adds a separate, read-only frame controller to `cap-editor`. It returns a lifecycle owner before setup awaits; a retained stop handle waits for its actual Tokio task and all native decoder threads. The controller reuses ordinary segment timing, cursor smoothing, zoom uniforms, frame layout and immediate render methods. It never constructs an ordinary `EditorInstance` or loads/writes project configuration. Unsupported presentation, unresolved calibration offsets or inconsistent metadata decline preparation. + +Requests are coalesced into one latest pending frame with one decode/render in flight. Lazy initialization is cancelled only when the entire preview stops; an obsolete scrub cannot cancel its decoder. Completed render output is checked for frame number, dimensions and request sequence before delivery. The ordinary three-attempt render retry policy is retained, with ordinary `get_frames` behavior on retry. Source/decoder/cursor errors remain terminal. At most two display/camera segment pairs stay open; eviction waits for actual teardown, and late failures are retained rather than lost with an evicted worker. + +Frozen cursor assets share immutable image bytes and retain the ordinary SVG/PNG priority and image-format selection. Existing file loaders delegate to the same new cursor-reader and keyboard-byte parsers; no ordinary parsing or fallback order changed. Keyboard-enabled presentation and camera background blur currently require ordinary loading. The Stage11 validated bundle has no app consumer; the subsequent Stage12 integration is described separately below. + +The corrected common v2 candidate passed the native scoped gates on all three systems: + +| Platform | Editor | Project | Rendering | Total passed | Existing ignored | +| -------------- | -----: | ------: | --------: | -----------: | ---------------: | +| macOS arm64 | 82 | 130 | 266 | 478 | 5 | +| Linux x86_64 | 83 | 130 | 239 | 452 | 5 | +| Windows x86_64 | 82 | 129 | 239 | 450 | 5 | + +Formatting, scoped checks and strict all-target Clippy passed. macOS scoped checks also include Tauri and GPUI. The project/rendering results carry forward from v1 because all runtime sources are byte-identical; v2 changes only the native fixture duration and reruns the complete editor suite on every system. Linux uses Mesa llvmpipe through Vulkan; a separate probe of the same production adapter factory selected Microsoft Basic Render Driver through DX12 on Windows. This is software-sandbox coverage on Windows/Linux, not proof across physical GPUs. + +The four-second v1 fixture failed before rendering on Linux and Windows: their libx264 encoder produced two fragments, below the test's required three. V2 generates three existing encoder GOPs (currently six seconds), preserving every assertion and requested frame time. All four composed cases then executed and passed on all three systems. The original failures remain recorded. Clippy also caught a test fixture initialization style issue; it was corrected and rerun. Controller tests caught a readiness notification race that could mask the original initialization error with cancellation; the final implementation retains the actual error. + +Four composed native tests compare visible RGBA pixels and frame layouts against independently finalized ordinary media. They cover PNG/SVG cursors with explicitly changing cursor samples, nonidentity zoom, independently encoded display/camera with distinct FPS and timing offsets, forward/backward/tail seeks, source relocation, three-clip eviction/reopening and a callback panic followed by native joins and source-owner release. Four additional deterministic native-thread tests cover failure while idle or after a returned frame and dropped terminal waiters, with release barriers proving both joins. These synthetic component tests do not measure Stop latency, audio synchronization, physical multi-clip capture, UI presentation or p95. Hardware decoding is requested but not asserted by the controller test API; the composed comparison uses RGBA. The original shared `.cap` fixtures remain unchanged. + +The frozen 15-file v2 bundle is `optimization-stage-11/preparing-controller-v2-overlay.tar.gz`, SHA-256 `78114380b3fba19df63a50e5a177f8c66fbbd1a1fc29397084e6a829e3382e60`; its manifest is `ef1b93c9a4c9c87b5b71a955a504a71d950a4c6dc8fa86bde44b931b6ab0e26b`. Evidence: `optimization-stage-11/native-combined-macos-v2/assessment.json`, `optimization-stage-11/native-linux/assessment-v2.json`, `windows-validation-20260910/stage11/`, `native-preview-macos/summary.json`, and `managed-terminal-tests/`. Exact v1/v2 editor executables are preserved. Stage11's helper compares collected visible bytes, dimensions and layout, but equal truncation of both buffers is not independently excluded by a byte-count assertion. No truncation was observed. Stage12 adds explicit complete-buffer length and stride assertions. + +## GPUI preparing image integration + +Stage12 connects the recording-owned observer to a GPUI loading window. The consumer requests the composed first image, checks the current recording attempt and window identity before delivery, and retains actual task and native decoder completion across close/reopen. Ordinary project loading waits for every retained consumer for that project. Closing a preview does not cancel recording finalization. The image remains read-only: playback, editing and export still require the ordinary ready editor. Progressive synchronized playback and a playable-range UI remain unfinished. + +The GPUI adapter preserves stopped configuration and uses the same initial clip-calibration calculation as ordinary loading. Current camera blur and selected animated-gradient preferences decline this early image. Keyboard presentation still declines; merely recording a keyboard sidecar does not, because GPUI ordinary rendering requires an explicitly configured keyboard overlay. The raw files remain available unchanged to ordinary loading and later user actions. + +The frozen Stage12 shared-core native matrix is complete, with the Linux filesystem qualification below. These scoped function and synthetic composed-frame tests exclude app consumers. Platform-specific tests account for the different totals; each platform also retains one ignored manual canonical probe. + +| Native platform | Editor | Recovery | Receipt | Passing total | +| ----------------------------------------- | -----: | -------: | ------: | ------------: | +| macOS | 88 | 101 | 8 | 197 | +| Linux, same recording executable on tmpfs | 87 | 101 | 8 | 196 | +| Windows | 86 | 96 | 8 | 190 | + +Linux recovery on the regular container filesystem passed 98 tests and failed three with `Conflicting recovery publication state; all files retained`; the same executable passed all 101 on tmpfs. Two failing test bodies and the relevant receipt guards are byte-identical to repository HEAD. The third test was introduced during Stage8 and already exposed the same rootfs limitation there; it is not an inherited HEAD test. The failure names and exact provenance are retained in `native-linux/receipt-source-provenance.json`. This remains a qualified result, not a clean Linux rootfs gate. + +Scoped format/check gates passed on all three platforms. Strict recording Clippy passed for lib/tests on macOS and Linux, and lib on Windows; editor all-target Clippy passed on all three. Seven new public-finalizer paired cases cover output/error parity and observed preview admission or decline, and three initial-calibration tests passed. Native composed cases include cursor assets, camera, zoom, relocation, three-clip eviction/reopen, keyboard-sidecar behavior and actual cleanup after callback panic. Full dimensions, stride and buffer length are checked before pixel comparison. macOS also asserts the ordinary AVAssetReader backend and actual BGRA surface parity. Linux ran the cases on software llvmpipe/Vulkan; there is no discrete Windows GPU claim. + +All 17 frozen overlay members, 26 Linux runtime members and 82 Windows evidence archive members were independently verified against their SHA256/size manifests, along with the exact retained macOS editor executable. Windows canonical fixtures matched before/after; Linux's three retained audio components matched, and its canonical media sandbox was not resumed. Linux and Windows build VMs were stopped with artifacts retained. Evidence and exact executable/source identities are aggregated in `.git/stop-editor-20260909/optimization-stage-12/native-summary.json`. The later Tauri dependency entry and renderer diagnostics are separate from this frozen source. + +On macOS, 39 targeted GPUI lifecycle, finalization, presentation and capture-stop tests also passed. The app compiles with the current stable toolchain. Strict GPUI all-target Clippy reports 28 unique inherited findings: each diagnostic context was checked against HEAD `0c403be8a57f7ba4de67140273708a392c8b4050`. This is a qualified gate, not a clean whole-app Clippy result. Evidence is under `native-gpui-macos/` and `gpui-consumer-review/clippy-provenance.json`. + +Four actual GPUI runs used matched builds in baseline/candidate/candidate/baseline order, each recording the same synthetic window scenario for about 60 seconds at 2560×1504. Each row is a distinct recording. Times below are milliseconds from the app's Stop marker; the ordinary-frame column records receipt by the ordinary editor. + +| Run | Capture stopped | Loading shell | Early composed image | Next app cycle for early image | Finalization finished | Ordinary first frame | +| ------------ | --------------: | ------------: | -------------------: | -----------------------------: | --------------------: | -------------------: | +| 01 baseline | 37.9 | 50.5 | — | — | 300.3 | 1,067.7 | +| 02 candidate | 46.1 | 60.6 | No early image | — | 298.0 | 878.1 | +| 03 candidate | 43.7 | 58.7 | 185.3 | 190.4 | 287.0 | 332.2 | +| 04 baseline | 49.2 | 61.6 | — | — | 291.9 | 383.2 | + +Only one of two candidate runs produced an early image. In run02, sources were available at 64.0 ms, but finalization ended before renderer readiness. The ordinary path proceeded after retained preview cleanup and preflight, reaching its validation marker at 814.5 ms; that interval is not an isolated cleanup measurement. Run03 produced the image at 185.3 ms and reached the next app cycle at 190.4 ms. The app-cycle marker does not independently prove external compositor presentation. Different initialization costs and only two observations per variant prevent p95, significance or universal speedup claims. No two-hour app timing or audio synchronization was measured. + +All four runs finalized, sought and played for about five seconds with zero dropped frames in the app logs. Complete post-run decoding compared every published frame and PTS against that run's retained original fragments: all 14,380 matched. Cursor/keyboard sidecars remained byte-identical, and all 192 retained project files were rehashed against the audit manifests. Configuration changes were limited to ordinary duration calibration (at most 4.5 microseconds in these runs); metadata changed only completion status and display path. The audit compares each recording to its own original, not media across runs. Raw results and matched-source/binary provenance are in `gpui-native-stop/summary.json`. + +The Tauri consumer is now applied. Its earlier complete native library run passed 446 tests; after the presentation extraction, nine new configuration/adapter-guard tests passed and the 61-test recording scope passed, including five of those nine. These counts must not be added as independent runs or described as a latest full 455-test run. Tauri Rust format/check and strict lib/tests Clippy passed, two command bindings were generated, six frontend files passed Biome, ten lease/socket tests passed, and desktop TypeScript checking passed. Tauri conservatively declines recorded camera and raw keyboard inputs: ordinary Tauri loading automatically creates keyboard presentation from raw events. The new parity tests establish configuration and guard behavior; subsequent genuine Tauri capture evidence and its remaining handoff issue are described below. + +A subsequent Tauri change stops an abandoned admission after its response receiver disappears, joining the owned transport without waiting for or cancelling recording finalization. Its ten-test native consumer scope passed, including the new pending-finalization case; formatting, scoped check and strict lib/tests Clippy also passed. The 15-file consumer snapshot is retained in `tauri-applied-v2/manifest.json`. These scoped runs overlap earlier tests. + +The opt-in native Tauri pixel/DOM capture harness subsequently passed formatting, scoped check and strict lib/tests Clippy. Its exact retained macOS library executable passed all 459 tests, including three frame-capture/fixture-selection tests. The first launch failed before tests because the nested artifact directory lacked its required Frameworks path; an owned Frameworks symlink fixed the layout, and the same executable then passed. This is harness and library validation; the two native WebView diagnostics below follow this suite. The fresh static frontend build succeeded with all 326 inspected source/config hashes unchanged, and its 693-file public output is retained. Evidence is in `tauri-native-preparation/harness/native-v2.json` and `tauri-native-preparation/frontend-result.json`. + +Two subsequent native Tauri diagnostic captures, 15 and 120 seconds, finalized successfully. All 895 and 7,169 decoded frames respectively matched each run's own retained source fragments in pixels and PTS; project/source trees were unchanged during verification. Both captured ordinary RGBA frame zero, but neither produced preparing RGBA or a visible preparing canvas. The parity verifier correctly reports incomplete. DOM observations therefore do not establish a seamless preview handoff. These unoptimized debug runs are functional diagnostics, not release latency benchmarks. The private log override passed its three native tests and scoped binary check/strict Clippy; normal log paths are unchanged. Evidence: `tauri-native-run/verification-matrix.json` and both `untimed*/benchmark.json` results. + +Source tracing explains the missing Tauri admission: `clean_capture::prepare` returns `None` on macOS/Windows, but the new preparing finalizer required the resulting Linux-only generation to be present. A correction giving actual recording finalizations their own checked, monotonically allocated identity is now applied; formatting, scoped check, strict Tauri lib/tests Clippy and all 461 native library tests passed, including allocation, duplicate, exhaustion and stale/recovery admission cases. The updated native executable then passed a genuine 120-second capture: preparing and ordinary frame zero matched all 3,358,368 visible RGBA bytes, dimensions and layout, and both canvases were observed visible. All 7,157 published frames and PTS matched the retained original fragments; all 66 original files were retained and the project tree stayed unchanged during audit. Generic crash-recovery requests, genuine clean-stop receipts, source ownership, current-attempt checks and failure handling remain guarded. Evidence is in `tauri-native-run/untimed-120s-v2/`. + +The corrected capture observed a live preparing canvas, then its retained image, then the ordinary canvas without a sampled blank state. A remaining size transient prevents a seamless-handoff claim: the preparing image measured 787.11 by 462 CSS pixels, the first ordinary image grew to 803 by 471.31, and it settled back after about 103 ms. The first visible preparing sample was approximately 725.7 ms after Stop; the benchmark ordinary-frame marker was 1,606.7 ms. These are unoptimized, instrumented functional observations, not release or p95 results, and DOM samples do not independently establish compositor presentation. The 569 native source, 326 frontend source/config and 693 embedded asset hashes remained unchanged through this capture. + +A further 120-second native layout diagnostic reproduced the transient and established its immediate cause. At first ordinary visibility, the actual container was already 835 by 494 pixels and all parent, toolbar and timeline geometry was at its final size. The accepted canvas alone retained 803 by 471.31 dimensions for 109 ms before settling to 787.11 by 462. The preparing/ordinary frame-zero pixels matched exactly again; all 7,171 published frames and PTS matched their original fragments, and all 26,930,830 original source bytes were retained. Native/frontend/asset hashes and owned-process shutdown passed. The additional bounded DOM geometry instrumentation makes this an untimed functional diagnostic. Evidence: `tauri-native-run/native-v3-assessment.json` and `untimed-120s-layout-v3/layout-analysis.json`. + +The first sizing fix passed 17 related frontend tests and desktop TypeScript, but its actual 120-second native v4 capture still showed the same 109 ms size transient. It therefore failed the intended visual outcome. Preparing and ordinary frame-zero pixels matched all 3,358,368 visible bytes; all 7,092 published video frames and PTS matched their retained originals, and all 26,559,920 original bytes remained unchanged. Source, binary, embedded assets and shutdown checks passed. The next refinement keeps initial geometry eager through one animation-frame remeasurement, preserving later 100 ms resize debouncing and cancelling pending work on teardown. Its subsequent native verification is recorded below. Evidence: `tauri-native-run/native-v4-assessment.json` and `tauri-initial-bounds-fix/first-paint-refinement/`. + +The refined v5 candidate passed 21 related frontend tests, desktop TypeScript and the private static/native builds. Its genuine 120-second capture used identical preparing and first ordinary bounds of 787.109375 by 462 pixels, with no later canvas-size transition in the following 1,001 ms of bounded DOM observations. Frame-zero RGBA parity passed. All 7,068 published video frames and PTS matched retained originals, all 26,426,138 original source bytes remained intact, the project tree stayed unchanged during verification, and owned processes shut down after finalization. This establishes the intended sizing outcome for this native case; it remains an unoptimized, instrumented functional check, with no audio/camera or external compositor/p95 claim. Evidence: `tauri-native-run/untimed-120s-bounds-v5/layout-analysis.json`, `parity.json` and `verification.json`. + +Fresh Windows validation of the current 1,356-file source freeze passed 770 native tests, with six existing/manual tests ignored. The native adapter was Microsoft Basic Render Driver through DX12. Scoped checks and strict encoder/audio/video/project/rendering/editor Clippy passed. Recording library Clippy passed; its test target retains an inherited `items_after_test_module` finding in a file byte-identical to HEAD. Both canonical 15-second screen and 60-second microphone probes passed: four video requests per recording matched independently finalized media, all 2,883,584 microphone PCM frames matched bit-for-bit through EOF, outputs/documents matched, originals stayed unchanged, and source release plus lock reacquisition passed. These are opt0 function diagnostics, not app latency or p95 evidence. Tauri compilation encountered compiler allocation failures with two jobs; the one-job native library check and test executable build later passed, but the storage reserve prevented that test execution. Expanded two-hour validation is paused under the narrower editor goal. Evidence: `platform-capacity/windows-fresh/current-shared-short-assessment.json`. + +Fresh Linux validation of the same source freeze passed 782 unique native tests, with the same six existing/manual tests ignored. The adapter was Mesa llvmpipe through Vulkan. Scoped formatting, checks and strict Clippy passed. All 101 recovery cases passed on the default root filesystem and again with the same binary on tmpfs; the duplicate run is excluded from the total. All three previously failing filesystem cases explicitly passed in both scopes. An initial receipt filter matched no tests and is retained as non-evidence; the corrected filter passed all eight intended cases. The 73-file evidence archive was downloaded and every member verified. Canonical integration runs remain separate from these unit fixtures. Evidence: `platform-capacity/linux-fresh/native-evidence/assessment.json`. + +The current Linux standard canonical 15-second and 60-second probes subsequently passed publication, retained-source, cancellation, actual-join and recovery-lock checks. All 2,883,584 microphone PCM frames matched bit-for-bit through EOF. Preparing video coverage was partial: finalization ended after two of four requested screen seeks and three of four microphone-recording seeks. Every returned frame matched its independently finalized software reference; the remaining requests were cancelled. No delay was added to force those requests to complete, and these results do not establish four-seek canonical parity. Evidence: `platform-capacity/linux-fresh/canonical-short-evidence/assessment.json`. + +A separate eight-file renderer diagnostic patch was applied after the frozen native matrix and matched GPUI runs. Its format/check, strict rendering all-target Clippy and 15 preparing-preview tests passed on macOS. A fresh 15-second native capture reached the ordinary first frame at 388.02 ms and played 300 frames at 60.0 fps with zero reported drops. All 895 decoded frames and PTS matched its retained originals; sidecars and source hashes were preserved. It produced no early image: finalization ended at 236.94 ms while preparing layer construction continued until 319.72 ms. + +That diagnostic measured 83.74 ms for managed decoder readiness, 4.09 ms for graphics constants and 176.42 ms for layer construction. The largest layer costs were the shared compositing pipeline (70.77 ms), first font-system scan (54.38 ms) and image layer (32.84 ms). These nested, instrumented timings identify work to investigate; they are not independent hardware timings or a matched speedup. No rendering phases occurred before Stop in this process. The exact diagnostic binary and 564 source hashes are retained under `renderer-readiness-diagnostics/summary.json`. No font warmup or initialization optimization has been accepted, and these later diagnostics do not change the provenance of the four reported timings. The instant, playable-editor goal is still in progress. + +An experimental follow-up omits the image/text/caption/keyboard layer bundle only for the checked preparing renderer. Existing ordinary constructors retain the complete bundle and original preparation/render order. Unsupported project or prepared-text inputs produce an explicit error before restricted rendering. On macOS, all 88 editor and 269 rendering library tests passed, with five existing rendering tests ignored; scoped checks also covered export and Tauri, and strict editor/rendering all-target Clippy passed after removing four unused imports from the extraction. Two additional native tests now pass on Apple M4 Max/Metal: ordinary image, text, caption and keyboard output remains visible, full/restricted frame-zero pixels and layout match, and late unsupported inputs leave the GPU target unchanged. Seven existing native preparing-preview tests also passed. The first boundary-test attempt used a segmented-text-only inspection method on ordinary batched text; that test assertion was corrected while retaining its rendered-pixel check, and both attempts are preserved under `preparing-overlay-boundary/native-macos/`. A fresh matched comparison is recorded below. No cross-platform or speedup acceptance is inferred from this local experiment; skipping early initialization can defer costs to ordinary loading. + +A fresh GPUI comparison isolates the optional overlay bundle change: both variants already include the preparing consumer and renderer diagnostics. Both opt2 executables were built from the same isolated source paths, with equal dependency profiles, features, locks, external inputs and assets. Only the documented four-path renderer/editor delta differs. Post-matrix source, binary and harness verification passed before any replay-test additions; an unused Tauri scaffold change in the original checkout is separately recorded and excluded from the GPUI dependency graph. + +| Fresh run | Early composed image | Early image app cycle | Recording finished | Ordinary first frame | +| ------------------------- | -------------------: | --------------------: | -----------------: | -------------------: | +| 01 full layers | No early image | — | 295.6 ms | 368.0 ms | +| 02 omitted overlay layers | No early image | — | 282.4 ms | 849.7 ms | +| 03 omitted overlay layers | 177.3 ms | 183.3 ms | 295.5 ms | 349.8 ms | +| 04 full layers | 184.3 ms | 190.4 ms | 298.1 ms | 340.6 ms | + +The 849.7 ms observation remains part of the result. Its preparing layer construction took 523.6 ms, dominated by several GPU pipeline constructors; none of the omitted overlay constructors ran. Finalization safely relocated the source and ended the job, but ordinary loading waited for the retained preparing task to return from synchronous construction and join its native workers. Ordinary validation occurred at 681.3 ms. The logs establish this ordering, not the precise worker-exit timestamp or shader-cache causality. Both variants completed five seconds of ordinary playback with zero reported dropped frames. All 14,379 published frames and PTS matched their respective retained original fragments (3,595, 3,594, 3,595 and 3,595 frames); sidecars and all project trees remained unchanged during verification. Separate same-source composition replays subsequently passed for all four recordings on Apple M4 Max/Metal with FFmpeg hardware decoding: every visible pixel, dimensions, stride and layout matched the ordinary full renderer at frame zero. Decoder workers joined, and project/source trees stayed unchanged. These replays use a retained pinned Rust 1.88 opt0 test executable, separately qualified from the stable opt2 measured apps. The initial nonmember-workspace failure and paused rebuild are retained; neither changes the measured binaries or their completed source audit. These recordings contain no cursor motion, so motion coverage belongs to the earlier dedicated native tests. This four-observation matrix establishes neither a reliable speedup nor p95. Exact phases and provenance are under `preparing-overlay-benchmark/phase-assessment.json` and `source-after-matrix.json`. + +A separate optimized macOS diagnostic over the immutable two-hour canonical input completed successfully. It uses the public preparing path, a test-only clean-stop receipt on a validated writer-normalized copy, and the current restricted preparing renderer. The exact three runtime source hashes match its retained build manifest; the earlier description of this binary as using full layers was incorrect. T0 precedes finalizer worker spawn; source-copy/hash/projection preflight warms the input before T0. It does not execute native recorder shutdown, app presentation or audible playback. + +| Two-hour diagnostic milestone | Time from T0 | +| ------------------------------------ | -----------: | +| Preparing sources observed available | 1,166.0 ms | +| First composed RGBA frame | 2,443.6 ms | +| Preparing finalization returned | 12,441.6 ms | +| Preview native workers joined | 12,444.2 ms | + +All first-frame pixels and layout matched the ordinary finalized-media compositor; published media bytes and normalized documents matched the ordinary run. Canonical originals, source/native/binary identities and lock availability after joins were verified. This diagnostic did not repeat PCM decoding; complete PCM comparison belongs to the earlier Stage10 evidence. Its subsequent ordinary finalization measured 11,560.5 ms, but this one ordered pair, with different concurrent work and warmed state, is not a matched no-regression performance result. + +Initial analysis consumed 397.7 ms, including 307.8 ms for 3,383 fragment-duration probes, all returning None. Initial whole-source snapshotting consumed 676.2 ms; hash groups contained 1.273 and 1.123 GB, so gross group imbalance does not explain the delay. Projection took 91.4 ms, primarily repeated completeness checks. After availability, managed decoder startup took 604.8 ms, graphics constants 91.4 ms, retained preparing layers 567.0 ms and RGBA output 12.1 ms. Nested/overlapping phases must not be summed as isolated costs. The next optimizations must preserve the existing integrity checks and duration fallback behavior. Raw common-origin phases, exact build qualification and results are in `two-hour-composed-diagnostic/isolated-preparation/evidence/two-hour-run-v2/assessment.json`. This single optimized function diagnostic establishes neither actual two-hour Stop/UI latency nor p95, and the instant/playable-editor goal remains incomplete. + +## Drivers + +From `apps/desktop-gpui`, build the drivers with `cargo build --example studio-finalization-benchmark --example media-readiness-benchmark --example audio-readiness-benchmark`. The finalization driver takes a disposable unfinished Studio project; it mutates that copy. The readiness drivers take media paths and are read-only. The audio driver's default mode reproduces the old background loader; `CAP_BENCH_PROGRESSIVE_AUDIO=1` selects the block loader. Both emit their execution and timing methodology. `CAP_BENCH_MIX_AUDIO=1` adds a current-renderer comparison with a barrier after every repetition. Hashing and mixing remain outside the decode timer. + +`CAP_BENCH_FULL_AUDIO_ONLY=1` skips the range experiment. Without it, the driver exits unsuccessfully if decoded ranges differ from the full-track reference. `CAP_BENCH_STREAM_AUDIO=1` also checks every sequential chunk against that reference. Preserve failed range experiments as failures; the known two-hour timestamp-seeking mismatch must not be mistaken for passing progressive-window coverage. Progressive windows use sequential sample positions instead. + +The debug Tauri driver is enabled by `CAP_STOP_EDITOR_BENCHMARK_OUTPUT`, with `CAP_STOP_BENCH_SECONDS`, optional `CAP_STOP_BENCH_MIC`, and optional `CAP_STOP_BENCH_SYSTEM_AUDIO`. Use an isolated app identifier and recordings directory. Its injected editor observer waits for a completed render in the existing frame statistics, a visible editor canvas, and two animation frames. A timeout is a failed benchmark, not a successful editor-open measurement. The initial observer only watched worker messages and missed Tauri's direct-canvas renderer; that incomplete run is excluded. + +Existing GPUI drivers include `CAP_GPUI_AUTO_RECORD=studio:60`, `CAP_GPUI_AUTO_NO_MIC=1`, `CAP_GPUI_AUTO_SYSTEM_AUDIO=1`, `CAP_GPUI_AUTO_EDITOR`, and `CAP_GPUI_AUTO_PLAYBACK`. `CAP_GPUI_AUTO_SEEK` targets a fraction of the visible timeline viewport. The new `CAP_GPUI_AUTO_SEEK_TIME=7194.5` targets seconds into the recording and logs the clamped time passed to the ordinary seek method. Use explicit time for long-recording tail checks. Keep app data and recordings isolated from the user's library. + +## Preparing playback integration on 2026-09-10 + +The active goal was narrowed and resumed through the app: immediate editor presentation, equivalent early footage, actual playable-range feedback, and retained preview/playhead/playing intent through ordinary-editor handoff. Main was subsequently synchronized to `284ae24a79bd0813743f51e6219d11ae0042d38f`, preserving the local candidate and all evidence. Neither the goal nor native screenshot acceptance is complete. + +The shared preparing controller uses actual composed frames and completed required audio to admit a contiguous playable range. It freezes at that range, retains the latest seek response, cancels abandoned audio-start requests, and joins media workers before handing a take-once completed PCM cache to the ordinary loader. Required audio that cannot be proven equivalent keeps playback unavailable. Cached metadata permits only the finalizer's existing clearing of audio gap summaries; it cannot relabel paths, devices or start times. + +The Tauri backend now exposes window/epoch/job-scoped state and playback commands, publishes actual status changes, and includes the joined snapshot in the ordinary editor response. Closing a window releases preparing audio after cleanup. A replacement preparing request retires the previous session after its join. The frontend owns resuming ordinary playback after its target frame is actually drawn; that integration and user-intent supersession are still being validated. + +Tauri audio admission uses the ordinary timing-repair calculation against the projected final metadata and an actual bounded recording-log snapshot. Only cleanly stopped audio writers can reach this path. A missing log matches ordinary loading; unreadable, changing, nonregular, invalid UTF-8 or over-4-MiB logs conservatively defer required audio to ordinary loading. Preserved M4A requires the same AAC/no-video probe as finalization; transcodes and changed filenames are not admitted. These conservative limits must not be presented as universal early audible playback. + +The latest combined macOS editor library suite passed all 116 tests, including full PCM equality through gap-cleared handoff, corrupt-audio refusal, composed image equality, source release, rename and cancellation of an old playback handle without stopping its replacement. Current scoped Tauri compilation and all 16 preparing adapter/audio/lifecycle tests passed, as did 14 tests selected by the finalization filter. Shared editor strict all-target Clippy passed. The filtered runs overlap; their counts must not be summed into a unique platform total. GPUI scoped compilation and 165 focused preparing, recording, session, window and timeline tests passed. The final Tauri frontend passed 86 tests across eight files and desktop TypeScript. Scoped Biome passed for the initial 25 handwritten files and the six-file transcript/clip correction. Those panels now use the same ownership queue; recording/import waits for an acknowledged pause and declines a superseded request. Guarded handoff starts return a receipt for their exact playback handle; late cleanup cannot stop a newer ordinary playback. The queue retains ownership during failure cleanup and transfers ownership after successful handoff. + +Windows passed 68 native audio tests with the shared 60-second fixture: 64 shared tests plus four existing Windows latency tests. All decoded samples and EOF matched. The older wrapper expected 64 and flagged the count despite the native executable exiting successfully; that wrapper result and the corrected source-qualified count are both retained. + +The initial Linux/Windows screenshot builds use a retained source-v1 snapshot plus an explicitly recorded Clone correction and required real build assets. They predate the final controller integration. Linux Tauri completed an actual 15-second X11 capture and opened its ordinary editor; that short run did not present a preparing frame. The first external desktop-video recorder failed because it inherited the app's native-library search path. A corrected run produced a verified actual desktop video and editor screenshot; the user accepted Linux and requested no further Linux screenshots. Linux GPUI also built successfully from its qualified seed source. Both owned Linux VMs are stopped with source, media and build evidence retained. + +Windows GPUI genuinely opens the preparing editor. Its capture also exposed a persistent outline matching the hidden main window, and a white/washed-out preview. The initial rendering failures are retained; their runtime-layout cause and corrected results are recorded below. An independently invoked native export-preview reproduced washed-out output before GPUI presentation and then exited with an access violation during or after teardown; its generated JPEG does not make that diagnostic successful. The original recording media and project remain unchanged. A fresh real Stop reproduced the outline with 772 native trace records: the main window was hidden 26 seconds before editor creation, with no later show event, move/resize loop or held mouse button at editor creation. Show/hide clears the artifact diagnostically, but that does not establish a production fix. + +The integrated macOS GPUI build uses a separate 2,326-file hash-verified snapshot because concurrent crop changes invalidated the preceding shared-checkout build's provenance. That preceding binary was retained and never launched. A genuine 60-second capture opened the shell on the next presentation cycle at 88 ms. Finalization finished at 199 ms before preparing rendering returned a frame, so the early-play hook did not run. Ordinary playback rendered 299 frames over five seconds at 59.8 fps with zero drops. This proves immediate chrome and ordinary fallback for that run, not playback during processing. All 3,594 decoded frames and PTS matched retained fragments, cursor/keyboard sidecars matched byte-for-byte and the full project tree remained unchanged during verification. + +The new Tauri frontend and native builds are source- and asset-hash qualified. The initial native launch exited before recording because newly merged startup code honors the shared GPUI preference even for a private Tauri identifier. It preserved the user's running GPUI. An explicit debug Stop-benchmark-only branch now skips that startup selector and its shared marker handling; release behavior and user settings are unchanged. The failed launch remains recorded. + +The subsequent actual 120-second Tauri run preserved all 7,131 decoded frames and PTS, sidecars and 19,106,595 bytes of original media. Stop returned at 172 ms and the ordinary frontend frame event arrived at 1,549 ms. Actual UI Space playback and native screenshots confirmed about 60 fps, with 603 rendered frames, zero skips and no starvation in the observed interval. Preparing correctly declined because recorded clicks require late ordinary auto-zoom settings; this is ordinary fallback proof, not early-frame or playback-handoff proof. + +The final 86-test frontend rebuilt in 26.5 seconds with all 345 source hashes unchanged and 696 retained assets. Its native build also includes the explicit handoff start-frame selection after awaited music loading, so late old playhead events cannot change the approved target. A genuine 15-second Stop smoke on that build returned at 194 ms and delivered the ordinary frontend frame event at 577 ms. Actual full playback and replay from the end passed; the replay screenshot shows 0:03.01. All 899 published decoded frames and PTS matched retained originals, and the source/project audit passed. These single debug runs use different recordings and are not a performance comparison. + +A broader Tauri library run passed 468 tests and failed the existing upload scheduling assertion at 78.3 ms versus its 50 ms threshold. The entire upload file is byte-identical to current main. The isolated recheck through Cargo passed without source or threshold changes. A preceding direct-binary recheck lacked Cargo's runtime library paths and executed no tests; its loader failure is retained. The final serial run passed all 469 tests in 6.64 seconds. These concurrency and runtime-path qualifications remain with the broad gate. + +Windows Tauri's first launch failed the private logger's canonical-path guard; the next exposed a frontendDist Windows-drive URL interpretation issue. Both failures are retained. A relative asset path to the same 693 verified seed assets fixed the configuration; the unchanged-source native rebuild passed in 90 seconds. Its genuine 59.59-second recording opened the editor and actual Play advanced to 0:27.56 before pausing. The captured region was covered by the GPUI editor, so the recording contains that window. The native desktop video retains both the initial disabled editor chrome and the completed editor. Finalization took about 397 ms and the ordinary preview arrived about 3.8 seconds after editor document start; no preparing frame was observed. This remains a seed-build run, not validation of the latest progressive handoff. Current Windows function validation is being refreshed against the final common source manifest; the refreshed Linux results follow below. Storage, software-GPU, build-profile and source-version limitations remain explicit; no screenshot is a release performance result or a zero-regression guarantee. + +Evidence is under `optimization-stage-13/preparing-playback-combined`, `tauri-playback-integration`, `tauri-audio-admission`, `gpui-playback-integration`, and `linux-native-ui`, with Windows artifacts under `optimization-stage-12/windows-native-ui`. + +The standalone absolute-color diagnostic uses the exact existing native rendering libraries and does not edit production source. On both macOS and Windows, known RGBA bars survive GPU upload/readback, identity compositing and full composition exactly at all 24 sampled interiors. CPU YUV conversion matches the scalar reference; GPU conversion differs by at most one channel unit. The exact Windows recording software-decodes and composites in normal color on both systems, with actual native workers joined. A second diagnostic loads the original project configuration and isolates shadow and screen motion blur: all four variants render in normal color on both platforms, at the same 1168-by-702 output geometry as the failed export. Native CPU YUV and scalar RGBA outputs match byte-for-byte. These probes explicitly force software decoding and omit real cursor sidecars and recording timing metadata; the ordinary Windows decoder fallback is still under investigation. Earlier preparing-versus-ordinary equality alone cannot rule out both paths sharing a color bug. Evidence: `optimization-stage-13/windows-color-diagnostic/`, `windows-color-diagnostic-v4/`, and `optimization-stage-12/windows-native-ui/color-probe-v3/` and `color-probe-v4/`. + +The outline's scoped native diagnostic replays also remain separate from product verification. A temporary hook on the task-owned GPUI main thread invoked the actual content-affinity setter and nonclient activation pair; both succeeded, preserved hidden state and produced byte-identical before/after desktop images. The hook was removed after each operation. The native affinity getter succeeded and confirmed zero. Hidden-window paint and forced GPUI repaint messages also failed to reproduce the border. These negative results do not justify a visibility, theme or affinity patch; the real editor-opening transition remains under investigation. + +The final Linux v3 source snapshot verified all 2,331 file hashes before and after native validation. The shared library scope passed 531 unique tests: audio 63, rendering 244, editor 115, recovery 101 and receipt 8; five existing rendering tests stayed ignored. The final Tauri library executable passed 528 tests with zero failures, filtering only the binding-writing export test to preserve frozen source. This gives 1,059 unique shared and Tauri library passes, excluding overlapping filtered runs and the separate native fixture cases. The same recovery binary also passed all 101 tests on tmpfs; that duplicate run is not added to the total. + +Linux's matched 15- and 60-second canonical runs compared every observed video frame against independently finalized software-decoded media. Actual end-of-stream cancelled outstanding later seek requests: the observed requests were 0 and 7 seconds on the short fixture, and 0, 30 and 58 seconds on the longer fixture. All 2,883,584 audio frames matched bit-for-bit through EOF. Required worker joins, source release, output/configuration checks and all 59 original files (167,229,187 bytes) passed. The actual renderer selected llvmpipe through Vulkan. These are native function results, not additional UI screenshots or physical-GPU coverage. + +Scoped Linux compilation and strict shared Clippy passed. Tauri strict Clippy exposed one equivalent optional-value condition, rewritten with `is_none_or`; macOS formatting and scoped Tauri compilation then passed, as did the current Linux scoped Tauri check. The remaining three Tauri Clippy warnings are in the upload file, whose full bytes match current HEAD. Initial storage failures and reserve stops are retained separately from source/test failures. The final native Tauri build succeeded after audited removal of obsolete or duplicated owned compiler artifacts. Evidence: `optimization-stage-13/current-linux-functions/` and `native-current-v2/`. + +The next color probe kept managed decoders alive through rendering, then awaited their actual joins. On macOS, software YUV420p and native VideoToolbox NV12 produced identical corresponding composed output. On Windows, both requested hardware settings resolved to FFmpeg software decoding; both produced identical normal-color output and clean joins. A further ordinary-factory diagnostic creates the renderer before the decoder, matching export initialization order. On macOS, actual AVAssetReader texture-backed NV12 and the FFmpeg VideoToolbox path again produce identical corresponding PNGs. Ordinary handles expose no join API, so those runs record handle drop without claiming a worker join. Their initial standalone dynamic-library loader failures are retained; actual tests passed with the verified native-library search path. All these probes still omit actual cursor sidecars and recording timing metadata. Evidence: `windows-color-diagnostic-v5/`, `windows-color-diagnostic-v6/` and the Windows `color-probe-v5/` directory. + +The additional same-thread Windows affinity replay verified successful hidden-window transitions from 0 to 17 to 0, with an unconditional final restore to 0 and successful removal of every temporary hook. All desktop captures remained byte-identical. Repeated hides and genuine reopen-from-Recents also stayed clear. Those probes used an expanded main window and do not rule out the original compact recording-to-editor sequence. The visible outline was still unresolved at this probe stage; the later Main-frame module and its source-qualified native checks are recorded below. + +The compact visible-affinity sequence later reproduced the exact outline: enabling exclusion while visible, hiding, then repeated hidden clears changed exactly 3,048 pixels along the original client rectangle. A matched comparison with `DwmFlush` remained clear in its immediate captures, but a later capture showed the outline again. This leaves delayed composition and inspection ordering unresolved. The same run exposed a safety issue with a proposed read-before-write guard: the hidden getter returned success and zero, while the subsequently visible window still reported exclusion enabled. That guard was rejected before any production edit. Removing the new early clear also needs an early-close check because the preparing editor can re-show the protected main window before finalization reaches Idle. + +The actual Windows exporter was run again on two disposable copies differing only in `cursor.hide`. Both native processes exited successfully, but both images were incorrect: the original remained washed out, while the hidden-cursor variant had white and green/magenta corruption. Hiding the cursor does not establish or fix the cause. The ordinary-factory v6 diagnostic then rendered normal output on Windows with both default MediaFoundation-failure-to-FFmpeg fallback and forced FFmpeg. Both resolved to software YUV420p and produced equal decoded/output bytes. The full-library v7 probe uses actual recording metadata and sidecars through `cap_export::preview::render_preview`; both macOS and Windows processes passed and rendered normal color, with equal corresponding outputs for both decoder preferences. All input and disposable-project bytes remained unchanged. Evidence: `same-thread-window-probe-v3/`, `same-thread-window-probe-v4/`, `gpui-export-cursor-ab-v3/`, `color-probe-v6/`, `color-probe-v7/`, and `optimization-stage-13/windows-color-diagnostic-v7/`. + +The shader runtime layout explains the color discrepancy. The production Windows renderer selects bundled DXC only when both `dxcompiler.dll` and `dxil.dll` are beside the executable, or beside its parent when the executable directory is named `deps`. The retained test executables, standalone GPUI output and retained CLI directory lacked that pair; they loaded FXC. The successful v7 diagnostic had the pair and loaded DXC. Copying the exact verified runtime pair beside the unchanged executables corrected the isolated overlay, cursor/ripple and editor PNG tests. Full corrected rendering passed 244 tests with five existing ignored tests; full corrected editor passed all 114 tests. Together with audio 67, recovery 96 and receipts 8, this gives 529 unique current Windows shared library passes. All 2,331 frozen source hashes and retained executable hashes remained unchanged. The original two rendering and five editor failures remain recorded as runtime-layout failures, rather than being erased or weakened. The actual adapter is Microsoft Basic Render Driver through DX12, not a physical GPU. Evidence: `optimization-stage-13/native-current-windows-v3/`. + +The same runtime correction restored full-color native GPUI playback in the unchanged app. Module receipts and the app log confirm the exact adjacent DXC/DXIL libraries were loaded. Real Play/Pause advanced the original footage from 0:37 through 1:04. The retained core-playback snapshot reports 3,489 rendered frames, one skipped frame and zero starvation; GPUI paint submission on this software desktop was about 4.6 fps, so this is not a visible 60 fps claim. That ordinary reopen was outline-free because the main window had been shown and hidden during reopening. It does not prove the Stop outline is fixed. The unchanged CLI also returns a correctly colored JPEG after the runtime correction, but still exits with `0xc0000005`; that diagnostic remains failed. Evidence: `optimization-stage-12/windows-native-ui/runtime-module-audit-v1/`, `runtime-module-audit-v2/` and `gpui-dxc-playback-v1/`. + +A further real 19.017-second Stop reproduced the outline using only real input, desktop video and a subsequent desktop capture, with no window-state queries, hooks or DWM probes during the flow. Capture stop took 81.8 ms and remux 134.1 ms; the 837 captured frames and originals were retained. These are single diagnostic observations, not performance comparisons. Separate compact-window replays reproduced the outline after either one actual hidden affinity clear or two back-to-back clears. Duplicate-clear removal alone is therefore not a demonstrated fix. No production window-style, visibility or affinity change has been applied. Evidence: `gpui-native-v7/`, `same-thread-window-probe-v6a/` and `same-thread-window-probe-v7b/`. + +An additional native GPUI Stop with DXC loaded retained the same desktop outline over correctly colored footage. App logs confirm a manual Stop and a validated 23.767-second recording with 1,030 frames; the initially observed 0:04 was the paused playhead, not automatic recording termination. Capture shutdown took 78.94 ms and remux 152.86 ms. A subsequent hidden-Main `SetWindowPos` using `SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE` returned success, but the before, 600 ms and six-second desktop captures remained byte-identical. A frame refresh alone does not fix the outline. The outline extends outside the video preview on the actual desktop; it is not merely a border painted into the displayed footage. Evidence: `gpui-dxc-stop-v1/` and `hidden-frame-changed-v1/`. + +The current Windows canonical 15- and 60-second cases subsequently passed all eight requested video comparisons against independently finalized software references. All 2,883,584 microphone audio frames matched bit-for-bit through EOF; the separate managed-audio EOF test also passed. Actual worker joins, source release, lock reacquisition, outputs, configuration and original-file audits passed. The 2,331 source hashes and retained test executables remained unchanged. These are functional equivalence results; they do not turn the unresolved outline or CLI exit failure into passes. Windows Tauri scoped compilation and strict library/test Clippy subsequently passed; native library test execution remains separate under the recorded storage limit. Evidence: `optimization-stage-13/native-current-windows-v3/`. + +The requested upstream Zed review is pinned to `86b2cf96623a12ae3f524c3780b6bcce4443ed23`. Its complete inspected Windows backend has no capture-affinity calls or `SW_HIDE`; application hide is empty, while minimize and destruction use separate paths. Cap's local GPUI working patch adds native caption/edge styles and custom client insets absent from both current upstream and the July 19 fork ancestor. Both default to DirectComposition. These findings identify a Cap-specific frame/lifecycle difference, not an upstream workaround already proven for this recording flow. Primary sources: [Zed window styles](https://github.com/zed-industries/zed/blob/86b2cf96623a12ae3f524c3780b6bcce4443ed23/crates/gpui_windows/src/window.rs#L471-L494) and [Windows application hide](https://github.com/zed-industries/zed/blob/86b2cf96623a12ae3f524c3780b6bcce4443ed23/crates/gpui_windows/src/platform.rs#L592-L599). Exact source snapshots, hashes, comparisons and the unapplied diagnostic-only frame patch are under `optimization-stage-13/zed-upstream-comparison/`. + +A fresh native run with the existing `GPUI_DISABLE_DIRECT_COMPOSITION=1` setting initially appeared outline-free. The later desktop capture instead showed the entire hidden main client region as a solid white rectangle. Floating controls also lost transparency, producing black rectangles in this recording. This alternative is rejected: it changes the artifact and introduces visible capture differences. It does not fix the settled result, and no production rendering mode was changed. + +The matched fresh default process restored normal composition and reproduced the outline. A subsequent reversible test on only the idle hidden Main cleared `WS_CAPTION`; Windows automatically cleared only the associated `WS_EX_WINDOWEDGE` bit. With a frame refresh, the outline disappeared while visibility, bounds and composition stayed unchanged. Restoring the exact original styles restored the outline. Both cleared captures matched each other byte-for-byte; the before and both restored captures also matched byte-for-byte. The first attempt stopped on that native coupled-style change and restored the baseline before a corrected diagnostic explicitly recognized it. This proves the visible artifact depends on the native caption/edge representation, but does not yet prove its trigger or justify changing visible-window appearance. No product source was changed. Evidence: `optimization-stage-12/windows-native-ui/hidden-frame-style-v1/` and `hidden-frame-style-v2/`. + +Current Windows scoped compilation for the shared audio, rendering, editor and recording crates passed. Strict library/test Clippy retained a Windows-only `items_after_test_module` finding in `capture_pipeline.rs`, whose full bytes match HEAD and the frozen manifest (`fdee8cb040177fe55cda42e7b595361d6becb87b955e5c8836ba1e0e99c07e7a`). It is recorded as an inherited lint qualification; the file and lint threshold were left unchanged. Tauri scoped compilation and strict library/test Clippy also passed. The native Tauri test run remains a separate gate. + +A bounded native API observer then captured an actual 21.550-second GPUI recording and manual Stop that reproduced the outline. Its 11 committed records contained eight `DefWindowProcW(WM_NCACTIVATE)` calls before recording start and none at Stop. Their common caller and varying message parameters do not identify them as Cap’s synthetic theme redraw pair. All three affinity writes requested zero: one at start and two at Stop. The active Virtual Display Driver explains the existing streamed-display exclusion bypass. The two Stop clears were about 0.870 ms apart. Successful API returns with stale last-error values were not treated as failures. The observer’s warmed disposable-window test preserved return values and last-error behavior; teardown restored exact import pointers and protections with zero dropped or in-flight records. Its inert pinned module was removed by safely exiting the owned idle process. This rules out an observed direct theme redraw at Stop in this run; no speculative theme guard was applied. Evidence: `optimization-stage-12/windows-native-ui/api-observer-gpui-v2/`. + +A subsequent hidden-Main nonclient policy experiment returned `S_OK` for `DWMNCRP_DISABLED` and removed the outline. Immediate and six-second captures matched byte-for-byte. Restoring `DWMNCRP_USEWINDOWSTYLE` returned `S_OK`, re-enabled nonclient rendering and restored a capture byte-identical to the baseline. Process/window identity, styles, extended styles, visibility, iconic state and outer bounds remained exact across all captures; client bounds were not recorded in this probe. The later restored capture differs and is retained. The original raw policy enum was not readable: restoring `USEWINDOWSTYLE` follows the source-default inference, and the owned idle process subsequently exited to clear diagnostic state. This proves the policy can remove the observed artifact without changing window styles. It does not yet validate automatic hide/show handling, visible appearance or a production fix. Evidence: `optimization-stage-12/windows-native-ui/hidden-nc-policy-v1/`. + +The resulting local GPUI candidate installs a native subclass on only the Main window. It restores the normal nonclient policy before show-related default handling, reconciles actual visibility after `WM_WINDOWPOSCHANGED`, and removes its subclass before forwarding destruction. It retains styles, bounds, capture-affinity calls and default message handling. Review removed a premature post-`WM_SHOWWINDOW` reconciliation and added show restoration before forwarding `WM_WINDOWPOSCHANGED` when earlier notification is suppressed. The shared GPUI fork is unchanged. The exact Windows module is `623fd5404020c0eeb63038bf29fd2922cad205826ee39a97dda25662e3f2f082`; macOS GPUI workspace formatting and scoped compilation passed with source/lock hashes unchanged. Those checks exclude Windows code paths; exact-module native Windows validation is pending. Evidence: `optimization-stage-13/windows-hidden-frame-production/`. + +The current Windows Tauri test binary initially failed before the Rust harness with loader status `0xc0000139`. Its missing Common Controls activation manifest caused this fixture-runtime failure. An external manifest did not resolve it. A retained derivative with the exact `tauri-build` application manifest embedded by Microsoft’s resource tool launched successfully; all original executable code/data sections and the untouched original executable were retained and verified. That run passed 437 tests and failed eight at the same closed-listener timeout helper. Four independently bound-and-closed Windows loopback ports correctly returned `WSAECONNREFUSED` after 2.003–2.019 seconds, beyond the helper’s two-second deadline; a fresh-process targeted rerun reproduced the timeout. The resulting test-only change allows five seconds on Windows, retains two seconds elsewhere, and preserves the required connection failure and retry logic. It changes no production listener behavior. Root workspace formatting and Tauri compilation passed with the edited source hash unchanged. Native targeted and full-suite reruns are pending; original loader and timeout failures remain retained. Evidence: `optimization-stage-13/native-current-windows-v3/loopback-report.json` and `frame-ws-test-deadline/`. + +The Windows Tauri test-only v4 rerun subsequently passed all eight formerly failing cases individually and all 445 library tests in the full suite, with only binding generation filtered. Combined with the 529 shared tests, this is 974 unique Windows library passes; the eight canonical video comparisons and 2,883,584 PCM EOF frames are separate integration evidence. All 2,331 source hashes in the frozen shared/Tauri v4 scope remained exact. This scope does not include the later GPUI Main-frame candidate, whose native checks are separate. No production behavior or assertion condition was changed to obtain this result. + +The exact GPUI Main-frame module passed a disposable native Windows test covering already-hidden and visible installation, duplicate installation, wrong-thread rejection, minimize/restore/maximize/move/resize, outer/client rectangle and style parity, forwarded message results/last-error state, and subclass removal before forwarding destruction. Explicit `SW_SHOWNORMAL` and `SWP_NOSENDCHANGING` cases confirmed restoration before underlying show handling. The initial standalone executable lacked Common Controls activation and failed before `main`; the retained v2 used the exact existing GPUI application manifest, compiled without diagnostics and passed. The production GPUI dependency already enables that manifest, so no application manifest change is required. Actual GPUI recording/Stop testing of this exact module is the next gate. Evidence: `optimization-stage-13/windows-hidden-frame-harness-v2/native-smoke-v2/`. + +The first actual GPUI recording with the exact Main-frame module passed the previously failing Stop transition. Installation occurred while Main was visible, before recording hid it and before the existing Stop affinity calls. The temporary installation hook was removed before capture. The 28.185-second recording opened a correctly colored editor with no Main outline in either the immediate image or the pure settled desktop capture; a later observation remained clear after three minutes. Direct native measurements kept the original outer rectangle `[339,164,685,605]`, client rectangle `[347,165,677,597]`, caption and extended style bits, while the hidden noniconic Main reported nonclient rendering disabled. Closing the editor restored a normally framed visible Main. The launcher’s first attempt failed on a PowerShell argument cast before sending any installation command; that failure and successful cleanup were retained before correction. This is a native app diagnostic using the earlier GPUI executable plus the exact new module, not a rebuilt current-controller app. Further lifecycle and current-app build verification remain pending. Evidence: `optimization-stage-13/windows-hidden-frame-harness-v2/native-gpui-v1/`; screenshots: `optimization-stage-12/windows-native-ui/gpui-input-HiddenFrame07-desktop.png` and `pure-capture-HiddenFrameSettled01-desktop.png`. + +## Main-frame lifecycle and remaining audio readiness work + +The exact Windows Main-frame module passed a second genuine manual Stop after editor reopen, compact/expanded layout changes, minimize/restore, caption movement and a System-to-Dark-to-System theme round trip. Both the immediate and 55-second settled repeat screenshots remained free of the Main outline. Visible framing, styles, outer/client bounds and normal restoration were preserved. The two task-owned recordings contain 1,691 and 1,412 independently decoded video frames with no FFprobe failure. They were screen-only recordings, so this is window lifecycle and recording-integrity evidence, not A/V or progressive-playback proof. The owned GPUI process exited normally, removing the diagnostic module. Actual Main fullscreen behavior remains untested; the compact/expanded button is a layout toggle. Full receipts are under `optimization-stage-13/windows-hidden-frame-harness-v2/ASSESSMENT.txt`. + +The audio goal audit found two separate constraints. GPUI had required an absent timing log and zero timing repairs, whereas the ordinary editor and Tauri accept valid timing data. GPUI now uses the same bounded regular-file/UTF-8/4-MiB log snapshot reader as Tauri and derives ordinary repairs from expected finalized metadata. Existing source paths, preserved AAC/container checks, cancellation and required-track semantics are unchanged. Scoped format/check and six native focused tests pass. The copied log reader is byte-identical to Tauri. Tests cover missing/valid/invalid logs, nonzero timing repairs, immutable snapshots and symlink refusal; no actual early-play app handoff is claimed. Evidence: `optimization-stage-13/gpui-audio-timing-admission/assessment.json`. + +The larger remaining constraint is in the shared playback controller: each required audio track must reach successful decoded EOF before its entire clip is admitted. A two-hour clip therefore cannot currently reveal a within-clip playable prefix. The initial video image is independent of that audio gate. The ordinary loader discards an audio track on a later decode error; earlier valid samples cannot be unheard, so partial playback must retain explicit failure handling rather than pretend the tail was validated. Neither a file hash nor packet-only preflight proves complete audio decoding. The full source audit and actual-app evidence boundaries are retained under `optimization-stage-13/preparing-audio-goal-audit/`. + +A bounded input API is now prepared in `cap-audio`: a nonblocking window read distinguishes pending samples, known absence, successful completed audio and errors. Pending windows report only the source-frame range they actually retain; they never claim EOF or advertise additional uncopied blocks as playable. PCM blocks are shared by Arc, and the ordinary full-track decoder/result path is unchanged. A separate review caught and corrected a race between successful result publication and sender closure. Tests exercise publication, dropped/error producers, short tails, empty/large ranges, exact sample mixing across block boundaries, and retained-block ownership. The reviewed API passed macOS formatting, scoped Cargo check, strict scoped Clippy and 69 default library tests. The separately requested matched 60-second fixture EOF test also passed, giving 70 unique audio test passes; its original 1,230,339-byte input SHA remained unchanged. Those are function checks, not Stop-to-editor benchmarks or Linux/Windows validation of this latest delta. Evidence: `optimization-stage-13/preparing-audio-windows-v1/assessment.json`. This remains input-side groundwork: the production playback controller still uses its completed-audio gate. Connecting a bounded output producer, deriving readiness from genuinely rendered output, preserving the output resampler across growth and proving the actual playing handoff are outstanding. + +Current Windows app compilation is held until this shared audio scope settles, to avoid rebuilding stale sources. The exact validated frontend assets and both app build plans are retained. The old Windows function VM is stopped. The Tauri build plan uses an explicitly documented isolated output-type-only manifest adjustment to avoid redundant static/CDylib artifacts in the constrained VM; that debug UI proof will not establish packaged-release or performance equivalence. No recording source, accepted fixture or application manifest in the shared checkout was changed for capacity preparation. + +One scoped unchanged-CLI diagnostic under ProcDump produced a valid full-color 1,168-by-702 JPEG and an observed CLI process exit code of zero. No unhandled exception dump was produced. Two first-chance COM exceptions were handled during the known Media Foundation-to-FFmpeg fallback. The executable, source project and intended adjacent shader DLL hashes were unchanged. This single non-reproduction does not resolve the previously intermittent exit-time access violation, and no crash stack or live module inventory was captured. An earlier attempt failed before rendering because ProcDump changed JSON argument quoting; an argv-only probe established the corrected transport before this run. Both attempts remain retained under `optimization-stage-12/windows-native-ui/cli-unhandled-dump-v1/` and `cli-unhandled-dump-v2/`. + +## Partial audio playback integration + +The shared preparing controller now admits a growing audio prefix within a single recording clip. A separate mixer checks every required source range before producing samples, retains ordinary calibrated offsets, timing repairs, gain and stereo mixing, and permits trailing silence only after successful EOF. Its output producer preserves the resampler across pending input and uses a bounded two-second ring. Ordinary editor mixing and output-buffer selection remain unchanged. Tests compare nonzero output samples with the ordinary path at 44.1 and 48 kHz, across channel layouts, sample formats, fractional seeks, preroll and the long-recording output mode. + +The callback starts with a zero consumption allowance. The controller installs the combined audio/video playable prefix before playback and grows that allowance on the same source generation. Unavailable samples produce hardware silence without advancing the media clock or acknowledging playback. Timestamped consumed spans account for queued latency and starvation; video follows the reported audible position. The last displayed image and its snapshot position remain separate from that live clock. One headless test starts before synthetic EOF, consumes exactly 4,800 frames under the initial prefix, verifies silent callbacks while held, then compares all 70,000 frames after growth without replacing the source. This is real output-control and callback execution using synthetic decoded input, not a new capture benchmark. + +Independent review corrected failed-stream retirement, post-spawn error ownership, initial latency pre-skipping and cleanup ownership after a timeout. A failed device stream is retired on the control thread because a stopped callback cannot process a removal command. Healthy streams retain generation-specific removal so a stale preparing request cannot stop an ordinary replacement. Output cleanup observes both actual producer-thread completion and callback-source removal. The controller retains its ticket until cleanup succeeds. Test-only construction errors and a weak silence-only long-mode fixture were corrected; failed attempts remain in the evidence. + +The final partial-audio snapshot before the successful-publication handoff passed 143 macOS Rust 1.88.0 editor tests after the cleanup ownership correction. The audio suite passed 69 default tests, and the separately requested matched 60-second native AAC fixture passed comparison through decoded EOF. Its original 1,230,339-byte SHA remains `05f898aca4373a8678a3487766858e2a97cdcdd72d101f54a6980440460da808`. Formatting, scoped compilation and strict Clippy for both libraries and their tests passed. Repeated and overlapping focused suites are not additional unique passes. Receipts and before/after source manifests are under `optimization-stage-13/preparing-audio-output-v1/`; independent reviews are under `preparing-audio-stream-retirement/` and `preparing-partial-audio-clock/`. + +Those partial-audio checks did not establish native application Stop-to-first-audible timing, Windows/Linux parity or uninterrupted ordinary-editor handoff. In that snapshot, an incomplete managed source was cancelled/joined and decoded again by ordinary loading; only completed tracks crossed the cache boundary. That implementation limitation motivated the successful-publication handoff below. Device-buffered audio cannot be retracted merely by removing a callback source. Windows retains its existing zero-latency estimate; the pinned CPAL WASAPI playback timestamp is not sufficient evidence for changing that policy. Physical endpoint/loopback timing and actual application presentation remain separate from function-test coverage. + +## Successful-publication playback handoff + +The final candidate records successful publication without reopening source admission: the observer still ends and rejects new leases. A controller that was already admitted for the same successful job can keep its existing readers and audio output alive while preparation finishes and ordinary loading reuses completed PCM. Recovery-lock ownership stays with the original readers until actual cleanup joins release them. Failure, close and supersession continue to stop and join owned work. + +The ordinary candidate commits adoption only against a matching presented frame and current playback intent. It then follows the existing consumed-audio clock and audio generation. Pause or seek can invalidate and replace a pending candidate, and cleanup of an uncommitted or stale candidate cannot shut down its predecessor or successor. Existing explicit ordinary pause/seek/restart paths stop and join the adopted owner before installing a replacement source. + +The GPUI v4 callback also permits new frames from the already-admitted, same-job preparing session after successful publication; failure, cancellation and supersession still reject delivery. Its added admission regression test is included in the three GPUI v4 suites. + +The macOS editor suite below includes native fixture and headless output tests for PCM reuse, candidate disposal, moving-frame handoff, cancellation and panic completion. These tests do not measure a physical audio endpoint or prove that a real application window presents an uninterrupted, aligned audio/image transition. + +## Current automated verification before manual testing + +The rows below distinguish completed native test scopes from checks left unrun when the user ended automated testing and requested PR publication. GPUI uses the matching v4 source on macOS, Linux and Windows. Root Rust inputs are unchanged across the v2–v4 source manifests; subsequent deltas are the GPUI fixes and generated Tauri bindings. The older three-platform table remains historical evidence and is not added to these counts. + +| Scope | Result | Qualification | +| ----------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| macOS editor library | 161 passed | Native handoff and headless audio ownership tests included | +| macOS recording/recovery | 597 passed | 535 library, 12 real-subprocess muxer and 50 recovery integration tests; three library ignores and one external-fixture integration ignore | +| macOS Tauri | 470 passed, plus one binding-generator test | Generated bindings were then used by frontend validation | +| macOS GPUI v4 | 993 passed | Two existing picker/screenshot benchmark tests ignored; all-target compilation passed | +| Linux GPUI v4 | 1,052 passed | Two existing ignores; native compilation and source/runtime/binary integrity checks passed | +| Windows GPUI v4 | 970 passed | Two existing ignores; native compilation and source/runtime/binary integrity checks passed | +| Desktop frontend | 473 passed in 47 files | Final generated bindings included; TypeScript passed | +| Web frontend | 3,047 passed, 28 skipped | 219 passing test files and six skipped files; TypeScript passed | +| Tooling | 64 passed | Separate function-test scope | +| Linux root libraries | Listed suites passed | Audio 69, editor 160, encoder 150, video decoder eight, project 130, rendering 244, recording 690, export 32 and muxer protocol seven | +| Linux integration follow-up | Enabled requested scopes passed | Real muxer/OOP, recovery, sync, pipeline, rendering and synthetic scopes passed; three explicit GPU correctness tests and the separate 60-second audio EOF case passed | +| Windows shared libraries | 1,314 passed | Audio 73, editor 159, encoder 138, decoder 11, project 129, rendering 244, recording 530 and export 30; nine default-suite ignores | +| Linux Tauri library | 530 passed | Binding writer filtered; native library/bin check and strict library/test/bin Clippy passed | +| Linux Tauri main binary | Six passed | Exact main source and 107 unchanged dependency artifacts; direct compiler invocation after ordinary Cargo staticlib packaging exhausted disk | +| Linux canonical follow-up | Three executions passed | 15-/60-second recording scenarios plus the separate audio EOF case; this repeats the earlier audio EOF test and is not three additional unique unit tests | +| Windows remaining root/Tauri scopes | **Not run; testing stopped** | Protocol check passed; protocol tests, Tauri, muxer, integrations, explicit GPU, canonical, hardware and remaining strict gates are incomplete | + +The final macOS editor and Tauri all-target strict Clippy gate passed, as did recording library/test strict Clippy. Strict GPUI Clippy did **not** pass on any of the three platforms. macOS v4 emitted 28 diagnostic blocks representing 27 distinct primary spans, including one repeated crop diagnostic. All 27 match v3; three additional unchanged `editor_frame0` spans from the v3 JSON were not emitted in the v4 text and are not classified as resolved. Linux emitted 23 errors and Windows 28. Every emitted primary excerpt and surrounding context was verified unchanged against HEAD and the initial adopted source. Linux's platform-specific `explicit_auto_deref` and Windows' `infallible_destructuring_match` also match that baseline. No new diagnostic context was found, and no suppression was added. Passing tests and compilation do not convert these strict-lint failures into green gates. Linux export strict Clippy also retains two test-only baseline diagnostics: an irrefutable `let` pattern on Linux and `field_reassign_with_default`. Their entire source files match HEAD and the initial adopted snapshot. No later strict export pass supersedes that receipt. + +The Linux integration follow-up passed nine muxer unit tests, 12 OOP tests, 50 recovery integrations, four sync tests, 61 instant-mode scenarios, five segmented-pipeline tests, two rendering integration tests and 28 synthetic tests. Three offscreen GPU tests and one audio EOF case were executed separately from their default suites. Empty platform targets, hardware exclusions and external-fixture/benchmark ignores are not counted as executed tests. The follow-up initially stopped on an unsuccessful 15-second canonical finalization probe (`exit 101`). A fresh native lane subsequently passed both the 15- and 60-second canonical recording scenarios and repeated the audio EOF test using the exact retained current binaries. Original fixtures, sources, native inputs and executable hashes remained unchanged. The first failed attempt remains evidence; the later result establishes successful retry execution, without claiming a cause for the first failure or complete seek-frame coverage from the summary alone. The repeated audio case is not an additional unique pass. Windows remaining root and Tauri scopes were not run before the user stopped further testing. + +Linux Tauri has 536 executed function-test passes in total: 530 from its normal Cargo-built library harness and six from the main-binary harness. The ordinary Cargo binary-test build failed while creating `libcap_desktop_lib.a` with `ENOSPC`. The six main tests then passed using a reconstructed direct Rust 1.88 invocation, the exact main source and 107 unchanged dependency artifacts, including the completed ordinary Rust library output. No source or Cargo manifest was changed. Strict Tauri Clippy subsequently passed for library, tests and binary targets. This closes those function-test scopes; the ordinary static-library packaging build remains failed and is not relabelled as a successful standard Cargo or release-package build. + +The Windows shared-library total comprises 232 unchanged-source passes inherited from the preceding VM (audio 73 and editor 159) and 1,082 newly executed passes (encoder 138, video decoder 11, project 129, rendering 244, recording 530 and export 30). The inherited suites were not rerun or counted twice. Their strict audio/editor gates had passed. The new protocol compilation check passed, but its test build did not start: the lane stopped at the 1.6-GB preflight with 1,581,453,312 bytes free. The remaining protocol tests, Tauri, muxer, integrations, explicit GPU correctness cases, canonical fixtures, hardware cases and remaining strict Clippy gates were not run for this final scope. The user then explicitly ended testing; older successful runs do not fill these gaps. Source, runtime, fixture and executable integrity checks passed at the stop. All eight executables and 50 logs are retained; the overall shared-phase receipt remains `passed: false` because the requested phase was incomplete. + +The earlier empty-output muxer crashes and filesystem-sensitive recovery failure retain their original failure evidence. Their focused Linux/Windows fix snapshots are historical; the current Linux recording library and real OOP/recovery results above provide later matching-root-source coverage. Current Windows recording-library passes do not complete the unrun real-helper/integration scopes; the older 27-test fix snapshot remains qualified historical evidence. + +Counts describe their listed scopes, not globally unique tests across operating systems. Earlier editor runs, focused frontend subsets, previous platform totals and repeated retries are not additional passes. Canonical 15-/60-second fixture scenarios remain separate from unit counts. No final-candidate physical audio-endpoint timing, continuous handoff UI test, new capture benchmark or expanded two-hour run is claimed. Manual testing should use a fresh candidate and include playback while preparation finishes, seeking, pausing and closing/reopening the editor. + +Current receipts are under `.git/codex-build/session-support/01a08773-a817-7de1-988e-ab9078f33c12/`: `macos-final-editor-v2-tests`, `macos-final-recording-tests`, `macos-final-tauri-tests`, `macos-final-tauri-bindings`, `macos-final-gpui-tests-v4`, their check/Clippy results, `gpui-clippy-v4-baseline-comparison.json`, `gpui-v4-platform-clippy-triage-v1/assessment.json`, `final-source-v2/linux-root-progress.json`, the captured `resume-progress-v1/{linux-root,linux-root-latest,windows-root-latest,linux-tauri-latest}.json` receipts, `linux-tauri-replacement-v1/linux-tauri-canonical-v1-latest.json`, `linux-tauri-main-test-v1/{progress,clippy-progress}.json`, `linux-tauri-bin-triage-v1/assessment.json`, `linux-export-clippy-triage-v1/assessment.json`, `windows-root-replacement-v1/shared-progress.json`, `windows-shared-retained-v1/receipt.json`, `windows-inherited-root-retained-v1/receipt.json`, `desktop-tests-final-bindings-v1.log`, `web-tests-postreview.log`, `tooling-tests.log`, and the final frontend typecheck logs. Interpret mutable polling filenames using the hashes retained with this report candidate. Further platform testing was stopped at the user’s request. Active test sandboxes were stopped and task resource removal was requested after local evidence retention. Three older restore requests rejected stop/delete because the provider reports a state change in progress; cleanup remains qualified until those requests are cleared. Receipts are in `final-sandbox-shutdown-v2/` and `final-sandbox-cleanup-v1/`; the earlier stopped-sandbox statement applies only to its historical run. + +### Earlier partial-audio verification snapshot, September 10 + +In the earlier frozen partial-audio snapshot, the enabled app suites passed on all three operating systems. That broader run was not all green; its failures motivated the later fixes: + +| Platform | Tauri app suite | GPUI app suite | Broader native results | +| -------- | ----------------------------------: | -------------: | -------------------------------------------------------------------- | +| macOS | 468 passed, plus binding generation | 992 passed | 2,854 passed, zero failures | +| Windows | 445 passed | 969 passed | 2,880 reported passes, three empty-output muxer failures | +| Linux | 528 passed | 1,051 passed | 3,099 passed on the normal filesystem, four failures described below | + +Counts are per platform and selected native suites, not globally unique tests across operating systems. Repeated checks are not additional passes. Windows' reported passes include one loopback test that returned early because no output device existed; this is not physical loopback proof. Two canonical recording scenarios per operating system are recorded separately from the table. The explicitly requested ignored audio-EOF test is included once in each native total. Manual device, benchmark and external-fixture ignores remain documented in the individual receipts. + +That verification-only phase changed no production source or test assertions. The common 2,337-input source archive has SHA-256 `7f82cd885259e9100e1fa32e5a54ab2597015e21029bf99d6c78029c97d1794d`; its manifest has SHA-256 `4a5b7ebb67f54e6e22678488a9592d6fc51f6953cdd42f36de199baf31021e4a`. macOS additionally requires 24 platform-specific source files, and both app test setups require two exact committed MP4 fixtures. The Windows root-workspace source manifest differs only in GPUI-specific files, which the separate Windows GPUI gate for that snapshot covered. Later unrelated sharing/reupload and development-launcher edits in the shared checkout are excluded. These results apply only to that frozen candidate. The subsequent combined build adopts additional sharing/reupload and launcher changes as well as the recovery, muxer and handoff fixes. + +The desktop frontend passed all 460 tests across 47 files, TypeScript compilation and Biome on all 40 frozen touched frontend files. Another 28 packaging-function tests passed on macOS; they are not native Linux execution. Scoped native compilation and formatting passed. Strict editor/audio library-and-test Clippy passed on all three platforms. Linux Tauri strict Clippy in that earlier snapshot reported three existing findings in unchanged `upload.rs`: unused `SegmentUploader::spawn`, unused `Control::complete`, and a redundant `Bytes` conversion. The binding-writing test was filtered on Windows/Linux and explicitly executed on the isolated macOS copy; generated bindings were byte-identical. + +Both preserved 15- and 60-second `.cap` fixtures were replayed on every platform. macOS and Windows observed all eight requested video frames and matched their independent ordinary-finalized references exactly. Linux observed five of eight requests before finalization ended the preparing session; every observed frame matched, but three cancelled requests are not frame-equivalence coverage. All three platforms matched every one of the 2,883,584 decoded audio frames through EOF on the 60-second fixture. Source, published-media, normalized-document, lock and worker-join checks passed. Original fixture hashes stayed unchanged. These are functional comparisons, not display-latency or audible-endpoint measurements. + +The three OOP muxer failures in that earlier Windows/Linux run were `respawning_subprocess_reports_clean_exit_when_no_crash`, `subprocess_spawns_and_finishes_cleanly_without_packets`, and `subprocess_survives_finish_after_init_only`. Actual packet-producing muxer tests passed in that run. The helper, protocol, calling OOP module, integration tests and all 89 transitive helper lock entries matched HEAD `284ae24a79bd0813743f51e6219d11ae0042d38f` before the later fix. The standalone Windows initialize/start/finish replay with no packets also crashed before the fix. Its minidump places exception `0xC0000094` inside the loaded `avformat-61.dll` at offset `0x4c8f2`. Native disassembly divides the byte-position-derived numerator by zero immediately before formatting representation bandwidth, matching FFmpeg 7.1's unguarded final DASH bitrate calculation ([pinned source](https://github.com/FFmpeg/FFmpeg/blob/n7.1/libavformat/dashenc.c#L834)). The small dump omits the divisor's heap page and has no complete symbolized internal stack; zero numerator registers rule out quotient overflow. Linux's Rust wrapper reports a non-successful child exit without retaining the signal. macOS passes all nine OOP tests with an ARM64 helper loading FFmpeg 7.1 / libavformat 61.7.100; that does not clear the failing x86 platform cases. No trailer, bitrate, library or assertion was changed during that diagnostic phase. The subsequent reviewed helper fix and added regression tests have their own passing native receipts above. + +The fourth failure in that earlier normal-filesystem Linux run was `recovery::transactional_recovery_tests::interrupted_rollback_reconciles_and_relaunches_at_every_rename_boundary`. That library suite reported 685 passes and one failure on the sandbox root filesystem. On tmpfs, that rollback test passes, while two executable-launch tests fail because the mount is noexec. Across these controls, 686 distinct recording-library tests pass, making 3,100 distinct passing names across all documented Linux filesystem scopes; this union is not a green normal-filesystem run. The retained older executable and current executable each reproduce the root-filesystem failure in all three alternating runs. Before the fix, the stamp, reconciliation function and failing test matched HEAD. A separate four-rename diagnostic reproduced directory size changing from 42 to 54 bytes without content or timestamp changes on rootfs; tmpfs remained stable. This supports an existing filesystem-sensitive guard but does not capture the exact differing field in the failed journal. No receipt format or recovery validation was changed during that diagnostic phase. The later guarded stamp fix was independently tested on the normal filesystem, as recorded above. + +Setup corrections are retained separately from product failures. Windows hardware compatibility passed its complete 13-test enabled suite in the existing Administrator desktop session, with seven hardware-specific ignores; the original SYSTEM/session-0 capture-support failure remains in the logs. Linux GPUI passed one complete 1,051-test invocation using the original Cargo test executable name and a stable, task-owned Xvfb display. Initial display/reset and renamed-executable failures are retained. The virtual display was stopped afterward; no application UI or screenshots were produced. Three explicitly executed offscreen GPU correctness tests passed per platform; Windows used WARP and Linux used llvmpipe, so this does not establish physical-GPU coverage. Linux's final 32-test export suite passed with the frozen root Rust 1.88 graph and unchanged source/lock, after downloading one already-pinned development dependency. + +That snapshot supplied source-bound regression evidence and concrete failures, not packaged-release clearance or a no-regressions guarantee. The final candidate now implements the successful-publication handoff, but its physical audible transition and application presentation remain unverified. + +Receipts live under `optimization-stage-13/current-macos-automated-v2/`, `native-partial-audio-windows-v1/extended-assessment.json`, `windows-gpui-automated-v1/`, `partial-audio-linux-v1/`, `partial-audio-gpui-linux-v1/`, `windows-integration-failure-audit/`, and `current-frontend-tests/`. Sources, original media, exact native executables, failed attempts and diagnostic evidence are retained. + +All four sandboxes used for that historical test run were stopped normally after evidence verification. The older sandbox with a pending restore remains archived with a requested start; its 60-minute idle-stop and auto-archive policies were verified, and auto-delete is disabled. That receipt recorded no remaining test process at the time; it is not a current operational-state claim. The consolidated historical receipt is `optimization-stage-13/automated-test-handoff-final.json`. diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 8e32053127e..4794ea30864 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -106,6 +106,7 @@ cap-fail = { version = "0.1.0", path = "../../../crates/fail" } tokio-stream = { version = "0.1.17", features = ["sync"] } md5 = "0.7.0" tokio-util = "0.7.15" +tokio-tungstenite = "=0.24.0" wgpu.workspace = true pollster = "0.4" bytemuck = "1.23.1" diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-tauri/src/api.rs index 1aa56dfd69e..94c20abc2fb 100644 --- a/apps/desktop/src-tauri/src/api.rs +++ b/apps/desktop/src-tauri/src/api.rs @@ -126,6 +126,7 @@ pub async fn upload_multipart_complete( upload_id: &str, parts: &[UploadedPart], meta: Option, + replace_existing: bool, ) -> Result, AuthedApiError> { #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -135,6 +136,7 @@ pub async fn upload_multipart_complete( parts: &'a [UploadedPart], #[serde(flatten)] meta: Option, + replace_existing: bool, } #[derive(Deserialize)] @@ -154,6 +156,7 @@ pub async fn upload_multipart_complete( upload_id, parts, meta, + replace_existing, }) }) .await diff --git a/apps/desktop/src-tauri/src/audio.rs b/apps/desktop/src-tauri/src/audio.rs index 7c717bd221c..deaa05a1e92 100644 --- a/apps/desktop/src-tauri/src/audio.rs +++ b/apps/desktop/src-tauri/src/audio.rs @@ -1,4 +1,4 @@ -use cap_audio::AudioData; +use cap_audio::DecodedAudio; fn play_audio(bytes: &'static [u8]) { use rodio::{Decoder, OutputStream, Sink}; @@ -39,33 +39,6 @@ impl AppSounds { } } -pub fn get_waveform(audio: &AudioData) -> Vec { - const CHUNK_SIZE: usize = (cap_audio::AudioData::SAMPLE_RATE as usize) / 10; // ~100ms - - let channels = audio.channels() as usize; - let samples = audio.samples(); - let mut waveform = Vec::new(); - - let mut i = 0; - while i < samples.len() { - let end = (i + CHUNK_SIZE * channels).min(samples.len()); - let mut sum = 0.0f32; - for s in &samples[i..end] { - sum += s.abs(); - } - let avg = if end > i { sum / (end - i) as f32 } else { 0.0 }; - waveform.push(avg); - i += CHUNK_SIZE * channels; - } - - // Convert to absolute dBFS (0 dBFS = digital full scale) - for v in waveform.iter_mut() { - *v = if *v > 0.0 { - 20.0 * v.log10() // Absolute dBFS relative to 1.0 - } else { - -60.0 // Set silence to -60dBFS instead of -∞ for practical use - }; - } - - waveform +pub fn get_waveform(audio: &DecodedAudio) -> Vec { + cap_audio::waveform_peaks(audio.sample_slices().flatten(), audio.channels()) } diff --git a/apps/desktop/src-tauri/src/editor_preparing.rs b/apps/desktop/src-tauri/src/editor_preparing.rs new file mode 100644 index 00000000000..d3a91041841 --- /dev/null +++ b/apps/desktop/src-tauri/src/editor_preparing.rs @@ -0,0 +1,1395 @@ +use std::{ + collections::HashMap, + io::{self, BufReader, Read}, + panic::AssertUnwindSafe, + path::{Path, PathBuf}, + str::FromStr, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use cap_editor::{ + EditorFrameFormat, PreparingEditorProgress, PreparingPlaybackController, PreparingPlaybackExit, + PreparingPlaybackOptions, PreparingPlaybackSession, PreparingPlaybackSnapshot, + PreparingPlaybackState, PreparingPlaybackStopHandle, PreparingPreviewInput, + PreparingPreviewOptions, PreparingPreviewSegment, +}; +use cap_project::{CursorEvents, ProjectConfiguration}; +use cap_recording::recovery::{ + PreparingSidecarKind, PreparingStudioSources, PreparingStudioState, PreparingVideoTrack, +}; +use cap_rendering::{FrozenRecordedCursorAssets, ManagedSegmentVideoInput, ManagedVideoTrackInput}; +use futures::{ + FutureExt, + future::{BoxFuture, Shared}, +}; +use serde::Serialize; +use specta::Type; +use tauri::{AppHandle, Manager, Window}; +use tauri_specta::Event; +use tokio::sync::{oneshot, watch}; + +use crate::frame_ws::{OwnedWatchFrameWs, WSFrame}; +use crate::{ + FinalizationProject, FinalizingRecordings, + preparing_finalization::FinalizationPreparing, + windows::{CapWindowId, EditorWindowIds}, +}; + +mod audio; + +const MAX_CURSOR_BYTES: u64 = 256 * 1024 * 1024; + +#[derive(Clone, Debug, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PreparingEditorSeed { + title: String, + tracks: Vec, +} + +#[derive(Clone, Debug, Serialize, Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PreparingEditorChanged { + request_epoch: u32, + job_id: String, + sequence: u32, + fps: u32, + progress: PreparingEditorProgress, + playback: PreparingPlaybackState, + seed: PreparingEditorSeed, +} + +#[derive(Default)] +struct PlaybackBinding { + latest: Option, + controller: Option, + audio_output: Option>, + exit: Option, + handoff: Option, +} + +#[derive(Clone)] +struct RetainedJoin(Shared>>); + +impl RetainedJoin { + fn new(task: tokio::task::JoinHandle>) -> Self { + let completion = async move { + task.await + .map_err(|error| format!("Preparing cleanup task failed: {error}"))? + } + .boxed() + .shared(); + let retained = completion.clone(); + drop(tokio::spawn(async move { + let _ = retained.await; + })); + Self(completion) + } + + async fn wait(&self) -> Result<(), String> { + self.0.clone().await + } + fn succeeded(&self) -> bool { + self.0.peek().is_some_and(Result::is_ok) + } +} + +struct ConsumerControl { + window_ids: EditorWindowIds, + window_id: u32, + request_epoch: u32, + path: PathBuf, + finalization: FinalizationPreparing, + cancelled: watch::Sender, + accepts_frames: AtomicBool, + playback: Mutex, + publish: Option>, +} + +impl ConsumerControl { + fn cancel(&self) { + if let Some(handoff) = &self.playback.lock().unwrap().handoff { + handoff.cancel(); + } + self.accepts_frames.store(false, Ordering::Release); + self.cancelled.send_replace(true); + } + + fn is_active(&self) -> bool { + !*self.cancelled.borrow() + && self + .window_ids + .ids + .lock() + .unwrap() + .iter() + .any(|(path, id)| *id == self.window_id && path == &self.path) + && (self.finalization.is_pending() || self.finalization.allows_preparing_continuation()) + } + + fn publish_snapshot(&self, snapshot: &PreparingPlaybackSnapshot) { + let update = { + let mut binding = self.playback.lock().unwrap(); + let Some(latest) = binding.latest.as_mut() else { + return; + }; + latest.sequence = latest.sequence.saturating_add(1); + latest.progress = snapshot.progress.clone(); + latest.playback = snapshot.playback; + latest.clone() + }; + if let Some(publish) = &self.publish { + publish(update); + } + } + + fn accepts(&self) -> bool { + self.accepts_frames.load(Ordering::Acquire) && self.is_active() + } + + fn retire_playback(&self) { + let output = { + let mut binding = self.playback.lock().unwrap(); + binding.controller = None; + binding.exit = None; + if let Some(handoff) = binding.handoff.take() { + handoff.cancel(); + } + binding.audio_output.take() + }; + if let Some(output) = output { + output.shutdown(); + } + } +} + +struct Entry { + control: Arc, + joined: RetainedJoin, +} + +#[derive(Default)] +struct ProjectConsumers { + ordinary_loading: bool, + entries: Vec, +} + +#[derive(Default)] +struct Registry { + projects: HashMap, + epochs: HashMap, +} + +#[derive(Clone, Default)] +pub(crate) struct PreparingConsumers(Arc>); + +impl PreparingConsumers { + pub(crate) async fn dispose_all(&self) -> Result<(), String> { + let paths = { + let registry = self.0.lock().unwrap(); + for project in registry.projects.values() { + for entry in &project.entries { + entry.control.cancel(); + } + } + registry.projects.keys().cloned().collect::>() + }; + let mut failure = None; + for path in paths { + if let Err(error) = self.before_ordinary_loading(&path).await { + failure.get_or_insert(error); + } + } + for project in self.0.lock().unwrap().projects.values() { + for entry in &project.entries { + entry.control.retire_playback(); + } + } + failure.map_or(Ok(()), Err) + } + + pub(crate) fn cancel_window(&self, window_id: u32, epoch: Option) { + let registry = self.0.lock().unwrap(); + let mut closed = Vec::new(); + for project in registry.projects.values() { + for entry in &project.entries { + if entry.control.window_id == window_id + && epoch.is_none_or(|epoch| entry.control.request_epoch == epoch) + { + let adopted = epoch.is_some() + && entry + .control + .playback + .lock() + .unwrap() + .handoff + .as_ref() + .is_some_and(|handoff| handoff.committed()); + if adopted { + entry.control.accepts_frames.store(false, Ordering::Release); + entry.control.cancelled.send_replace(true); + } else { + entry.control.cancel(); + } + if epoch.is_none() { + closed.push((entry.control.clone(), entry.joined.clone())); + } + } + } + } + drop(registry); + if !closed.is_empty() { + let registry = self.clone(); + drop(tokio::spawn(async move { + for (control, joined) in closed { + let _ = joined.wait().await; + control.retire_playback(); + } + for project in registry.0.lock().unwrap().projects.values_mut() { + project.entries.retain(|entry| { + entry.control.window_id != window_id || !entry.joined.succeeded() + }); + } + })); + } + } + + fn control_for_window( + &self, + window_id: u32, + epoch: Option, + ) -> Option> { + self.0 + .lock() + .unwrap() + .projects + .values() + .flat_map(|project| &project.entries) + .filter(|entry| { + entry.control.window_id == window_id + && epoch.is_none_or(|epoch| entry.control.request_epoch == epoch) + }) + .max_by_key(|entry| entry.control.request_epoch) + .map(|entry| entry.control.clone()) + } + + pub(crate) fn snapshot_for_window(&self, window_id: u32) -> Option { + self.control_for_window(window_id, None)? + .playback + .lock() + .unwrap() + .latest + .clone() + } + + pub(crate) async fn take_startup( + &self, + path: &Path, + ) -> Result< + ( + Arc, + cap_editor::EditorStartupInputs, + Option, + ), + String, + > { + let latest = { + let registry = self.0.lock().unwrap(); + registry.projects.get(path).and_then(|project| { + project + .entries + .iter() + .filter(|entry| { + entry.joined.succeeded() + || (entry.control.is_active() + && entry.control.finalization.allows_preparing_continuation()) + }) + .max_by_key(|entry| entry.control.request_epoch) + .map(|entry| (entry.control.clone(), entry.joined.clone())) + }) + }; + if let Some((control, joined)) = latest { + let handoff = control.playback.lock().unwrap().handoff.clone(); + let mut completed_audio = None; + let mut continuing = None; + if let Some(handoff) = handoff { + match handoff.take_completed_audio().await { + Ok(audio) => { + completed_audio = Some(audio); + continuing = Some(handoff); + } + Err(_) => { + control.cancel(); + joined.wait().await?; + } + } + } + let mut binding = control.playback.lock().unwrap(); + if completed_audio.is_none() { + completed_audio = binding + .exit + .take() + .and_then(|exit| exit.take_completed_audio()); + } + let output = binding + .audio_output + .take() + .unwrap_or_else(|| Arc::new(cap_editor::AudioOutput::new())); + return Ok(( + output, + cap_editor::EditorStartupInputs { + recordings: None, + completed_audio, + }, + continuing, + )); + } + Ok(( + Arc::new(cap_editor::AudioOutput::new()), + cap_editor::EditorStartupInputs::default(), + None, + )) + } + + pub(crate) async fn before_ordinary_loading(&self, path: &Path) -> Result<(), String> { + let pending = { + let mut registry = self.0.lock().unwrap(); + let project = registry.projects.entry(path.to_path_buf()).or_default(); + project.ordinary_loading = true; + project + .entries + .iter() + .filter_map(|entry| { + if entry.control.is_active() + && entry.control.finalization.allows_preparing_continuation() + && entry.control.playback.lock().unwrap().handoff.is_some() + { + return None; + } + entry.control.cancel(); + Some(entry.joined.clone()) + }) + .collect::>() + }; + let mut failure = None; + for joined in pending { + if let Err(error) = joined.wait().await { + failure.get_or_insert(error); + } + } + failure.map_or(Ok(()), Err) + } +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn create_preparing_editor_frame( + window: Window, + request_epoch: u32, +) -> Result, String> { + let CapWindowId::Editor { id } = + CapWindowId::from_str(window.label()).map_err(|error| error.to_string())? + else { + return Err("Invalid editor window".into()); + }; + let app = window.app_handle(); + let window_ids = EditorWindowIds::get(app); + let path = window_ids + .ids + .lock() + .unwrap() + .iter() + .find(|(_, current)| *current == id) + .map(|(path, _)| path.clone()) + .ok_or("Editor window is closed")?; + let project = FinalizationProject::observe(path.clone()).await?; + if path != project.work_path() { + return Ok(None); + } + let Some(finalization) = app + .state::() + .preparing_for_project(&project) + else { + return Ok(None); + }; + let registry = app.state::().inner().clone(); + let (response, result) = oneshot::channel(); + { + let ids = window_ids.ids.lock().unwrap(); + if !ids + .iter() + .any(|(registered, current)| *current == id && registered == &path) + { + return Ok(None); + } + let mut registry = registry.0.lock().unwrap(); + if request_epoch == 0 + || registry + .epochs + .get(&id) + .is_some_and(|epoch| *epoch >= request_epoch) + { + return Ok(None); + } + registry.epochs.insert(id, request_epoch); + let project = registry.projects.entry(path.clone()).or_default(); + if project.ordinary_loading || !finalization.is_pending() { + return Ok(None); + } + project.entries.retain(|entry| !entry.joined.succeeded()); + let previous = project + .entries + .iter() + .map(|entry| { + entry.control.cancel(); + (entry.joined.clone(), entry.control.clone()) + }) + .collect(); + let control = Arc::new(ConsumerControl { + window_ids: window_ids.clone(), + window_id: id, + request_epoch, + path, + finalization, + cancelled: watch::channel(false).0, + accepts_frames: AtomicBool::new(false), + playback: Mutex::default(), + publish: Some(Box::new({ + let window = window.clone(); + move |snapshot| { + let _ = snapshot.emit_to(&window, window.label()); + } + })), + }); + let runner = Runner { + control: control.clone(), + previous, + response: Some(response), + transport: None, + stop: None, + frames: watch::channel(None).0, + }; + let joined = RetainedJoin::new(tokio::spawn(runner.run())); + project.entries.push(Entry { control, joined }); + } + result + .await + .map_err(|error| format!("Preparing frame admission failed: {error}")) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn stop_preparing_editor_frame( + window: Window, + request_epoch: u32, +) -> Result<(), String> { + let CapWindowId::Editor { id } = + CapWindowId::from_str(window.label()).map_err(|error| error.to_string())? + else { + return Err("Invalid editor window".into()); + }; + window + .app_handle() + .state::() + .cancel_window(id, Some(request_epoch)); + Ok(()) +} + +fn editor_window_id(window: &Window) -> Result { + match CapWindowId::from_str(window.label()).map_err(|error| error.to_string())? { + CapWindowId::Editor { id } => Ok(id), + _ => Err("Invalid editor window".into()), + } +} + +#[tauri::command] +#[specta::specta] +pub(crate) fn get_preparing_editor_state( + window: Window, + request_epoch: u32, +) -> Result, String> { + let id = editor_window_id(&window)?; + let state = window.state::(); + Ok(state + .control_for_window(id, Some(request_epoch)) + .and_then(|control| control.playback.lock().unwrap().latest.clone())) +} + +fn playback_controller( + window: &Window, + request_epoch: u32, + job_id: &str, +) -> Result { + let id = editor_window_id(window)?; + let control = window + .state::() + .control_for_window(id, Some(request_epoch)) + .ok_or("Preparing editor is closed")?; + { + let binding = control.playback.lock().unwrap(); + if binding + .latest + .as_ref() + .is_none_or(|latest| latest.job_id != job_id) + { + return Err("Preparing editor identity changed".into()); + } + if binding + .handoff + .as_ref() + .is_some_and(|handoff| handoff.committed()) + { + return Err("Preparing playback has been adopted".into()); + } + } + if !control.is_active() { + return Err("Preparing editor has ended".into()); + } + let binding = control.playback.lock().unwrap(); + binding + .controller + .clone() + .ok_or_else(|| "Preparing playback is not available".into()) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn seek_preparing_editor( + window: Window, + request_epoch: u32, + job_id: String, + seconds: f64, +) -> Result<(), String> { + playback_controller(&window, request_epoch, &job_id)? + .seek(seconds) + .await +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn set_preparing_editor_playing( + window: Window, + request_epoch: u32, + job_id: String, + playing: bool, +) -> Result<(), String> { + playback_controller(&window, request_epoch, &job_id)? + .set_playing(playing) + .await +} + +pub(crate) async fn join_before_ordinary(app: &AppHandle, path: &Path) -> Result<(), String> { + app.state::() + .inner() + .clone() + .before_ordinary_loading(path) + .await +} + +struct Runner { + control: Arc, + previous: Vec<(RetainedJoin, Arc)>, + response: Option>>, + transport: Option, + stop: Option, + frames: watch::Sender>>, +} + +impl Runner { + async fn run(mut self) -> Result<(), String> { + let result = AssertUnwindSafe(self.run_inner()).catch_unwind().await; + let adopted = matches!(&result, Ok(Ok(()))) + && self + .control + .playback + .lock() + .unwrap() + .handoff + .as_ref() + .is_some_and(|handoff| handoff.committed()); + if adopted { + self.control.accepts_frames.store(false, Ordering::Release); + self.control.cancelled.send_replace(true); + self.stop = None; + } else { + self.control.cancel(); + } + self.frames.send_replace(None); + if let Some(response) = self.response.take() { + let _ = response.send(None); + } + let mut failure = match result { + Ok(Ok(())) => None, + Ok(Err(error)) => { + tracing::debug!(%error, "Tauri preparing preview declined"); + None + } + Err(_) => { + tracing::warn!("Tauri preparing adapter panicked"); + None + } + }; + if let Some(stop) = &self.stop { + stop.cancel(); + } + if let Some(transport) = self.transport.take() + && let Err(error) = transport.stop_and_wait().await + { + failure.get_or_insert(error); + } + if let Some(stop) = self.stop.take() { + let exit = stop.stop_and_wait().await; + self.control.publish_snapshot(&exit.snapshot); + if exit.cleanup_failed { + failure.get_or_insert_with(|| { + exit.error + .clone() + .unwrap_or_else(|| "Preparing media cleanup failed".into()) + }); + } + let mut binding = self.control.playback.lock().unwrap(); + binding.controller = None; + binding.exit = Some(exit); + } + failure.map_or(Ok(()), Err) + } + + async fn run_inner(&mut self) -> Result<(), String> { + for (previous, control) in &self.previous { + let result = previous.wait().await; + control.retire_playback(); + result?; + } + if !self.control.is_active() { + return Ok(()); + } + let control = self.control.clone(); + let transport = crate::frame_ws::create_owned_watch_frame_ws( + self.frames.subscribe(), + Arc::new(move || control.accepts()), + ) + .await?; + let url = transport.url.clone(); + self.transport = Some(transport); + if let Some(response) = self.response.take() + && response.send(Some(url)).is_err() + { + return Ok(()); + } + let mut cancel = self.control.cancelled.subscribe(); + if *cancel.borrow() { + return Ok(()); + } + let observer = tokio::select! { + biased; + _ = cancel.changed() => return Ok(()), + observer = self.control.finalization.wait_for_preparing() => observer, + }; + let Some(mut observer) = observer else { + return Ok(()); + }; + let presentation = tokio::select! { + biased; + _ = cancel.changed() => return Ok(()), + presentation = self.control.finalization.wait_for_presentation() => presentation, + }; + let Some(presentation) = presentation else { + return Ok(()); + }; + let sources = loop { + match observer.latest() { + PreparingStudioState::Available(sources) => break sources, + PreparingStudioState::Unavailable(error) => return Err(error), + PreparingStudioState::Ended => return Ok(()), + PreparingStudioState::Waiting => {} + } + tokio::select! { + biased; + _ = cancel.changed() => return Ok(()), + _ = observer.changed() => {}, + } + }; + let control = self.control.clone(); + let job_id = sources.identity().job_id().to_string(); + let (input, audio) = + tokio::task::spawn_blocking(move || adapt(sources, &control, &presentation)) + .await + .map_err(|error| format!("Preparing adapter join failed: {error}"))??; + if !self.control.is_active() + || !matches!(observer.latest(), PreparingStudioState::Available(_)) + { + return Ok(()); + } + let control = self.control.clone(); + let frame_observer = observer.clone(); + let frames = self.frames.clone(); + let mut tracks = vec!["display".to_string()]; + if let Some(cap_project::StudioRecordingMeta::MultipleSegments { inner }) = + input.recording_meta.studio_meta() + { + if inner + .segments + .iter() + .any(|segment| segment.camera.is_some()) + { + tracks.push("camera".into()); + } + if inner.segments.iter().any(|segment| segment.mic.is_some()) { + tracks.push("microphone".into()); + } + if inner + .segments + .iter() + .any(|segment| segment.system_audio.is_some()) + { + tracks.push("systemAudio".into()); + } + } + let audio_output = Arc::new(cap_editor::AudioOutput::new()); + { + let mut binding = self.control.playback.lock().unwrap(); + binding.audio_output = Some(audio_output.clone()); + binding.latest = Some(PreparingEditorChanged { + request_epoch: self.control.request_epoch, + job_id, + sequence: 0, + fps: crate::EDITOR_PREVIEW_FPS, + progress: PreparingEditorProgress::default(), + playback: PreparingPlaybackState::default(), + seed: PreparingEditorSeed { + title: input.recording_meta.pretty_name.clone(), + tracks, + }, + }); + } + self.control.accepts_frames.store(true, Ordering::Release); + let session = PreparingPlaybackSession::spawn_with_expected_metadata( + input, + audio.tracks, + PreparingPlaybackOptions { + preview: PreparingPreviewOptions { + use_hardware_decoding: !cfg!(target_os = "windows"), + frame_format: EditorFrameFormat::Rgba, + }, + fps: crate::EDITOR_PREVIEW_FPS, + resolution: crate::default_editor_preview_resolution(), + }, + audio_output, + Box::new(move |_, output, _| { + if control.accepts() + && (matches!(frame_observer.latest(), PreparingStudioState::Available(_)) + || control.finalization.allows_preparing_continuation()) + && let Some(frame) = crate::editor_window::frame_for_websocket(output) + { + #[cfg(debug_assertions)] + crate::stop_editor_benchmark::capture_ws_frame( + crate::stop_editor_benchmark::CaptureKind::Preparing, + &control.path, + &frame, + ); + frames.send_replace(Some(Arc::new(frame))); + } + }), + audio.expected_metadata, + )?; + self.stop = Some(session.stop_handle()); + let handoff = session.handoff_handle(); + { + let mut binding = self.control.playback.lock().unwrap(); + binding.controller = Some(session.controller()); + binding.handoff = Some(handoff.clone()); + } + let mut updates = session.updates(); + self.control + .publish_snapshot(&updates.borrow_and_update().clone()); + let mut health = tokio::time::interval(Duration::from_millis(50)); + let mut observing = true; + loop { + tokio::select! { + biased; + _ = cancel.changed() => return Ok(()), + _ = handoff.wait_committed() => return Ok(()), + state = observer.changed(), if observing => { + if !matches!(state, PreparingStudioState::Available(_)) { + if !self.control.finalization.allows_preparing_continuation() { return Ok(()); } + observing = false; + } + }, + changed = updates.changed() => { + if changed.is_err() { return Ok(()); } + let snapshot = updates.borrow_and_update().clone(); + self.control.publish_snapshot(&snapshot); + if snapshot.progress.phase != cap_editor::PreparingEditorPhase::Preparing { + return Ok(()); + } + }, + _ = health.tick() => { + if !self.control.is_active() { return Ok(()); } + if self.transport.as_ref().is_some_and(OwnedWatchFrameWs::is_finished) { + return Ok(()); + } + + }, + } + } + } +} + +fn ensure_preparing_stopped_layout( + timeline: &cap_project::TimelineConfiguration, + studio: &cap_project::StudioRecordingMeta, +) -> Result<(), String> { + if timeline.segments.len() != 1 || studio.display_notch().is_some() { + Err("Tauri preparing requires one screen segment without a recorded notch".into()) + } else { + Ok(()) + } +} + +pub(crate) fn project_from_preparing_presentation( + presentation: &ProjectConfiguration, + stopped_timeline: &cap_project::TimelineConfiguration, +) -> ProjectConfiguration { + let mut project = presentation.clone(); + project.timeline = Some(crate::recording::recording_timeline( + stopped_timeline.segments.clone(), + Vec::new(), + )); + project +} + +fn ensure_preparing_track_presentation<'a>( + tracks: impl IntoIterator, Option<&'a Path>)>, +) -> Result<(), String> { + if tracks + .into_iter() + .any(|(camera, keyboard)| camera.is_some() || keyboard.is_some()) + { + Err("Camera and keyboard presentation require ordinary editor loading".into()) + } else { + Ok(()) + } +} + +fn ensure_preparing_cursor_presentation(cursor: &CursorEvents) -> Result<(), String> { + if cursor.clicks.is_empty() { + Ok(()) + } else { + Err("Recorded clicks require late ordinary auto-zoom settings".into()) + } +} + +fn adapt( + sources: Arc, + control: &Arc, + presentation: &ProjectConfiguration, +) -> Result<(PreparingPreviewInput, audio::AudioAdaptation), String> { + let ended = || "Preparing sources ended".to_string(); + let live = sources.live().ok_or_else(ended)?; + let recording_meta = live.metadata().ok_or_else(ended)?.clone(); + let studio = recording_meta.studio_meta().ok_or_else(ended)?; + + let timeline = live + .configuration() + .ok_or_else(ended)? + .timeline + .as_ref() + .ok_or("Missing stopped timeline")?; + ensure_preparing_stopped_layout(timeline, studio)?; + let mut project = project_from_preparing_presentation(presentation, timeline); + if project.clips.is_empty() { + project.clips = + cap_editor::initial_clip_configuration(&recording_meta.project_path, studio); + } + let descriptors = live.segments().ok_or_else(ended)?; + ensure_preparing_track_presentation(descriptors.iter().map(|segment| { + ( + segment + .camera() + .map(cap_recording::recovery::PreparingVideoInput::metadata), + segment.keyboard_path(), + ) + }))?; + let pointer_ids = studio.pointer_cursor_ids(); + let mut segments = Vec::with_capacity(descriptors.len()); + let mut cursor_budget = MAX_CURSOR_BYTES; + for descriptor in descriptors { + if *control.cancelled.borrow() || sources.live().is_none() { + return Err(ended()); + } + let display = live + .video(descriptor.index(), PreparingVideoTrack::Display) + .ok_or_else(ended)?; + let (source, paths) = display.input().ok_or_else(ended)?; + let display = ManagedVideoTrackInput::new(source.clone(), paths.to_vec()) + .map_err(|error| error.to_string())?; + let camera = if descriptor.camera().is_some() { + let camera = live + .video(descriptor.index(), PreparingVideoTrack::Camera) + .ok_or_else(ended)?; + let (source, paths) = camera.input().ok_or_else(ended)?; + Some( + ManagedVideoTrackInput::new(source.clone(), paths.to_vec()) + .map_err(|error| error.to_string())?, + ) + } else { + None + }; + let mut cursor = if descriptor.cursor_path().is_some() { + let cursor = live + .sidecar(descriptor.index(), PreparingSidecarKind::Cursor) + .ok_or_else(ended)?; + let (source, path) = cursor.input().ok_or_else(ended)?; + let reader = source.reader(path).map_err(|error| error.to_string())?; + let mut reader = BufReader::new(CheckedCursorReader { + reader, + is_live: || !*control.cancelled.borrow() && sources.live().is_some(), + remaining: cursor_budget, + }); + let cursor = CursorEvents::load_from_reader(&mut reader)?; + cursor_budget = reader.get_ref().remaining; + cursor + } else { + CursorEvents::default() + }; + ensure_preparing_cursor_presentation(&cursor)?; + cursor.stabilize_short_lived_cursor_shapes( + (!pointer_ids.is_empty()).then_some(&pointer_ids), + cap_project::cursor::SHORT_CURSOR_SHAPE_DEBOUNCE_MS, + ); + segments.push(PreparingPreviewSegment { + video: ManagedSegmentVideoInput::new( + descriptor.index() as usize, + studio, + display, + camera, + ) + .map_err(|error| error.to_string())?, + cursor: Arc::new(cursor), + }); + } + let images = descriptors + .first() + .ok_or_else(ended)? + .cursor_images() + .iter() + .map(|asset| { + ( + asset.id().to_string(), + asset.metadata().clone(), + Arc::<[u8]>::from(asset.bytes()), + ) + }); + let cursor_assets = + FrozenRecordedCursorAssets::new(images).map_err(|error| error.to_string())?; + if *control.cancelled.borrow() || sources.live().is_none() { + return Err(ended()); + } + let audio = audio::adapt_audio(&sources, &recording_meta, control)?; + Ok(( + PreparingPreviewInput { + recording_meta, + project, + segments, + cursor_assets, + }, + audio, + )) +} + +struct CheckedCursorReader { + reader: R, + is_live: F, + remaining: u64, +} + +impl bool> Read for CheckedCursorReader { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if output.is_empty() { + return Ok(0); + } + if !(self.is_live)() { + return Err(io::Error::other("Preparing sources ended")); + } + if self.remaining == 0 { + let mut next = [0]; + return if self.reader.read(&mut next)? == 0 { + Ok(0) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "Cursor sidecar exceeds preparing limit", + )) + }; + } + let length = output.len().min(self.remaining as usize); + let count = self.reader.read(&mut output[..length])?; + self.remaining -= count as u64; + Ok(count) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::FinalizationAccess; + + fn control( + path: &Path, + window_id: u32, + epoch: u32, + ) -> (crate::FinalizationToken, Arc) { + let project = + FinalizationProject::capture(path.to_path_buf(), FinalizationAccess::Write).unwrap(); + let finalizations = FinalizingRecordings::default(); + let token = finalizations.start_finalizing(project).unwrap(); + let window_ids = EditorWindowIds::default(); + window_ids + .ids + .lock() + .unwrap() + .push((path.to_path_buf(), window_id)); + let control = Arc::new(ConsumerControl { + window_ids, + window_id, + request_epoch: epoch, + path: path.to_path_buf(), + finalization: token.preparing(), + cancelled: watch::channel(false).0, + accepts_frames: AtomicBool::new(true), + playback: Mutex::default(), + publish: None, + }); + (token, control) + } + + #[tokio::test] + async fn abandoned_admission_joins_transport_without_waiting_for_finalization() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().canonicalize().unwrap(); + let (token, control) = control(&path, 1, 1); + let (response, receiver) = oneshot::channel(); + drop(receiver); + let runner = Runner { + control: control.clone(), + previous: Vec::new(), + response: Some(response), + transport: None, + stop: None, + frames: watch::channel(None).0, + }; + tokio::time::timeout(Duration::from_secs(2), runner.run()) + .await + .expect("Abandoned admission waited for recording finalization") + .unwrap(); + assert!(!control.is_active()); + assert!(control.finalization.is_pending()); + token.finish(Ok(())); + } + + #[tokio::test] + async fn loading_barrier_cancels_and_joins_even_when_the_first_waiter_is_dropped() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().canonicalize().unwrap(); + let (_token, control) = control(&path, 1, 1); + let (release, receiver) = oneshot::channel(); + let (completed, mut completion) = watch::channel(false); + let joined = RetainedJoin::new(tokio::spawn(async move { + receiver.await.unwrap(); + completed.send_replace(true); + Ok(()) + })); + let registry = PreparingConsumers::default(); + registry + .0 + .lock() + .unwrap() + .projects + .entry(path.clone()) + .or_default() + .entries + .push(Entry { + control: control.clone(), + joined: joined.clone(), + }); + let waiter = { + let registry = registry.clone(); + let path = path.clone(); + tokio::spawn(async move { registry.before_ordinary_loading(&path).await }) + }; + while !*control.cancelled.borrow() { + tokio::task::yield_now().await; + } + assert!(!waiter.is_finished()); + assert!(registry.0.lock().unwrap().projects[&path].ordinary_loading); + waiter.abort(); + assert!(waiter.await.unwrap_err().is_cancelled()); + release.send(()).unwrap(); + completion.wait_for(|done| *done).await.unwrap(); + joined.wait().await.unwrap(); + registry.before_ordinary_loading(&path).await.unwrap(); + } + + #[tokio::test] + async fn replacement_admission_retires_previous_audio_after_join() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().canonicalize().unwrap(); + let (_previous_token, previous) = control(&path, 1, 1); + let (_next_token, next) = control(&path, 1, 2); + let output = Arc::new(cap_editor::AudioOutput::new()); + let retained = Arc::downgrade(&output); + previous.playback.lock().unwrap().audio_output = Some(output); + let (release, receiver) = oneshot::channel(); + let joined = RetainedJoin::new(tokio::spawn(async move { + receiver.await.unwrap(); + Ok(()) + })); + let (response, receiver) = oneshot::channel(); + drop(receiver); + let runner = Runner { + control: next, + previous: vec![(joined, previous)], + response: Some(response), + transport: None, + stop: None, + frames: watch::channel(None).0, + }; + let task = tokio::spawn(runner.run()); + tokio::task::yield_now().await; + assert!(retained.upgrade().is_some()); + assert!(!task.is_finished()); + release.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(retained.upgrade().is_none()); + } + + #[tokio::test] + async fn failed_cleanup_remains_a_barrier_on_reopen_and_retry() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().canonicalize().unwrap(); + let (_token, control) = control(&path, 1, 1); + let joined = RetainedJoin::new(tokio::spawn(async { + panic!("injected owned cleanup failure"); + })); + let registry = PreparingConsumers::default(); + registry + .0 + .lock() + .unwrap() + .projects + .entry(path.clone()) + .or_default() + .entries + .push(Entry { control, joined }); + let first = registry.before_ordinary_loading(&path).await.unwrap_err(); + let next = registry.before_ordinary_loading(&path).await.unwrap_err(); + assert_eq!(first, next); + assert_eq!(registry.0.lock().unwrap().projects[&path].entries.len(), 1); + } + + #[tokio::test] + async fn closed_window_retires_audio_only_after_owned_cleanup_finishes() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().canonicalize().unwrap(); + let (_token, control) = control(&path, 3, 1); + let output = Arc::new(cap_editor::AudioOutput::new()); + let retained = Arc::downgrade(&output); + control.playback.lock().unwrap().audio_output = Some(output); + let (release, receiver) = oneshot::channel(); + let joined = RetainedJoin::new(tokio::spawn(async move { + receiver.await.unwrap(); + Ok(()) + })); + let registry = PreparingConsumers::default(); + registry + .0 + .lock() + .unwrap() + .projects + .entry(path.clone()) + .or_default() + .entries + .push(Entry { + control: control.clone(), + joined, + }); + registry.cancel_window(3, None); + tokio::task::yield_now().await; + assert!(retained.upgrade().is_some()); + assert!(!control.is_active()); + release.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if registry.0.lock().unwrap().projects[&path] + .entries + .is_empty() + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(retained.upgrade().is_none()); + } + + #[tokio::test] + async fn old_frontend_stop_does_not_cancel_a_new_window_request() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().canonicalize().unwrap(); + let (_token, control) = control(&path, 8, 2); + let registry = PreparingConsumers::default(); + let joined = RetainedJoin::new(tokio::spawn(async { Ok(()) })); + registry + .0 + .lock() + .unwrap() + .projects + .entry(path) + .or_default() + .entries + .push(Entry { + control: control.clone(), + joined, + }); + registry.cancel_window(8, Some(1)); + registry.cancel_window(7, None); + assert!(control.accepts()); + registry.cancel_window(8, Some(2)); + assert!(!control.accepts()); + } + + #[test] + fn native_registration_and_finalization_both_guard_frame_delivery() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().canonicalize().unwrap(); + let (token, control) = control(&path, 1, 1); + assert!(control.accepts()); + control.window_ids.ids.lock().unwrap().clear(); + assert!(!control.accepts()); + control.window_ids.ids.lock().unwrap().push((path, 2)); + assert!(!control.accepts()); + token.finish(Ok(())); + assert!(!control.is_active()); + } + + #[test] + fn cancelled_cursor_parse_terminates_without_retrying_interrupted_reads() { + let mut reader = CheckedCursorReader { + reader: std::io::Cursor::new([1, 2]), + is_live: || false, + remaining: 2, + }; + let error = reader.read(&mut [0; 1]).unwrap_err(); + assert_ne!(error.kind(), std::io::ErrorKind::Interrupted); + assert_eq!(reader.reader.position(), 0); + } +} + +#[cfg(test)] +mod presentation_guard_tests { + use super::*; + + fn camera() -> cap_project::VideoMeta { + serde_json::from_value(serde_json::json!({ + "path": "camera", "fps": 24, "start_time": 0.125 + })) + .unwrap() + } + + #[test] + fn screen_only_descriptors_pass_but_any_recorded_camera_is_declined() { + let camera = camera(); + ensure_preparing_track_presentation([(None, None)]).unwrap(); + for tracks in [ + vec![(Some(&camera), None)], + vec![(None, None), (Some(&camera), None)], + ] { + assert_eq!( + ensure_preparing_track_presentation(tracks).unwrap_err(), + "Camera and keyboard presentation require ordinary editor loading" + ); + } + } + + #[test] + fn keyboard_sidecars_decline_even_without_a_camera_or_visible_overlay() { + for path in [Path::new("keyboard.json"), Path::new("keyboard.msgpack")] { + assert_eq!( + ensure_preparing_track_presentation([(None, Some(path))]).unwrap_err(), + "Camera and keyboard presentation require ordinary editor loading" + ); + } + } + + #[test] + fn click_up_and_click_down_both_decline_but_pointer_movement_alone_passes() { + let mut cursor = CursorEvents::default(); + cursor.moves.push(cap_project::CursorMoveEvent { + active_modifiers: Vec::new(), + cursor_id: "arrow".into(), + time_ms: 0.0, + x: 0.2, + y: 0.8, + }); + ensure_preparing_cursor_presentation(&cursor).unwrap(); + for down in [false, true] { + cursor.clicks = vec![cap_project::CursorClickEvent { + active_modifiers: Vec::new(), + cursor_num: 0, + cursor_id: "arrow".into(), + time_ms: 0.0, + down, + }]; + assert_eq!( + ensure_preparing_cursor_presentation(&cursor).unwrap_err(), + "Recorded clicks require late ordinary auto-zoom settings" + ); + } + } + #[test] + fn stopped_layout_rejects_multiple_or_missing_segments_and_recorded_notches() { + let segment = cap_project::TimelineSegment { + recording_clip: 0, + start: 0.0, + end: 6.0, + timescale: 1.0, + name: None, + speed_audio_mode: None, + }; + for notch in [ + None, + Some(cap_project::DisplayNotch { + x: 0.4, + width: 0.2, + height: 0.03, + }), + ] { + let metadata: cap_project::RecordingMeta = serde_json::from_value(serde_json::json!({ + "pretty_name": "Tauri presentation metadata", "sharing": null, + "segments": [{"display": {"path": "display", "fps": 30, "start_time": 0.0}, "display_notch": notch}] + })).unwrap(); + for count in [0, 1, 2] { + let timeline = + crate::recording::recording_timeline(vec![segment.clone(); count], Vec::new()); + let result = + ensure_preparing_stopped_layout(&timeline, metadata.studio_meta().unwrap()); + assert_eq!(result.is_ok(), count == 1 && notch.is_none()); + if let Err(error) = result { + assert_eq!( + error, + "Tauri preparing requires one screen segment without a recorded notch" + ); + } + } + } + } +} diff --git a/apps/desktop/src-tauri/src/editor_preparing/audio.rs b/apps/desktop/src-tauri/src/editor_preparing/audio.rs new file mode 100644 index 00000000000..0a58e3d7364 --- /dev/null +++ b/apps/desktop/src-tauri/src/editor_preparing/audio.rs @@ -0,0 +1,226 @@ +use super::*; +use cap_editor::PreparingAudioSegmentInput; +use cap_project::{AudioMeta, RecordingMeta, StudioRecordingMeta}; +use cap_recording::recovery::PreparingAudioTrack; + +const MAX_AUDIO_TIMING_LOG_BYTES: u64 = 4 * 1024 * 1024; + +pub(super) struct AudioAdaptation { + pub(super) tracks: Vec, + pub(super) expected_metadata: RecordingMeta, +} + +pub(super) fn adapt_audio( + sources: &Arc, + metadata: &RecordingMeta, + control: &Arc, +) -> Result { + let mut expected_metadata = metadata.clone(); + let cap_project::RecordingMetaInner::Studio(studio) = &mut expected_metadata.inner else { + return Err("Preparing audio requires Studio metadata".into()); + }; + let StudioRecordingMeta::MultipleSegments { inner } = studio.as_mut() else { + return Err("Preparing audio requires stopped segments".into()); + }; + for segment in &mut inner.segments { + for audio in [&mut segment.mic, &mut segment.system_audio] + .into_iter() + .flatten() + { + audio.gap_summary = None; + } + } + let expected_meta = expected_metadata + .studio_meta() + .ok_or("Preparing Studio metadata ended")?; + // Clean Stop joins the synchronous writers of timing-repair lines; finalization can still append unrelated log events. + let timing_log = read_timing_log( + &metadata.project_path.join("recording-logs.log"), + MAX_AUDIO_TIMING_LOG_BYTES, + ); + let timing_eligible = timing_log.is_ok(); + if let Err(error) = &timing_log { + tracing::debug!(%error, "Preparing audio timing requires ordinary loading"); + } + let timing_log = timing_log.ok().flatten(); + let repairs = cap_editor::segment_audio_timing_repairs(expected_meta, timing_log.as_deref()); + let live = sources.live().ok_or("Preparing audio sources ended")?; + let descriptors = live.segments().ok_or("Preparing audio sources ended")?; + let mut tracks = Vec::with_capacity(descriptors.len()); + for (index, descriptor) in descriptors.iter().enumerate() { + let repair = *repairs + .get(index) + .ok_or("Preparing audio timing layout changed")?; + let track = |kind, stem| { + if !timing_eligible { + return None; + } + let lease = live.audio(descriptor.index(), kind)?; + let metadata = lease.metadata()?; + let (source, path) = lease.input()?; + if !preserved_audio_path(metadata, path, stem) { + return None; + } + if path.extension().is_some_and(|extension| extension == "m4a") { + let control = control.clone(); + let sources = sources.clone(); + let input = cap_enc_ffmpeg::SegmentedInput::open_relocatable_interruptible( + source, + [path], + Arc::new(move || *control.cancelled.borrow() || sources.live().is_none()), + ) + .ok()?; + if input + .input() + .streams() + .best(ffmpeg::media::Type::Video) + .is_some() + || !input + .input() + .streams() + .best(ffmpeg::media::Type::Audio) + .is_some_and(|stream| stream.parameters().id() == ffmpeg::codec::Id::AAC) + { + return None; + } + } + cap_audio::ManagedAudioInput::new(source.clone(), path.to_path_buf()).ok() + }; + tracks.push(PreparingAudioSegmentInput { + mic: track(PreparingAudioTrack::Mic, "audio-input"), + system_audio: track(PreparingAudioTrack::SystemAudio, "system_audio"), + timing_repair: repair, + }); + if *control.cancelled.borrow() || sources.live().is_none() { + return Err("Preparing audio sources ended".into()); + } + } + Ok(AudioAdaptation { + tracks, + expected_metadata, + }) +} + +fn read_timing_log(path: &Path, limit: u64) -> Result, String> { + let initial = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("Preparing audio timing log metadata: {error}")), + }; + if !initial.is_file() || initial.len() > limit { + return Err("Preparing audio timing log is not a bounded regular file".into()); + } + let file = std::fs::File::open(path) + .map_err(|error| format!("Preparing audio timing log open: {error}"))?; + let opened = file.metadata().map_err(|error| error.to_string())?; + if !opened.is_file() + || opened.len() != initial.len() + || opened.modified().ok() != initial.modified().ok() + { + return Err("Preparing audio timing log changed before reading".into()); + } + let mut bytes = String::with_capacity(opened.len() as usize); + let mut bounded = file.take(limit.saturating_add(1)); + bounded + .read_to_string(&mut bytes) + .map_err(|error| format!("Preparing audio timing log read: {error}"))?; + let finished = bounded + .get_ref() + .metadata() + .map_err(|error| error.to_string())?; + if bytes.len() as u64 != opened.len() + || finished.len() != opened.len() + || finished.modified().ok() != opened.modified().ok() + { + return Err("Preparing audio timing log changed while reading".into()); + } + Ok(Some(bytes)) +} + +fn preserved_audio_path(metadata: &AudioMeta, relative: &Path, stem: &str) -> bool { + let expected = Path::new("content/segments").join(relative); + if Path::new(metadata.path.as_str()) != expected { + return false; + } + ["m4a", "ogg"].into_iter().any(|extension| { + relative + .file_name() + .is_some_and(|name| name == format!("{stem}.{extension}").as_str()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn timing_snapshot_preserves_utf8_and_distinguishes_absence_from_ineligible() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("recording-logs.log"); + assert_eq!(read_timing_log(&path, 100).unwrap(), None); + let text = "微phone segment{index=0}:mic-out: Dropping overlapping audio frame frame_count=1 overlap_ms=100\n"; + std::fs::write(&path, text).unwrap(); + assert_eq!( + read_timing_log(&path, text.len() as u64) + .unwrap() + .as_deref(), + Some(text) + ); + assert!(read_timing_log(&path, text.len() as u64 - 1).is_err()); + std::fs::write(&path, [0xff, 0xfe]).unwrap(); + assert!(read_timing_log(&path, 100).is_err()); + assert!(read_timing_log(root.path(), 100).is_err()); + } + + #[test] + fn oversized_timing_log_is_declined_without_reading_its_payload() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("recording-logs.log"); + let file = std::fs::File::create(&path).unwrap(); + file.set_len(MAX_AUDIO_TIMING_LOG_BYTES + 1).unwrap(); + assert!(read_timing_log(&path, MAX_AUDIO_TIMING_LOG_BYTES).is_err()); + } + + #[cfg(unix)] + #[test] + fn timing_log_symlink_is_ineligible() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("target.log"); + let path = root.path().join("recording-logs.log"); + std::fs::write(&target, "retained").unwrap(); + std::os::unix::fs::symlink(&target, &path).unwrap(); + assert!(read_timing_log(&path, 100).is_err()); + } + + #[test] + fn audio_admission_requires_the_unchanged_final_path_and_container_name() { + let metadata = |path: &str| AudioMeta { + path: path.into(), + start_time: Some(0.0), + device_id: None, + gap_summary: None, + }; + for extension in ["m4a", "ogg"] { + let relative = PathBuf::from(format!("segment-0/audio-input.{extension}")); + let meta = metadata(&format!("content/segments/{}", relative.display())); + assert!(preserved_audio_path(&meta, &relative, "audio-input")); + assert!(!preserved_audio_path(&meta, &relative, "system_audio")); + } + for path in [ + "segment-0/audio-input.mp3", + "segment-0/audio-input/audio.m4a", + "segment-0/audio-input.M4A", + ] { + assert!(!preserved_audio_path( + &metadata(&format!("content/segments/{path}")), + Path::new(path), + "audio-input" + )); + } + assert!(!preserved_audio_path( + &metadata("content/segments/other/audio-input.m4a"), + Path::new("segment-0/audio-input.m4a"), + "audio-input" + )); + } +} diff --git a/apps/desktop/src-tauri/src/editor_window.rs b/apps/desktop/src-tauri/src/editor_window.rs index 2b9da332a1e..8def29c522c 100644 --- a/apps/desktop/src-tauri/src/editor_window.rs +++ b/apps/desktop/src-tauri/src/editor_window.rs @@ -30,37 +30,8 @@ fn make_frame_callback( frame_tx: watch::Sender>>, ) -> cap_editor::EditorFrameCallback { Box::new(move |output, layout| { - let ws_frame = match output { - cap_editor::EditorFrameOutput::Nv12(frame) => { - let ws_format = match frame.format { - GpuOutputFormat::Nv12 => WSFrameFormat::Nv12 { full_range: false }, - GpuOutputFormat::Rgba => WSFrameFormat::Rgba, - }; - WSFrame { - data: Arc::new(frame.data.into_vec()), - width: frame.width, - height: frame.height, - stride: frame.y_stride, - frame_number: frame.frame_number, - target_time_ns: frame.target_time_ns, - format: ws_format, - created_at: Instant::now(), - } - } - cap_editor::EditorFrameOutput::Rgba(frame) => WSFrame { - data: frame.data, - width: frame.width, - height: frame.height, - stride: frame.padded_bytes_per_row, - frame_number: frame.frame_number, - target_time_ns: frame.target_time_ns, - format: WSFrameFormat::Rgba, - created_at: Instant::now(), - }, - // The Tauri editor transports frames over a websocket, so it never - // requests the gpui-only zero-copy surface format. - #[cfg(target_os = "macos")] - cap_editor::EditorFrameOutput::Surface(_) => return, + let Some(ws_frame) = frame_for_websocket(output) else { + return; }; let _ = frame_tx.send(Some(std::sync::Arc::new(ws_frame))); @@ -71,8 +42,47 @@ fn make_frame_callback( }) } +pub(crate) fn frame_for_websocket(output: cap_editor::EditorFrameOutput) -> Option { + let frame = match output { + cap_editor::EditorFrameOutput::Nv12(frame) => { + let ws_format = match frame.format { + GpuOutputFormat::Nv12 => WSFrameFormat::Nv12 { full_range: false }, + GpuOutputFormat::Rgba => WSFrameFormat::Rgba, + }; + WSFrame { + data: Arc::new(frame.data.into_vec()), + width: frame.width, + height: frame.height, + stride: frame.y_stride, + frame_number: frame.frame_number, + target_time_ns: frame.target_time_ns, + format: ws_format, + created_at: Instant::now(), + } + } + cap_editor::EditorFrameOutput::Rgba(frame) => WSFrame { + data: frame.data, + width: frame.width, + height: frame.height, + stride: frame.padded_bytes_per_row, + frame_number: frame.frame_number, + target_time_ns: frame.target_time_ns, + format: WSFrameFormat::Rgba, + created_at: Instant::now(), + }, + // The Tauri editor transports frames over a websocket, so it never + // requests the gpui-only zero-copy surface format. + #[cfg(target_os = "macos")] + cap_editor::EditorFrameOutput::Surface(_) => return None, + }; + Some(frame) +} + pub struct EditorInstance { inner: Arc, + pub instance_id: uuid::Uuid, + handoff_playback: tokio::sync::Mutex>, + disposed: std::sync::atomic::AtomicBool, pub ws_port: u16, pub ws_shutdown_token: CancellationToken, app_handle: AppHandle, @@ -160,11 +170,82 @@ async fn do_prewarm(app: AppHandle, path: PathBuf) -> Result let instance = Arc::new(EditorInstance { inner, + instance_id: uuid::Uuid::new_v4(), + handoff_playback: Default::default(), + disposed: Default::default(), + ws_port, + ws_shutdown_token, + app_handle: app, + render_frame_event_id, + }); + ws_guard.disarm(); + Ok(instance) +} + +async fn recreate_preparing_instance( + app: AppHandle, + previous: &Arc, +) -> Result, String> { + let (frame_tx, frame_rx) = watch::channel(None); + let (ws_port, ws_shutdown_token) = create_watch_frame_ws(frame_rx, Default::default()).await; + let ws_guard = ws_shutdown_token.clone().drop_guard(); + let state_app = app.clone(); + let inner = previous + .inner + .recreate_preparing_candidate( + move |state| { + let _ = crate::EditorStateChanged::new(state).emit(&state_app); + }, + make_frame_callback(app.clone(), frame_tx), + cap_editor::EditorFrameFormat::Rgba, + ) + .await?; + let render_frame_event_id = crate::RenderFrameEvent::listen_any(&app, { + let preview_tx = inner.preview_tx.clone(); + move |event| { + preview_tx.send_modify(|request| { + *request = Some(( + event.payload.frame_number, + event.payload.fps, + event.payload.resolution_base, + )) + }); + } + }); + let instance = Arc::new(EditorInstance { + inner, + instance_id: uuid::Uuid::new_v4(), + handoff_playback: Default::default(), + disposed: Default::default(), ws_port, ws_shutdown_token, app_handle: app, render_frame_event_id, }); + let fps = crate::EDITOR_PREVIEW_FPS; + let resolution = crate::default_editor_preview_resolution(); + let started = match instance + .inner + .start_preparing_handoff(fps, resolution) + .await + { + Ok(started) => started, + Err(error) => { + instance.dispose().await; + return Err(format!("Preparing handoff start failed: {error:?}")); + } + }; + if !started { + let frame = instance + .inner + .preparing_adoption() + .and_then(|adoption| adoption.frame_number(fps)) + .unwrap_or(0); + instance + .inner + .preview_tx + .send_modify(|request| *request = Some((frame, fps, resolution))); + } ws_guard.disarm(); Ok(instance) } @@ -319,11 +400,104 @@ impl PendingEditorInstances { impl EditorInstance { pub async fn dispose(&self) { + self.disposed.store(true, Ordering::Release); + let mut handoff = self.handoff_playback.lock().await; + if let Some((_, playback)) = handoff.take() { + playback.stop(); + } self.inner.dispose().await; self.ws_shutdown_token.cancel(); self.app_handle.unlisten(self.render_frame_event_id); } + + fn validate_handoff(&self, expected: &str) -> Result<(), String> { + if expected != self.instance_id.to_string() || self.disposed.load(Ordering::Acquire) { + Err("The handoff editor instance has ended".into()) + } else { + Ok(()) + } + } + + pub(crate) fn commit_preparing_frame( + &self, + expected: &str, + frame: u32, + fps: u32, + ) -> Result { + self.validate_handoff(expected)?; + if self + .inner + .preparing_adoption() + .is_some_and(|adoption| adoption.is_owner()) + { + return Ok(true); + } + if self + .inner + .preparing_adoption() + .is_some_and(|adoption| adoption.invalidated()) + { + return Err("Preparing handoff candidate was superseded".into()); + } + Ok(self + .inner + .preparing_adoption() + .is_none_or(|adoption| adoption.try_commit(frame, fps))) + } + + pub(crate) async fn start_handoff_playback( + &self, + expected: &str, + frame_number: u32, + fps: u32, + resolution: cap_project::XY, + ) -> Result { + let mut handoff = self.handoff_playback.lock().await; + self.validate_handoff(expected)?; + if self + .inner + .preparing_adoption() + .is_some_and(|adoption| adoption.is_owner()) + && let Some(playback) = self.inner.state.lock().await.playback_task.clone() + { + let playback_id = uuid::Uuid::new_v4(); + *handoff = Some((playback_id, playback)); + return Ok(playback_id.to_string()); + } + self.inner + .modify_and_emit_state(|state| state.playhead_position = frame_number) + .await; + let playback = self + .inner + .start_playback_with_handle(fps, resolution, Some(frame_number)) + .await + .map_err(|error| format!("The handoff playback could not start: {error:?}"))?; + if let Err(error) = self.validate_handoff(expected) { + playback.stop(); + return Err(error); + } + let playback_id = uuid::Uuid::new_v4(); + *handoff = Some((playback_id, playback)); + Ok(playback_id.to_string()) + } + + pub(crate) async fn stop_handoff_playback( + &self, + expected: &str, + playback_id: &str, + ) -> Result<(), String> { + let mut handoff = self.handoff_playback.lock().await; + self.validate_handoff(expected)?; + if handoff + .as_ref() + .is_some_and(|(id, _)| id.to_string() == playback_id) + && let Some((_, playback)) = handoff.take() + { + playback.stop(); + } + Ok(()) + } } impl Drop for EditorInstance { @@ -451,7 +625,22 @@ impl EditorInstances { if let Some(instance) = with_registered_editor(&window_ids, id, || instances.get(window.label()).cloned())? { - return Ok(instance); + if !instance + .inner + .preparing_adoption() + .is_some_and(|adoption| adoption.invalidated()) + { + return Ok(instance); + } + let replacement = + recreate_preparing_instance(window.app_handle().clone(), &instance).await?; + if let Err(error) = with_registered_editor(&window_ids, id, || ()) { + replacement.dispose().await; + return Err(error); + } + let _ = instances.insert(window.label().to_string(), replacement.clone()); + instance.dispose().await; + return Ok(replacement); } let requested_at = Instant::now(); @@ -493,6 +682,9 @@ impl EditorInstances { .await?; let instance = Arc::new(EditorInstance { inner, + instance_id: uuid::Uuid::new_v4(), + handoff_playback: Default::default(), + disposed: Default::default(), ws_port, ws_shutdown_token, app_handle, diff --git a/apps/desktop/src-tauri/src/frame_ws.rs b/apps/desktop/src-tauri/src/frame_ws.rs index 33d05b8a949..24220b826f6 100644 --- a/apps/desktop/src-tauri/src/frame_ws.rs +++ b/apps/desktop/src-tauri/src/frame_ws.rs @@ -668,7 +668,9 @@ mod shutdown_tests { } async fn assert_listener_closed(port: u16) { - tokio::time::timeout(Duration::from_secs(2), async { + // Windows can take just over two seconds to report a refused loopback connection. + let timeout = Duration::from_secs(if cfg!(windows) { 5 } else { 2 }); + tokio::time::timeout(timeout, async { while TcpStream::connect(("127.0.0.1", port)).await.is_ok() { tokio::time::sleep(Duration::from_millis(5)).await; } @@ -936,3 +938,223 @@ mod shutdown_tests { assert_listener_closed(port).await; } } + +pub(crate) struct OwnedWatchFrameWs { + pub(crate) url: String, + shutdown: CancellationToken, + task: Option>>, +} + +impl OwnedWatchFrameWs { + pub(crate) fn is_finished(&self) -> bool { + self.task + .as_ref() + .is_none_or(tokio::task::JoinHandle::is_finished) + } + + pub(crate) async fn stop_and_wait(mut self) -> Result<(), String> { + self.shutdown.cancel(); + self.task + .take() + .ok_or_else(|| "Preparing frame transport already joined".to_string())? + .await + .map_err(|error| format!("Preparing frame transport join failed: {error}"))? + } +} + +impl Drop for OwnedWatchFrameWs { + fn drop(&mut self) { + self.shutdown.cancel(); + } +} + +pub(crate) async fn create_owned_watch_frame_ws( + frame_rx: watch::Receiver>>, + accepts: Arc bool + Send + Sync>, +) -> Result { + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .map_err(|error| error.to_string())?; + let port = listener + .local_addr() + .map_err(|error| error.to_string())? + .port(); + let route = format!("/{}", uuid::Uuid::new_v4()); + let url = format!("ws://127.0.0.1:{port}{route}"); + let shutdown = CancellationToken::new(); + let cancelled = shutdown.clone(); + let task = tokio::spawn(async move { + let mut connections = tokio::task::JoinSet::new(); + let mut failure = None; + loop { + tokio::select! { + biased; + _ = cancelled.cancelled() => break, + joined = connections.join_next(), if !connections.is_empty() => { + if let Some(Err(error)) = joined { + failure = Some(format!("Preparing frame connection join failed: {error}")); + break; + } + } + accepted = listener.accept() => { + let (stream, _) = match accepted { + Ok(value) => value, + Err(error) => { + tracing::debug!(%error, "Preparing frame transport accept stopped"); + break; + } + }; + if connections.len() >= 8 { + drop(stream); + continue; + } + let expected = route.clone(); + let mut frames = frame_rx.clone(); + let accepts = accepts.clone(); + let cancelled = cancelled.clone(); + connections.spawn(async move { + let exchange = async move { + let Ok(mut socket) = tokio_tungstenite::accept_hdr_async( + stream, + move |request: &tokio_tungstenite::tungstenite::handshake::server::Request, response| { + if request.uri().path() == expected && request.uri().query().is_none() { + Ok(response) + } else { + Err(tokio_tungstenite::tungstenite::http::Response::builder().status(404).body(None).unwrap()) + } + }, + ).await else { return; }; + loop { + let frame = frames.borrow_and_update().clone(); + if let Some(frame) = frame + && accepts() + && socket.send(Message::Binary(pack_ws_frame(&frame))).await.is_err() + { + return; + } + tokio::select! { + changed = frames.changed() => if changed.is_err() { return; }, + message = socket.next() => match message { + None | Some(Err(_)) | Some(Ok(Message::Close(_))) => return, + _ => {} + } + } + } + }; + tokio::select! { + biased; + _ = cancelled.cancelled() => {}, + _ = exchange => {}, + } + }); + } + } + } + cancelled.cancel(); + drop(listener); + while let Some(joined) = connections.join_next().await { + if let Err(error) = joined { + failure.get_or_insert_with(|| { + format!("Preparing frame connection join failed: {error}") + }); + } + } + failure.map_or(Ok(()), Err) + }); + Ok(OwnedWatchFrameWs { + url, + shutdown, + task: Some(task), + }) +} + +#[cfg(test)] +mod owned_preparing_tests { + use super::*; + use futures::StreamExt; + use tokio::io::AsyncWriteExt; + + #[tokio::test] + async fn owned_transport_joins_incomplete_handshake_and_releases_port() { + let (_frames, rx) = watch::channel(None); + let server = create_owned_watch_frame_ws(rx, Arc::new(|| true)) + .await + .unwrap(); + let address = server + .url + .strip_prefix("ws://") + .unwrap() + .split('/') + .next() + .unwrap() + .to_string(); + let mut client = tokio::net::TcpStream::connect(&address).await.unwrap(); + client.write_all(b"GET / HTTP/1.1\r\n").await.unwrap(); + tokio::task::yield_now().await; + tokio::time::timeout(std::time::Duration::from_secs(2), server.stop_and_wait()) + .await + .unwrap() + .unwrap(); + let listener = tokio::net::TcpListener::bind(address).await.unwrap(); + drop(listener); + } + + #[tokio::test] + async fn owned_transport_keeps_exact_frame_encoding_and_joins_live_client() { + let frame = Arc::new(WSFrame { + data: Arc::new(vec![1, 2, 3, 4]), + width: 1, + height: 1, + stride: 4, + frame_number: 0, + target_time_ns: 0, + format: WSFrameFormat::Rgba, + created_at: Instant::now(), + }); + let weak = Arc::downgrade(&frame); + let expected = pack_ws_frame(&frame); + let (frames, rx) = watch::channel(Some(frame)); + let server = create_owned_watch_frame_ws(rx, Arc::new(|| true)) + .await + .unwrap(); + let (mut socket, _) = tokio_tungstenite::connect_async(&server.url).await.unwrap(); + assert_eq!(socket.next().await.unwrap().unwrap().into_data(), expected); + tokio::time::timeout(std::time::Duration::from_secs(2), server.stop_and_wait()) + .await + .unwrap() + .unwrap(); + drop(frames); + assert!(weak.upgrade().is_none()); + } + + #[tokio::test] + async fn owned_transport_cancellation_joins_stalled_large_frame_send() { + let frame = Arc::new(WSFrame { + data: Arc::new(vec![1; 16 * 1024 * 1024]), + width: 2048, + height: 2048, + stride: 8192, + frame_number: 0, + target_time_ns: 0, + format: WSFrameFormat::Rgba, + created_at: Instant::now(), + }); + let weak = Arc::downgrade(&frame); + let (frames, rx) = watch::channel(Some(frame)); + let server = create_owned_watch_frame_ws(rx, Arc::new(|| true)) + .await + .unwrap(); + let (socket, _) = tokio_tungstenite::connect_async(&server.url).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + tokio::time::timeout(std::time::Duration::from_secs(2), server.stop_and_wait()) + .await + .unwrap() + .unwrap(); + drop(socket); + drop(frames); + assert!(weak.upgrade().is_none()); + } +} diff --git a/apps/desktop/src-tauri/src/gpui_app.rs b/apps/desktop/src-tauri/src/gpui_app.rs index 0157c31fed7..f01c87bf92e 100644 --- a/apps/desktop/src-tauri/src/gpui_app.rs +++ b/apps/desktop/src-tauri/src/gpui_app.rs @@ -968,6 +968,10 @@ pub async fn switch_to_gpui_app(app: AppHandle) -> Result<(), String> { /// /// `true` means the caller must exit before any window is created. pub fn redirect_at_startup_if_enabled(app: &AppHandle) -> Result { + #[cfg(debug_assertions)] + if crate::stop_editor_benchmark::enabled() { + return Ok(false); + } let update_handoff = handle_update_handoff(app); let redirect = !update_handoff && redirect_decision(app)?; if !redirect { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 751f027c4e6..17447ba4aaa 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ mod clip_thumbnails; mod crash_sentinel; mod deeplink_actions; mod diagnostics; +mod editor_preparing; mod editor_window; mod exit_shutdown; mod export; @@ -39,6 +40,7 @@ mod permissions; mod picker_benchmark; mod platform; mod power_observer; +mod preparing_finalization; mod presets; mod recording; mod recording_settings; @@ -46,6 +48,8 @@ mod recording_telemetry; mod recordings_locations; mod recovery; mod screenshot_editor; +#[cfg(debug_assertions)] +mod stop_editor_benchmark; mod target_select_overlay; mod telemetry; mod thumbnails; @@ -159,6 +163,7 @@ const MAX_SETTLED_FINALIZATIONS: usize = 32; struct FinalizingRecordingsMap { attempts: std::collections::HashMap>, settled: std::collections::VecDeque<(ProjectObjectId, String)>, + last_preparing_generation: u64, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -172,6 +177,10 @@ struct FinalizationAttempt { origin: FinalizationOrigin, project: Arc, result: watch::Sender, + preparing_generation: Option, + preparing_presentation: + watch::Sender, String>>>, + preparing: watch::Sender, } pub(crate) struct FinalizationToken { @@ -974,6 +983,7 @@ impl FinalizingRecordings { project: Arc, retry_failed: bool, origin: FinalizationOrigin, + preparing_requested: bool, ) -> Result { if project.access != FinalizationAccess::Write { return Err("Recording directory was not admitted for recovery.".into()); @@ -991,11 +1001,23 @@ impl FinalizingRecordings { { return Ok(FinalizationRequest::Existing(attempt.result.subscribe())); } + let preparing_generation = if preparing_requested + && origin == FinalizationOrigin::Recording + && let Some(generation) = recordings.last_preparing_generation.checked_add(1) + { + recordings.last_preparing_generation = generation; + Some(generation) + } else { + None + }; let attempt = Arc::new(FinalizationAttempt { id: uuid::Uuid::new_v4().to_string(), origin, project, result: watch::channel(None).0, + preparing_generation, + preparing_presentation: watch::channel(None).0, + preparing: watch::channel(Default::default()).0, }); recordings .settled @@ -1013,8 +1035,9 @@ impl FinalizingRecordings { &self, project: Arc, origin: FinalizationOrigin, + preparing_requested: bool, ) -> Result { - match self.request(project, true, origin)? { + match self.request(project, true, origin, preparing_requested)? { FinalizationRequest::Started(token) => Ok(token), FinalizationRequest::Existing(_) => Err( "This recording is already being prepared. Please wait for it to finish.".into(), @@ -1026,14 +1049,14 @@ impl FinalizingRecordings { &self, project: Arc, ) -> Result { - self.start_with_origin(project, FinalizationOrigin::Recording) + self.start_with_origin(project, FinalizationOrigin::Recording, true) } pub(crate) fn start_recovering( &self, project: Arc, ) -> Result { - self.start_with_origin(project, FinalizationOrigin::Recovery) + self.start_with_origin(project, FinalizationOrigin::Recovery, false) } pub(crate) async fn recovery_success(&self, path: &Path) -> Result, String> { @@ -3376,6 +3399,17 @@ async fn cleanup_app_resources_for_exit(app: &AppHandle) { fake_window::cancel_all_fake_window_listeners(app); close_target_select_overlays(app); + let preparing = app + .state::() + .inner() + .clone(); + let _ = await_exit_step( + "dispose_preparing_editor_frames", + APP_EXIT_STEP_TIMEOUT, + preparing.dispose_all(), + ) + .await; + let app_for_pending_editors = app.clone(); let _ = await_exit_step( "dispose_pending_editor_instances", @@ -4672,10 +4706,50 @@ async fn stop_playback(editor_instance: WindowEditorInstance) -> Result<(), Stri Ok(()) } +#[tauri::command] +#[specta::specta] +async fn commit_editor_preparing_frame( + editor_instance: WindowEditorInstance, + instance_id: String, + frame_number: u32, + fps: u32, +) -> Result { + editor_instance.commit_preparing_frame(&instance_id, frame_number, fps) +} + +#[tauri::command] +#[specta::specta] +async fn start_editor_handoff_playback( + editor_instance: WindowEditorInstance, + instance_id: String, + frame_number: u32, + fps: u32, + resolution_base: XY, +) -> Result { + editor_instance + .start_handoff_playback(&instance_id, frame_number, fps, resolution_base) + .await +} + +#[tauri::command] +#[specta::specta] +async fn stop_editor_handoff_playback( + editor_instance: WindowEditorInstance, + instance_id: String, + playback_id: String, +) -> Result<(), String> { + editor_instance + .stop_handoff_playback(&instance_id, &playback_id) + .await +} + #[derive(Serialize, Type, Debug)] #[serde(rename_all = "camelCase")] struct SerializedEditorInstance { + instance_id: String, + preparing_playback: bool, frames_socket_url: String, + preparing_snapshot: Option, recording_duration: f64, saved_project_config: ProjectConfiguration, recordings: Arc, @@ -4709,6 +4783,11 @@ async fn create_editor_instance(window: Window) -> Result() + .snapshot_for_window(id), frames_socket_url: format!("ws://localhost:{}", editor_instance.ws_port), recording_duration: editor_instance.recordings.duration(), saved_project_config: { @@ -5107,13 +5186,17 @@ async fn upload_exported_video( channel.send(UploadProgress { progress: 0.0 }).ok(); + let existing_video_id = upload::reusable_video_id(meta.sharing.as_ref(), meta.upload.as_ref()); let s3_config = match async { let video_id = match mode { UploadMode::Initial { pre_created_video } => { - if let Some(pre_created) = pre_created_video { + if let Some(video_id) = existing_video_id.clone() { + Some(video_id) + } else if let Some(pre_created) = pre_created_video { return Ok(pre_created.config); + } else { + None } - None } UploadMode::Reupload => { let Some(sharing) = meta.sharing.clone() else { @@ -5142,6 +5225,13 @@ async fn upload_exported_video( Err(err) => return Err(err.to_string()), }; + if existing_video_id + .as_ref() + .is_some_and(|video_id| *video_id != s3_config.id) + { + return Err("Server did not preserve the existing share link".into()); + } + let screenshot_path = meta.project_path.join("screenshots/display.jpg"); meta.upload = Some(UploadMeta::SinglePartUpload { video_id: s3_config.id.clone(), @@ -5150,15 +5240,14 @@ async fn upload_exported_video( recording_dir: path.clone(), }); meta.save_for_project() - .map_err(|e| error!("Failed to save recording meta: {e}")) - .ok(); + .map_err(|error| format!("Failed to persist upload state: {error}"))?; match upload_video( &app, s3_config.id.clone(), file_path, screenshot_path, - metadata, + meta.sharing.is_some(), Some(channel.clone()), ) .await @@ -5166,24 +5255,29 @@ async fn upload_exported_video( Ok(uploaded_video) => { channel.send(UploadProgress { progress: 1.0 }).ok(); + let link = meta + .sharing + .as_ref() + .map(|sharing| sharing.link.clone()) + .unwrap_or(uploaded_video.link); + meta.upload = Some(UploadMeta::Complete); meta.sharing = Some(SharingMeta { - link: uploaded_video.link.clone(), + link: link.clone(), id: uploaded_video.id.clone(), content_hash: None, }); meta.save_for_project() - .map_err(|e| error!("Failed to save recording meta: {e}")) - .ok(); + .map_err(|error| format!("Failed to persist sharing state: {error}"))?; let _ = app .state::>() .write() .await - .set_text(uploaded_video.link.clone()); + .set_text(link.clone()); NotificationType::ShareableLinkCopied.send(&app); - Ok(UploadResult::Success(uploaded_video.link)) + Ok(UploadResult::Success(link)) } Err(AuthedApiError::UpgradeRequired) => Ok(UploadResult::UpgradeRequired), Err(e) => { @@ -6631,6 +6725,11 @@ fn specta_builder() -> tauri_specta::Builder { open_file_path, get_video_metadata, create_editor_instance, + editor_preparing::create_preparing_editor_frame, + editor_preparing::get_preparing_editor_state, + editor_preparing::seek_preparing_editor, + editor_preparing::set_preparing_editor_playing, + editor_preparing::stop_preparing_editor_frame, get_editor_project_path, get_mic_waveforms, get_system_audio_waveforms, @@ -6639,6 +6738,9 @@ fn specta_builder() -> tauri_specta::Builder { audio_library::import_audio_track_file, start_playback, stop_playback, + commit_editor_preparing_frame, + start_editor_handoff_playback, + stop_editor_handoff_playback, set_playhead_position, set_project_config, update_project_config_in_memory, @@ -6745,6 +6847,7 @@ fn specta_builder() -> tauri_specta::Builder { NewScreenshotAdded, RenderFrameEvent, EditorStateChanged, + editor_preparing::PreparingEditorChanged, FrameLayoutEvent, CurrentRecordingChanged, RecordingStarted, @@ -7043,6 +7146,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { app.manage(http_client::RetryableHttpClient::default()); app.manage(PendingScreenshots::default()); app.manage(FinalizingRecordings::default()); + app.manage(editor_preparing::PreparingConsumers::default()); app.manage(updates::UpdatesState::default()); updates::spawn_background_loop(app.clone()); @@ -7211,6 +7315,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { app.state::().set_ready(true); tracing::info!("Main window frontend ready"); #[cfg(debug_assertions)] + stop_editor_benchmark::run(app.clone()); + #[cfg(debug_assertions)] picker_benchmark::run(app.clone()); gpu_context::prewarm_gpu(); tokio::task::spawn_blocking(cap_rendering::prewarm_fonts); @@ -8282,6 +8388,8 @@ fn retire_project_window(window: &Window, window_id: &CapWindowId) { let app = window.app_handle(); match window_id { CapWindowId::Editor { id } => { + app.state::() + .cancel_window(*id, None); let window_ids = EditorWindowIds::get(app); match window_ids.ids.lock() { Ok(mut ids) => ids.retain(|(_, current_id)| current_id != id), @@ -8574,19 +8682,47 @@ async fn create_editor_instance_impl( is_software_adapter: shared.is_software_adapter, }); + #[cfg(debug_assertions)] + let frame_cb: cap_editor::EditorFrameCallback = + if stop_editor_benchmark::frame_capture_requested() { + let capture_path = path.clone(); + let mut frame_cb = frame_cb; + Box::new(move |output, layout| { + stop_editor_benchmark::capture_output( + stop_editor_benchmark::CaptureKind::Ordinary, + &capture_path, + &output, + ); + frame_cb(output, layout); + }) + } else { + frame_cb + }; + + let (audio_output, startup, live_handoff) = app + .state::() + .take_startup(&path) + .await?; let instance = { let app = app.clone(); - EditorInstance::new( + EditorInstance::new_with_startup_inputs( path, move |state| { let _ = EditorStateChanged::new(state).emit(&app); }, frame_cb, shared_device, + cap_editor::EditorFrameFormat::Rgba, + audio_output, + startup, ) .await? }; + if let Some(handoff) = &live_handoff { + instance.install_preparing_handoff(handoff).await?; + } + let event_id = RenderFrameEvent::listen_any(&app, { let preview_tx = instance.preview_tx.clone(); move |e| { @@ -8600,21 +8736,73 @@ async fn create_editor_instance_impl( } }); - instance - .preview_tx - .send_modify(|v| *v = Some((0, EDITOR_PREVIEW_FPS, default_editor_preview_resolution()))); + let started = match instance + .start_preparing_handoff(EDITOR_PREVIEW_FPS, default_editor_preview_resolution()) + .await + { + Ok(started) => started, + Err(error) => { + app.unlisten(event_id); + let cleanup_failed = if let Some(handoff) = &live_handoff { + handoff.stop_and_wait().await.cleanup_failed + } else { + false + }; + instance.dispose().await; + return Err(if cleanup_failed { + format!( + "Preparing handoff start failed: {error:?}; preparing playback cleanup failed" + ) + } else { + format!("Preparing handoff start failed: {error:?}") + }); + } + }; + if !started { + let frame = instance + .preparing_adoption() + .and_then(|adoption| adoption.frame_number(EDITOR_PREVIEW_FPS)) + .unwrap_or(0); + instance.preview_tx.send_modify(|v| { + *v = Some(( + frame, + EDITOR_PREVIEW_FPS, + default_editor_preview_resolution(), + )) + }); + } Ok((instance, event_id)) } pub(crate) async fn wait_for_recording_ready(app: &AppHandle, path: &Path) -> Result<(), String> { + let result = wait_for_recording_ready_inner(app, path).await; + if result.is_err() { + let cleanup = editor_preparing::join_before_ordinary(app, path).await; + return result.and(cleanup); + } + result +} + +async fn await_finalization_before_ordinary( + app: &AppHandle, + path: &Path, + result: watch::Receiver, +) -> Result<(), String> { + let finalization = await_finalization_result(result).await; + let cleanup = editor_preparing::join_before_ordinary(app, path).await; + finalization.and(cleanup) +} + +async fn wait_for_recording_ready_inner(app: &AppHandle, path: &Path) -> Result<(), String> { + let display_path = path; let project = FinalizationProject::observe(path.to_path_buf()).await?; let path = project.work_path(); let finalizing_state = app.state::(); if let Some(result) = finalizing_state.is_finalizing(&project) { info!("Recording is being finalized, waiting for completion..."); - await_finalization_result(result).await?; + await_finalization_before_ordinary(app, display_path, result).await?; project.validate_async().await?; info!("Recording finalization completed"); let meta = RecordingMeta::load_for_project(path) @@ -8625,6 +8813,8 @@ pub(crate) async fn wait_for_recording_ready(app: &AppHandle, path: &Path) -> Re return Ok(()); } + editor_preparing::join_before_ordinary(app, display_path).await?; + let meta = match RecordingMeta::load_for_project(path) { Ok(meta) => meta, Err(e) => { @@ -8649,7 +8839,7 @@ pub(crate) async fn wait_for_recording_ready(app: &AppHandle, path: &Path) -> Re project.validate_async().await?; if let Some(result) = finalizing_state.is_finalizing(&project) { - await_finalization_result(result).await?; + await_finalization_before_ordinary(app, display_path, result).await?; break; } @@ -8676,7 +8866,7 @@ pub(crate) async fn wait_for_recording_ready(app: &AppHandle, path: &Path) -> Re project.validate_async().await?; if let Some(result) = finalizing_state.is_finalizing(&project) { - await_finalization_result(result).await?; + await_finalization_before_ordinary(app, display_path, result).await?; project.validate_async().await?; } @@ -8697,11 +8887,13 @@ pub(crate) async fn wait_for_recording_ready(app: &AppHandle, path: &Path) -> Re "Recording directory changed before recovery", )); } - match finalizing_state.request(work_project, false, FinalizationOrigin::Recording)? { + match finalizing_state.request(work_project, false, FinalizationOrigin::Recording, false)? { FinalizationRequest::Started(token) => { run_finalization_worker(token, recording::remux_fragmented_recording).await?; } - FinalizationRequest::Existing(result) => await_finalization_result(result).await?, + FinalizationRequest::Existing(result) => { + await_finalization_before_ordinary(app, display_path, result).await? + } } project.validate_async().await?; info!("Crash recovery remux completed"); diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index b3113b6586b..7ae1d705245 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -100,6 +100,24 @@ fn main() { .join("so.cap.desktop") .join("logs"); + #[cfg(debug_assertions)] + let path = match ( + std::env::var_os("CAP_STOP_EDITOR_BENCHMARK_OUTPUT"), + std::env::var_os("CAP_STOP_BENCH_LOG_DIR"), + ) { + (Some(output), Some(directory)) => match create_benchmark_log_directory( + std::path::Path::new(&output), + std::path::Path::new(&directory), + ) { + Ok(directory) => directory, + Err(error) => { + eprintln!("Invalid private benchmark log directory: {error}"); + std::process::exit(2); + } + }, + _ => path, + }; + path }; @@ -246,6 +264,66 @@ fn create_log_appender( } } +#[cfg(debug_assertions)] +fn create_benchmark_log_directory( + output: &std::path::Path, + directory: &std::path::Path, +) -> std::io::Result { + use std::{io, path::Component}; + + if !output.is_absolute() + || !directory.is_absolute() + || output == directory + || [output, directory].into_iter().any(|path| { + path.components().any(|component| { + !matches!( + component, + Component::Prefix(_) | Component::RootDir | Component::Normal(_) + ) + }) + }) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Benchmark paths must be distinct absolute paths without traversal", + )); + } + let parent = directory.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Benchmark log directory has no parent", + ) + })?; + if output.parent() != Some(parent) || parent.canonicalize()? != parent || !parent.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Benchmark output and logs must share an existing canonical parent", + )); + } + for path in [output, directory] { + match std::fs::symlink_metadata(path) { + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "Benchmark output and log paths must be new", + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + let builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + let builder = { + use std::os::unix::fs::DirBuilderExt; + let mut builder = builder; + builder.mode(0o700); + builder + }; + builder.create(directory)?; + Ok(directory.to_path_buf()) +} + fn install_panic_hook(logs_dir: std::path::PathBuf) { let prev = std::panic::take_hook(); let panics_log = logs_dir.join("panics.log"); @@ -384,3 +462,78 @@ mod logging_tests { assert!(create_log_appender(&directory.0, "other.log").is_some()); } } + +#[cfg(all(test, debug_assertions))] +mod benchmark_log_directory_tests { + use super::create_benchmark_log_directory; + + #[test] + fn benchmark_logs_use_a_new_private_sibling_without_creating_output() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + let output = root.join("benchmark.json"); + let logs = root.join("logs"); + assert_eq!( + create_benchmark_log_directory(&output, &logs).unwrap(), + logs + ); + assert!(logs.is_dir()); + assert!(!output.exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(logs.metadata().unwrap().permissions().mode() & 0o777, 0o700); + } + } + + #[test] + fn existing_or_foreign_benchmark_paths_are_preserved() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + let output = root.join("benchmark.json"); + let logs = root.join("logs"); + std::fs::write(&output, "retained evidence").unwrap(); + assert!(create_benchmark_log_directory(&output, &logs).is_err()); + assert_eq!( + std::fs::read_to_string(&output).unwrap(), + "retained evidence" + ); + assert!(!logs.exists()); + std::fs::create_dir(&logs).unwrap(); + assert!(create_benchmark_log_directory(&root.join("new.json"), &logs).is_err()); + assert!( + create_benchmark_log_directory(&root.join("new.json"), &root.join("other/logs")) + .is_err() + ); + assert!( + create_benchmark_log_directory( + std::path::Path::new("relative.json"), + &root.join("fresh") + ) + .is_err() + ); + assert!( + create_benchmark_log_directory(&root.join("new.json"), &root.join("nested/../fresh")) + .is_err() + ); + assert!(!root.join("fresh").exists()); + } + + #[cfg(unix)] + #[test] + fn symlinked_benchmark_parents_are_rejected_without_writes() { + use std::os::unix::fs::symlink; + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + let actual = root.join("actual"); + let alias = root.join("alias"); + std::fs::create_dir(&actual).unwrap(); + symlink(&actual, &alias).unwrap(); + assert!( + create_benchmark_log_directory(&alias.join("result.json"), &alias.join("logs")) + .is_err() + ); + assert!(!actual.join("logs").exists()); + assert!(!actual.join("result.json").exists()); + } +} diff --git a/apps/desktop/src-tauri/src/preparing_finalization.rs b/apps/desktop/src-tauri/src/preparing_finalization.rs new file mode 100644 index 00000000000..31abc24de62 --- /dev/null +++ b/apps/desktop/src-tauri/src/preparing_finalization.rs @@ -0,0 +1,588 @@ +use super::{ + FinalizationAttempt, FinalizationOrigin, FinalizationProject, FinalizationToken, + FinalizingRecordings, FinalizingRecordingsMap, +}; +use cap_recording::{ + recovery::{PreparingStudioJob, PreparingStudioObserver}, + studio_recording::CompletedRecording, +}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Default)] +pub(super) enum PreparingFinalizationState { + #[default] + Waiting, + Declined, + Offered(PreparingStudioObserver), +} + +#[derive(Clone)] +pub(crate) struct FinalizationPreparing { + recordings: Arc>, + attempt: Arc, +} + +impl FinalizationToken { + pub(crate) fn preparing(&self) -> FinalizationPreparing { + FinalizationPreparing { + recordings: self.recordings.clone(), + attempt: self.attempt.clone(), + } + } +} + +impl FinalizingRecordings { + pub(crate) fn preparing_for_project( + &self, + project: &FinalizationProject, + ) -> Option { + let recordings = self.recordings.lock().unwrap(); + let attempt = recordings.attempts.get(&project.identity)?; + if attempt.origin != FinalizationOrigin::Recording || attempt.result.borrow().is_some() { + return None; + } + Some(FinalizationPreparing { + recordings: self.recordings.clone(), + attempt: attempt.clone(), + }) + } +} + +impl FinalizationPreparing { + pub(crate) fn allows_preparing_continuation(&self) -> bool { + let recordings = self.recordings.lock().unwrap(); + self.attempt.origin == FinalizationOrigin::Recording + && recordings + .attempts + .get(&self.attempt.project.identity) + .map_or_else( + || matches!(&*self.attempt.result.borrow(), Some(Ok(()))), + |current| Arc::ptr_eq(current, &self.attempt), + ) + && self + .attempt + .result + .borrow() + .as_ref() + .is_none_or(Result::is_ok) + && matches!(&*self.attempt.preparing.borrow(), PreparingFinalizationState::Offered(observer) if observer.publication_succeeded()) + } + + pub(crate) fn is_pending(&self) -> bool { + self.is_current(&self.recordings.lock().unwrap()) + } + + pub(crate) fn set_presentation( + &self, + presentation: Result, + ) { + let recordings = self.recordings.lock().unwrap(); + if self.is_current(&recordings) { + self.attempt + .preparing_presentation + .send_if_modified(|state| { + if state.is_some() { + return false; + } + *state = Some(presentation.map(Arc::new)); + true + }); + } + } + + pub(crate) async fn wait_for_presentation( + &self, + ) -> Option> { + let mut presentation = self.attempt.preparing_presentation.subscribe(); + let mut result = self.attempt.result.subscribe(); + loop { + let state = { + let recordings = self.recordings.lock().unwrap(); + if !self.is_current(&recordings) { + return None; + } + presentation.borrow_and_update().clone() + }; + if let Some(state) = state { + return state.ok(); + } + tokio::select! { + changed = presentation.changed() => if changed.is_err() { return None; }, + changed = result.changed() => if changed.is_err() { return None; }, + } + } + } + + #[cfg(test)] + fn same_attempt(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.attempt, &other.attempt) + } + + fn is_current(&self, recordings: &FinalizingRecordingsMap) -> bool { + self.attempt.origin == FinalizationOrigin::Recording + && recordings + .attempts + .get(&self.attempt.project.identity) + .is_some_and(|current| Arc::ptr_eq(current, &self.attempt)) + && self.attempt.result.borrow().is_none() + } + + pub(crate) fn claim(&self, completed: &CompletedRecording) -> Option { + self.claim_with(completed, PreparingStudioJob::claim) + } + + fn claim_with( + &self, + completed: &CompletedRecording, + claim: impl FnOnce( + &CompletedRecording, + u64, + ) -> Option<(PreparingStudioJob, PreparingStudioObserver)>, + ) -> Option { + let paths_match = completed.project_path == self.attempt.project.display_path() + && self.attempt.project.display_path() == self.attempt.project.work_path(); + let directory_valid = paths_match + && self.attempt.preparing_generation.is_some() + && self.attempt.project.validate().is_ok(); + let recordings = self.recordings.lock().unwrap(); + if !self.is_current(&recordings) { + return None; + } + let mut job = None; + self.attempt.preparing.send_if_modified(|state| { + if !matches!(state, PreparingFinalizationState::Waiting) { + return false; + } + *state = PreparingFinalizationState::Declined; + if directory_valid + && let Some(generation) = self.attempt.preparing_generation + && let Some((claimed, observer)) = claim(completed, generation) + { + job = Some(claimed); + *state = PreparingFinalizationState::Offered(observer); + } + true + }); + job + } + + pub(crate) async fn wait_for_preparing(&self) -> Option { + let mut preparing = self.attempt.preparing.subscribe(); + let mut result = self.attempt.result.subscribe(); + loop { + let state = { + let recordings = self.recordings.lock().unwrap(); + if !self.is_current(&recordings) { + return None; + } + preparing.borrow_and_update().clone() + }; + match state { + PreparingFinalizationState::Waiting => {} + PreparingFinalizationState::Declined => return None, + PreparingFinalizationState::Offered(observer) => return Some(observer), + } + tokio::select! { + changed = preparing.changed() => if changed.is_err() { return None; }, + changed = result.changed() => if changed.is_err() { return None; }, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FinalizationAccess, await_finalization_result, has_pending_finalizations}; + use cap_project::RecordingMeta; + use std::path::Path; + use std::time::Duration; + + fn project() -> (tempfile::TempDir, Arc) { + let directory = tempfile::tempdir().unwrap(); + let project = FinalizationProject::capture( + directory.path().canonicalize().unwrap(), + FinalizationAccess::Write, + ) + .unwrap(); + (directory, project) + } + + async fn wait_for_preparing( + preparing: &FinalizationPreparing, + ) -> Option { + tokio::time::timeout(Duration::from_secs(1), preparing.wait_for_preparing()) + .await + .unwrap() + } + + fn completed_without_receipt(path: &Path) -> CompletedRecording { + let metadata: RecordingMeta = serde_json::from_value(serde_json::json!({ + "pretty_name": "Preparing finalization test", "sharing": null, + "segments": [{"display": {"path": "content/segments/segment-0/display", "fps": 30}}], + "status": {"status": "NeedsRemux"} + })) + .unwrap(); + CompletedRecording { + project_path: path.to_path_buf(), + meta: metadata.studio_meta().unwrap().clone(), + cursor_data: Default::default(), + clean_stopped: None, + } + } + + #[tokio::test] + async fn declined_receipt_is_not_finalization_success() { + let (_directory, project) = project(); + let recordings = FinalizingRecordings::default(); + let token = recordings.start_finalizing(project.clone()).unwrap(); + let preparing = recordings.preparing_for_project(&project).unwrap(); + assert!(preparing.same_attempt(&token.preparing())); + assert!( + preparing + .claim(&completed_without_receipt(project.display_path())) + .is_none() + ); + assert!(wait_for_preparing(&preparing).await.is_none()); + assert!(has_pending_finalizations( + &recordings.recordings.lock().unwrap() + )); + let result = token.attempt.result.subscribe(); + token.finish(Err("configuration write failed".into())); + assert!( + await_finalization_result(result) + .await + .unwrap_err() + .contains("configuration write failed") + ); + assert!(recordings.preparing_for_project(&project).is_none()); + } + + #[tokio::test] + async fn completion_before_admission_does_not_invoke_claim() { + let (_directory, project) = project(); + let recordings = FinalizingRecordings::default(); + let token = recordings.start_finalizing(project.clone()).unwrap(); + let preparing = token.preparing(); + token.finish(Ok(())); + assert!( + preparing + .claim_with( + &completed_without_receipt(project.display_path()), + |_, _| panic!("Completed attempts cannot claim") + ) + .is_none() + ); + assert!(wait_for_preparing(&preparing).await.is_none()); + assert!(!has_pending_finalizations( + &recordings.recordings.lock().unwrap() + )); + } + + #[tokio::test] + async fn failed_retry_has_a_new_attempt_and_rejects_stale_claims() { + let (_directory, project) = project(); + let recordings = FinalizingRecordings::default(); + let first = recordings.start_finalizing(project.clone()).unwrap(); + assert_eq!(first.attempt.preparing_generation, Some(1)); + let stale = first.preparing(); + first.finish(Err("first failure".into())); + let second = recordings.start_finalizing(project.clone()).unwrap(); + assert_eq!(second.attempt.preparing_generation, Some(2)); + let current = second.preparing(); + assert!(!stale.same_attempt(¤t)); + assert!( + stale + .claim_with( + &completed_without_receipt(project.display_path()), + |_, _| panic!("Stale attempts cannot claim") + ) + .is_none() + ); + assert!(wait_for_preparing(&stale).await.is_none()); + assert!( + tokio::time::timeout(Duration::from_millis(20), current.wait_for_preparing()) + .await + .is_err() + ); + second.finish(Ok(())); + assert!(wait_for_preparing(¤t).await.is_none()); + } + + #[tokio::test] + async fn token_drop_wakes_pending_observer_and_preserves_interrupted_error() { + let (_directory, project) = project(); + let recordings = FinalizingRecordings::default(); + let token = recordings.start_finalizing(project).unwrap(); + let preparing = token.preparing(); + let result = token.attempt.result.subscribe(); + assert!( + tokio::time::timeout(Duration::from_millis(20), preparing.wait_for_preparing()) + .await + .is_err() + ); + drop(token); + assert!( + tokio::time::timeout(Duration::from_secs(1), preparing.wait_for_preparing()) + .await + .unwrap() + .is_none() + ); + assert!( + await_finalization_result(result) + .await + .unwrap_err() + .contains("interrupted") + ); + } + + #[test] + fn native_recording_attempt_allocates_generation_without_capture_coordinator() { + let (_directory, project) = project(); + let recordings = FinalizingRecordings::default(); + let token = recordings.start_finalizing(project.clone()).unwrap(); + let preparing = token.preparing(); + let clone = preparing.clone(); + let completed = completed_without_receipt(project.display_path()); + let called = std::cell::Cell::new(false); + assert!( + preparing + .claim_with(&completed, |actual, generation| { + called.set(true); + assert_eq!(actual.project_path, project.display_path()); + assert_eq!(generation, 1); + None + }) + .is_none() + ); + assert!(called.get()); + assert!( + clone + .claim_with(&completed, |_, _| panic!( + "Duplicate admission cannot claim" + )) + .is_none() + ); + assert!(token.attempt.result.borrow().is_none()); + token.finish(Ok(())); + } + + #[test] + fn lazy_open_and_recovery_origin_never_allocate_or_invoke_claim() { + let (_directory, project) = project(); + for (origin, preparing_requested) in [ + (FinalizationOrigin::Recording, false), + (FinalizationOrigin::Recovery, true), + ] { + let recordings = FinalizingRecordings::default(); + let token = recordings + .start_with_origin(project.clone(), origin, preparing_requested) + .unwrap(); + assert!( + token + .preparing() + .claim_with( + &completed_without_receipt(project.display_path()), + |_, _| panic!("Ineligible finalization cannot claim") + ) + .is_none() + ); + assert_eq!( + recordings + .recordings + .lock() + .unwrap() + .last_preparing_generation, + 0 + ); + if origin == FinalizationOrigin::Recovery { + assert!(recordings.preparing_for_project(&project).is_none()); + } + token.finish(Ok(())); + } + } + + #[test] + fn duplicate_finalization_keeps_the_original_attempt_generation() { + let (_directory, project) = project(); + let recordings = FinalizingRecordings::default(); + let first = recordings.start_finalizing(project.clone()).unwrap(); + assert!(recordings.start_finalizing(project.clone()).is_err()); + let existing = recordings + .request(project.clone(), false, FinalizationOrigin::Recording, false) + .unwrap(); + assert!(matches!(existing, crate::FinalizationRequest::Existing(_))); + assert_eq!(first.attempt.preparing_generation, Some(1)); + assert_eq!( + recordings + .recordings + .lock() + .unwrap() + .last_preparing_generation, + 1 + ); + assert!( + first + .preparing() + .same_attempt(&recordings.preparing_for_project(&project).unwrap()) + ); + first.finish(Err("retained retry".into())); + let next = recordings.start_finalizing(project).unwrap(); + assert_eq!(next.attempt.preparing_generation, Some(2)); + next.finish(Ok(())); + } + + #[tokio::test] + async fn generation_exhaustion_declines_preview_and_preserves_finalization() { + let (_directory, project) = project(); + let recordings = FinalizingRecordings::default(); + recordings + .recordings + .lock() + .unwrap() + .last_preparing_generation = u64::MAX - 1; + let last = recordings.start_finalizing(project.clone()).unwrap(); + assert_eq!(last.attempt.preparing_generation, Some(u64::MAX)); + last.finish(Err("retained retry".into())); + let exhausted = recordings.start_finalizing(project.clone()).unwrap(); + assert_eq!(exhausted.attempt.preparing_generation, None); + assert_eq!( + recordings + .recordings + .lock() + .unwrap() + .last_preparing_generation, + u64::MAX + ); + let preparing = exhausted.preparing(); + assert!( + preparing + .claim_with( + &completed_without_receipt(project.display_path()), + |_, _| { panic!("An exhausted sequence cannot claim") } + ) + .is_none() + ); + assert!(wait_for_preparing(&preparing).await.is_none()); + assert!(has_pending_finalizations( + &recordings.recordings.lock().unwrap() + )); + let result = exhausted.attempt.result.subscribe(); + exhausted.finish(Ok(())); + assert_eq!(await_finalization_result(result).await, Ok(())); + assert!(!has_pending_finalizations( + &recordings.recordings.lock().unwrap() + )); + } + + #[test] + fn a_different_stopped_project_never_invokes_claim() { + let (_directory, project) = project(); + let other = tempfile::tempdir().unwrap(); + let recordings = FinalizingRecordings::default(); + let token = recordings.start_finalizing(project).unwrap(); + assert!( + token + .preparing() + .claim_with(&completed_without_receipt(other.path()), |_, _| panic!( + "Wrong project cannot claim" + )) + .is_none() + ); + token.finish(Ok(())); + } + + #[test] + fn canonical_alias_declines_without_rewriting_the_stopped_path() { + let directory = tempfile::tempdir().unwrap(); + std::fs::create_dir(directory.path().join("alias")).unwrap(); + std::fs::create_dir(directory.path().join("project")).unwrap(); + let display = directory.path().join("alias/../project"); + let project = + FinalizationProject::capture(display.clone(), FinalizationAccess::Write).unwrap(); + assert_ne!(project.display_path(), project.work_path()); + let recordings = FinalizingRecordings::default(); + let token = recordings.start_finalizing(project).unwrap(); + let completed = completed_without_receipt(&display); + assert!( + token + .preparing() + .claim_with(&completed, |_, _| panic!( + "Alias must not rebind a clean receipt" + )) + .is_none() + ); + assert_eq!(completed.project_path, display); + token.finish(Ok(())); + } + + #[test] + fn replaced_directory_declines_before_claim() { + let directory = tempfile::tempdir().unwrap(); + let original = directory.path().join("project"); + let moved = directory.path().join("moved"); + std::fs::create_dir(&original).unwrap(); + let project = FinalizationProject::capture( + original.canonicalize().unwrap(), + FinalizationAccess::Write, + ) + .unwrap(); + let recordings = FinalizingRecordings::default(); + let token = recordings.start_finalizing(project.clone()).unwrap(); + std::fs::rename(&original, &moved).unwrap(); + std::fs::create_dir(&original).unwrap(); + assert!( + token + .preparing() + .claim_with( + &completed_without_receipt(project.display_path()), + |_, _| panic!("Replaced directory cannot claim") + ) + .is_none() + ); + assert!(token.attempt.result.borrow().is_none()); + token.finish(Err("directory replaced".into())); + } + + #[tokio::test] + async fn missing_fragments_keep_the_existing_error_and_do_not_claim() { + let (_directory, project) = project(); + let sentinel = project.work_path().join("retained-original.bin"); + std::fs::write(&sentinel, b"retain original bytes").unwrap(); + let completed = completed_without_receipt(project.display_path()); + let recordings = FinalizingRecordings::default(); + let token = recordings.start_finalizing(project.clone()).unwrap(); + let preparing = token.preparing(); + let ordinary = crate::recording::remux_fragmented_recording_with_trigger( + project.work_path(), + project.display_path(), + "recording_stop", + None, + ); + let candidate = crate::recording::remux_fragmented_recording_with_preparing( + project.work_path(), + project.display_path(), + "recording_stop", + None, + Some((&preparing, &completed)), + ); + assert_eq!(candidate, ordinary); + assert_eq!( + candidate.as_ref().unwrap_err(), + "Could not find fragments to remux" + ); + assert!(matches!( + *token.attempt.preparing.borrow(), + PreparingFinalizationState::Waiting + )); + assert_eq!(std::fs::read(sentinel).unwrap(), b"retain original bytes"); + assert!( + !project + .work_path() + .join(crate::recording::FRAGMENTED_EXPORT_FFMPEG_MARKER) + .exists() + ); + token.finish(candidate); + assert!(wait_for_preparing(&preparing).await.is_none()); + } +} diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 2a3a1788709..2123769b9b6 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -5927,6 +5927,7 @@ async fn handle_recording_finish( recording, default_preset, Some(capture_target), + finalization.preparing(), ) .await; @@ -5975,6 +5976,7 @@ async fn handle_recording_finish( project_path: recording.project_path, meta: updated_studio_meta.clone(), cursor_data: recording.cursor_data, + clean_stopped: None, }, &recordings, PresetsStore::get_default_preset(app)?.map(|p| p.config), @@ -6164,21 +6166,28 @@ async fn finalize_studio_recording( recording: cap_recording::studio_recording::CompletedRecording, default_preset: Option, capture_target: Option, + preparing: crate::preparing_finalization::FinalizationPreparing, ) -> Result<(), String> { info!("Starting background finalization for recording"); project.validate_async().await?; + preparing.set_presentation(preparing_presentation_snapshot( + default_preset.as_ref(), + capture_target.as_ref(), + )); let recording_dir = project.work_path().to_path_buf(); let screenshots_dir = recording_dir.join("screenshots"); let display_path = project.display_path().to_path_buf(); let recording_dir_for_remux = recording_dir.clone(); let app_for_remux = app.clone(); - let remux_result = tokio::task::spawn_blocking(move || { - remux_fragmented_recording_with_trigger( + let (remux_result, recording) = tokio::task::spawn_blocking(move || { + let result = remux_fragmented_recording_with_preparing( &recording_dir_for_remux, &display_path, "recording_stop", Some(&app_for_remux), - ) + Some((&preparing, &recording)), + ); + (result, recording) }) .await .map_err(|e| format!("Recording finalization task panicked: {e}"))?; @@ -6220,6 +6229,7 @@ async fn finalize_studio_recording( project_path: recording.project_path, meta: updated_studio_meta, cursor_data: recording.cursor_data, + clean_stopped: None, }, &recordings, default_preset, @@ -6421,24 +6431,7 @@ fn project_config_from_recording( let camera_preview_manager = CameraPreviewManager::new(app); if let Ok(camera_preview_state) = camera_preview_manager.get_state() { - match camera_preview_state.shape { - CameraPreviewShape::Round => { - config.camera.shape = CameraShape::Square; - config.camera.rounding = 100.0; - } - CameraPreviewShape::Square => { - config.camera.shape = CameraShape::Square; - config.camera.rounding = 25.0; - } - CameraPreviewShape::Full => { - config.camera.shape = CameraShape::Source; - config.camera.rounding = 25.0; - } - } - - config.camera.background_blur = cap_project::BackgroundBlurConfig { - mode: camera_preview_state.background_blur, - }; + apply_recording_camera_preview_state(&mut config, &camera_preview_state); } let timeline_segments = recordings @@ -6478,8 +6471,17 @@ fn project_config_from_recording( }); } - config.timeline = Some(TimelineConfiguration { - segments: timeline_segments, + config.timeline = Some(recording_timeline(timeline_segments, zoom_segments)); + + config +} + +pub(crate) fn recording_timeline( + segments: Vec, + zoom_segments: Vec, +) -> TimelineConfiguration { + TimelineConfiguration { + segments, transitions: Vec::new(), zoom_segments, scene_segments: Vec::new(), @@ -6491,9 +6493,31 @@ fn project_config_from_recording( keyboard_segments: Vec::new(), audio_segments: Vec::new(), camera3d_segments: Vec::new(), - }); + } +} - config +fn apply_recording_camera_preview_state( + config: &mut ProjectConfiguration, + camera_preview_state: &crate::camera::CameraPreviewState, +) { + match camera_preview_state.shape { + CameraPreviewShape::Round => { + config.camera.shape = CameraShape::Square; + config.camera.rounding = 100.0; + } + CameraPreviewShape::Square => { + config.camera.shape = CameraShape::Square; + config.camera.rounding = 25.0; + } + CameraPreviewShape::Full => { + config.camera.shape = CameraShape::Source; + config.camera.rounding = 25.0; + } + } + + config.camera.background_blur = cap_project::BackgroundBlurConfig { + mode: camera_preview_state.background_blur, + }; } fn should_enable_notch_overlay( @@ -6661,6 +6685,19 @@ pub fn remux_fragmented_recording_with_trigger( display_path: &Path, trigger: &'static str, app: Option<&AppHandle>, +) -> Result<(), String> { + remux_fragmented_recording_with_preparing(recording_dir, display_path, trigger, app, None) +} + +pub(crate) fn remux_fragmented_recording_with_preparing( + recording_dir: &Path, + display_path: &Path, + trigger: &'static str, + app: Option<&AppHandle>, + preparing: Option<( + &crate::preparing_finalization::FinalizationPreparing, + &studio_recording::CompletedRecording, + )>, ) -> Result<(), String> { crate::recovery::ensure_finalization_storage(recording_dir, display_path)?; let incomplete_recording = RecoveryManager::inspect_recording(recording_dir); @@ -6669,7 +6706,10 @@ pub fn remux_fragmented_recording_with_trigger( let normal_stop = trigger == "recording_stop"; let validation_start = std::time::Instant::now(); let outcome = if normal_stop { - RecoveryManager::finalize(&recording) + match preparing.and_then(|(publisher, completed)| publisher.claim(completed)) { + Some(job) => RecoveryManager::finalize_with_preparing(&recording, job), + None => RecoveryManager::finalize(&recording), + } } else { RecoveryManager::recover(&recording) }; @@ -9505,6 +9545,7 @@ mod studio_joined_completion_tests { }, }, cursor_data: Default::default(), + clean_stopped: None, }), } }, @@ -9758,3 +9799,335 @@ mod studio_capture_control_tests { } } } + +pub(crate) fn preparing_presentation_snapshot( + preset: Option<&ProjectConfiguration>, + capture_target: Option<&ScreenCaptureTarget>, +) -> Result { + let mut config = preset + .cloned() + .ok_or("Default presentation requires ordinary finalization")?; + if matches!(capture_target, None | Some(ScreenCaptureTarget::CameraOnly)) + || !matches!( + config.background.source, + cap_project::BackgroundSource::Color { .. } + | cap_project::BackgroundSource::Gradient { + animated: None | Some(false), + .. + } + ) + || config.background.notch.is_some() + { + return Err("Presentation requires ordinary finalization".into()); + } + config.cursor.size = cap_project::CursorConfiguration::default().size; + apply_screen_recording_presentation_defaults(&mut config, capture_target, false, None); + Ok(config) +} + +#[cfg(test)] +mod preparing_presentation_tests { + use super::*; + + #[test] + fn preparing_never_guesses_default_wallpaper_or_animated_background() { + assert!(preparing_presentation_snapshot(None, None).is_err()); + let config = ProjectConfiguration::default(); + assert!(preparing_presentation_snapshot(Some(&config), None).is_err()); + assert!( + preparing_presentation_snapshot(Some(&config), Some(&ScreenCaptureTarget::CameraOnly)) + .is_err() + ); + } + + #[test] + fn static_preset_defaults_match_the_existing_screen_defaults() { + let target = ScreenCaptureTarget::Display { + id: "1".parse().unwrap(), + }; + let mut config = ProjectConfiguration::default(); + config.cursor.size = 173; + config.background.padding = 0.0; + config.background.rounding = 0.0; + let projected = preparing_presentation_snapshot(Some(&config), Some(&target)).unwrap(); + let mut ordinary = config; + ordinary.cursor.size = cap_project::CursorConfiguration::default().size; + apply_screen_recording_presentation_defaults(&mut ordinary, Some(&target), false, None); + assert_eq!( + serde_json::to_value(projected).unwrap(), + serde_json::to_value(ordinary).unwrap() + ); + } +} + +#[cfg(test)] +mod preparing_presentation_parity_tests { + use super::*; + use cap_project::{BackgroundSource, ClipConfiguration, ClipOffsets, ScreenMovementSpring}; + + fn value(config: &ProjectConfiguration) -> serde_json::Value { + serde_json::to_value(config).unwrap() + } + + fn targets() -> Vec { + vec![ + ScreenCaptureTarget::Display { + id: "1".parse().unwrap(), + }, + ScreenCaptureTarget::Window { + id: "1".parse().unwrap(), + }, + ScreenCaptureTarget::Area { + screen: "1".parse().unwrap(), + bounds: scap_targets::bounds::LogicalBounds::new( + scap_targets::bounds::LogicalPosition::new(10.0, 20.0), + scap_targets::bounds::LogicalSize::new(320.0, 240.0), + ), + }, + ] + } + + fn one_segment(end: f64) -> Vec { + vec![TimelineSegment { + recording_clip: 0, + start: 0.0, + end, + timescale: 1.0, + name: None, + speed_audio_mode: None, + }] + } + + fn preset() -> ProjectConfiguration { + ProjectConfiguration::default() + } + + fn ordinary_static_projection( + preset: &ProjectConfiguration, + target: &ScreenCaptureTarget, + segments: Vec, + ) -> ProjectConfiguration { + let mut config = preset.clone(); + config.cursor.size = cap_project::CursorConfiguration::default().size; + apply_screen_recording_presentation_defaults(&mut config, Some(target), false, None); + apply_recording_camera_preview_state( + &mut config, + &crate::camera::CameraPreviewState::default(), + ); + config.timeline = Some(recording_timeline(segments, Vec::new())); + config + } + + #[test] + fn static_color_projection_preserves_preset_and_matches_ordinary_screen_defaults() { + for target in targets() { + let mut preset = preset(); + preset.cursor.size = 173; + preset.background.padding = 0.0; + preset.background.rounding = 0.0; + preset.background.source = BackgroundSource::Color { + value: [32, 64, 128], + alpha: 160, + }; + preset.screen_movement_spring = ScreenMovementSpring { + stiffness: 120.0, + damping: 14.0, + mass: 1.0, + }; + let original = value(&preset); + let snapshot = preparing_presentation_snapshot(Some(&preset), Some(&target)).unwrap(); + let stopped = recording_timeline(one_segment(6.0), Vec::new()); + let projected = + crate::editor_preparing::project_from_preparing_presentation(&snapshot, &stopped); + let ordinary = ordinary_static_projection(&preset, &target, one_segment(6.0)); + assert_eq!(value(&projected), value(&ordinary)); + assert_eq!(value(&preset), original); + assert_eq!(projected.background.padding, 10.0); + assert_eq!( + projected.background.rounding, + if matches!(target, ScreenCaptureTarget::Area { .. }) { + 0.0 + } else { + 7.5 + } + ); + assert_eq!( + projected.cursor.size, + cap_project::CursorConfiguration::default().size + ); + assert_eq!( + serde_json::to_value(projected.screen_movement_spring).unwrap(), + serde_json::to_value(ScreenMovementSpring::default()).unwrap() + ); + assert!(matches!( + projected.background.source, + BackgroundSource::Color { + value: [32, 64, 128], + alpha: 160 + } + )); + } + } + + #[test] + fn static_gradients_preserve_explicit_presentation_values_for_each_screen_target() { + for target in targets() { + for animated in [None, Some(false)] { + let mut preset = preset(); + preset.background.padding = 23.0; + preset.background.rounding = 11.0; + preset.background.source = BackgroundSource::Gradient { + from: [17, 41, 65], + to: [193, 211, 227], + angle: 217, + noise_intensity: Some(0.125), + noise_scale: Some(1.5), + animated, + animation_speed: Some(0.25), + }; + preset.screen_movement_spring = ScreenMovementSpring { + stiffness: 150.0, + damping: 22.0, + mass: 0.8, + }; + let snapshot = + preparing_presentation_snapshot(Some(&preset), Some(&target)).unwrap(); + let stopped = recording_timeline(one_segment(6.0), Vec::new()); + let projected = crate::editor_preparing::project_from_preparing_presentation( + &snapshot, &stopped, + ); + assert_eq!( + value(&projected), + value(&ordinary_static_projection( + &preset, + &target, + one_segment(6.0) + )) + ); + assert_eq!(projected.background.padding, 23.0); + assert_eq!(projected.background.rounding, 11.0); + assert_eq!( + serde_json::to_value(&projected.background.source).unwrap(), + serde_json::to_value(&preset.background.source).unwrap() + ); + } + } + } + + #[test] + fn unresolved_and_late_presentation_inputs_are_declined_without_mutating_presets() { + let target = targets().remove(0); + assert!(preparing_presentation_snapshot(None, Some(&target)).is_err()); + let mut preset = preset(); + for source in [ + BackgroundSource::Wallpaper { + path: Some("wallpaper.jpg".into()), + }, + BackgroundSource::Image { + path: Some("image.png".into()), + }, + BackgroundSource::AnimatedGradient { + config: Default::default(), + }, + BackgroundSource::Gradient { + from: [0, 0, 0], + to: [255, 255, 255], + angle: 90, + noise_intensity: None, + noise_scale: None, + animated: Some(true), + animation_speed: None, + }, + ] { + preset.background.source = source; + let original = value(&preset); + assert!(preparing_presentation_snapshot(Some(&preset), Some(&target)).is_err()); + assert_eq!(value(&preset), original); + } + preset.background.source = BackgroundSource::default(); + for enabled in [false, true] { + preset.background.notch = Some(cap_project::NotchConfiguration { + enabled, + ..Default::default() + }); + assert!(preparing_presentation_snapshot(Some(&preset), Some(&target)).is_err()); + } + preset.background.notch = None; + assert!(preparing_presentation_snapshot(Some(&preset), None).is_err()); + assert!( + preparing_presentation_snapshot(Some(&preset), Some(&ScreenCaptureTarget::CameraOnly)) + .is_err() + ); + } + + #[test] + fn stopped_timeline_replaces_preset_edits_but_keeps_explicit_clip_offsets() { + let target = targets().remove(0); + let mut preset = preset(); + preset.clips = vec![ClipConfiguration { + index: 0, + offsets: ClipOffsets { + camera: 0.125, + mic: -0.25, + system_audio: 0.0625, + }, + offsets_auto_calculated: false, + }]; + let old_zoom: ZoomSegment = serde_json::from_value(serde_json::json!({ + "start": 1.0, "end": 4.0, "amount": 2.0, "mode": "auto" + })) + .unwrap(); + preset.timeline = Some(recording_timeline(one_segment(2.0), vec![old_zoom])); + let snapshot = preparing_presentation_snapshot(Some(&preset), Some(&target)).unwrap(); + let stopped = recording_timeline(one_segment(6.0), Vec::new()); + let projected = + crate::editor_preparing::project_from_preparing_presentation(&snapshot, &stopped); + let ordinary = ordinary_static_projection(&preset, &target, one_segment(6.0)); + assert_eq!(value(&projected), value(&ordinary)); + let timeline = projected.timeline.as_ref().unwrap(); + assert_eq!(timeline.segments.len(), 1); + assert_eq!(timeline.segments[0].end, 6.0); + assert!(timeline.zoom_segments.is_empty()); + assert_eq!( + serde_json::to_value(&projected.clips).unwrap(), + serde_json::to_value(&preset.clips).unwrap() + ); + assert_eq!(preset.timeline.as_ref().unwrap().segments[0].end, 2.0); + assert_eq!(preset.timeline.as_ref().unwrap().zoom_segments.len(), 1); + } + + #[test] + fn ordinary_late_camera_state_changes_only_the_three_existing_camera_fields() { + for (shape, expected_shape, rounding) in [ + (CameraPreviewShape::Round, CameraShape::Square, 100.0), + (CameraPreviewShape::Square, CameraShape::Square, 25.0), + (CameraPreviewShape::Full, CameraShape::Source, 25.0), + ] { + for blur in [ + cap_project::BackgroundBlurMode::Off, + cap_project::BackgroundBlurMode::Light, + cap_project::BackgroundBlurMode::Heavy, + ] { + let mut config = preset(); + config.camera.size = 41.0; + config.camera.mirror = true; + let mut expected = config.clone(); + expected.camera.shape = expected_shape; + expected.camera.rounding = rounding; + expected.camera.background_blur.mode = blur; + apply_recording_camera_preview_state( + &mut config, + &crate::camera::CameraPreviewState { + size: 99.0, + shape: shape.clone(), + mirrored: false, + background_blur: blur, + }, + ); + assert_eq!(value(&config), value(&expected)); + assert_eq!(config.camera.size, 41.0); + assert!(config.camera.mirror); + } + } + } +} diff --git a/apps/desktop/src-tauri/src/stop-editor-benchmark.js b/apps/desktop/src-tauri/src/stop-editor-benchmark.js new file mode 100644 index 00000000000..7f991f5f853 --- /dev/null +++ b/apps/desktop/src-tauri/src/stop-editor-benchmark.js @@ -0,0 +1,30 @@ +(() => { + let reported = false; + const report = () => { + if (reported) return; + reported = true; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + void window.__TAURI_INTERNALS__.invoke("plugin:event|emit", { + event: "cap-stop-benchmark-frame", + payload: {}, + }); + }); + }); + }; + const deadline = performance.now() + 180_000; + const observe = () => { + const canvas = document.getElementById("canvas"); + const stats = window.__capFpsStats?.(); + if ( + stats?.renderCount > 0 && + canvas?.width > 0 && + getComputedStyle(canvas).visibility === "visible" + ) { + report(); + } else if (performance.now() < deadline) { + requestAnimationFrame(observe); + } + }; + requestAnimationFrame(observe); +})(); diff --git a/apps/desktop/src-tauri/src/stop-editor-parity-dom.js b/apps/desktop/src-tauri/src/stop-editor-parity-dom.js new file mode 100644 index 00000000000..3900ad50144 --- /dev/null +++ b/apps/desktop/src-tauri/src/stop-editor-parity-dom.js @@ -0,0 +1,214 @@ +(() => { + const ids = new WeakMap(); + let nextId = 1; + let sequence = 0; + let lastState = ""; + let previousPreparingIds = []; + let firstOrdinaryVisibleAt; + const started = performance.now(); + const deadline = started + 180_000; + const idFor = (element) => { + if (!ids.has(element)) ids.set(element, nextId++); + return ids.get(element); + }; + const describe = (canvas) => { + const rect = canvas.getBoundingClientRect(); + let ancestorsVisible = true; + for (let element = canvas; element; element = element.parentElement) { + const style = getComputedStyle(element); + if ( + style.display === "none" || + style.visibility !== "visible" || + Number(style.opacity) === 0 + ) { + ancestorsVisible = false; + break; + } + } + return { + id: idFor(canvas), + connected: canvas.isConnected, + width: canvas.width, + height: canvas.height, + bounds: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + visible: + ancestorsVisible && + canvas.width > 0 && + canvas.height > 0 && + rect.width > 0 && + rect.height > 0 && + rect.bottom > 0 && + rect.right > 0 && + rect.top < innerHeight && + rect.left < innerWidth, + }; + }; + const geometry = (element) => { + if (!element) return null; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + id: idFor(element), + connected: element.isConnected, + bounds: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + inline: { + width: element.style.width, + height: element.style.height, + minHeight: element.style.minHeight, + }, + computed: { + width: style.width, + height: style.height, + minHeight: style.minHeight, + maxHeight: style.maxHeight, + display: style.display, + visibility: style.visibility, + flex: style.flex, + flexDirection: style.flexDirection, + gap: style.gap, + paddingTop: style.paddingTop, + paddingBottom: style.paddingBottom, + }, + }; + }; + const ordinaryLayout = (canvas) => { + if (!canvas) return null; + const ancestors = []; + for ( + let element = canvas.parentElement; + element && ancestors.length < 10; + element = element.parentElement + ) { + ancestors.push(element); + } + const [wrapper, visibilityLayer, container, player, card, row, layout] = + ancestors; + const timelineSeparator = document.querySelector( + '[role="separator"][aria-label="Resize timeline height"]', + ); + const timeline = timelineSeparator?.parentElement; + const sourceStructureMatches = Boolean( + container?.parentElement === player && + player?.children.length === 3 && + player?.children[1] === container && + timeline?.parentElement === layout && + row?.nextElementSibling === timeline, + ); + return { + sourceStructureMatches, + canvas: geometry(canvas), + ancestors: ancestors.map(geometry), + roles: sourceStructureMatches + ? { + wrapper: idFor(wrapper), + visibilityLayer: idFor(visibilityLayer), + container: idFor(container), + player: idFor(player), + card: idFor(card), + row: idFor(row), + layout: idFor(layout), + } + : null, + topToolbar: sourceStructureMatches + ? geometry(container.previousElementSibling) + : null, + bottomToolbar: sourceStructureMatches + ? geometry(container.nextElementSibling) + : null, + timeline: geometry(timeline), + }; + }; + const emit = (value) => { + void window.__TAURI_INTERNALS__ + .invoke("plugin:event|emit", { + event: "cap-stop-benchmark-dom", + payload: value, + }) + .catch(() => {}); + }; + const sample = () => { + const now = performance.now(); + const preparing = Array.from( + document.querySelectorAll( + 'canvas[aria-label="Recording preview while the editor prepares"]', + ), + ).map((canvas) => { + const siblings = Array.from(canvas.parentElement.children).filter( + (element) => element.tagName === "CANVAS", + ); + return { + ...describe(canvas), + role: siblings.indexOf(canvas) === 1 ? "retained" : "live", + }; + }); + const ordinaryCanvas = document.getElementById("canvas"); + const ordinary = ordinaryCanvas ? describe(ordinaryCanvas) : null; + const stats = window.__capFpsStats?.(); + const ordinaryRenderedVisible = Boolean( + ordinary?.visible && stats?.renderCount > 0, + ); + if (ordinaryRenderedVisible && firstOrdinaryVisibleAt === undefined) { + firstOrdinaryVisibleAt = now; + } + const state = { + preparing, + ordinary, + ordinaryLayout: ordinaryLayout(ordinaryCanvas), + ordinaryRenderedVisible, + documentVisibility: document.visibilityState, + viewport: { + width: innerWidth, + height: innerHeight, + dpr: devicePixelRatio, + }, + }; + const serialized = JSON.stringify(state); + if (serialized !== lastState) { + const currentIds = preparing.map((canvas) => canvas.id); + emit({ + sequence: sequence++, + timeOriginMs: performance.timeOrigin, + elapsedMs: now - started, + observation: "canvas_state", + preparingSubtreesDetached: previousPreparingIds.filter( + (id) => !currentIds.includes(id), + ), + state, + }); + previousPreparingIds = currentIds; + lastState = serialized; + } + if ( + now < deadline && + sequence < 512 && + (firstOrdinaryVisibleAt === undefined || + now - firstOrdinaryVisibleAt < 1000) + ) { + requestAnimationFrame(sample); + } else { + emit({ + sequence: sequence++, + timeOriginMs: performance.timeOrigin, + elapsedMs: now - started, + observation: "complete", + reason: + firstOrdinaryVisibleAt !== undefined + ? "ordinary_visible_settled" + : now >= deadline + ? "deadline" + : "observation_limit", + }); + } + }; + requestAnimationFrame(sample); +})(); diff --git a/apps/desktop/src-tauri/src/stop_editor_benchmark.rs b/apps/desktop/src-tauri/src/stop_editor_benchmark.rs new file mode 100644 index 00000000000..b8c11c19818 --- /dev/null +++ b/apps/desktop/src-tauri/src/stop_editor_benchmark.rs @@ -0,0 +1,472 @@ +use std::{ + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; + +use tauri::{Listener, Manager}; +use tokio::sync::RwLock; + +pub const SCRIPT: &str = include_str!("stop-editor-benchmark.js"); +pub const DOM_SCRIPT: &str = include_str!("stop-editor-parity-dom.js"); +static FRAME_CAPTURE: OnceLock> = OnceLock::new(); +static STARTED: AtomicBool = AtomicBool::new(false); + +pub fn enabled() -> bool { + std::env::var_os("CAP_STOP_EDITOR_BENCHMARK_OUTPUT").is_some() +} + +pub fn run(app: tauri::AppHandle) { + let Some(output) = std::env::var_os("CAP_STOP_EDITOR_BENCHMARK_OUTPUT") else { + return; + }; + if STARTED.swap(true, Ordering::SeqCst) { + return; + } + tokio::spawn(async move { + let result = measure(&app).await; + let mut value = match result { + Ok(value) => value, + Err(error) => serde_json::json!({ "error": error }), + }; + match finish_frame_capture() { + Ok(Some(capture)) => value["frameCapture"] = capture, + Ok(None) => {} + Err(error) => { + value["frameCapture"] = + serde_json::json!({ "status": "incomplete", "error": error }) + } + } + if let Err(error) = std::fs::write(output, value.to_string()) { + tracing::error!(%error, "Could not write Stop benchmark results"); + } + }); +} + +async fn measure(app: &tauri::AppHandle) -> Result { + tokio::time::sleep(Duration::from_secs(3)).await; + let state = app.state::>>(); + crate::set_mic_input( + app.clone(), + state.clone(), + std::env::var("CAP_STOP_BENCH_MIC").ok(), + ) + .await?; + let capture_target = benchmark_capture_target()?; + let action = crate::recording::start_recording( + app.clone(), + state.clone(), + crate::recording::StartRecordingInputs { + capture_target, + capture_system_audio: std::env::var_os("CAP_STOP_BENCH_SYSTEM_AUDIO").is_some(), + mode: cap_recording::RecordingMode::Studio, + organization_id: None, + }, + ) + .await?; + if !matches!(action, crate::recording::RecordingAction::Started) { + return Err("Benchmark recording did not start".into()); + } + let seconds = std::env::var("CAP_STOP_BENCH_SECONDS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(15); + tokio::time::sleep(Duration::from_secs(seconds)).await; + let project = state + .read() + .await + .current_recording() + .map(|recording| recording.recording_dir().clone()); + if let Err(error) = arm_frame_capture(app, project.as_deref()) { + let cleanup = crate::recording::stop_recording(app.clone(), state.clone()).await; + return Err(match cleanup { + Ok(()) => error, + Err(cleanup_error) => format!("{error}; recording stop also failed: {cleanup_error}"), + }); + } + let dom_capture = DomCapture::start(app); + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + let listener = app.listen_any("cap-stop-benchmark-frame", move |_| { + let _ = sender.send(Instant::now()); + }); + let started = Instant::now(); + tracing::info!("Stop editor benchmark stop requested"); + let stopped = crate::recording::stop_recording(app.clone(), state).await; + let stop_ms = started.elapsed().as_secs_f64() * 1000.0; + if let Err(error) = stopped { + app.unlisten(listener); + return Err(error); + } + let frame = tokio::time::timeout(Duration::from_secs(180), receiver.recv()).await; + app.unlisten(listener); + let frame = frame + .map_err(|error| error.to_string())? + .ok_or("Editor frame channel closed")?; + let mut result = serde_json::json!({ + "project": project, + "recordingSeconds": seconds, + "stopCommandMs": stop_ms, + "editorFrameMs": frame.saturating_duration_since(started).as_secs_f64() * 1000.0, + }); + if let Some(dom_capture) = dom_capture { + result["domObservations"] = dom_capture.finish().await; + } + Ok(result) +} + +fn benchmark_capture_target() -> Result +{ + use cap_recording::screen_capture::ScreenCaptureTarget; + if std::env::var_os("CAP_STOP_BENCH_FIXTURE_WINDOW").is_none() { + return Ok(ScreenCaptureTarget::Display { + id: scap_targets::Display::primary().id(), + }); + } + let window = select_exact_fixture( + scap_targets::Window::list() + .into_iter() + .map(|window| (window, window.name())), + )?; + Ok(ScreenCaptureTarget::Window { id: window.id() }) +} + +fn select_exact_fixture( + windows: impl Iterator)>, +) -> Result { + let mut matching = + windows.filter(|(_, name)| name.as_deref() == Some("Cap Stop benchmark fixture")); + let window = matching + .next() + .ok_or("Exact benchmark fixture window is absent")? + .0; + if matching.next().is_some() { + return Err("Exact benchmark fixture window is ambiguous".into()); + } + Ok(window) +} + +pub fn frame_capture_requested() -> bool { + enabled() && std::env::var_os("CAP_STOP_BENCH_FRAME_DIR").is_some() +} + +pub fn dom_capture_requested() -> bool { + enabled() && std::env::var_os("CAP_STOP_BENCH_DOM_CAPTURE").is_some() +} + +#[derive(Clone, Copy)] +pub enum CaptureKind { + Preparing, + Ordinary, +} + +struct CapturedFrame { + data: Arc>, + width: u32, + height: u32, + stride: u32, + frame_number: u32, + target_time_ns: u64, +} + +struct FrameCapture { + directory: PathBuf, + project: PathBuf, + preparing: Option, + ordinary: Option, + closed: bool, +} + +impl FrameCapture { + fn record(&mut self, kind: CaptureKind, project: &Path, frame: CapturedFrame) { + if self.closed || self.project != project { + return; + } + let slot = match kind { + CaptureKind::Preparing => &mut self.preparing, + CaptureKind::Ordinary => &mut self.ordinary, + }; + if slot.is_none() { + *slot = Some(frame); + } + } +} + +fn arm_frame_capture(app: &tauri::AppHandle, project: Option<&Path>) -> Result<(), String> { + let Some(directory) = std::env::var_os("CAP_STOP_BENCH_FRAME_DIR") else { + return Ok(()); + }; + if !app + .config() + .identifier + .starts_with("so.cap.desktop.stop-editor-benchmark.") + { + return Err("Frame capture requires a private benchmark app identifier".into()); + } + let directory = PathBuf::from(directory); + if !directory.is_absolute() { + return Err("Frame capture directory must be absolute".into()); + } + let project = project + .ok_or("Frame capture has no active recording project")? + .to_path_buf(); + std::fs::create_dir(&directory).map_err(|error| error.to_string())?; + FRAME_CAPTURE + .set(Mutex::new(FrameCapture { + directory, + project, + preparing: None, + ordinary: None, + closed: false, + })) + .map_err(|_| "Frame capture was already armed".to_string()) +} + +pub fn capture_output(kind: CaptureKind, project: &Path, output: &cap_editor::EditorFrameOutput) { + let Some(capture) = FRAME_CAPTURE.get() else { + return; + }; + let cap_editor::EditorFrameOutput::Rgba(frame) = output else { + return; + }; + capture + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .record( + kind, + project, + CapturedFrame { + data: frame.data.clone(), + width: frame.width, + height: frame.height, + stride: frame.padded_bytes_per_row, + frame_number: frame.frame_number, + target_time_ns: frame.target_time_ns, + }, + ); +} + +pub fn capture_ws_frame(kind: CaptureKind, project: &Path, frame: &crate::frame_ws::WSFrame) { + let Some(capture) = FRAME_CAPTURE.get() else { + return; + }; + if frame.format != crate::frame_ws::WSFrameFormat::Rgba { + return; + } + capture + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .record( + kind, + project, + CapturedFrame { + data: frame.data.clone(), + width: frame.width, + height: frame.height, + stride: frame.stride, + frame_number: frame.frame_number, + target_time_ns: frame.target_time_ns, + }, + ); +} + +fn write_captured_frame( + directory: &Path, + name: &str, + frame: CapturedFrame, +) -> Result { + let row_bytes = frame + .width + .checked_mul(4) + .ok_or("Captured frame row overflow")?; + let expected_bytes = (frame.stride as usize) + .checked_mul(frame.height as usize) + .ok_or("Captured frame length overflow")?; + if frame.width == 0 + || frame.height == 0 + || frame.stride < row_bytes + || frame.data.len() != expected_bytes + { + return Err("Captured frame has an incomplete RGBA buffer".into()); + } + let file = directory.join(format!("{name}.rgba")); + std::fs::write(&file, frame.data.as_slice()).map_err(|error| error.to_string())?; + Ok(serde_json::json!({ + "file": file, + "format": "rgba", + "width": frame.width, + "height": frame.height, + "stride": frame.stride, + "frameNumber": frame.frame_number, + "targetTimeNs": frame.target_time_ns, + "bytes": expected_bytes, + })) +} + +fn finish_frame_capture() -> Result, String> { + let Some(capture) = FRAME_CAPTURE.get() else { + return Ok(None); + }; + let (directory, project, preparing, ordinary) = { + let mut capture = capture + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + capture.closed = true; + ( + capture.directory.clone(), + capture.project.clone(), + capture.preparing.take(), + capture.ordinary.take(), + ) + }; + let mut frames = serde_json::Map::new(); + for (name, frame) in [("preparing", preparing), ("ordinary", ordinary)] { + if let Some(frame) = frame { + let _ = frames.insert( + name.to_string(), + write_captured_frame(&directory, name, frame)?, + ); + } + } + let value = serde_json::json!({ + "status": if frames.len() == 2 { "captured_uncompared" } else { "incomplete" }, + "project": project, + "frames": frames, + "timingQualification": "Separate untimed parity capture; renderer callback bytes are not external compositor proof", + }); + std::fs::write(directory.join("frames.json"), value.to_string()) + .map_err(|error| error.to_string())?; + Ok(Some(value)) +} + +struct DomCapture { + app: tauri::AppHandle, + listener: tauri::EventId, + observations: Arc>>, +} + +impl DomCapture { + fn start(app: &tauri::AppHandle) -> Option { + if !dom_capture_requested() { + return None; + } + let observations = Arc::new(Mutex::new(Vec::new())); + let captured = observations.clone(); + let listener = app.listen_any("cap-stop-benchmark-dom", move |event| { + if let Ok(value) = serde_json::from_str(event.payload()) { + let mut observations = captured + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if observations.len() < 1024 { + observations.push(value); + } + } + }); + Some(Self { + app: app.clone(), + listener, + observations, + }) + } + + async fn finish(self) -> serde_json::Value { + tokio::time::sleep(Duration::from_secs(2)).await; + let observations = self + .observations + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + serde_json::json!(observations) + } +} + +impl Drop for DomCapture { + fn drop(&mut self) { + self.app.unlisten(self.listener); + } +} + +#[cfg(test)] +mod parity_capture_tests { + use super::*; + + fn frame(value: u8) -> CapturedFrame { + CapturedFrame { + data: Arc::new(vec![value; 8]), + width: 2, + height: 1, + stride: 8, + frame_number: 0, + target_time_ns: 0, + } + } + + #[test] + fn exact_fixture_selection_rejects_missing_and_ambiguous_titles() { + assert!(select_exact_fixture(std::iter::empty::<(u8, Option)>()).is_err()); + assert_eq!( + select_exact_fixture( + [ + (1, Some("Cap Stop benchmark fixture".into())), + (2, Some("Cap Stop benchmark fixture extra".into())) + ] + .into_iter() + ) + .unwrap(), + 1 + ); + assert!( + select_exact_fixture( + [ + (1, Some("Cap Stop benchmark fixture".into())), + (2, Some("Cap Stop benchmark fixture".into())) + ] + .into_iter() + ) + .is_err() + ); + } + + #[test] + fn first_frames_are_bound_to_project_and_capture_closure() { + let project = PathBuf::from("owned.cap"); + let mut capture = FrameCapture { + directory: PathBuf::new(), + project: project.clone(), + preparing: None, + ordinary: None, + closed: false, + }; + capture.record(CaptureKind::Preparing, Path::new("other.cap"), frame(1)); + assert!(capture.preparing.is_none()); + capture.record(CaptureKind::Preparing, &project, frame(2)); + capture.record(CaptureKind::Preparing, &project, frame(3)); + capture.record(CaptureKind::Ordinary, &project, frame(4)); + assert_eq!(capture.preparing.as_ref().unwrap().data.as_slice(), &[2; 8]); + assert_eq!(capture.ordinary.as_ref().unwrap().data.as_slice(), &[4; 8]); + capture.closed = true; + capture.ordinary = None; + capture.record(CaptureKind::Ordinary, &project, frame(5)); + assert!(capture.ordinary.is_none()); + } + + #[test] + fn rgba_capture_rejects_truncation_and_preserves_full_padded_rows() { + let directory = tempfile::tempdir().unwrap(); + let mut truncated = frame(1); + truncated.data = Arc::new(vec![1; 7]); + assert!(write_captured_frame(directory.path(), "truncated", truncated).is_err()); + assert!(!directory.path().join("truncated.rgba").exists()); + let mut padded = frame(2); + padded.stride = 12; + padded.data = Arc::new(vec![2; 12]); + let metadata = write_captured_frame(directory.path(), "padded", padded).unwrap(); + assert_eq!(metadata["bytes"], 12); + assert_eq!( + std::fs::read(directory.path().join("padded.rgba")).unwrap(), + vec![2; 12] + ); + } +} diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index b35ff955255..62df3648648 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -226,13 +226,29 @@ fn content_type_for_upload_subpath(subpath: &str) -> &'static str { } } +pub fn reusable_video_id( + sharing: Option<&cap_project::SharingMeta>, + upload: Option<&cap_project::UploadMeta>, +) -> Option { + sharing + .map(|sharing| sharing.id.clone()) + .or_else(|| match upload { + Some(cap_project::UploadMeta::SinglePartUpload { video_id, .. }) + | Some(cap_project::UploadMeta::SegmentUpload { video_id, .. }) + | Some(cap_project::UploadMeta::MultipartUpload { video_id, .. }) => { + Some(video_id.clone()) + } + _ => None, + }) +} + #[instrument(skip(app, channel, file_path, screenshot_path))] pub async fn upload_video( app: &AppHandle, video_id: String, file_path: PathBuf, screenshot_path: PathBuf, - meta: S3VideoMeta, + replace_existing: bool, channel: Option>, ) -> Result { let _active_upload = ActiveUploadGuard::new(&ACTIVE_UPLOADS); @@ -291,9 +307,15 @@ pub async fn upload_video( .map_err(|e| error!("Failed to get video metadata: {e}")) .ok(); - let completed_identity = - api::upload_multipart_complete(app, &video_id, &upload_id, &parts, metadata.clone()) - .await?; + let completed_identity = api::upload_multipart_complete( + app, + &video_id, + &upload_id, + &parts, + metadata.clone(), + replace_existing, + ) + .await?; let object_identity = if is_drive_upload { parts .iter() @@ -758,9 +780,15 @@ impl InstantMultipartUpload { let duration = metadata.duration_in_secs; let metadata = Some(metadata); session.wait_ready().await?; - let completed_identity = - api::upload_multipart_complete(&app, &video_id, &upload_id, &parts, metadata.clone()) - .await?; + let completed_identity = api::upload_multipart_complete( + &app, + &video_id, + &upload_id, + &parts, + metadata.clone(), + false, + ) + .await?; let object_identity = if is_drive_upload { parts .iter() @@ -1046,6 +1074,7 @@ impl PresignedUrlCache { } impl SegmentUploader { + #[cfg(not(target_os = "linux"))] pub(crate) fn spawn( app: AppHandle, segment_rx: std::sync::mpsc::Receiver< @@ -2717,6 +2746,42 @@ mod tests { use std::io::Write; use std::sync::atomic::{AtomicU32, Ordering}; + #[test] + fn reupload_keeps_sharing_identity_after_completion_or_failure() { + let sharing = cap_project::SharingMeta { + id: "existing-video".into(), + link: "https://cap.link/existing-video".into(), + content_hash: None, + }; + for upload in [ + None, + Some(cap_project::UploadMeta::Complete), + Some(cap_project::UploadMeta::Failed { + error: "offline".into(), + }), + ] { + assert_eq!( + reusable_video_id(Some(&sharing), upload.as_ref()).as_deref(), + Some("existing-video") + ); + } + } + + #[test] + fn retry_keeps_the_reserved_identity_until_sharing_is_saved() { + let upload = cap_project::UploadMeta::SinglePartUpload { + video_id: "reserved-video".into(), + file_path: "output.mp4".into(), + screenshot_path: "display.jpg".into(), + recording_dir: "recording.cap".into(), + }; + assert_eq!( + reusable_video_id(None, Some(&upload)).as_deref(), + Some("reserved-video") + ); + assert_eq!(reusable_video_id(None, None), None); + } + #[test] fn active_upload_guards_track_overlapping_sessions() { let active = AtomicUsize::new(0); @@ -3648,13 +3713,6 @@ pub(crate) mod strict_instant { result } - async fn complete(&self, complete: impl FnOnce() -> F) -> Result<(), AuthedApiError> - where - F: Future>, - { - self.step(complete).await.map(drop) - } - async fn permission(&self) -> Result<(), AuthedApiError> { let mut decision = self.0.decision.subscribe(); loop { @@ -4248,6 +4306,7 @@ pub(crate) mod strict_instant { &upload.upload_id, &parts, Some(metadata), + false, ) }) .await?; @@ -4386,7 +4445,7 @@ pub(crate) mod strict_instant { part_number: info.part_number, offset: info.offset, total_size: info.total_size, - chunk: bytes.into(), + chunk: bytes, }) }) .await?, @@ -4787,24 +4846,24 @@ pub(crate) mod strict_instant { std::fs::remove_dir_all(dir).unwrap(); } #[tokio::test] - async fn strict_multipart_complete_maps_optional_success_and_retains_errors_and_late_revocation() + async fn strict_multipart_completion_step_retains_optional_success_errors_and_late_revocation() { let (control, permission) = Control::new(); permission.grant().unwrap(); - let result: Result<(), AuthedApiError> = control - .complete(|| async { Ok(Some("synthetic-location".to_string())) }) + let result: Result, AuthedApiError> = control + .step(|| async { Ok(Some("synthetic-location".to_string())) }) .await; - result.unwrap(); + assert_eq!(result.unwrap().as_deref(), Some("synthetic-location")); assert!( control - .complete(|| async { + .step(|| async { Err::, _>("required completion failed".into()) }) .await .is_err() ); let (release, released) = tokio::sync::oneshot::channel(); - let future = control.complete(|| async { + let future = control.step(|| async { released.await.unwrap(); Ok(Some("late-location".to_string())) }); diff --git a/apps/desktop/src-tauri/src/upload/lifecycle.rs b/apps/desktop/src-tauri/src/upload/lifecycle.rs index c4f2940077e..9e1f5df7833 100644 --- a/apps/desktop/src-tauri/src/upload/lifecycle.rs +++ b/apps/desktop/src-tauri/src/upload/lifecycle.rs @@ -786,7 +786,8 @@ pub(crate) async fn resume_existing( video_id.clone(), file_path, screenshot_path, - metadata, + meta.sharing.is_some() + && matches!(meta.inner, cap_project::RecordingMetaInner::Studio(_)), None, ) .await?; diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index c0a1663422c..4753fa10ebe 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -2193,11 +2193,23 @@ impl ShowCapWindow { PendingEditorInstances::start_prewarm(app, _id.label(), project_path.clone()).await; - let window = self + let builder = self .window_builder_with_id(app, "/editor", &_id, _id.label()) .maximizable(true) - .focused(true) - .build()?; + .focused(true); + #[cfg(debug_assertions)] + let builder = if crate::stop_editor_benchmark::enabled() { + builder.initialization_script(crate::stop_editor_benchmark::SCRIPT) + } else { + builder + }; + #[cfg(debug_assertions)] + let builder = if crate::stop_editor_benchmark::dom_capture_requested() { + builder.initialization_script(crate::stop_editor_benchmark::DOM_SCRIPT) + } else { + builder + }; + let window = builder.build()?; if let Some(opening) = project_opening.as_mut() { opening.own_window(&window); } diff --git a/apps/desktop/src/components/Cropper.tsx b/apps/desktop/src/components/Cropper.tsx index 57462ee48cc..411eeb3df48 100644 --- a/apps/desktop/src/components/Cropper.tsx +++ b/apps/desktop/src/components/Cropper.tsx @@ -24,6 +24,8 @@ import { Transition } from "solid-transition-group"; import { createKeyDownSignal } from "~/utils/events"; import { commands } from "~/utils/tauri"; +import { alignCrop, type CropGuides } from "./crop-alignment"; +import "./cropper.css"; export interface CropBounds { x: number; y: number; @@ -247,6 +249,8 @@ export function Cropper( snapToRatioEnabled?: boolean; useBackdropFilter?: boolean; allowLightMode?: boolean; + appearance?: "editor"; + snapToAlignmentEnabled?: boolean; }>, ) { let containerRef: HTMLDivElement | undefined; @@ -285,6 +289,11 @@ export function Cropper( ) & { hoveringHandle: HandleSide | null } >({ drag: null, hoveringHandle: null }); + const [alignmentGuides, setAlignmentGuides] = createSignal({ + x: null, + y: null, + }); + const resizing = () => mouseState.drag === "handle" || mouseState.drag === "overlay"; const cursorStyle = () => { @@ -666,6 +675,12 @@ export function Cropper( newY = clamp(newY, 0, containerRect.height - currentBounds.height); currentBounds = moveBounds(currentBounds, newX, newY); + const aligned = + props.snapToAlignmentEnabled && !e.shiftKey + ? alignCrop(currentBounds, containerSize()) + : { bounds: currentBounds, guides: { x: null, y: null } }; + currentBounds = aligned.bounds; + setAlignmentGuides(aligned.guides); setRawBounds(currentBounds); if (!isAnimating()) setDisplayRawBounds(currentBounds); @@ -699,6 +714,7 @@ export function Cropper( if (target.hasPointerCapture?.(pointerId)) { target.releasePointerCapture(pointerId); } + setAlignmentGuides({ x: null, y: null }); onEnd(); activePointerSessionDispose = undefined; dispose(); @@ -799,6 +815,7 @@ export function Cropper( e: PointerEvent, context: ResizeSessionState, ) { + const previousSnappedRatio = aspectState.snapped; const pointX = e.clientX - context.containerRect.left; const pointY = e.clientY - context.containerRect.top; @@ -851,9 +868,6 @@ export function Cropper( options, ); nextBounds = bounds; - if (snappedRatio && !aspectState.snapped) { - triggerHaptic(); - } setAspectState("snapped", snappedRatio); } @@ -863,8 +877,39 @@ export function Cropper( containerSize().y, ); - setRawBounds(finalBounds); - if (!isAnimating()) setDisplayRawBounds(finalBounds); + let alignedBounds = finalBounds; + setAlignmentGuides({ x: null, y: null }); + if (props.snapToAlignmentEnabled && !e.shiftKey) { + const { movable } = context.activeHandle; + const start = context.startBounds; + const centered = context.isAltMode && ratioValue === null; + const aligned = alignCrop(finalBounds, containerSize(), { + origin: { + x: centered + ? 0.5 + : pointX < start.x + (movable.left ? start.width : 0) + ? 1 + : 0, + y: centered + ? 0.5 + : pointY < start.y + (movable.top ? start.height : 0) + ? 1 + : 0, + }, + axes: { + x: movable.left || movable.right, + y: movable.top || movable.bottom, + }, + ratio: ratioValue, + }); + alignedBounds = aligned.bounds; + setAlignmentGuides(aligned.guides); + if (aligned.guides.x !== null || aligned.guides.y !== null) + setAspectState("snapped", null); + } + if (aspectState.snapped && !previousSnappedRatio) triggerHaptic(); + setRawBounds(alignedBounds); + if (!isAnimating()) setDisplayRawBounds(alignedBounds); } function onHandleDoubleClick(handle: HandleSide, e: MouseEvent) { @@ -1087,6 +1132,7 @@ export function Cropper(
+ + {(axis) => ( + +