mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-30 04:57:19 -06:00
242 lines
11 KiB
Rust
242 lines
11 KiB
Rust
use super::*;
|
|
use filmcraft_project::{ItemId, ItemKind};
|
|
use filmcraft_time::{Tick, TimeRange};
|
|
use serde_json::json;
|
|
|
|
fn setup() -> Session {
|
|
let mut s = Session::default();
|
|
s.execute("file.openDemoProject", json!({})).unwrap();
|
|
s.execute("file.newSequence", json!({"name":"Source test","width":1280,"height":720,"fps":24})).unwrap();
|
|
s.execute("source.open", json!({"item":5})).unwrap();
|
|
s
|
|
}
|
|
fn counts(s: &Session) -> (usize, usize) {
|
|
let q = s.active_sequence().unwrap();
|
|
(q.video_tracks.iter().map(|t| t.items.len()).sum(), q.audio_tracks.iter().map(|t| t.items.len()).sum())
|
|
}
|
|
|
|
#[test]
|
|
fn unmarked_source_defaults_to_full_span_without_writing_marks() {
|
|
let s = setup();
|
|
let view = clip_ops::source_view(&s, ItemId(5)).unwrap();
|
|
assert_eq!(view.selected_range(), TimeRange::from_bounds(view.start, view.end));
|
|
assert!(view.mark_in.is_none() && view.mark_out.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn marked_range_places_video_audio_or_linked_both_as_one_undo_step() {
|
|
for (video, audio, expected) in [(true, false, (1, 0)), (false, true, (0, 1)), (true, true, (1, 1))] {
|
|
let mut s = setup();
|
|
let start = Tick::from_seconds_f64(1.0);
|
|
let out = Tick::from_seconds_f64(3.0);
|
|
s.execute("project.setMarks", json!({"item":5,"in":start.0,"out":out.0})).unwrap();
|
|
let view = clip_ops::source_view(&s, ItemId(5)).unwrap();
|
|
let range = view.selected_range();
|
|
assert_eq!(range.end(), out + view.rate.frame_duration());
|
|
let before = s.project.to_json();
|
|
s.execute(
|
|
"timeline.place",
|
|
json!({"item":5,"track":"V1","audioTrack":"A1","time":0,"sourceIn":range.start.0,"duration":range.duration.0,"video":video,"audio":audio}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(counts(&s), expected);
|
|
let q = s.active_sequence().unwrap();
|
|
for clip in q.all_tracks().flat_map(|t| t.items.iter()) {
|
|
assert_eq!(clip.source_in, start);
|
|
assert_eq!(clip.duration, q.settings.frame_rate.snap_nearest(range.duration));
|
|
}
|
|
if video && audio {
|
|
assert!(q.video_tracks[0].items[0].link.is_some());
|
|
assert_eq!(q.video_tracks[0].items[0].link, q.audio_tracks[0].items[0].link);
|
|
}
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(s.project.to_json(), before);
|
|
s.execute("edit.redo", json!({})).unwrap();
|
|
assert_eq!(counts(&s), expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_stream_choices_and_locked_destinations_do_not_mutate_project() {
|
|
let mut s = setup();
|
|
let before = s.project.to_json();
|
|
for params in [json!({"video":false,"audio":false}), json!({"video":"yes"}), json!({"audio":2})] {
|
|
let mut p = json!({"item":5,"track":"V1","audioTrack":"A1","time":0});
|
|
for (k, v) in params.as_object().unwrap() {
|
|
p[k] = v.clone();
|
|
}
|
|
assert!(s.execute("timeline.place", p).is_err());
|
|
assert_eq!(s.project.to_json(), before);
|
|
}
|
|
let id = s.state.active_sequence.unwrap();
|
|
std::sync::Arc::make_mut(&mut s.project).sequence_mut(id).unwrap().audio_tracks[0].locked = true;
|
|
let locked = s.project.to_json();
|
|
assert!(s.execute("timeline.place", json!({"item":5,"track":"V1","audioTrack":"A1","time":0,"video":true,"audio":true})).is_err());
|
|
assert_eq!(s.project.to_json(), locked);
|
|
}
|
|
|
|
#[test]
|
|
fn audio_only_source_drops_only_sound() {
|
|
let mut s = setup();
|
|
s.execute("timeline.place", json!({"item":11,"track":"A2","time":0,"video":false,"audio":true})).unwrap();
|
|
assert_eq!(counts(&s), (0, 1));
|
|
assert_eq!(s.active_sequence().unwrap().audio_tracks[1].items.len(), 1);
|
|
}
|
|
|
|
fn subclip_session(video: bool, audio: bool) -> (Session, ItemId) {
|
|
let mut s = Session::default();
|
|
s.execute("file.newSequence", json!({"name":"Subclip test","fps":24})).unwrap();
|
|
let parent =
|
|
s.execute("file.newOfflineFile", json!({"name":"Parent","video":video,"audio":audio,"fps":24,"seconds":10})).unwrap()["item"].as_u64().unwrap();
|
|
let sub = s.execute("clip.makeSubclip", json!({"item":parent,"startFrame":10,"endFrame":60})).unwrap()["item"].as_u64().unwrap();
|
|
(s, ItemId(sub))
|
|
}
|
|
|
|
fn assert_subclip_placement(s: &Session, item: ItemId, expected: (usize, usize)) {
|
|
assert_eq!(counts(s), expected);
|
|
let q = s.active_sequence().unwrap();
|
|
let ItemKind::Subclip { range, .. } = &s.project.item(item).unwrap().kind else { panic!("expected a subclip") };
|
|
for clip in q.all_tracks().flat_map(|t| t.items.iter()) {
|
|
assert_eq!(clip.item, item);
|
|
assert_eq!(clip.source_in, range.start);
|
|
assert_eq!(clip.duration, q.settings.frame_rate.snap_nearest(range.duration));
|
|
}
|
|
if expected == (1, 1) {
|
|
let video = &q.video_tracks[0].items[0];
|
|
let audio = &q.audio_tracks[0].items[0];
|
|
assert!(video.link.is_some());
|
|
assert_eq!(video.link, audio.link);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn subclip_placement_keeps_parent_streams_range_and_history() {
|
|
for (video, audio, expected) in [(true, true, (1, 1)), (false, true, (0, 1)), (true, false, (1, 0))] {
|
|
let (mut s, sub) = subclip_session(video, audio);
|
|
let before = s.project.to_json();
|
|
s.execute("timeline.place", json!({"item":sub.0,"track":"V1","audioTrack":"A1","time":0,"video":true,"audio":true})).unwrap();
|
|
assert_subclip_placement(&s, sub, expected);
|
|
let placed = s.project.to_json();
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(s.project.to_json(), before);
|
|
s.execute("edit.redo", json!({})).unwrap();
|
|
assert_eq!(s.project.to_json(), placed);
|
|
s.project = std::sync::Arc::new(Project::from_json(&placed).unwrap());
|
|
assert_subclip_placement(&s, sub, expected);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn subclip_audio_can_be_placed_on_an_audio_track() {
|
|
for video in [false, true] {
|
|
let (mut s, sub) = subclip_session(video, true);
|
|
s.execute("timeline.place", json!({"item":sub.0,"track":"A1","time":0,"video":false,"audio":true})).unwrap();
|
|
assert_subclip_placement(&s, sub, (0, 1));
|
|
}
|
|
let (mut s, sub) = subclip_session(true, false);
|
|
let before = s.project.to_json();
|
|
assert!(s.execute("timeline.place", json!({"item":sub.0,"track":"A1","time":0,"video":false,"audio":true})).is_err());
|
|
assert_eq!(s.project.to_json(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn source_insert_and_overwrite_keep_subclip_sound() {
|
|
for command in ["source.insert", "source.overwrite"] {
|
|
for (video, audio, expected) in [(true, true, (1, 1)), (false, true, (0, 1)), (true, false, (1, 0))] {
|
|
let (mut s, sub) = subclip_session(video, audio);
|
|
s.execute("source.open", json!({"item":sub.0})).unwrap();
|
|
s.execute(command, json!({})).unwrap();
|
|
assert_subclip_placement(&s, sub, expected);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn subclip_placement_can_add_tracks_for_parent_streams() {
|
|
for (video, audio, expected) in [(true, true, (1, 1)), (false, true, (0, 1)), (true, false, (1, 0))] {
|
|
let (mut s, sub) = subclip_session(video, audio);
|
|
let before = s.project.to_json();
|
|
s.execute("timeline.place", json!({"item":sub.0,"track":"new","audioTrack":"new","time":0})).unwrap();
|
|
assert_eq!(counts(&s), expected);
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(s.project.to_json(), before);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn subclip_placement_keeps_parent_channel_map_and_all_audio_streams() {
|
|
let (mut s, sub) = subclip_session(true, true);
|
|
let parent = s.project.resolve_media(sub).unwrap().0;
|
|
let m = std::sync::Arc::make_mut(&mut s.project).item_mut(parent).unwrap().as_media_mut().unwrap();
|
|
m.info.audio_streams.push(m.info.audio_streams[0].clone());
|
|
m.interpret.audio_channels = Some(filmcraft_project::AudioChannelMap { format: filmcraft_project::AudioChannels::Mono, clips: vec![vec![1], vec![0]] });
|
|
s.execute("timeline.place", json!({"item":sub.0,"track":"V1","audioTrack":"A1","time":0})).unwrap();
|
|
assert_eq!(counts(&s), (1, 3));
|
|
let q = s.active_sequence().unwrap();
|
|
let link = q.video_tracks[0].items[0].link;
|
|
assert!(link.is_some());
|
|
for (index, (stream, channels)) in [(0, vec![1]), (0, vec![0]), (1, vec![])].into_iter().enumerate() {
|
|
let clip = &q.audio_tracks[index].items[0];
|
|
assert_eq!(clip.item, sub);
|
|
assert_eq!(clip.audio_stream, stream);
|
|
assert_eq!(clip.source_channels, channels);
|
|
assert_eq!(clip.link, link);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_source_spans_are_bounded() {
|
|
let s = setup();
|
|
let mut view = clip_ops::source_view(&s, ItemId(5)).unwrap();
|
|
view.mark_in = Some(Tick::MAX);
|
|
view.mark_out = Some(Tick::ZERO);
|
|
let range = view.selected_range();
|
|
assert_eq!(range.start, view.end);
|
|
assert_eq!(range.duration, Tick::ZERO);
|
|
view.end = Tick::ZERO;
|
|
view.start = Tick::from_seconds_f64(1.0);
|
|
assert_eq!(view.selected_range().duration, Tick::ZERO);
|
|
}
|
|
|
|
/// #1101: a placement that starts between frames must not run past `time + duration`, and the
|
|
/// frame count `sequence.inspect` reports is the one export renders.
|
|
#[test]
|
|
fn sub_frame_place_ends_at_requested_end_and_inspect_matches_export_frames() {
|
|
let mut s = Session::default();
|
|
s.execute("file.openDemoProject", json!({})).unwrap();
|
|
s.execute("file.newSequence", json!({"name":"f","width":640,"height":360,"fps":24,"video":1,"audio":2})).unwrap();
|
|
let rate = s.sequence_rate();
|
|
let fd = rate.frame_duration().0;
|
|
let end = rate.tick_of(120).0;
|
|
s.execute("timeline.place", json!({"item":5,"track":"V1","audioTrack":"A1","frame":0,"sourceIn":0,"duration":end})).unwrap();
|
|
// a third of a frame after frame 60, ending exactly on frame 120
|
|
let at = rate.tick_of(60).0 + fd / 3;
|
|
let placed = s.execute("timeline.place", json!({"item":5,"track":"A2","time":at,"sourceIn":0,"duration":end - at})).unwrap();
|
|
let clip = placed["clips"][0].as_u64().unwrap();
|
|
let js = s.execute("sequence.inspect", json!({})).unwrap();
|
|
let c = js["audio"][1]["items"].as_array().unwrap().iter().find(|c| c["clip"] == clip).unwrap().clone();
|
|
assert!(c["end"].as_i64().unwrap() <= end, "clip runs past the requested end: {c}");
|
|
assert_eq!(rate.snap(Tick(c["start"].as_i64().unwrap())).0, c["start"].as_i64().unwrap(), "clip starts between frames: {c}");
|
|
assert_eq!(js["duration"], end);
|
|
let frames = s.execute("export.resolve", json!({})).unwrap()["frames"].clone();
|
|
assert_eq!(js["durationFrames"], frames);
|
|
assert_eq!(frames, 120);
|
|
}
|
|
|
|
/// A sequence that ends between frames (a clip moved to a sub-frame time) still reports the
|
|
/// frame count export renders, the partial last frame included.
|
|
#[test]
|
|
fn inspect_duration_frames_counts_a_partial_last_frame_like_export() {
|
|
let mut s = setup();
|
|
let rate = s.sequence_rate();
|
|
let placed = s.execute("timeline.place", json!({"item":5,"track":"V1","audioTrack":"A1","frame":0,"sourceIn":0,"duration":rate.tick_of(48).0})).unwrap();
|
|
let clips: Vec<u64> = placed["clips"].as_array().unwrap().iter().filter_map(|v| v.as_u64()).collect();
|
|
let tracks = s.active_sequence().map(|q| (q.video_tracks[0].id.0, q.audio_tracks[0].id.0)).unwrap();
|
|
let t = rate.frame_duration().0 / 3;
|
|
s.execute("timeline.move", json!({"moves":[{"clip":clips[0],"track":tracks.0,"time":t},{"clip":clips[1],"track":tracks.1,"time":t}]})).unwrap();
|
|
let js = s.execute("sequence.inspect", json!({})).unwrap();
|
|
let frames = s.execute("export.resolve", json!({})).unwrap()["frames"].clone();
|
|
assert_eq!(js["durationFrames"], frames);
|
|
assert_eq!(frames, 49);
|
|
}
|