Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
38f3e55
improve: index cursor data and prepare export zoom incrementally
richiemcilroy Sep 3, 2026
320fd4e
improve: reuse cursor interpolation across editor previews
richiemcilroy Sep 3, 2026
30df10a
improve: avoid redundant screenshot compositing work
richiemcilroy Sep 3, 2026
f67d49c
improve: borrow decoded audio when preparing waveforms
richiemcilroy Sep 3, 2026
07401a3
improve: stream MP4 export audio with bounded buffers and safe cancel…
richiemcilroy Sep 3, 2026
bc9a4bd
fix: prevent editor saves before the project finishes loading
richiemcilroy Sep 3, 2026
1055737
test: run streaming export regressions in the sync matrix
richiemcilroy Sep 3, 2026
f613212
improve: avoid caption word copies and empty zoom allocations
richiemcilroy Sep 3, 2026
6cc96d3
fix: preserve cursor texture sampling across sprite edges
richiemcilroy Sep 3, 2026
124885a
improve: anchor editor menus and preserve selection behavior
richiemcilroy Sep 3, 2026
dfc8c89
fix: keep Windows capture targets and window controls usable
richiemcilroy Sep 3, 2026
980024a
fix: complete app handoff after the settings window closes
richiemcilroy Sep 3, 2026
6a55569
fix: retain Windows caption controls during loading and errors
richiemcilroy Sep 3, 2026
a427401
fix: keep incomplete MP4 exports private
richiemcilroy Sep 3, 2026
bc2fa21
test: isolate the zoom timing gate from GPU test contention
richiemcilroy Sep 3, 2026
d44db99
test: compare cursor edges with uniform implicit sampling
richiemcilroy Sep 3, 2026
576b3f1
test: expose cursor placement diagnostics on native GPUs
richiemcilroy Sep 3, 2026
eb7150c
test: compare cursor failures without anisotropic filtering
richiemcilroy Sep 3, 2026
37e7fe1
fix: keep camera edge antialiasing uniform
richiemcilroy Sep 3, 2026
0886c8c
test: accept Windows shader line endings
richiemcilroy Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/sync-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ on:
- "crates/timestamp/**"
- "crates/rendering/**"
- "crates/editor/**"
- "crates/export/**"
- "crates/audio/**"
- "crates/media-info/**"
- "crates/project/**"
Expand Down Expand Up @@ -139,11 +140,14 @@ jobs:
cargo test --locked -p cap-recording --lib -- --test-threads=1
# --nocapture so a WARP-adapter notch skip prints instead of
# looking identical to a pass in the CI log.
cargo test --locked -p cap-rendering -- --nocapture
cargo test --locked -p cap-rendering -- --nocapture --skip zoom_spring::tests::precompute_cost_is_bounded_for_long_projects
cargo test --locked -p cap-rendering --lib zoom_spring::tests::precompute_cost_is_bounded_for_long_projects -- --exact --nocapture --test-threads=1

- name: Editor audio playback and export regressions
shell: bash
run: |
cargo test --locked -p cap-audio --lib
cargo test --locked -p cap-export --lib
cargo test --locked -p cap-editor --lib audio::tests::
cargo test --locked -p cap-editor --lib audio_output::tests::
cargo test --locked -p cap-editor --lib playback::tests::
Expand Down
97 changes: 41 additions & 56 deletions apps/desktop-gpui/src/app_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4463,10 +4463,7 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle<EditorWindow>, cx: &m
);
log_timeline_model(&summary.timeline);
let recordings = summary.recordings.clone();
if handle
.update(cx, |view, window, cx| view.set_summary(summary, window, cx))
.is_err()
{
if handle.update(cx, |_, _, _| ()).is_err() {
return;
}

Expand Down Expand Up @@ -4548,8 +4545,35 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle<EditorWindow>, cx: &m
};

tracing::info!(path = %path.display(), "editor instance ready");
let (total, config) = {
let config = instance.project_config.1.borrow().clone();
let total = config
.timeline
.as_ref()
.map_or(0.0, |timeline| timeline.duration());
(total, config)
};
let has_camera = instance
.recordings
.segments
.iter()
.any(|segment| segment.camera.is_some());
let multiple_clips = instance.recordings.segments.len() > 1;
log_timeline_model(&editor_timeline::TimelineModel::build(
&config,
has_camera,
multiple_clips,
));
if handle
.update(cx, |view, _window, _cx| view.set_instance(instance.clone()))
.update(cx, |view, window, cx| {
// Loading controls can queue a save before the engine is ready.
// Publish the loaded config and instance together so those edits
// cannot replace the saved project with the initial defaults.
view.pending_save().borrow_mut().discard();
view.set_summary(summary, window, cx);
view.set_project(config, window, cx);
view.set_instance(instance.clone());
})
.is_err()
{
instance.dispose().await;
Expand Down Expand Up @@ -4659,44 +4683,6 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle<EditorWindow>, cx: &m
})
.detach();

// `totalDuration()` (`context.ts:1374-1380`). Read off the instance
// rather than the pre-flight, because `EditorInstance::new`
// synthesises a timeline for a raw bundle -- and `timeline.duration()`
// is exactly what the playback engine stops at
// (`playback.rs:560-570`).
//
// The whole track model comes from the same read: the config the
// instance actually loaded is the one being rendered, holds, clip
// offsets and all. E4 hands the window the config itself rather than
// the derived model, because it is what every edit mutates and what
// the debounced save writes back.
let (total, config) = {
let config = instance.project_config.1.borrow().clone();
let total = config
.timeline
.as_ref()
.map_or(0.0, |timeline| timeline.duration());
(total, config)
};
{
let has_camera = instance
.recordings
.segments
.iter()
.any(|segment| segment.camera.is_some());
let multiple_clips = instance.recordings.segments.len() > 1;
log_timeline_model(&editor_timeline::TimelineModel::build(
&config,
has_camera,
multiple_clips,
));
}
if handle
.update(cx, |view, window, cx| view.set_project(config, window, cx))
.is_err()
{
return;
}
load_editor_waveforms(instance.clone(), handle, cx);

if handle
Expand Down Expand Up @@ -4805,14 +4791,7 @@ fn load_editor_waveforms(
(&segment.audio, &mut mic),
(&segment.system_audio, &mut system),
] {
match loader.get().await {
Ok(Some(audio)) => {
out.push((audio.samples().to_vec(), audio.channels()))
}
// A failed track is an empty waveform; playback and
// export surface the actual error.
_ => out.push((Vec::new(), 1)),
}
out.push(loader.get().await.ok().flatten());
}
}
(mic, system)
Expand All @@ -4824,15 +4803,21 @@ fn load_editor_waveforms(
let peaks = cx
.background_executor()
.spawn(async move {
let extract = |tracks: Vec<(Vec<f32>, u16)>| {
let [mic, system] = [mic, system].map(|tracks| {
tracks
.into_iter()
.map(|(samples, channels)| {
Arc::new(editor_timeline::waveform_peaks(&samples, channels))
.map(|audio| {
Arc::new(match audio {
Some(audio) => editor_timeline::waveform_peaks(
audio.samples(),
audio.channels(),
),
None => Vec::new(),
})
})
.collect::<Vec<_>>()
};
(extract(mic), extract(system))
});
(mic, system)
})
.await;
let _ = handle.update(cx, |view, window, cx| {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-gpui/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ mod tests {
include_str!("screenshot_annotations.rs"),
// `ui::SelectionHeader` names the check and the trash itself.
include_str!("ui/selection_header.rs"),
include_str!("ui/radio_cards.rs"),
// The onboarding window's welcome cards and permissions surface; the
// per-permission row glyphs are named on `OSPermission::icon`.
include_str!("onboarding_window.rs"),
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop-gpui/src/devices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,11 @@ pub fn list_window_targets() -> Vec<(WindowOption, Window)> {
Window::list()
.into_iter()
.filter_map(|window| {
#[cfg(target_os = "windows")]
if !window.raw_handle().is_valid() || !window.raw_handle().is_on_screen() {
return None;
}

let label = window.name().filter(|name| !name.trim().is_empty())?;
let app = window.owner_name()?;

Expand Down
25 changes: 20 additions & 5 deletions apps/desktop-gpui/src/editor_clips.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,12 +408,17 @@ impl EditorWindow {
ui::Button::plain(&self.theme, "clips-pill", variant, ui::ButtonSize::Md)
.icon("icons/clapperboard.svg")
.label("Clips")
.disabled(!self.project_ready())
.height(px(40.))
.radius(px(12.))
.font_weight(FontWeight::MEDIUM)
.on_click(cx.listener(|this, _, window, cx| this.toggle_clips(window, cx)))
}

pub(crate) fn toggle_clips(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.project_ready() {
return;
}
self.set_selection(None, cx);
if self.clips.open {
self.close_clips(window, cx);
Expand Down Expand Up @@ -754,6 +759,7 @@ impl EditorWindow {
.px(px(16.))
.w_full()
.h(px(64.))
.rounded_t(px(11.))
.border_b_1()
.border_color(Hsla::from(theme.gray_3))
.text_size(px(14.))
Expand Down Expand Up @@ -838,12 +844,13 @@ impl EditorWindow {
.gap(px(8.))
.font_weight(FontWeight::MEDIUM)
.disabled(self.clips.importing)
.on_click(cx.listener(
|this, event: &gpui::ClickEvent, _window, cx| {
.on_open(cx.listener(
|this, bounds: &Bounds<Pixels>, _window, cx| {
if this.clips.importing {
return;
}
this.clips.import_menu = Some(event.position());
this.clips.import_menu =
Some(bounds.bottom_left() + gpui::point(px(0.), px(4.)));
cx.notify();
},
)),
Expand Down Expand Up @@ -1200,7 +1207,7 @@ impl EditorWindow {
}

fn begin_editor_recording(&mut self, cx: &mut Context<Self>) -> bool {
if self.clips.importing {
if !self.project_ready() || self.clips.importing {
return false;
}
let session = RecordingSession::global(cx);
Expand Down Expand Up @@ -1252,6 +1259,13 @@ impl EditorWindow {
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.project_ready() {
tracing::warn!(
recording = %recording_dir.display(),
"the editor is not ready; leaving the recording in the library"
);
return;
}
if self.clips.importing {
// A concurrent import owns the bundle merge; the capture stays in
// the library and can be pulled in through "Existing recording".
Expand Down Expand Up @@ -1353,6 +1367,7 @@ impl EditorWindow {
.child(
div()
.id("clips-import-backdrop")
.occlude()
.absolute()
.top_0()
.left_0()
Expand Down Expand Up @@ -1506,7 +1521,7 @@ impl EditorWindow {
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.clips.importing {
if !self.project_ready() || self.clips.importing {
return;
}
if self.playing {
Expand Down
70 changes: 44 additions & 26 deletions apps/desktop-gpui/src/editor_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2171,8 +2171,22 @@ async fn run_export(
builder = builder.with_output_path(path);
}

let base = builder.build().await.map_err(|error| error.to_string())?;
let total = base.total_frames(fps);
enum PreparedBase {
Mp4(cap_export::Mp4ExporterBase),
Other(ExporterBase),
}
let (base, total) = if !cursor_only && format != ExportFormatKind::Gif {
let base = builder
.build_for_mp4(cancel.clone())
.await
.map_err(|error| error.to_string())?;
let total = base.total_frames(fps);
(PreparedBase::Mp4(base), total)
} else {
let base = builder.build().await.map_err(|error| error.to_string())?;
let total = base.total_frames(fps);
(PreparedBase::Other(base), total)
};
let _ = progress_tx.send((0, total));

let progress = {
Expand All @@ -2189,33 +2203,37 @@ async fn run_export(
};

let resolution = XY::new(width, height);
if cursor_only {
MovExportSettings {
fps,
resolution_base: resolution,
cursor_only: true,
match base {
PreparedBase::Other(base) if cursor_only => {
MovExportSettings {
fps,
resolution_base: resolution,
cursor_only: true,
}
.export(base, progress)
.await
}
.export(base, progress)
.await
} else if format == ExportFormatKind::Gif {
GifExportSettings {
fps,
resolution_base: resolution,
quality: None,
PreparedBase::Other(base) => {
GifExportSettings {
fps,
resolution_base: resolution,
quality: None,
}
.export(base, progress)
.await
}
.export(base, progress)
.await
} else {
Mp4ExportSettings {
fps,
resolution_base: resolution,
compression,
custom_bpp,
force_ffmpeg_decoder: force,
optimize_filesize: optimize,
PreparedBase::Mp4(base) => {
Mp4ExportSettings {
fps,
resolution_base: resolution,
compression,
custom_bpp,
force_ffmpeg_decoder: force,
optimize_filesize: optimize,
}
.export_prepared(base, progress)
.await
}
.export(base, progress)
.await
}
}

Expand Down
Loading
Loading