Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 109 additions & 3 deletions crates/editor/src/completed_audio.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use cap_audio::DecodedAudio;
use cap_project::{AudioGapSummary, AudioMeta, RecordingMeta, StudioRecordingMeta};
use std::{path::PathBuf, sync::Arc};
use std::{
path::{Path, PathBuf},
sync::Arc,
};

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

fn same_project_path(left: &Path, right: &Path) -> bool {
left == right
|| matches!(
(left.canonicalize(), right.canonicalize()),
(Ok(left), Ok(right)) if left == right
)
}

#[derive(Clone)]
pub struct CompletedAudioHandoff {
project_path: PathBuf,
Expand All @@ -63,7 +74,10 @@ impl CompletedAudioHandoff {
expected_finalized_metadata: &RecordingMeta,
segments: Vec<CompletedAudioSegment>,
) -> Result<Self, String> {
if source_metadata.project_path != expected_finalized_metadata.project_path {
if !same_project_path(
&source_metadata.project_path,
&expected_finalized_metadata.project_path,
) {
return Err("Completed audio belongs to a different project".into());
}
let source = audio_identities(
Expand Down Expand Up @@ -107,7 +121,7 @@ impl CompletedAudioHandoff {
recording_meta: &RecordingMeta,
meta: &StudioRecordingMeta,
) -> Option<Vec<CompletedAudioSegment>> {
(self.project_path == recording_meta.project_path
(same_project_path(&self.project_path, &recording_meta.project_path)
&& self.identities == audio_identities(meta))
.then_some(self.segments)
}
Expand Down Expand Up @@ -227,6 +241,98 @@ pub(crate) mod tests {
assert!(matched[1].mic.is_none());
}

#[test]
fn completed_cache_accepts_equivalent_project_paths_and_preserves_pcm() {
let directory = tempfile::tempdir().unwrap();
let entry = directory.path().join("editor-entry");
std::fs::create_dir(&entry).unwrap();
let mut original = metadata();
original.project_path = entry.join("..");
let mut finalized = original.clone();
finalized.project_path = directory.path().canonicalize().unwrap();
assert_ne!(original.project_path, finalized.project_path);
let audio = audio();
for expected in [&original, &finalized] {
let cache = CompletedAudioHandoff::from_completed_tracks(
&original,
expected,
completed(&audio),
)
.unwrap();
let matched = cache
.into_matching(&finalized, finalized.studio_meta().unwrap())
.unwrap();
assert!(Arc::ptr_eq(matched[0].mic.as_ref().unwrap(), &audio));
}
}

#[test]
fn silent_completed_cache_accepts_canonical_editor_paths() {
let directory = tempfile::tempdir().unwrap();
let entry = directory.path().join("editor-entry");
std::fs::create_dir(&entry).unwrap();
for count in [1, 2] {
let mut original = metadata();
original.project_path = entry.join("..");
let segments = segments_mut(&mut original);
segments.truncate(count);
for segment in segments {
segment.mic = None;
segment.system_audio = None;
}
let cache = CompletedAudioHandoff::from_completed_tracks(
&original,
&original,
vec![CompletedAudioSegment::default(); count],
)
.unwrap();
let mut finalized = original.clone();
finalized.project_path = directory.path().canonicalize().unwrap();
let matched = cache
.into_matching(&finalized, finalized.studio_meta().unwrap())
.unwrap();
assert_eq!(matched.len(), count);
assert!(
matched
.iter()
.all(|segment| segment.mic.is_none() && segment.system_audio.is_none())
);
}
}

#[test]
fn completed_cache_rejects_different_or_unresolvable_project_paths() {
let directory = tempfile::tempdir().unwrap();
let mut original = metadata();
original.project_path = directory.path().join("original.cap");
std::fs::create_dir(&original.project_path).unwrap();
let mut other = original.clone();
other.project_path = directory.path().join("other.cap");
std::fs::create_dir(&other.project_path).unwrap();
let audio = audio();
for missing in [false, true] {
if missing {
original.project_path = directory.path().join("missing-original.cap");
other.project_path = directory.path().join("missing-other.cap");
}
assert!(
CompletedAudioHandoff::from_completed_tracks(&original, &other, completed(&audio))
.is_err()
);
let cache = CompletedAudioHandoff::from_completed_tracks(
&original,
&original,
completed(&audio),
)
.unwrap();
assert!(
cache
.into_matching(&other, other.studio_meta().unwrap())
.is_none()
);
}
}

#[test]
fn completed_cache_rejects_every_changed_track_identity() {
let original = metadata();
Expand Down
140 changes: 139 additions & 1 deletion crates/editor/src/preparing_preview/native_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1160,7 +1160,10 @@ async fn native_handoff_reuses_pcm_preserves_audio_across_candidate_disposal_and
use std::sync::atomic::AtomicUsize;

let fixture = handoff_fixture();
let root = fixture.metadata.project_path.clone();
let entry = fixture.metadata.project_path.join("editor-entry");
std::fs::create_dir(&entry).unwrap();
let root = entry.join("..");
assert_ne!(root, fixture.metadata.project_path);
let nonzero = Arc::new(AtomicUsize::new(0));
let tapped = nonzero.clone();
let output = Arc::new(crate::AudioOutput::new_headless(Box::new(
Expand Down Expand Up @@ -1327,6 +1330,141 @@ async fn native_handoff_reuses_pcm_preserves_audio_across_candidate_disposal_and
assert!(fixture.released.load(Ordering::Acquire));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn native_silent_handoff_opens_and_renders_through_an_equivalent_editor_path() {
for count in [1, 2] {
let mut fixture = handoff_fixture();
let segments = crate::completed_audio::tests::segments_mut(&mut fixture.metadata);
segments.truncate(count);
for segment in segments {
segment.mic = None;
segment.system_audio = None;
segment.camera = None;
}
fixture.project.clips.truncate(count);
fixture
.project
.timeline
.as_mut()
.unwrap()
.segments
.truncate(count);
fixture.metadata.save_for_project().unwrap();
fixture
.project
.write(&fixture.metadata.project_path)
.unwrap();
let metadata = fixture.metadata.studio_meta().unwrap();
let input = PreparingPreviewInput {
recording_meta: fixture.metadata.clone(),
project: fixture.project.clone(),
segments: (0..count)
.map(|index| PreparingPreviewSegment {
video: ManagedSegmentVideoInput::new(
index,
metadata,
ManagedVideoTrackInput::new(
fixture.source.clone(),
fixture.display.clone(),
)
.unwrap(),
None,
)
.unwrap(),
cursor: fixture.cursor.clone(),
})
.collect(),
cursor_assets: fixture.assets.clone(),
};
let output = Arc::new(crate::AudioOutput::new_headless(Box::new(|_, _| {})));
let session = crate::PreparingPlaybackSession::spawn(
input,
(0..count)
.map(|_| crate::PreparingAudioSegmentInput {
mic: None,
system_audio: None,
timing_repair: Default::default(),
})
.collect(),
crate::PreparingPlaybackOptions {
preview: PreparingPreviewOptions::default(),
fps: 30,
resolution: XY::new(320, 240),
},
output.clone(),
Box::new(|_, _, _| {}),
)
.unwrap();
let handoff = session.handoff_handle();
let completed_audio =
tokio::time::timeout(Duration::from_secs(30), handoff.take_completed_audio())
.await
.unwrap()
.unwrap();
let entry = fixture.metadata.project_path.join("editor-entry");
std::fs::create_dir(&entry).unwrap();
let (frames, mut received) = watch::channel(None);
let candidate = crate::EditorInstance::new_with_startup_inputs(
entry.join(".."),
|_| {},
Box::new(move |output, _| {
let frame = match output {
crate::EditorFrameOutput::Rgba(frame) => frame.frame_number,
crate::EditorFrameOutput::Nv12(frame) => frame.frame_number,
#[cfg(target_os = "macos")]
crate::EditorFrameOutput::Surface(frame) => frame.frame_number,
};
frames.send_replace(Some(frame));
}),
None,
crate::EditorFrameFormat::Rgba,
output,
crate::EditorStartupInputs {
recordings: None,
completed_audio: Some(completed_audio),
},
)
.await
.unwrap();
assert_eq!(candidate.segment_medias.len(), count);
let mut updates = session.updates();
tokio::time::timeout(Duration::from_secs(30), async {
while !updates.borrow_and_update().progress.preview_available {
updates.changed().await.unwrap();
}
})
.await
.unwrap();
candidate.install_preparing_handoff(&handoff).await.unwrap();
candidate
.preview_tx
.send(Some((0, 30, XY::new(320, 240))))
.unwrap();
tokio::time::timeout(Duration::from_secs(30), async {
loop {
if received
.borrow_and_update()
.is_some_and(|frame| candidate.commit_preparing_frame(frame, 30))
{
break;
}
received.changed().await.unwrap();
}
})
.await
.unwrap();
assert!(handoff.committed());
candidate.dispose().await;
let exit = handoff.stop_and_wait().await;
assert!(!exit.cleanup_failed, "{:?}", exit.error);
drop(candidate);
drop(handoff);
drop(session);
drop(fixture.source);
assert!(fixture.released.load(Ordering::Acquire));
}
}

async fn failed_handoff_installation_retires_native_candidate(cancelled: bool) {
use futures::FutureExt;

Expand Down
Loading
Loading