Files
filmcraft/crates/format/tests/fixtures.rs
5ed4952d43 Add shared custom colors and complete marker editing (#1435)
* M3: Add shared custom colors and complete marker editing

* #1435: refine native colors and range endpoint flags

Use the macOS Colors panel for custom label colors and reuse the established egui picker on other hosts. Preserve draft confirmation, exact sRGB values, native text shortcuts and project ownership. Compact the swatches and center the add control, split range glyphs across their endpoints, localize the new controls, protect newer marker edits from stale dialogs, and cover the behavior with rendered UI and hit-test regressions.

Co-authored-by: Codex <noreply@openai.com>

* #1435: preserve imported palettes and marker double-click ownership

Union saved OTIO palettes across timelines with deduplication, bounded capacity,
first-name preservation and explicit overflow warnings. Keep null authors
consistent with omitted fields, and consume marker double-clicks before the
work-area gesture so opening the editor does not alter project history.

Correct the CSV URL column and schema documentation. Add six regressions for
palette retention/capacity, author handling, and physical overlapping gestures.

Validation: full cargo xtask ci (3666 passed, 45 skipped), 42 rendered UI tests,
and strict workspace/all-target Clippy on Rust 1.99.1.

Co-authored-by: Codex <noreply@openai.com>

* #1435: retain OTIO swatch names and generator labels

Ignore orphan name entries when retaining saved palette names so input ordering
does not hide valid names after the first 64 entries. Keep names bounded by the
retained palette and preserve existing valid names.

Include generator preset labels alongside custom label colors in OTIO metadata.
Add regressions for orphan-prefixed name maps and three generator kinds with
preset/custom colors through actual export/import round trips.

Validation: both regressions failed before fixes; all 72 interchange tests and
full cargo xtask ci pass (3668 tests, 45 skipped), plus strict workspace/all-target
Clippy on Rust 1.99.1.

Co-authored-by: Codex <noreply@openai.com>

* #1435: preserve distinct OTIO generator item identity

Use exported FilmCraft item IDs for generator media identity so identical
content can retain distinct source names, labels and custom colors. Repeated
references to one source item still share a single imported media entry.

Preserve content-based fallback for missing or invalid item IDs. Cover black
video, bars and tone, and color mattes with distinct items/shared references,
plus legacy and malformed identity metadata.

Validation: distinct-generator regression failed before fix; legacy compatibility
passed. All 74 interchange tests and final full cargo xtask ci pass (3670 tests,
45 skipped); strict workspace/all-target Clippy also passes on Rust 1.99.1.

Co-authored-by: Codex <noreply@openai.com>

* #1435: align Edit Marker layout and add safe marker navigation

Give the editor a wide aligned form, larger Notes area, side action column,
and grouped palette with vertical marker types. Expand the Web Link field
with the form and keep actions accessible in small windows.

Previous/Next saves valid edits as one Undo step and stays with the original
sequence/source owner, including coincident markers. Invalid drafts stay open;
unchanged navigation and Done add no history. Keep native/shared color pickers,
Author, Notes, URL validation and immediate marker creation.

Validation: all 45 rendered sequence/marker UI tests pass, including five new
navigation/owner/validation/layout regressions. Native macOS layout, picker
Cancel and exact single-step Undo restoration verified. Full standard cargo
xtask ci passes 3675 tests (45 skipped), doctests and all remaining gates;
strict Rust 1.99.1 workspace/all-target Clippy also passes.

Co-authored-by: Codex <noreply@openai.com>

* #1435: refine Edit Marker proportions and action alignment

Narrow the dialog to 680 logical pixels and keep the core order Name,
Time/Duration, Notes, Color and marker type. Place Author after the type-specific
options as secondary metadata. Preserve our palette, custom picker and range controls.

Use consistently sized, centered rounded action buttons with a primary Done action.
Shorten Delete Marker to Delete and remove the navigation helper and unused translations.
Keep the existing navigation save/validation/Undo semantics and automation IDs.

Validation: all 45 rendered marker/sequence UI tests pass, including small-window
action accessibility. Actual macOS layout and no-op Previous/Next/Cancel verified.
Full standard cargo xtask ci passes 3675 tests (45 skipped), doctests, formatting,
Clippy, translations, layers, assets and 49 WASM crates. Strict Rust 1.99.1
workspace/all-target Clippy passes separately.

Co-authored-by: Codex <noreply@openai.com>

* #1435: tighten marker layout and validate text editing

Use a narrower aligned form with Author below Name, bounded scrolling Notes,
left-aligned colors, centered actions and an inline Web Link URL/open icon.
Remove the legacy Flash Cue choice while preserving imported marker types.

Constrain timecode typing/paste to complete numeric colon-separated fields,
validate drop-frame labels and frame bounds, and disable Done for invalid timing.
Preserve high-frame-rate timecodes and point-marker semantics when Duration is
unchecked. Route native/menu clipboard and text Undo commands to focused fields;
explicit engine commands retain project Undo and selection semantics.

Validation: standard cargo xtask ci passes 3679 tests (45 standard skips),
doctests, fmt, strict workspace/all-target Clippy, i18n, layers, 69 assets and
49 WASM crates. Also passed 48 rendered marker/sequence tests and strict Rust
1.99.1 Clippy. Actual macOS clipboard, bounded Notes, point save and exact Undo
were checked. Windows/Linux desktop interaction remains pending.

Co-authored-by: Codex <noreply@openai.com>

* M0: Wait for frame-worker recovery before asserting

Do not treat a nonempty request queue as proof of worker death in the
panic-recovery regression. Keep waiting for all injected faults and enough
post-fault completions, then drain the queue without adding more seeks.
Retain the exact fault-count, successful-recovery and empty-queue assertions
and bound the wait with a two-minute deadline.

The inherited test failed twice in independent fork CI. It did not reproduce
locally: baseline passed 20 normal and 10 delayed-report runs; the repair
passed 50 normal and 10 delayed-report runs. No product or marker UI changes.

Validation: standard cargo xtask ci passed all 4485 tests (52 standard skips),
doctests, formatting, strict workspace/all-target Clippy, i18n, layers,
69 assets and 49 WASM crates.

Assisted-by: OpenAI Codex

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Codex <codex@openai.com>
2026-11-01 03:04:14 +01:00

479 lines
22 KiB
Rust

//! Old project files keep opening: hand-made fixtures of every past schema are upgraded on load.
use filmcraft_format::{FormatError, SCHEMA_VERSION, decode, encode};
use filmcraft_project::{ClipId, ItemId, ItemKind, Project};
use filmcraft_time::Tick;
fn fixture(name: &str) -> Vec<u8> {
std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name)).unwrap()
}
#[test]
fn v1_minimal_loads() {
let l = decode(&fixture("v1-minimal.fcproj")).unwrap();
assert_eq!(l.schema_version, 1);
assert!(l.migrated());
assert_eq!(l.project.name, "Legacy Minimal");
assert!(l.project.items.is_empty());
assert_eq!(l.project.next_id, 1);
}
/// Main briefly wrote bare projects with `"version": 2` (caption tracks) before the envelope landed.
#[test]
fn bare_version_2_loads_as_schema_v1() {
let mut v: serde_json::Value = serde_json::from_slice(&fixture("v1-edit.fcproj")).unwrap();
v["version"] = 2.into();
let l = decode(&serde_json::to_vec(&v).unwrap()).unwrap();
assert_eq!(l.schema_version, 1);
assert!(l.migrated());
assert_eq!(l.project.name, decode(&fixture("v1-edit.fcproj")).unwrap().project.name);
}
#[test]
fn v1_edit_loads_with_defaults_for_later_fields() {
let l = decode(&fixture("v1-edit.fcproj")).unwrap();
assert_eq!(l.schema_version, 1);
let p = &l.project;
assert_eq!(p.name, "Legacy Edit");
// the file says 48, but its sequence marker is 800000: new ids start past it (#2809)
assert_eq!(p.next_id, 800_001);
let media = p.item(ItemId(5)).unwrap();
assert_eq!(media.name, "Ocean_Sunset.mp4");
assert!(media.metadata.is_empty());
let seq = p.sequence(ItemId(21)).unwrap();
assert_eq!(seq.video_tracks.len(), 1);
assert_eq!(seq.audio_tracks.len(), 1);
assert_eq!(seq.markers.len(), 1);
let clip = &seq.video_tracks[0].items[0];
assert_eq!(clip.id, ClipId(29));
assert_eq!(clip.source_in, Tick(243_675_432_000));
assert!(!clip.reverse && clip.link.is_none() && clip.gain_db == 0.0 && !clip.scale_to_frame);
assert_eq!(clip.effects.len(), 3);
assert!(seq.audio_tracks[0].effects.is_empty());
seq.check().unwrap();
}
#[test]
fn legacy_audio_field_becomes_primary_stream_and_clip_defaults_to_zero() {
let p = decode(&fixture("v1-edit.fcproj")).unwrap().project;
let media = p.item(ItemId(5)).unwrap().as_media().unwrap();
assert_eq!(media.info.audio_streams.len(), 1);
assert!(media.info.has_audio());
let clip = &p.sequence(ItemId(21)).unwrap().audio_tracks[0].items[0];
assert_eq!(clip.audio_stream, 0);
let again = decode(&encode(&p, false)).unwrap();
assert_eq!(again.project.item(ItemId(5)).unwrap().as_media().unwrap().info.audio_streams, media.info.audio_streams);
}
/// Out-of-range audio stream data is repaired on load, never a reason to refuse the project.
#[test]
fn hostile_audio_stream_counts_and_indices_are_repaired_not_rejected() {
let mut p = decode(&fixture("v1-edit.fcproj")).unwrap().project;
let seq = ItemId(21);
for bad in [filmcraft_media::MAX_AUDIO_STREAMS, usize::MAX] {
p.sequence_mut(seq).unwrap().audio_tracks[0].items[0].audio_stream = bad;
let loaded = decode(&encode(&p, false)).unwrap().project;
assert_eq!(loaded.sequence(seq).unwrap().audio_tracks[0].items[0].audio_stream, 0);
}
p.sequence_mut(seq).unwrap().audio_tracks[0].items[0].audio_stream = filmcraft_media::MAX_AUDIO_STREAMS - 1;
let loaded = decode(&encode(&p, false)).unwrap().project;
assert_eq!(loaded.sequence(seq).unwrap().audio_tracks[0].items[0].audio_stream, filmcraft_media::MAX_AUDIO_STREAMS - 1, "in range: kept");
let media = p.item_mut(ItemId(5)).unwrap().as_media_mut().unwrap();
let stream = media.info.audio_streams[0].clone();
media.info.audio_streams.resize(filmcraft_media::MAX_AUDIO_STREAMS + 1, stream);
let loaded = decode(&encode(&p, false)).unwrap().project;
assert_eq!(loaded.item(ItemId(5)).unwrap().as_media().unwrap().info.audio_streams.len(), filmcraft_media::MAX_AUDIO_STREAMS);
}
/// Persisted media metadata is used to allocate synthetic and offline source frames, so it must
/// have the same bounded dimensions as a sequence before the project is opened.
#[test]
fn hostile_media_video_dimensions_are_rejected() {
let mut p = decode(&fixture("v1-edit.fcproj")).unwrap().project;
let media = p.item_mut(ItemId(5)).unwrap().as_media_mut().unwrap();
let video = media.info.video.as_mut().unwrap();
video.width = u32::MAX;
video.height = 1;
let err = decode(&encode(&p, false)).unwrap_err();
assert!(matches!(err, FormatError::Corrupt(ref message) if message.contains("media `Ocean_Sunset.mp4`") && message.contains("frame size")), "{err}");
}
/// Schema 8 (before M3.10): no time interpolation / Hold Filters / Field Options / source channels
/// on clips, no audio channel map, subclips without Restrict Trims.
#[test]
fn v8_subclip_loads_with_m3_10_defaults() {
let l = decode(&fixture("v8-subclip.fcproj")).unwrap();
assert_eq!(l.schema_version, 8);
assert!(l.migrated());
let p = &l.project;
assert_eq!(p.name, "Schema 8 Edit");
let ItemKind::Subclip { parent, range, restrict_trims } = &p.item(ItemId(48)).unwrap().kind else { panic!("not a subclip") };
assert_eq!((*parent, range.duration.0 > 0, *restrict_trims), (ItemId(5), true, false));
assert!(p.item(ItemId(5)).unwrap().as_media().unwrap().interpret.audio_channels.is_none());
let clip = &p.sequence(ItemId(21)).unwrap().video_tracks[0].items[0];
assert!(clip.time_interpolation.is_default() && !clip.hold_filters && clip.field_options.is_none() && clip.source_channels.is_empty());
// resaved in the current schema, losslessly
let again = decode(&encode(p, true)).unwrap();
assert_eq!(again.schema_version, SCHEMA_VERSION);
assert_eq!(&again.project, p);
}
#[test]
fn upgraded_file_resaves_in_current_schema_losslessly() {
let old = decode(&fixture("v1-edit.fcproj")).unwrap().project;
let bytes = encode(&old, true);
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["schema_version"], SCHEMA_VERSION);
let again = decode(&bytes).unwrap();
assert!(!again.migrated());
assert_eq!(again.project, old);
}
#[test]
fn future_schema_is_refused_with_a_clear_error() {
let mut v: serde_json::Value = serde_json::from_slice(&fixture("v1-minimal.fcproj")).unwrap();
// A bare v1-shaped project claiming a future `version` is refused too.
v["version"] = 99.into();
let e = decode(&serde_json::to_vec(&v).unwrap()).unwrap_err();
assert!(matches!(e, FormatError::TooNew { found: 99, .. }), "{e}");
assert!(e.to_string().contains("Update FilmCraft"));
}
/// Build a project with `n` clips (half video, half linked audio) on 4+4 tracks.
pub fn big_project(n: usize) -> Project {
let mut p = decode(&fixture("v1-edit.fcproj")).unwrap().project;
let seq_id = ItemId(21);
let mut next = 1_000_000u64;
let seq = match &mut p.items.get_mut(&seq_id).unwrap().kind {
ItemKind::Sequence(s) => std::sync::Arc::make_mut(s),
_ => unreachable!(),
};
let v0 = seq.video_tracks[0].items[0].clone();
let a0 = seq.audio_tracks[0].items[0].clone();
for t in seq.video_tracks.iter_mut().chain(seq.audio_tracks.iter_mut()) {
t.items.clear();
}
let mut vt = seq.video_tracks[0].clone();
let mut at = seq.audio_tracks[0].clone();
for k in 1..4 {
vt.id = filmcraft_project::TrackId(500 + k);
vt.name = format!("Video {}", k + 1);
seq.video_tracks.push(vt.clone());
at.id = filmcraft_project::TrackId(600 + k);
at.name = format!("Audio {}", k + 1);
seq.audio_tracks.push(at.clone());
}
let dur = Tick(254_016_000_000); // 1 s each
for i in 0..n / 2 {
let track = i % 4;
let slot = (i / 4) as i64;
let mut v = v0.clone();
v.id = ClipId(next);
v.start = Tick(dur.0 * slot);
v.duration = dur;
v.link = Some(next);
let mut a = a0.clone();
a.id = ClipId(next + 1);
a.start = v.start;
a.duration = dur;
a.link = Some(next);
next += 2;
seq.video_tracks[track].items.push(v);
seq.audio_tracks[track].items.push(a);
}
p.next_id = next;
p
}
#[test]
fn large_project_roundtrip_and_timing() {
let p = big_project(2000);
let clips: usize = p.sequence(ItemId(21)).unwrap().all_tracks().map(|t| t.items.len()).sum();
assert_eq!(clips, 2000);
let reps = 5;
let t0 = std::time::Instant::now();
let mut pretty = Vec::new();
for _ in 0..reps {
pretty = encode(&p, true);
}
let pretty_ms = t0.elapsed().as_secs_f64() * 1000.0 / reps as f64;
let t0 = std::time::Instant::now();
let mut compact = Vec::new();
for _ in 0..reps {
compact = encode(&p, false);
}
let compact_ms = t0.elapsed().as_secs_f64() * 1000.0 / reps as f64;
let t0 = std::time::Instant::now();
let back = decode(&compact).unwrap();
let decode_ms = t0.elapsed().as_secs_f64() * 1000.0;
assert_eq!(back.project, p);
let t0 = std::time::Instant::now();
let dir = std::env::temp_dir().join(format!("filmcraft-big-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
filmcraft_format::atomic_write(&dir.join("big.fcproj"), &compact).unwrap();
let write_ms = t0.elapsed().as_secs_f64() * 1000.0;
std::fs::remove_dir_all(&dir).unwrap();
println!(
"2000-clip project: pretty {:.1} KB in {pretty_ms:.2} ms, compact {:.1} KB in {compact_ms:.2} ms, decode {decode_ms:.2} ms, atomic write+fsync {write_ms:.2} ms",
pretty.len() as f64 / 1024.0,
compact.len() as f64 / 1024.0
);
}
#[test]
fn v8_loads_without_transcripts_and_v9_roundtrips_them() {
let l = decode(&fixture("v8-minimal.fcproj")).unwrap();
assert_eq!(l.schema_version, 8);
assert!(l.migrated());
assert!(l.project.transcripts.is_empty());
let mut p = l.project;
let mut t = filmcraft_project::Transcript { language: "en".into(), source: "manual".into(), ..Default::default() };
t.words.push(filmcraft_project::Word::new("Hello", Tick(0), Tick(1000)));
t.words[0].speaker = Some(0);
t.normalize();
p.transcripts.insert(ItemId(7), std::sync::Arc::new(t));
let again = decode(&encode(&p, true)).unwrap();
// the transcript's key is an id in use, so new ids start past it (#2809)
p.next_id = 8;
assert!(!again.migrated());
assert_eq!(again.project, p);
assert_eq!(again.project.transcripts[&ItemId(7)].speakers[0].name, "Speaker 1");
}
/// Schema 11 (before M10.7): no source graphics; graphic clips without template / roll /
/// responsive data and layers without style runs or pins load with those empty.
#[test]
fn v11_minimal_loads_without_graphics_design_data() {
let l = decode(&fixture("v11-minimal.fcproj")).unwrap();
assert_eq!(l.schema_version, 11);
assert!(l.migrated());
let p = &l.project;
assert_eq!(p.name, "Before Graphics Templates");
assert!(p.source_graphics.is_empty());
assert!(p.root.find_bin(filmcraft_project::BinId(1)).is_some());
let again = decode(&encode(p, true)).unwrap();
assert_eq!(again.schema_version, SCHEMA_VERSION);
const { assert!(SCHEMA_VERSION >= 12) };
assert_eq!(&again.project, p);
}
/// Schema 10 (before M3.11): no search bins, no safe areas / capture format / scratch disks in
/// the project settings.
#[test]
fn v10_minimal_loads_with_m3_11_defaults() {
let l = decode(&fixture("v10-minimal.fcproj")).unwrap();
assert_eq!(l.schema_version, 10);
assert!(l.migrated());
let p = &l.project;
assert_eq!(p.name, "Before Search Bins");
assert!(p.search_bins.is_empty());
assert_eq!((p.settings.title_safe, p.settings.action_safe), ((20.0, 20.0), (10.0, 10.0)));
assert_eq!(p.settings.capture_format, "DV");
assert_eq!(p.settings.scratch, Default::default());
assert!(p.root.find_bin(filmcraft_project::BinId(1)).is_some());
let again = decode(&encode(p, true)).unwrap();
assert_eq!(again.schema_version, SCHEMA_VERSION);
assert_eq!(&again.project, p);
}
/// #2809: `next_id` is read from the file, and nothing raised it past the ids already in it. A file
/// whose `next_id` lagged behind (an older build, a hand edit, a merge) handed out an id in use, and
/// the new item silently replaced the old one. Each kind of id is raised past on its own, so a kind
/// the loader forgets fails here.
#[test]
fn next_id_is_raised_past_every_id_in_the_file() {
use filmcraft_project::{
BinId, Camera, Caption, CaptionFormat, CaptionTrack, Label, Marker, MarkerId, MarkerKind, MergedClip, MulticamSource, Narration, ProjectLut, SearchBin,
Track, TrackId, TrackKind, TrackSend, Transition, TransitionId,
};
const BIG: u64 = 5_000_000;
let base = decode(&fixture("v1-edit.fcproj")).unwrap().project;
let seq = ItemId(21);
let marker = |id| Marker {
url: String::new(),
author: String::new(),
custom_color: None,
id: MarkerId(id),
start: Tick::ZERO,
duration: Tick::ZERO,
name: String::new(),
comment: String::new(),
kind: MarkerKind::Comment,
color: Label::Green,
};
let kinds: Vec<(&str, Box<dyn Fn(&mut Project)>)> = vec![
(
"item",
Box::new(|p| {
p.next_id = BIG;
p.add_item("Layer", Label::Iris, p.item(ItemId(5)).unwrap().kind.clone(), None);
}),
),
(
"bin",
Box::new(|p| {
p.next_id = BIG;
p.add_bin("Bin", None);
}),
),
("search bin", Box::new(|p| p.search_bins.push(SearchBin { id: BinId(BIG), name: "Find".into(), query: Default::default() }))),
("media marker", Box::new(move |p| p.item_mut(ItemId(5)).unwrap().as_media_mut().unwrap().markers.push(marker(BIG)))),
("sequence marker", Box::new(move |p| p.sequence_mut(seq).unwrap().markers[0].id = MarkerId(BIG))),
("track", Box::new(move |p| p.sequence_mut(seq).unwrap().audio_tracks[0].id = TrackId(BIG))),
("submix track", Box::new(move |p| p.sequence_mut(seq).unwrap().submix_tracks.push(Track::new(TrackId(BIG), TrackKind::Audio, "Submix".into())))),
("clip", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].id = ClipId(BIG))),
("link", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].link = Some(BIG))),
("group", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].group = Some(BIG))),
("clip marker", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].markers.push(marker(BIG)))),
(
"transition",
Box::new(move |p| {
let s = p.sequence_mut(seq).unwrap();
let clip = s.video_tracks[0].items[0].clone();
let effect = clip.effects[0].clone();
s.video_tracks[0].transitions.push(Transition {
id: TransitionId(BIG),
effect,
start: clip.start,
duration: Tick::ZERO,
from: None,
to: Some(clip.id),
align: Default::default(),
reverse: false,
});
}),
),
(
"caption track",
Box::new(move |p| p.sequence_mut(seq).unwrap().caption_tracks.push(CaptionTrack::new(TrackId(BIG), "Subtitle".into(), CaptionFormat::Subtitle))),
),
(
"caption",
Box::new(move |p| {
let mut track = CaptionTrack::new(TrackId(7), "Subtitle".into(), CaptionFormat::Subtitle);
track.captions.push(Caption {
id: ClipId(BIG),
start: Tick::ZERO,
duration: Tick(1),
text: "Hi".into(),
speaker: None,
cue_id: None,
settings: String::new(),
});
p.sequence_mut(seq).unwrap().caption_tracks.push(track);
}),
),
(
"LUT",
Box::new(|p| p.luts.push(ProjectLut { id: format!("lut{BIG}"), name: "Look".into(), source_path: None, format: "cube".into(), text: "".into() })),
),
// left behind by a deleted item: a new item must not take its id and inherit them
("transcript", Box::new(|p| _ = p.transcripts.insert(ItemId(BIG), Default::default()))),
("source graphic", Box::new(|p| _ = p.source_graphics.insert(ItemId(BIG), Default::default()))),
(
"narration",
Box::new(|p| {
let narration = Narration {
text: "Hello".into(),
language: "en-US".into(),
voice: "basic-female".into(),
pitch: Default::default(),
pace: 1.0,
speech_duration: Tick(1),
};
p.narrations.insert(ItemId(BIG), narration);
}),
),
// only referenced, the target gone: a new item taking that id would be picked up by the reference
("clip's item", Box::new(move |p| p.sequence_mut(seq).unwrap().video_tracks[0].items[0].item = ItemId(BIG))),
(
"subclip's parent",
Box::new(|p| {
let range = filmcraft_time::TimeRange::new(Tick::ZERO, Tick(1));
p.add_item("Subclip", Label::Iris, ItemKind::Subclip { parent: ItemId(BIG), range, restrict_trims: false }, None);
}),
),
(
"transition's clip",
Box::new(move |p| {
let s = p.sequence_mut(seq).unwrap();
let clip = s.video_tracks[0].items[0].clone();
s.video_tracks[0].transitions.push(Transition {
id: TransitionId(9),
effect: clip.effects[0].clone(),
start: clip.start,
duration: Tick::ZERO,
from: None,
to: Some(ClipId(BIG)),
align: Default::default(),
reverse: false,
});
}),
),
("send target", Box::new(move |p| p.sequence_mut(seq).unwrap().audio_tracks[0].mixer.sends.push(TrackSend::new(TrackId(BIG))))),
("track output", Box::new(move |p| p.sequence_mut(seq).unwrap().master_mixer.output = Some(TrackId(BIG)))),
(
"camera's source",
Box::new(move |p| {
let camera = Camera { name: "Cam 1".into(), video_track: None, audio_tracks: Vec::new(), enabled: true, source: Some(ItemId(BIG)) };
p.sequence_mut(seq).unwrap().multicam = Some(MulticamSource { cameras: vec![camera], ..Default::default() });
}),
),
(
"camera's track",
Box::new(move |p| {
let camera = Camera { name: "Cam 1".into(), video_track: Some(TrackId(BIG)), audio_tracks: Vec::new(), enabled: true, source: None };
p.sequence_mut(seq).unwrap().multicam = Some(MulticamSource { cameras: vec![camera], ..Default::default() });
}),
),
("merged clip", Box::new(move |p| p.sequence_mut(seq).unwrap().merged = Some(MergedClip { audio: vec![ItemId(BIG)], ..Default::default() }))),
];
for (kind, add) in &kinds {
let mut p = base.clone();
add(&mut p);
p.next_id = 1;
let loaded = decode(&encode(&p, false)).unwrap_or_else(|e| panic!("{kind}: {e}"));
assert_eq!(loaded.project.next_id, BIG + 1, "{kind}");
}
}
/// #2809: the case the issue describes. A file whose `next_id` names an item already in it: the next
/// new item used to take that id and replace the item. It gets a fresh id, and both are still there
/// after a save and a reload.
#[test]
fn a_new_item_never_replaces_one_from_a_file_with_a_stale_next_id() {
let mut p = decode(&fixture("v1-edit.fcproj")).unwrap().project;
p.next_id = 5;
let mut p = decode(&encode(&p, false)).unwrap().project;
let before = p.items.len();
let kind = p.item(ItemId(5)).unwrap().kind.clone();
let new = p.add_item("Copy", filmcraft_project::Label::Iris, kind, None);
assert_ne!(new, ItemId(5));
assert_eq!(p.items.len(), before + 1, "a new item replaced an old one");
assert_eq!(p.item(ItemId(5)).unwrap().name, "Ocean_Sunset.mp4");
let again = decode(&encode(&p, false)).unwrap().project;
assert_eq!(again, p, "the round trip keeps both items and the raised next_id");
assert!(again.next_id > new.0);
}
/// #2809: ids past what can ever be allocated. The largest possible id has no successor, and a
/// `next_id` that high overflows `alloc_id`; such a file is refused instead of panicking (debug) or
/// wrapping to 0 (release) on the first new item.
#[test]
fn an_exhausted_id_space_is_refused_on_load() {
use filmcraft_project::MarkerId;
let base = decode(&fixture("v1-edit.fcproj")).unwrap().project;
let mut p = base.clone();
p.sequence_mut(ItemId(21)).unwrap().markers[0].id = MarkerId(u64::MAX);
let e = decode(&encode(&p, false)).unwrap_err();
assert!(matches!(&e, FormatError::Corrupt(m) if m.contains("ID space exhausted")), "{e}");
let mut p = base.clone();
p.next_id = u64::MAX;
let e = decode(&encode(&p, false)).unwrap_err();
assert!(matches!(&e, FormatError::Corrupt(m) if m.contains("ID space exhausted")), "{e}");
let mut p = base;
p.next_id = Project::MAX_NEXT_ID;
assert_eq!(decode(&encode(&p, false)).unwrap().project.next_id, Project::MAX_NEXT_ID, "the last usable next_id still opens");
}