engine: a project saved after an interchange import opens again
Problem: a Final Cut 7 XML whose Basic Motion filter sets only `scale` makes the importer add the Motion effect with its defaults, where `position` and `anchor` are the NaN "auto" sentinel. Placing a clip resolves those with `resolve_auto_points`; the interchange import did not (any format: the clips are built with the effect defaults). serde_json writes NaN as `null`, and the reader then refused the file: "project file is damaged: invalid type: null, expected f64". The project could be saved but never opened again. Solution: - project: a point parameter's value (`ParamValue::Vec2`) reads a `null` coordinate back as NaN, so "auto" survives a save. Only there: `Vec2` itself is unchanged, and a `null` in a mask vertex or any other point is still a damaged file. - project: `Project::resolve_placed_auto_points` resolves the points of the clips in chosen sequences. Points measured in the frame use the sequence size. `anchor` is measured in the clip's source (`Project::source_size`, a subclip through its parent, as the renderer resolves it) and is resolved only for sources the caller says have their final size. - engine: the import resolves in the edits it already makes, so the undo steps are what they were. In "Import ...": everything but the anchors of file media, whose size is only what the document says so far (an EDL says nothing and the importer assumes the sequence size). In each "Link Media": the anchors of that file's clips, from the probed size. A clip whose file is not found keeps an "auto" anchor, which the renderer centres in whatever size the media has, as before this change. - engine: a later Link Media (relink.rs `apply`, behind `media.relink` and `media.autoRelink`) resolves those waiting anchors in its own undo step, when it has read the file. Nothing else resolves them: a file that merely comes back at its old path, or a forced relink that does not read the file, leaves the anchor "auto" (still rendered centred). Why no schema step and no repair on load: the file's shape is not changed. Files with `null` points were written by every build before this one, at the current schema version, so a migration (keyed on the version) cannot select them, and a bump would only make every new project unreadable to those builds. They open now and their points are "auto", exactly as in the session that wrote them; resolving them on load would mean guessing the size of media that was never linked. docs/project-files.md describes `null` points. Tests: project `point_params_keep_auto_through_json_and_no_other_point_reads_null` and `placed_auto_points_resolve_once_the_source_size_is_final`; format `auto_points_written_as_null_open_again`; engine (interchange_auto_points_tests.rs): import with the media missing, save, reopen, undo, redo; a linked file whose real size differs from the declared and the sequence size (undo of Link Media and of the import, redo); an EDL clip smaller than its sequence; import with the media missing, save, reopen, relink (anchor becomes a number, undo, redo, a second relink leaves it). All fail without the change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ada55eb625
commit
ff8aa8749d
|
|
@ -20,6 +20,10 @@ fn report_json(r: &filmcraft_interchange::Report) -> Vec<String> {
|
||||||
r.entries.iter().map(|e| if e.count > 1 { format!("{} (×{})", e.message, e.count) } else { e.message.clone() }).collect()
|
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
|
/// 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.
|
/// 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<Value> {
|
pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Result<Value> {
|
||||||
|
|
@ -46,7 +50,15 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
|
||||||
};
|
};
|
||||||
let before: std::collections::HashSet<ItemId> = s.project.items.keys().copied().collect();
|
let before: std::collections::HashSet<ItemId> = s.project.items.keys().copied().collect();
|
||||||
let name = opts.name.clone().unwrap_or_else(|| format.name().to_string());
|
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.
|
// Link media: probe each new file-backed item that exists.
|
||||||
let new_media: Vec<(ItemId, String)> = s
|
let new_media: Vec<(ItemId, String)> = s
|
||||||
.project
|
.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);
|
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(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
s.media.insert_file(id, &mpath, src);
|
s.media.insert_file(id, &mpath, src);
|
||||||
|
|
|
||||||
|
|
@ -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 = "<rate><timebase>24</timebase><ntsc>FALSE</ntsc></rate>";
|
||||||
|
|
||||||
|
/// 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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE xmeml>
|
||||||
|
<xmeml version="4">
|
||||||
|
<sequence id="sequence-1">
|
||||||
|
<name>Cut</name>
|
||||||
|
<duration>24</duration>
|
||||||
|
{RATE}
|
||||||
|
<media>
|
||||||
|
<video>
|
||||||
|
<format><samplecharacteristics>{RATE}<width>1920</width><height>1080</height></samplecharacteristics></format>
|
||||||
|
<track>
|
||||||
|
<clipitem id="clipitem-1">
|
||||||
|
<name>shot</name>
|
||||||
|
{RATE}
|
||||||
|
<start>0</start><end>24</end><in>0</in><out>24</out>
|
||||||
|
<file id="file-1">
|
||||||
|
<name>shot.mov</name>
|
||||||
|
<pathurl>media/shot.mov</pathurl>
|
||||||
|
{RATE}
|
||||||
|
<duration>24</duration>
|
||||||
|
<media><video><samplecharacteristics><width>3840</width><height>2160</height></samplecharacteristics></video></media>
|
||||||
|
</file>
|
||||||
|
<filter><effect>
|
||||||
|
<name>Basic Motion</name><effectid>basic</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>
|
||||||
|
<parameter><parameterid>scale</parameterid><name>Scale</name><value>50</value></parameter>
|
||||||
|
</effect></filter>
|
||||||
|
</clipitem>
|
||||||
|
</track>
|
||||||
|
</video>
|
||||||
|
</media>
|
||||||
|
</sequence>
|
||||||
|
</xmeml>
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
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::<String>::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::<String>::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::<String>::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);
|
||||||
|
}
|
||||||
|
|
@ -886,6 +886,8 @@ mod file_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod image_sequence_tests;
|
mod image_sequence_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod interchange_auto_points_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod keyboard_tests;
|
mod keyboard_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod masks_tests;
|
mod masks_tests;
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,12 @@ fn apply(s: &mut Session, plans: Vec<Plan>, align_timecode: bool, label: &str) -
|
||||||
m.info = info.clone();
|
m.info = info.clone();
|
||||||
}
|
}
|
||||||
proj.shift_media_time(p.item, shift);
|
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(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
|
||||||
|
|
@ -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]
|
#[test]
|
||||||
fn envelope_has_explicit_schema_version() {
|
fn envelope_has_explicit_schema_version() {
|
||||||
let v: Value = serde_json::from_slice(&encode(&Project::new("x"), true)).unwrap();
|
let v: Value = serde_json::from_slice(&encode(&Project::new("x"), true)).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ use serde::{Deserialize, Serialize};
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum ParamValue {
|
pub enum ParamValue {
|
||||||
Float(f64),
|
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]),
|
Color([f32; 4]),
|
||||||
Bool(bool),
|
Bool(bool),
|
||||||
Choice(u32),
|
Choice(u32),
|
||||||
|
|
@ -22,6 +26,22 @@ pub enum ParamValue {
|
||||||
Path(crate::mask::MaskPath),
|
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<Vec2, D::Error> {
|
||||||
|
fn nan_if_null<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||||
|
Ok(Option::<f64>::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 {
|
impl ParamValue {
|
||||||
pub fn as_f64(&self) -> Option<f64> {
|
pub fn as_f64(&self) -> Option<f64> {
|
||||||
match self {
|
match self {
|
||||||
|
|
|
||||||
|
|
@ -1335,9 +1335,15 @@ impl Project {
|
||||||
|
|
||||||
/// Resolve NaN "auto" point defaults (frame centre / source centre) in an effect instance.
|
/// 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)) {
|
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() {
|
for (k, p) in e.params.iter_mut() {
|
||||||
if let ParamValue::Vec2(v) = &mut p.value {
|
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 let Some((fx, fy)) = effect::auto_point(&e.effect, k) {
|
||||||
if v.x.is_nan() {
|
if v.x.is_nan() {
|
||||||
v.x = w as f64 * fx;
|
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;
|
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<ItemId> = 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<ClipId, Option<(u32, u32)>> = 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -1401,6 +1480,78 @@ mod tests {
|
||||||
assert!(q.sequence(seq).unwrap().check().is_ok());
|
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::<ParamValue>(&json).unwrap().as_vec2().unwrap();
|
||||||
|
assert!(v.x.is_nan() && v.y == 540.0);
|
||||||
|
let both = serde_json::from_str::<ParamValue>(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::<ParamValue>(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::<ParamValue>(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::<Vec2>(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::<MaskPath>(path.clone()).is_ok());
|
||||||
|
path["vertices"][0]["p"]["x"] = serde_json::Value::Null;
|
||||||
|
assert!(serde_json::from_value::<MaskPath>(path.clone()).is_err());
|
||||||
|
assert!(serde_json::from_value::<ParamValue>(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]
|
#[test]
|
||||||
fn item_at_and_source_time() {
|
fn item_at_and_source_time() {
|
||||||
let (mut p, clip, seq) = demo_project();
|
let (mut p, clip, seq) = demo_project();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
- `project` is the serialized `filmcraft_project::Project` (bins, media clips, sequences, tracks,
|
||||||
clips, effects, keyframes, markers). Media is referenced by path, never embedded.
|
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.
|
- 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
|
### Schema history
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue