diff --git a/crates/engine/src/interchange.rs b/crates/engine/src/interchange.rs index b961eea2..3328025e 100644 --- a/crates/engine/src/interchange.rs +++ b/crates/engine/src/interchange.rs @@ -20,6 +20,10 @@ fn report_json(r: &filmcraft_interchange::Report) -> Vec { r.entries.iter().map(|e| if e.count > 1 { format!("{} (×{})", e.message, e.count) } else { e.message.clone() }).collect() } +fn is_file_media(item: &filmcraft_project::ProjectItem) -> bool { + matches!(&item.kind, ItemKind::Media(m) if matches!(m.media, MediaRef::File { .. })) +} + /// Import a document: merge its bins, media and sequences into the project (one undo step), then /// link each media file that exists on disk. Returns the new sequences. pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Result { @@ -46,7 +50,15 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu }; let before: std::collections::HashSet = s.project.items.keys().copied().collect(); let name = opts.name.clone().unwrap_or_else(|| format.name().to_string()); - let seqs = s.edit(&format!("Import {name}"), |proj, _| Ok(filmcraft_interchange::merge_into(proj, fragment, None)))?; + let seqs = s.edit(&format!("Import {name}"), |proj, _| { + let seqs = filmcraft_interchange::merge_into(proj, fragment, None); + // A document that sets only some Motion parameters leaves the others at their "auto" + // (NaN) defaults. Resolve them as placing a clip does, for every format. A media file's + // picture size is only what the document says so far (an EDL says nothing: the importer + // assumes the sequence size), so its clips' anchors wait for Link Media below. + proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| !is_file_media(source)); + Ok(seqs) + })?; // Link media: probe each new file-backed item that exists. let new_media: Vec<(ItemId, String)> = s .project @@ -85,6 +97,8 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu filmcraft_interchange::rebase_source_timecode(proj, id, tc, rate); } } + // the picture size is now the file's own: centre the anchors of its clips in it + proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| source.id == id); Ok(()) })?; s.media.insert_file(id, &mpath, src); diff --git a/crates/engine/src/interchange_auto_points_tests.rs b/crates/engine/src/interchange_auto_points_tests.rs new file mode 100644 index 00000000..d5d5e625 --- /dev/null +++ b/crates/engine/src/interchange_auto_points_tests.rs @@ -0,0 +1,218 @@ +//! Interchange imports and the "auto" (NaN) point defaults of the effects they add: the points +//! are resolved as placing a clip resolves them, the anchor once the media's real size is known, +//! and a project saved after an import opens again. + +use filmcraft_geom::Vec2; +use filmcraft_media::DemoScene; +use filmcraft_project::{ItemId, ParamValue, Project, TrackItem}; +use filmcraft_time::Tick; +use serde_json::json; + +use crate::Session; +use crate::media_test_util::{make_movie, tmp_dir}; + +const RATE: &str = "24FALSE"; + +/// A 1920×1080 FCP7 XML sequence with one clip of `media/shot.mov` (declared 3840×2160) whose +/// Basic Motion filter sets only `scale`. +fn xml_with_a_scale_only_motion_filter() -> String { + format!( + r#" + + + + Cut + 24 + {RATE} + + + + + +"# + ) +} + +fn first_clip(s: &Session, seq: ItemId) -> TrackItem { + s.project.sequence(seq).unwrap().video_tracks[0].items[0].clone() +} + +fn motion_point(c: &TrackItem, k: &str) -> Vec2 { + c.effect("motion").unwrap().vec2_at(k, Tick::ZERO) +} + +/// Every effect point parameter of every clip that is still "auto". +fn auto_points(p: &Project) -> Vec { + let mut out = Vec::new(); + for q in p.sequences().filter_map(|i| i.as_sequence()) { + for c in q.all_tracks().flat_map(|t| &t.items) { + for e in &c.effects { + for (k, v) in &e.params { + if matches!(&v.value, ParamValue::Vec2(v) if v.x.is_nan() || v.y.is_nan()) { + out.push(format!("{}.{k}", e.effect)); + } + } + } + } + } + out +} + +fn import(s: &mut Session, doc: &std::path::Path) -> (serde_json::Value, ItemId) { + let r = s.execute("file.import", json!({"paths": [doc.to_string_lossy()]})).unwrap(); + let seq = ItemId(r["sequences"][0].as_u64().unwrap_or_else(|| panic!("no sequence in {r}"))); + (r["documents"][0].clone(), seq) +} + +/// A project saved after such an import held `null` points and could not be opened again +/// ("project file is damaged: invalid type: null, expected f64"). +#[test] +fn a_project_saved_after_an_interchange_import_opens_again() { + let d = tmp_dir("ix-auto-offline"); + let doc = d.join("cut.xml"); + std::fs::write(&doc, xml_with_a_scale_only_motion_filter()).unwrap(); + let mut s = Session::default(); + let before = s.project.clone(); + // the media file is not there: its real size is not known + let (r, seq) = import(&mut s, &doc); + assert_eq!((r["linkedMedia"].as_u64(), r["offlineMedia"].as_array().map(Vec::len)), (Some(0), Some(1)), "{r}"); + let clip = first_clip(&s, seq); + assert_eq!(clip.effect("motion").unwrap().f64_at("scale", Tick::ZERO), 50.0); + assert_eq!(motion_point(&clip, "position"), Vec2::new(960.0, 540.0), "the frame centre, as placing a clip resolves it"); + assert!(motion_point(&clip, "anchor").x.is_nan(), "the anchor waits for the media's real size"); + assert_eq!(auto_points(&s.project), ["motion.anchor"]); + // the saved file opens again and holds the same project (NaN != NaN, so compare what is written) + let path = d.join("p.fcproj").to_string_lossy().to_string(); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + let mut t = Session::default(); + t.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(filmcraft_format::encode(&t.project, false), filmcraft_format::encode(&s.project, false)); + assert_eq!(auto_points(&t.project), ["motion.anchor"]); + assert_eq!(motion_point(&first_clip(&t, seq), "position"), Vec2::new(960.0, 540.0)); + // without a link step the import is one undo step + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(auto_points(&s.project), ["motion.anchor"]); + let _ = std::fs::remove_dir_all(&d); +} + +/// The anchor is centred in the file's real picture, not in the size the document declares +/// (3840×2160 here) or the sequence's. +#[test] +fn a_linked_clip_gets_its_anchor_from_the_real_size_of_the_media() { + let d = tmp_dir("ix-auto-linked"); + std::fs::create_dir_all(d.join("media")).unwrap(); + make_movie(&d.join("media/shot.mov"), DemoScene::OceanSunset, 320, 180, 24); + let doc = d.join("cut.xml"); + std::fs::write(&doc, xml_with_a_scale_only_motion_filter()).unwrap(); + let mut s = Session::default(); + let before = s.project.clone(); + let (r, seq) = import(&mut s, &doc); + assert_eq!(r["linkedMedia"], 1, "{r}"); + let clip = first_clip(&s, seq); + assert_eq!(s.project.source_size(clip.item), Some((320, 180))); + assert_eq!((motion_point(&clip, "position"), motion_point(&clip, "anchor")), (Vec2::new(960.0, 540.0), Vec2::new(160.0, 90.0))); + assert_eq!(auto_points(&s.project), Vec::::new()); + // nothing is NaN, so the reopened project is equal + let path = d.join("p.fcproj").to_string_lossy().to_string(); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + let mut t = Session::default(); + t.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(*t.project, *s.project); + // undo: Link Media takes the real size and the anchor back together, then the import itself + let linked = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + let clip = first_clip(&s, seq); + assert_eq!(s.project.source_size(clip.item), Some((3840, 2160)), "the size the document declared"); + assert!(motion_point(&clip, "anchor").x.is_nan()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before); + s.execute("edit.redo", json!({})).unwrap(); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(*s.project, *linked); + let _ = std::fs::remove_dir_all(&d); +} + +/// An EDL says nothing about the picture: the importer assumes the sequence size for the media +/// until the file is probed. A clip smaller than the sequence must still be centred. +#[test] +fn an_edl_clip_is_centred_in_its_real_size_not_the_assumed_sequence_size() { + let d = tmp_dir("ix-auto-edl"); + make_movie(&d.join("shot.mov"), DemoScene::OceanSunset, 320, 180, 24); + let doc = d.join("cut.edl"); + let edl = "TITLE: Cut\nFCM: NON-DROP FRAME\n\n001 AX V C 00:00:00:00 00:00:01:00 01:00:00:00 01:00:01:00\n* FROM CLIP NAME: shot.mov\n* SOURCE FILE: shot.mov\n"; + std::fs::write(&doc, edl).unwrap(); + let mut s = Session::default(); + let (r, seq) = import(&mut s, &doc); + assert_eq!(r["linkedMedia"], 1, "{r}"); + let st = s.project.sequence(seq).unwrap().settings.clone(); + assert_ne!((st.width, st.height), (320, 180), "the media is not the size of the sequence"); + let clip = first_clip(&s, seq); + assert_eq!(motion_point(&clip, "position"), Vec2::new(st.width as f64 / 2.0, st.height as f64 / 2.0)); + assert_eq!(motion_point(&clip, "anchor"), Vec2::new(160.0, 90.0), "the centre of the 320×180 picture"); + assert_eq!(auto_points(&s.project), Vec::::new()); + let _ = std::fs::remove_dir_all(&d); +} + +/// Imported with the media missing, saved, reopened, and only then linked: Link Media gives the +/// item its real size, and the anchors that waited for it become numbers. +#[test] +fn relinking_later_resolves_the_anchor_that_waited_for_the_real_size() { + let d = tmp_dir("ix-auto-relink"); + let doc = d.join("cut.xml"); + std::fs::write(&doc, xml_with_a_scale_only_motion_filter()).unwrap(); + let mut s = Session::default(); + let (_, seq) = import(&mut s, &doc); + let path = d.join("p.fcproj").to_string_lossy().to_string(); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + let mut t = Session::default(); + t.execute("file.open", json!({"path": path})).unwrap(); + let clip = first_clip(&t, seq); + assert!(motion_point(&clip, "anchor").x.is_nan(), "still auto after the reopen"); + // the file turns up in another folder, smaller than the document said + std::fs::create_dir_all(d.join("found")).unwrap(); + let found = d.join("found/shot.mov"); + make_movie(&found, DemoScene::OceanSunset, 320, 180, 24); + let waiting = t.project.clone(); + let r = t.execute("media.relink", json!({"item": clip.item.0, "path": found.to_string_lossy(), "force": true})).unwrap(); + assert_eq!(r["relinked"].as_array().map(Vec::len), Some(1), "{r}"); + let clip = first_clip(&t, seq); + assert_eq!(t.project.source_size(clip.item), Some((320, 180))); + assert_eq!((motion_point(&clip, "position"), motion_point(&clip, "anchor")), (Vec2::new(960.0, 540.0), Vec2::new(160.0, 90.0))); + assert_eq!(auto_points(&t.project), Vec::::new()); + // one undo step takes the link and the anchor back together + let linked = t.project.clone(); + t.execute("edit.undo", json!({})).unwrap(); + assert_eq!(filmcraft_format::encode(&t.project, false), filmcraft_format::encode(&waiting, false)); + assert_eq!(auto_points(&t.project), ["motion.anchor"]); + t.execute("edit.redo", json!({})).unwrap(); + assert_eq!(*t.project, *linked); + // an anchor that is already a number is not touched by a later relink to another size + std::fs::create_dir_all(d.join("other")).unwrap(); + let other = d.join("other/shot.mov"); + make_movie(&other, DemoScene::OceanSunset, 160, 90, 24); + t.execute("media.relink", json!({"item": clip.item.0, "path": other.to_string_lossy(), "force": true})).unwrap(); + assert_eq!(motion_point(&first_clip(&t, seq), "anchor"), Vec2::new(160.0, 90.0)); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a5f34e61..91cb079a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -886,6 +886,8 @@ mod file_tests; #[cfg(test)] mod image_sequence_tests; #[cfg(test)] +mod interchange_auto_points_tests; +#[cfg(test)] mod keyboard_tests; #[cfg(test)] mod masks_tests; diff --git a/crates/engine/src/relink.rs b/crates/engine/src/relink.rs index 373ece98..7adf81c6 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -342,6 +342,12 @@ fn apply(s: &mut Session, plans: Vec, align_timecode: bool, label: &str) - m.info = info.clone(); } proj.shift_media_time(p.item, shift); + if p.cand.info.is_some() { + // The item's size is now the file's own. Clips that came from an interchange import + // while the file was missing still have an "auto" (NaN) anchor waiting for it: + // centre it in the real picture. Points that are numbers are not touched. + proj.resolve_placed_auto_points(|_| true, |source| source.id == p.item); + } } Ok(()) })?; diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 220bd854..39e0aaa5 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -303,6 +303,38 @@ mod tests { } } + /// A clip with unresolved "auto" points (NaN) is written with `null` coordinates. Such a file + /// used to be refused as damaged; it opens, and the points are still "auto". + #[test] + fn auto_points_written_as_null_open_again() { + use filmcraft_project::{ItemKind, SequenceSettings, TrackKind}; + use filmcraft_time::{FrameRate, Tick, TimeRange}; + let mut p = Project::new("Imported"); + let layer = p.add_item( + "Layer", + filmcraft_project::Label::Iris, + ItemKind::AdjustmentLayer { width: 3840, height: 2160, rate: FrameRate::FPS_24, duration: FrameRate::FPS_24.tick_of(48) }, + None, + ); + let seq = p.new_sequence("Sequence 01", SequenceSettings::default(), 1, 1, None); + let ti = p.make_track_item(layer, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(48)), FrameRate::FPS_24).unwrap(); + p.sequence_mut(seq).unwrap().video_tracks[0].items.push(ti); + for pretty in [true, false] { + let bytes = encode(&p, pretty); + let text = String::from_utf8(bytes.clone()).unwrap(); + assert!(text.replace([' ', '\n'], "").contains(r#"{"Vec2":{"x":null,"y":null}}"#), "the file holds NaN points as null"); + let l = decode(&bytes).unwrap_or_else(|e| panic!("a file with `null` points is refused: {e}")); + let motion = l.project.sequence(seq).unwrap().video_tracks[0].items[0].effect("motion").unwrap().clone(); + let anchor = motion.params["anchor"].value.as_vec2().unwrap(); + assert!(anchor.x.is_nan() && anchor.y.is_nan(), "still auto"); + assert_eq!(motion.params["scale"].value.as_f64(), Some(100.0)); + assert_eq!(encode(&l.project, pretty), bytes, "and the file is written back the same"); + // a `null` where no "auto" exists is still a damaged file + let broken = text.replacen("3840", "null", 1); + assert!(matches!(decode(broken.as_bytes()), Err(FormatError::Corrupt(_)))); + } + } + #[test] fn envelope_has_explicit_schema_version() { let v: Value = serde_json::from_slice(&encode(&Project::new("x"), true)).unwrap(); diff --git a/crates/project/src/keyframe.rs b/crates/project/src/keyframe.rs index 982453a8..1ddfbc15 100644 --- a/crates/project/src/keyframe.rs +++ b/crates/project/src/keyframe.rs @@ -11,7 +11,11 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum ParamValue { Float(f64), - Vec2(Vec2), + /// A point. A coordinate may be NaN: point parameters use NaN for "auto" (the frame or source + /// centre) until [`crate::resolve_auto_points`] fills it in. JSON has no NaN and writes it as + /// `null`, so `null` reads back as NaN here, and only here: a `null` coordinate anywhere else + /// in a project (a mask vertex, a layer rectangle) is still a damaged file. + Vec2(#[serde(deserialize_with = "point_or_auto")] Vec2), Color([f32; 4]), Bool(bool), Choice(u32), @@ -22,6 +26,22 @@ pub enum ParamValue { Path(crate::mask::MaskPath), } +/// Read a point parameter's value, taking a `null` coordinate as NaN ("auto"). +fn point_or_auto<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + fn nan_if_null<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(Option::::deserialize(d)?.unwrap_or(f64::NAN)) + } + #[derive(Deserialize)] + struct Point { + #[serde(deserialize_with = "nan_if_null")] + x: f64, + #[serde(deserialize_with = "nan_if_null")] + y: f64, + } + let p = Point::deserialize(d)?; + Ok(Vec2::new(p.x, p.y)) +} + impl ParamValue { pub fn as_f64(&self) -> Option { match self { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 35f3a01c..692ce432 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1335,9 +1335,15 @@ impl Project { /// Resolve NaN "auto" point defaults (frame centre / source centre) in an effect instance. pub fn resolve_auto_points(e: &mut EffectInstance, frame: (u32, u32), source: (u32, u32)) { + resolve_auto_points_sized(e, frame, Some(source)); +} + +/// [`resolve_auto_points`] for a source whose size may not be known yet (`None`): the points +/// measured in the source (`anchor`) then stay "auto". +fn resolve_auto_points_sized(e: &mut EffectInstance, frame: (u32, u32), source: Option<(u32, u32)>) { for (k, p) in e.params.iter_mut() { if let ParamValue::Vec2(v) = &mut p.value { - let (w, h) = if k == "anchor" { source } else { frame }; + let Some((w, h)) = (if k == "anchor" { source } else { Some(frame) }) else { continue }; if let Some((fx, fy)) = effect::auto_point(&e.effect, k) { if v.x.is_nan() { v.x = w as f64 * fx; @@ -1357,6 +1363,79 @@ pub fn resolve_auto_points(e: &mut EffectInstance, frame: (u32, u32), source: (u let _ = Vec2::ZERO; } +impl Project { + /// Size in pixels of what an item shows: a media clip's picture, a sequence's frame, an + /// adjustment layer or graphic; a subclip has the size of its parent. `None` without + /// picture. This is the size the renderer centres a clip's "auto" anchor in. + pub fn source_size(&self, item: ItemId) -> Option<(u32, u32)> { + let mut id = item; + // subclips of subclips: bounded, a damaged project could point a subclip at itself + for _ in 0..16 { + return match &self.item(id)?.kind { + ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)), + ItemKind::Sequence(q) => Some((q.settings.width, q.settings.height)), + ItemKind::AdjustmentLayer { width, height, .. } | ItemKind::Graphic { width, height, .. } => Some((*width, *height)), + ItemKind::Subclip { parent, .. } => { + id = *parent; + continue; + } + }; + } + None + } + + /// Resolve the "auto" (NaN) points of the clips in the sequences `sequences` selects, for + /// clips that did not go through a placing command (an interchange import builds them with + /// the effect defaults). Points measured in the frame use the sequence's size. `anchor` is + /// measured in the clip's source ([`Project::source_size`], a subclip through its parent) and + /// is resolved only when `size_is_final` says so for that source item (the media item for a + /// subclip); otherwise it stays "auto", which the renderer centres in whatever size the media + /// turns out to have. Points that are not NaN are never touched, so this can run again when + /// more sizes are known. + pub fn resolve_placed_auto_points(&mut self, sequences: impl Fn(ItemId) -> bool, size_is_final: impl Fn(&ProjectItem) -> bool) { + let seq_ids: Vec = self.items.values().filter(|i| matches!(i.kind, ItemKind::Sequence(_)) && sequences(i.id)).map(|i| i.id).collect(); + for seq_id in seq_ids { + let Some(seq) = self.sequence(seq_id) else { continue }; + // the source size of each clip that has an unresolved point, looked up before the edit + let sources: BTreeMap> = seq + .all_tracks() + .flat_map(|t| t.items.iter()) + .filter(|c| c.effects.iter().any(has_auto_point)) + .map(|c| (c.id, self.final_source_size(c.item, &size_is_final))) + .collect(); + if sources.is_empty() { + continue; + } + let Some(seq) = self.sequence_mut(seq_id) else { continue }; + let frame = (seq.settings.width, seq.settings.height); + for clip in seq.all_tracks_mut().flat_map(|t| t.items.iter_mut()) { + let Some(source) = sources.get(&clip.id) else { continue }; + for e in &mut clip.effects { + resolve_auto_points_sized(e, frame, *source); + } + } + } + } + + /// [`Project::source_size`] of `item` when the item that carries the size (the media item of + /// a subclip) passes `size_is_final`. + fn final_source_size(&self, item: ItemId, size_is_final: &impl Fn(&ProjectItem) -> bool) -> Option<(u32, u32)> { + let mut id = item; + for _ in 0..16 { + let it = self.item(id)?; + match &it.kind { + ItemKind::Subclip { parent, .. } => id = *parent, + _ => return if size_is_final(it) { self.source_size(id) } else { None }, + } + } + None + } +} + +fn has_auto_point(e: &EffectInstance) -> bool { + e.params.values().any(|p| matches!(&p.value, ParamValue::Vec2(v) if v.x.is_nan() || v.y.is_nan())) +} + #[cfg(test)] mod tests { use super::*; @@ -1401,6 +1480,78 @@ mod tests { assert!(q.sequence(seq).unwrap().check().is_ok()); } + #[test] + fn point_params_keep_auto_through_json_and_no_other_point_reads_null() { + // serde_json writes NaN as `null`; a point parameter reads it back as NaN ("auto") + let json = serde_json::to_string(&ParamValue::Vec2(Vec2::new(f64::NAN, 540.0))).unwrap(); + assert_eq!(json, r#"{"Vec2":{"x":null,"y":540.0}}"#); + let v = serde_json::from_str::(&json).unwrap().as_vec2().unwrap(); + assert!(v.x.is_nan() && v.y == 540.0); + let both = serde_json::from_str::(r#"{"Vec2":{"x":null,"y":null}}"#).unwrap().as_vec2().unwrap(); + assert!(both.x.is_nan() && both.y.is_nan()); + assert_eq!(serde_json::from_str::(r#"{"Vec2":{"x":1.5,"y":-2}}"#).unwrap(), ParamValue::Vec2(Vec2::new(1.5, -2.0))); + // anything else is still an error, and so is a missing coordinate + for bad in [r#"{"Vec2":{"x":"a","y":0}}"#, r#"{"Vec2":{"x":0}}"#, r#"{"Vec2":null}"#, r#"{"Float":null}"#] { + assert!(serde_json::from_str::(bad).is_err(), "{bad}"); + } + // only point parameters: a `null` coordinate in a mask vertex (or any other point) is damage + assert!(serde_json::from_str::(r#"{"x":null,"y":0}"#).is_err()); + let mut path = serde_json::to_value(MaskPath::ellipse(Vec2::new(100.0, 100.0), Vec2::new(50.0, 40.0))).unwrap(); + assert!(serde_json::from_value::(path.clone()).is_ok()); + path["vertices"][0]["p"]["x"] = serde_json::Value::Null; + assert!(serde_json::from_value::(path.clone()).is_err()); + assert!(serde_json::from_value::(serde_json::json!({ "Path": path })).is_err()); + } + + #[test] + fn placed_auto_points_resolve_once_the_source_size_is_final() { + let (mut p, clip, seq) = demo_project(); + let src = p.item(clip).and_then(|i| i.as_media()).and_then(|m| m.info.video.clone()).unwrap(); + let rate = p.sequence(seq).unwrap().settings.frame_rate; + let second = TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND)); + let sub = p.add_item("Sub", Label::Iris, ItemKind::Subclip { parent: clip, range: second, restrict_trims: true }, None); + let sub_of_sub = p.add_item("Sub 2", Label::Iris, ItemKind::Subclip { parent: sub, range: second, restrict_trims: true }, None); + assert_eq!(p.source_size(clip), Some((src.width, src.height))); + assert_eq!((p.source_size(sub), p.source_size(sub_of_sub)), (p.source_size(clip), p.source_size(clip)), "a subclip has its parent's size"); + assert_eq!(p.source_size(ItemId(987_654_321)), None); + // clips whose Motion position / anchor were never resolved (as an interchange import builds them) + let mut ids = Vec::new(); + for (n, item) in [clip, sub, sub_of_sub].into_iter().enumerate() { + let ti = p.make_track_item(item, TrackKind::Video, Tick(n as i64 * TICKS_PER_SECOND), second, rate).unwrap(); + assert!(ti.effect("motion").unwrap().vec2_at("position", Tick::ZERO).x.is_nan()); + ids.push(ti.id); + p.sequence_mut(seq).unwrap().video_tracks[0].items.push(ti); + } + let st = p.sequence(seq).unwrap().settings.clone(); + let point = |p: &Project, id: ClipId, k: &str| p.sequence(seq).unwrap().find_item(id).unwrap().1.effect("motion").unwrap().vec2_at(k, Tick::ZERO); + let (frame_centre, source_centre) = + (Vec2::new(st.width as f64 / 2.0, st.height as f64 / 2.0), Vec2::new(src.width as f64 / 2.0, src.height as f64 / 2.0)); + // sequences that are not selected are left alone + let mut untouched = p.clone(); + untouched.resolve_placed_auto_points(|_| false, |_| true); + assert!(point(&untouched, ids[0], "position").x.is_nan()); + // the source size is not final yet: the frame's points resolve, the anchor stays auto + p.resolve_placed_auto_points(|_| true, |_| false); + for id in &ids { + assert_eq!(point(&p, *id, "position"), frame_centre); + assert!(point(&p, *id, "anchor").x.is_nan() && point(&p, *id, "anchor").y.is_nan()); + } + // ...and survives a save: NaN is written as `null` and read back as NaN + let mut q = Project::from_json(&p.to_json()).unwrap(); + assert!(point(&q, ids[0], "anchor").x.is_nan()); + assert_eq!(q.to_json(), p.to_json()); + // final for the media item only: its clip and the clips of its subclips get the source centre + q.resolve_placed_auto_points(|_| true, |i| i.id == clip); + for id in &ids { + assert_eq!((point(&q, *id, "position"), point(&q, *id, "anchor")), (frame_centre, source_centre)); + } + assert!(!q.to_json().contains("null}}"), "no NaN point is left to write"); + // running it again changes nothing + let once = q.clone(); + q.resolve_placed_auto_points(|_| true, |_| true); + assert_eq!(q, once); + } + #[test] fn item_at_and_source_time() { let (mut p, clip, seq) = demo_project(); diff --git a/docs/project-files.md b/docs/project-files.md index 4458bbf2..fb68cd36 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -19,6 +19,13 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm - `project` is the serialized `filmcraft_project::Project` (bins, media clips, sequences, tracks, clips, effects, keyframes, markers). Media is referenced by path, never embedded. - Files are written compact (no indentation). A 2,000-clip project is about 1.4 MB. +- A coordinate of an effect's point parameter may be `null`. Point parameters use NaN for "auto" + (the frame centre, or the source centre for `anchor`), and JSON writes NaN as `null`; it reads back + as "auto". Placing a clip resolves these points at once. An interchange import resolves them + too, except the `anchor` of a clip whose media file was not found. That one stays "auto", and + the renderer centres it, until Link Media (`media.relink`, `media.autoRelink`) reads the file + and with it the real picture size; a file that merely comes back at its old path does not + change it. `null` anywhere else is a damaged file. ### Schema history