Skip to content

Commit e7ebd94

Browse files
Merge pull request #2280 from CapSoftware/fix/windows-editor-handoff-20260912
fix: preserve editor handoff across equivalent Windows paths
2 parents c59c2b8 + b1b081d commit e7ebd94

3 files changed

Lines changed: 381 additions & 9 deletions

File tree

crates/editor/src/completed_audio.rs

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
use cap_audio::DecodedAudio;
22
use cap_project::{AudioGapSummary, AudioMeta, RecordingMeta, StudioRecordingMeta};
3-
use std::{path::PathBuf, sync::Arc};
3+
use std::{
4+
path::{Path, PathBuf},
5+
sync::Arc,
6+
};
47

58
#[derive(Clone, Default)]
69
pub(crate) struct CompletedAudioSegment {
@@ -50,6 +53,14 @@ fn audio_identities(meta: &StudioRecordingMeta) -> Vec<SegmentAudioIdentity> {
5053
}
5154
}
5255

56+
fn same_project_path(left: &Path, right: &Path) -> bool {
57+
left == right
58+
|| matches!(
59+
(left.canonicalize(), right.canonicalize()),
60+
(Ok(left), Ok(right)) if left == right
61+
)
62+
}
63+
5364
#[derive(Clone)]
5465
pub struct CompletedAudioHandoff {
5566
project_path: PathBuf,
@@ -63,7 +74,10 @@ impl CompletedAudioHandoff {
6374
expected_finalized_metadata: &RecordingMeta,
6475
segments: Vec<CompletedAudioSegment>,
6576
) -> Result<Self, String> {
66-
if source_metadata.project_path != expected_finalized_metadata.project_path {
77+
if !same_project_path(
78+
&source_metadata.project_path,
79+
&expected_finalized_metadata.project_path,
80+
) {
6781
return Err("Completed audio belongs to a different project".into());
6882
}
6983
let source = audio_identities(
@@ -107,7 +121,7 @@ impl CompletedAudioHandoff {
107121
recording_meta: &RecordingMeta,
108122
meta: &StudioRecordingMeta,
109123
) -> Option<Vec<CompletedAudioSegment>> {
110-
(self.project_path == recording_meta.project_path
124+
(same_project_path(&self.project_path, &recording_meta.project_path)
111125
&& self.identities == audio_identities(meta))
112126
.then_some(self.segments)
113127
}
@@ -227,6 +241,98 @@ pub(crate) mod tests {
227241
assert!(matched[1].mic.is_none());
228242
}
229243

244+
#[test]
245+
fn completed_cache_accepts_equivalent_project_paths_and_preserves_pcm() {
246+
let directory = tempfile::tempdir().unwrap();
247+
let entry = directory.path().join("editor-entry");
248+
std::fs::create_dir(&entry).unwrap();
249+
let mut original = metadata();
250+
original.project_path = entry.join("..");
251+
let mut finalized = original.clone();
252+
finalized.project_path = directory.path().canonicalize().unwrap();
253+
assert_ne!(original.project_path, finalized.project_path);
254+
let audio = audio();
255+
for expected in [&original, &finalized] {
256+
let cache = CompletedAudioHandoff::from_completed_tracks(
257+
&original,
258+
expected,
259+
completed(&audio),
260+
)
261+
.unwrap();
262+
let matched = cache
263+
.into_matching(&finalized, finalized.studio_meta().unwrap())
264+
.unwrap();
265+
assert!(Arc::ptr_eq(matched[0].mic.as_ref().unwrap(), &audio));
266+
}
267+
}
268+
269+
#[test]
270+
fn silent_completed_cache_accepts_canonical_editor_paths() {
271+
let directory = tempfile::tempdir().unwrap();
272+
let entry = directory.path().join("editor-entry");
273+
std::fs::create_dir(&entry).unwrap();
274+
for count in [1, 2] {
275+
let mut original = metadata();
276+
original.project_path = entry.join("..");
277+
let segments = segments_mut(&mut original);
278+
segments.truncate(count);
279+
for segment in segments {
280+
segment.mic = None;
281+
segment.system_audio = None;
282+
}
283+
let cache = CompletedAudioHandoff::from_completed_tracks(
284+
&original,
285+
&original,
286+
vec![CompletedAudioSegment::default(); count],
287+
)
288+
.unwrap();
289+
let mut finalized = original.clone();
290+
finalized.project_path = directory.path().canonicalize().unwrap();
291+
let matched = cache
292+
.into_matching(&finalized, finalized.studio_meta().unwrap())
293+
.unwrap();
294+
assert_eq!(matched.len(), count);
295+
assert!(
296+
matched
297+
.iter()
298+
.all(|segment| segment.mic.is_none() && segment.system_audio.is_none())
299+
);
300+
}
301+
}
302+
303+
#[test]
304+
fn completed_cache_rejects_different_or_unresolvable_project_paths() {
305+
let directory = tempfile::tempdir().unwrap();
306+
let mut original = metadata();
307+
original.project_path = directory.path().join("original.cap");
308+
std::fs::create_dir(&original.project_path).unwrap();
309+
let mut other = original.clone();
310+
other.project_path = directory.path().join("other.cap");
311+
std::fs::create_dir(&other.project_path).unwrap();
312+
let audio = audio();
313+
for missing in [false, true] {
314+
if missing {
315+
original.project_path = directory.path().join("missing-original.cap");
316+
other.project_path = directory.path().join("missing-other.cap");
317+
}
318+
assert!(
319+
CompletedAudioHandoff::from_completed_tracks(&original, &other, completed(&audio))
320+
.is_err()
321+
);
322+
let cache = CompletedAudioHandoff::from_completed_tracks(
323+
&original,
324+
&original,
325+
completed(&audio),
326+
)
327+
.unwrap();
328+
assert!(
329+
cache
330+
.into_matching(&other, other.studio_meta().unwrap())
331+
.is_none()
332+
);
333+
}
334+
}
335+
230336
#[test]
231337
fn completed_cache_rejects_every_changed_track_identity() {
232338
let original = metadata();

crates/editor/src/preparing_preview/native_tests.rs

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1160,7 +1160,10 @@ async fn native_handoff_reuses_pcm_preserves_audio_across_candidate_disposal_and
11601160
use std::sync::atomic::AtomicUsize;
11611161

11621162
let fixture = handoff_fixture();
1163-
let root = fixture.metadata.project_path.clone();
1163+
let entry = fixture.metadata.project_path.join("editor-entry");
1164+
std::fs::create_dir(&entry).unwrap();
1165+
let root = entry.join("..");
1166+
assert_ne!(root, fixture.metadata.project_path);
11641167
let nonzero = Arc::new(AtomicUsize::new(0));
11651168
let tapped = nonzero.clone();
11661169
let output = Arc::new(crate::AudioOutput::new_headless(Box::new(
@@ -1327,6 +1330,141 @@ async fn native_handoff_reuses_pcm_preserves_audio_across_candidate_disposal_and
13271330
assert!(fixture.released.load(Ordering::Acquire));
13281331
}
13291332

1333+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1334+
async fn native_silent_handoff_opens_and_renders_through_an_equivalent_editor_path() {
1335+
for count in [1, 2] {
1336+
let mut fixture = handoff_fixture();
1337+
let segments = crate::completed_audio::tests::segments_mut(&mut fixture.metadata);
1338+
segments.truncate(count);
1339+
for segment in segments {
1340+
segment.mic = None;
1341+
segment.system_audio = None;
1342+
segment.camera = None;
1343+
}
1344+
fixture.project.clips.truncate(count);
1345+
fixture
1346+
.project
1347+
.timeline
1348+
.as_mut()
1349+
.unwrap()
1350+
.segments
1351+
.truncate(count);
1352+
fixture.metadata.save_for_project().unwrap();
1353+
fixture
1354+
.project
1355+
.write(&fixture.metadata.project_path)
1356+
.unwrap();
1357+
let metadata = fixture.metadata.studio_meta().unwrap();
1358+
let input = PreparingPreviewInput {
1359+
recording_meta: fixture.metadata.clone(),
1360+
project: fixture.project.clone(),
1361+
segments: (0..count)
1362+
.map(|index| PreparingPreviewSegment {
1363+
video: ManagedSegmentVideoInput::new(
1364+
index,
1365+
metadata,
1366+
ManagedVideoTrackInput::new(
1367+
fixture.source.clone(),
1368+
fixture.display.clone(),
1369+
)
1370+
.unwrap(),
1371+
None,
1372+
)
1373+
.unwrap(),
1374+
cursor: fixture.cursor.clone(),
1375+
})
1376+
.collect(),
1377+
cursor_assets: fixture.assets.clone(),
1378+
};
1379+
let output = Arc::new(crate::AudioOutput::new_headless(Box::new(|_, _| {})));
1380+
let session = crate::PreparingPlaybackSession::spawn(
1381+
input,
1382+
(0..count)
1383+
.map(|_| crate::PreparingAudioSegmentInput {
1384+
mic: None,
1385+
system_audio: None,
1386+
timing_repair: Default::default(),
1387+
})
1388+
.collect(),
1389+
crate::PreparingPlaybackOptions {
1390+
preview: PreparingPreviewOptions::default(),
1391+
fps: 30,
1392+
resolution: XY::new(320, 240),
1393+
},
1394+
output.clone(),
1395+
Box::new(|_, _, _| {}),
1396+
)
1397+
.unwrap();
1398+
let handoff = session.handoff_handle();
1399+
let completed_audio =
1400+
tokio::time::timeout(Duration::from_secs(30), handoff.take_completed_audio())
1401+
.await
1402+
.unwrap()
1403+
.unwrap();
1404+
let entry = fixture.metadata.project_path.join("editor-entry");
1405+
std::fs::create_dir(&entry).unwrap();
1406+
let (frames, mut received) = watch::channel(None);
1407+
let candidate = crate::EditorInstance::new_with_startup_inputs(
1408+
entry.join(".."),
1409+
|_| {},
1410+
Box::new(move |output, _| {
1411+
let frame = match output {
1412+
crate::EditorFrameOutput::Rgba(frame) => frame.frame_number,
1413+
crate::EditorFrameOutput::Nv12(frame) => frame.frame_number,
1414+
#[cfg(target_os = "macos")]
1415+
crate::EditorFrameOutput::Surface(frame) => frame.frame_number,
1416+
};
1417+
frames.send_replace(Some(frame));
1418+
}),
1419+
None,
1420+
crate::EditorFrameFormat::Rgba,
1421+
output,
1422+
crate::EditorStartupInputs {
1423+
recordings: None,
1424+
completed_audio: Some(completed_audio),
1425+
},
1426+
)
1427+
.await
1428+
.unwrap();
1429+
assert_eq!(candidate.segment_medias.len(), count);
1430+
let mut updates = session.updates();
1431+
tokio::time::timeout(Duration::from_secs(30), async {
1432+
while !updates.borrow_and_update().progress.preview_available {
1433+
updates.changed().await.unwrap();
1434+
}
1435+
})
1436+
.await
1437+
.unwrap();
1438+
candidate.install_preparing_handoff(&handoff).await.unwrap();
1439+
candidate
1440+
.preview_tx
1441+
.send(Some((0, 30, XY::new(320, 240))))
1442+
.unwrap();
1443+
tokio::time::timeout(Duration::from_secs(30), async {
1444+
loop {
1445+
if received
1446+
.borrow_and_update()
1447+
.is_some_and(|frame| candidate.commit_preparing_frame(frame, 30))
1448+
{
1449+
break;
1450+
}
1451+
received.changed().await.unwrap();
1452+
}
1453+
})
1454+
.await
1455+
.unwrap();
1456+
assert!(handoff.committed());
1457+
candidate.dispose().await;
1458+
let exit = handoff.stop_and_wait().await;
1459+
assert!(!exit.cleanup_failed, "{:?}", exit.error);
1460+
drop(candidate);
1461+
drop(handoff);
1462+
drop(session);
1463+
drop(fixture.source);
1464+
assert!(fixture.released.load(Ordering::Acquire));
1465+
}
1466+
}
1467+
13301468
async fn failed_handoff_installation_retires_native_candidate(cancelled: bool) {
13311469
use futures::FutureExt;
13321470

0 commit comments

Comments
 (0)