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
135 changes: 135 additions & 0 deletions crates/editor/examples/zoom-seek-preview-benchmark.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
use cap_editor::{EditorFrameOutput, EditorInstance};
use cap_project::{
ClipConfiguration, CursorEvents, CursorMoveEvent, GlideDirection, ProjectConfiguration,
RecordingMeta, TimelineSegment, XY, ZoomMode, ZoomSegment,
};
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
path::PathBuf,
sync::mpsc,
time::{Duration, Instant},
};

#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() {
let root = PathBuf::from(std::env::args_os().nth(1).expect("fixture directory"));
let duration: f64 = std::env::var("CAP_ZOOM_DURATION").map_or(3600.0, |v| v.parse().unwrap());
let clips: usize = std::env::var("CAP_ZOOM_CLIPS").map_or(1000, |v| v.parse().unwrap());
let mut project = ProjectConfiguration {
timeline: Some(
serde_json::from_value(serde_json::json!({"segments":[],"zoomSegments":[]})).unwrap(),
),
..Default::default()
};
project.camera.hide = true;
project.cursor.hide = true;
project.cursor.raw = true;
project.clips = (0..4)
.map(|index| ClipConfiguration {
index,
..Default::default()
})
.collect();
let timeline = project.timeline.as_mut().unwrap();
let length = duration / clips as f64;
timeline.segments = (0..clips)
.map(|index| TimelineSegment {
recording_clip: (index % 4) as u32,
start: (index / 4) as f64 * length,
end: ((index / 4) + 1) as f64 * length,
timescale: 1.0,
..Default::default()
})
.collect();
timeline.zoom_segments = (0..30)
.map(|index| ZoomSegment {
start: index as f64 * duration / 30.0,
end: (index as f64 + 0.85) * duration / 30.0,
amount: 2.0,
mode: ZoomMode::Auto,
glide_direction: GlideDirection::default(),
glide_speed: 0.5,
instant_animation: false,
edge_snap_ratio: 0.25,
})
.collect();
project.write(&root).unwrap();
let cursor = CursorEvents {
moves: (0..=(duration / 4.0 * 60.0) as usize)
.map(|index| CursorMoveEvent {
time_ms: index as f64 * 1000.0 / 60.0,
x: 0.2 + (index % 700) as f64 / 1200.0,
y: 0.2 + (index % 900) as f64 / 1500.0,
cursor_id: "default".into(),
active_modifiers: Vec::new(),
})
.collect(),
..Default::default()
};
std::fs::write(
root.join("cursor.json"),
serde_json::to_vec(&cursor).unwrap(),
)
.unwrap();
let mut meta: RecordingMeta = serde_json::from_value(serde_json::json!({
"platform":"Linux", "pretty_name":"Dense zoom seek benchmark", "sharing":null,
"segments":(0..4).map(|_|serde_json::json!({"display":{"path":"display.mp4","fps":1,"start_time":0.0},"cursor":"cursor.json"})).collect::<Vec<_>>(),
"cursors":{},"status":{"status":"Complete"}
})).unwrap();
meta.project_path = root.clone();
meta.save_for_project().unwrap();
let (tx, rx) = mpsc::channel();
let startup = Instant::now();
let editor = EditorInstance::new(
root,
|_| {},
Box::new(move |output, _| {
if let EditorFrameOutput::Rgba(frame) = output {
let _ = tx.send((Instant::now(), frame));
}
}),
None,
)
.await
.expect("editor startup");
let setup_seconds = startup.elapsed().as_secs_f64();
editor
.preview_tx
.send_modify(|value| *value = Some((0, 30, XY::new(640, 360))));
loop {
let (_, frame) = rx
.recv_timeout(Duration::from_secs(60))
.expect("initial frame");
if frame.frame_number == 0 {
break;
}
}
let target = (duration * 0.95 * 30.0) as u32;
let start = Instant::now();
editor
.preview_tx
.send_modify(|value| *value = Some((target, 30, XY::new(640, 360))));
let (ready, frame) = loop {
let value = rx
.recv_timeout(Duration::from_secs(60))
.expect("late seek frame");
if value.1.frame_number == target {
break value;
}
};
let elapsed = ready.saturating_duration_since(start).as_secs_f64();
let mut hash = DefaultHasher::new();
for row in frame
.data
.chunks_exact(frame.padded_bytes_per_row as usize)
.take(frame.height as usize)
{
row[..frame.width as usize * 4].hash(&mut hash);
}
println!(
"PREVIEW_BENCH {}",
serde_json::json!({"duration":duration,"clips":clips,"startup_seconds":setup_seconds,"seek_seconds":elapsed,"fingerprint":hash.finish(),"width":frame.width,"height":frame.height})
);
editor.dispose().await;
}
146 changes: 145 additions & 1 deletion crates/rendering/src/zoom_spring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,28 @@ fn build_time_map(timeline: Option<&TimelineConfiguration>) -> Vec<TimeMapSegmen
out
}

fn ordered_time_map(map: &[TimeMapSegment]) -> bool {
map.iter()
.all(|segment| segment.timeline_start.is_finite() && segment.timeline_end.is_finite())
&& map.windows(2).all(|pair| {
pair[0].timeline_start <= pair[1].timeline_start
&& pair[0].timeline_end <= pair[1].timeline_end
})
}

fn time_map_at(map: &[TimeMapSegment], time: f64, ordered: bool) -> &[TimeMapSegment] {
if !ordered || !time.is_finite() {
return map;
}
let first = map.partition_point(|segment| segment.timeline_end <= time);
let end = map.partition_point(|segment| segment.timeline_start <= time);
if first < end && time > map[first].timeline_start {
&map[first..end]
} else {
map
}
}

/// Maps a timeline timestamp to recording seconds. Identity when no timeline
/// is configured; clamps to the nearest edit boundary outside the timeline.
fn map_timeline_to_recording_secs(
Expand Down Expand Up @@ -688,6 +710,7 @@ pub struct ZoomTransformTimeline {
/// Auto segments, `None` for Manual ones.
clusters: Vec<Option<ZoomClusters>>,
time_map: Vec<TimeMapSegment>,
time_map_ordered: bool,
recording_clip: Option<u32>,
prefer_outgoing: bool,
/// Total number of samples covering [0, duration] plus one lerp partner.
Expand Down Expand Up @@ -753,6 +776,7 @@ impl ZoomTransformTimeline {
} else {
build_time_map(timeline)
};
let time_map_ordered = ordered_time_map(&time_map);
let clusters = zoom_segments
.iter()
.map(|segment| match segment.mode {
Expand Down Expand Up @@ -799,6 +823,7 @@ impl ZoomTransformTimeline {
segment_index,
clusters,
time_map,
time_map_ordered,
recording_clip,
prefer_outgoing,
total_samples: 1,
Expand All @@ -818,6 +843,7 @@ impl ZoomTransformTimeline {
segment_index,
clusters,
time_map,
time_map_ordered,
recording_clip,
prefer_outgoing,
total_samples,
Expand Down Expand Up @@ -1132,7 +1158,7 @@ impl ZoomTransformTimeline {
}
ZoomMode::Auto => {
let recording_ms = map_timeline_to_recording_secs(
&self.time_map,
time_map_at(&self.time_map, timeline_secs, self.time_map_ordered),
timeline_secs,
self.recording_clip,
self.prefer_outgoing,
Expand Down Expand Up @@ -1326,6 +1352,124 @@ mod tests {
project
}

#[test]
fn narrowed_time_maps_match_full_scans_at_boundaries_and_overlaps() {
let span = |start, end, clip| TimeMapSegment {
timeline_start: start,
timeline_end: end,
recording_start: -0.0,
timescale: 1.5,
recording_clip: clip,
};
for map in [
vec![],
vec![span(0.0, 2.0, 0)],
vec![span(0.0, 4.0, 0), span(3.0, 7.0, 1), span(6.0, 10.0, 0)],
vec![span(0.0, 10.0, 0), span(1.0, 3.0, 1)],
vec![span(3.0, 5.0, 1), span(0.0, 4.0, 0)],
vec![span(0.0, 3.0, 0), span(0.0, 4.0, 1)],
vec![span(0.0, 2.0, 0), span(3.0, 4.0, 1)],
vec![
span(0.0, 2.0, 0),
TimeMapSegment {
timescale: 0.0,
..span(2.0, 4.0, 0)
},
span(4.0, 6.0, 1),
],
vec![span(0.0, f64::INFINITY, 0)],
vec![span(f64::NAN, 4.0, 0)],
] {
let ordered = ordered_time_map(&map);
let mut queries = vec![
f64::NEG_INFINITY,
-1.0,
-0.0,
0.0,
1.5,
3.5,
8.0,
20.0,
f64::INFINITY,
f64::NAN,
];
for segment in &map {
for boundary in [segment.timeline_start, segment.timeline_end] {
queries.extend([boundary.next_down(), boundary, boundary.next_up()]);
}
}
for query in queries {
let selected = time_map_at(&map, query, ordered);
if !ordered || !query.is_finite() {
assert!(std::ptr::eq(selected, map.as_slice()));
}
for clip in [None, Some(0), Some(1), Some(99)] {
for outgoing in [false, true] {
let expected = map_timeline_to_recording_secs(&map, query, clip, outgoing);
let actual =
map_timeline_to_recording_secs(selected, query, clip, outgoing);
if expected.is_nan() {
assert!(actual.is_nan());
} else {
assert_eq!(
actual.to_bits(),
expected.to_bits(),
"query {query}, clip {clip:?}, outgoing {outgoing}"
);
}
}
}
}
}
let map = [span(0.0, 2.0, 0), span(2.0, 4.0, 1), span(4.0, 6.0, 0)];
assert_eq!(time_map_at(&map, 3.0, ordered_time_map(&map)).len(), 1);
}

#[test]
fn indexed_mapping_preserves_complete_zoom_simulation_samples() {
let project = project_for_zoom_reuse();
let duration = project.timeline.as_ref().unwrap().duration();
let cursor = CursorEvents {
moves: vec![
move_event(0.0, 0.1, 0.2),
move_event(5000.0, 0.4, 0.8),
move_event(10000.0, 0.8, 0.3),
move_event(20000.0, 0.2, 0.6),
],
clicks: vec![click_event(4500.0), click_event(11000.0)],
};
for clip in [0, 1] {
for outgoing in [false, true] {
let build = || {
if outgoing {
ZoomTransformTimeline::from_project_for_outgoing_clip(
&project,
&cursor,
duration,
XY::new(1920, 1080),
clip,
)
} else {
ZoomTransformTimeline::from_project_for_clip(
&project,
&cursor,
duration,
XY::new(1920, 1080),
clip,
)
}
};
let mut expected = build();
expected.time_map_ordered = false;
let mut actual = build();
assert!(actual.time_map_ordered);
expected.precompute();
actual.precompute();
assert_samples_identical(&expected, &actual);
}
}
}

#[test]
fn unrelated_project_edits_preserve_incoming_and_outgoing_zoom_samples() {
let project = project_for_zoom_reuse();
Expand Down
Loading