diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index b463e79ea..3aa429a53 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -134,6 +134,7 @@ configuration from these Apache-2.0 projects: | Asset | Author | Source | Licence | |---|---|---|---| | `crates/engine/src/tests/fixtures/object-matte-0.22.ecproj` | lisn0 | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 | +| `crates/engine/src/tests/fixtures/tracked-0.22.ecproj` | arnav-hiwarkar | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 | ## First-party brand marks diff --git a/crates/aep/src/container.rs b/crates/aep/src/container.rs index 531d74843..77511fcf1 100644 --- a/crates/aep/src/container.rs +++ b/crates/aep/src/container.rs @@ -44,7 +44,7 @@ pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM}; pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk}; pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue}; pub use error::AepError; -pub use footage::{Footage, SequenceInfo}; +pub use footage::{Footage, PsdLayer, SequenceInfo}; pub use header::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Quality, Rational, Renderer}; pub use keys::{ExpressionFlags, Interpolation, KeyEase, KeyframeKind, SpatialKey}; pub use masks::{Mask, MaskAtom, MaskMode, PathVertex, ShapePath}; diff --git a/crates/aep/src/container/footage.rs b/crates/aep/src/container/footage.rs index da0369d64..8fca7c27b 100644 --- a/crates/aep/src/container/footage.rs +++ b/crates/aep/src/container/footage.rs @@ -1,7 +1,7 @@ //! Item ids and labels, footage items (solids and files), precomp linkage, and the name and size //! of a layer's source. -use crate::container::bytes::{be_f32, be_f64, be_u32, cstr, le_u32, text}; +use crate::container::bytes::{be_f32, be_f64, be_i32, be_u32, cstr, le_u32, text, until_nul}; use crate::container::chunk::Chunk; use crate::container::header::Rational; use crate::container::typed::{Item, ItemKind, Layer, Project}; @@ -45,6 +45,59 @@ pub struct Footage { /// True when the alias points at a folder (`"target_is_folder":true`): an image sequence is ONE /// item whose alias is its folder and whose `opti` holds the first file name. pub is_sequence: bool, + /// The one layer of a Photoshop file the item shows; `None` for the merged image and for + /// any other footage. + pub psd_layer: Option, +} + +/// One layer of a Photoshop file shown by a footage item, from its `opti` (tag `8BPS`, +/// docs/aep-format.md 3.3): the layer record's index as a big-endian `i32` at byte 14 (-1 for the +/// merged image), the document's sides as little-endian `u32` at 32 and 36, its bounds top, left, +/// bottom, right as little-endian `i32` at 78, 82, 86 and 90, and its name (UTF-8, NUL +/// terminated) from byte 344. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PsdLayer { + /// The layer record's position in the file (bottom of the stack = 0). + pub index: u32, + pub name: String, + /// Top, left, bottom, right in document pixels; all 0 for a layer without pixels (an + /// adjustment layer). + pub bounds: [i32; 4], + /// The document's two sides (bytes 32 and 36; which one is the width is not sampled: the one + /// file read has a square document). 0 when not stored. + pub document: [u32; 2], +} + +/// Most bytes of a Photoshop layer name read. +const MAX_NAME: usize = 1024; + +impl PsdLayer { + /// The bounds' width and height; `None` when they are empty. + pub fn size(&self) -> Option<(u32, u32)> { + let [top, left, bottom, right] = self.bounds; + let w = u32::try_from(i64::from(right) - i64::from(left)).ok().filter(|w| *w > 0)?; + let h = u32::try_from(i64::from(bottom) - i64::from(top)).ok().filter(|h| *h > 0)?; + Some((w, h)) + } + + /// Whether the document is `size` big, its sides in either order. + pub fn document_is(&self, size: (u32, u32)) -> bool { + let [a, b] = self.document; + a > 0 && b > 0 && ((a, b) == size || (b, a) == size) + } + + /// The layer an `opti` names; `None` for another tag, the merged image or a cut record. + fn read(opti: &[u8]) -> Option { + if opti.get(..4) != Some(&b"8BPS"[..]) { + return None; + } + let index = u32::try_from(be_i32(opti, 14)?).ok()?; + let le_i32 = |at| le_u32(opti, at).map(|v| i32::from_le_bytes(v.to_le_bytes())); + // (After Effects writes 602 bytes; a longer hostile name is cut.) + let name = opti.get(344..).map(|b| text(until_nul(b.get(..MAX_NAME).unwrap_or(b)))).unwrap_or_default(); + let document = [le_u32(opti, 32).unwrap_or(0), le_u32(opti, 36).unwrap_or(0)]; + Some(Self { index, name, bounds: [le_i32(78)?, le_i32(82)?, le_i32(86)?, le_i32(90)?], document }) + } } impl Footage { @@ -330,6 +383,7 @@ impl<'f, 'a> Item<'f, 'a> { file_size: be_u32(sspc, 208).unwrap_or(0), audio_sample_rate: be_f64(sspc, 160).filter(|r| r.is_finite() && *r > 0.0), is_sequence, + psd_layer: PsdLayer::read(opti), }) } @@ -370,7 +424,46 @@ impl<'f, 'a> Project<'f, 'a> { #[cfg(test)] mod tests { - use super::json_string; + use super::{PsdLayer, json_string}; + + /// A 602-byte Photoshop `opti` naming layer `index` with `bounds` and `name`. + fn opti(index: i32, bounds: [i32; 4], name: &[u8]) -> Vec { + let mut b = vec![0u8; 602]; + b[..4].copy_from_slice(b"8BPS"); + b[14..18].copy_from_slice(&index.to_be_bytes()); + for (i, v) in bounds.iter().enumerate() { + b[78 + 4 * i..82 + 4 * i].copy_from_slice(&v.to_le_bytes()); + } + b[344..344 + name.len()].copy_from_slice(name); + b + } + + #[test] + fn a_photoshop_layer_reads_its_index_bounds_and_name() { + let l = PsdLayer::read(&opti(7, [749, 800, 764, 848], "tést".as_bytes())).unwrap(); + assert_eq!((l.index, l.name.as_str(), l.bounds, l.size()), (7, "tést", [749, 800, 764, 848], Some((48, 15)))); + // The merged image, another tag, a cut record: no layer. + assert_eq!(PsdLayer::read(&opti(-1, [0; 4], b"x")), None); + let mut other = opti(1, [0; 4], b"x"); + other[..4].copy_from_slice(b"Soli"); + assert_eq!(PsdLayer::read(&other), None); + assert_eq!(PsdLayer::read(&opti(1, [0; 4], b"x")[..80]), None); + // A cut name, and no name at all. + assert_eq!(PsdLayer::read(&opti(1, [0; 4], b"x")[..345]).unwrap().name, "x"); + assert_eq!(PsdLayer::read(&opti(1, [0; 4], b"x")[..300]).unwrap().name, ""); + // A layer without pixels has no size; extreme bounds do not overflow. + assert_eq!(PsdLayer::read(&opti(2, [0; 4], b"")).unwrap().size(), None); + assert_eq!(PsdLayer::read(&opti(2, [i32::MIN, i32::MIN, i32::MAX, i32::MAX], b"")).unwrap().size(), Some((u32::MAX, u32::MAX))); + assert_eq!(PsdLayer::read(&opti(2, [i32::MAX, 0, i32::MIN, 1], b"")).unwrap().size(), None); + // The document's size, its sides in either order; none when not stored. + let mut doc = opti(0, [0, 0, 1000, 800], b""); + doc[32..36].copy_from_slice(&800u32.to_le_bytes()); + doc[36..40].copy_from_slice(&1000u32.to_le_bytes()); + let l = PsdLayer::read(&doc).unwrap(); + assert_eq!(l.document, [800, 1000]); + assert!(l.document_is((800, 1000)) && l.document_is((1000, 800)) && !l.document_is((800, 800))); + assert!(!PsdLayer::read(&opti(0, [0; 4], b"")).unwrap().document_is((0, 0))); + } #[test] fn reads_a_string_with_escapes() { diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 7d5b902f1..f6ac69cda 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -12,7 +12,8 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::Value; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ - AutoOrient, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, MatteKind, Project, Quality, Solid, TrackMatte, + AutoOrient, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, MatteKind, Project, Quality, Solid, SourceLayer, + TrackMatte, }; use effectcraft_time::Tick; @@ -216,7 +217,17 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ let aep_id = item.id(); // An item without a stored name shows its file's name, a sequence `shot_[0101-0103].exr` // (126, 603-607). - let name = if name.is_empty() { default_footage_name(item, &f).unwrap_or_else(|| "Footage".to_string()) } else { name }; + // One layer of a Photoshop file is `/`, as EffectCraft's own Photoshop import + // names it (#3087). + let name = if name.is_empty() { + let file = default_footage_name(item, &f).unwrap_or_else(|| "Footage".to_string()); + match &f.psd_layer { + Some(l) if !l.name.is_empty() => format!("{}/{file}", l.name), + _ => file, + } + } else { + name + }; if f.is_solid { // A null object's placeholder solid is not an item here. if aep_id.is_some_and(|i| null_only.contains(&i)) { @@ -299,6 +310,7 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_ missing: !present, placed_page, sequence, + layer: source_layer(&f, &name, &mut cx.col), ..Footage::default() }; // The native frame rate and the stored duration of sequences and clips (352, 603, 606). @@ -338,6 +350,34 @@ fn is_placed_page_path(path: &str) -> bool { matches!(ext.as_deref(), Some("pdf" | "ai")) } +/// The Photoshop layer a footage item shows (#3087): at its own bounds when the item is as big +/// as they are (Retain Layer Sizes), else on the document. A layer without pixels (an adjustment +/// layer) keeps the merged image and is reported. So is a layer as big as the document but not at +/// its corner (the item's size fits both framings; its bounds are used), and an item as big as +/// neither (the document is used). +fn source_layer(f: &crate::Footage, name: &str, col: &mut Collector) -> Option { + let l = f.psd_layer.as_ref()?; + let Some(size) = l.size() else { + col.lose(LossCategory::UnsupportedItems, format!("footage '{name}': a Photoshop layer without pixels (an adjustment layer), the merged image used")); + return None; + }; + let layer_size = size == (f.width, f.height); + if !layer_size && l.document.iter().all(|side| *side > 0) && !l.document_is((f.width, f.height)) { + col.lose( + LossCategory::UnsupportedItems, + format!("footage '{name}': {} x {} is neither the Photoshop layer's size nor the document's, shown on the document", f.width, f.height), + ); + } + let [top, left, ..] = l.bounds; + if layer_size && (top, left) != (0, 0) && l.document_is(size) { + col.lose( + LossCategory::UnsupportedItems, + format!("footage '{name}': a Photoshop layer as big as the document but at ({left}, {top}), shown at its own bounds (Retain Layer Sizes assumed)"), + ); + } + Some(SourceLayer { index: l.index, name: l.name.clone(), layer_size, ..SourceLayer::default() }) +} + /// The name After Effects shows for a footage item that stores none: the file's name, or for a /// sequence the name with the frame range (`shot_[0101-0103].exr`). fn default_footage_name(item: &AepItem<'_, '_>, f: &crate::Footage) -> Option { diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index 7675f3f09..e52421dc0 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -56,7 +56,8 @@ pub enum LossCategory { /// Layers that could not be imported for another reason (unknown layer type, missing or /// unsupported source, precomposition loop). UnsupportedLayers, - /// Project items of a kind that is not imported. + /// Project items of a kind that is not imported, and items made from a Photoshop layer + /// without pixels (they show the merged image). UnsupportedItems, /// Data that could not be read (damaged headers, unreadable keyframes). DamagedData, diff --git a/crates/aep/src/lib.rs b/crates/aep/src/lib.rs index 4bcdbab9e..cb4b9ff60 100644 --- a/crates/aep/src/lib.rs +++ b/crates/aep/src/lib.rs @@ -20,8 +20,8 @@ pub use container::{ GradientType, HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer, LayerHeader, LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS, Orientation, OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode, - Property, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun, - TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace, - curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, - write, write_to, + Property, PsdLayer, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, + StyleRun, TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, + WorkingSpace, curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, + read_aepx_with_limits, style_map, write, write_to, }; diff --git a/crates/aep/tests/it/container/robustness.rs b/crates/aep/tests/it/container/robustness.rs index 5c3422a10..0c887b8c4 100644 --- a/crates/aep/tests/it/container/robustness.rs +++ b/crates/aep/tests/it/container/robustness.rs @@ -720,6 +720,7 @@ fn extreme_values_in_the_batch_3_fields() { file_size: 0, audio_sample_rate: None, is_sequence: true, + psd_layer: None, }; for (num, den, rate) in [(3i64, 30i64, 30u32), (3, 0, 30), (-3, 30, 30), (i64::MAX, 1, u32::MAX), (i64::MIN, -1, u32::MAX), (1, i64::MAX, 1), (0, 1, 0)] { let f = Footage { duration: Some(Rational { num, den }), native_frame_rate: rate, ..base.clone() }; diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index a31a61679..d1c030401 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -843,6 +843,92 @@ fn a_default_state_colour_record_holds_the_value() { assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default"); } +/// The project with its png item made one layer of a Photoshop file `art.psd`: the `opti` of +/// docs/aep-format.md 3.3 (tag `8BPS`, the layer index at 14, a 1000 x 1000 document at 32 and 36, +/// its bounds at 78-94, its name at 344) and the item `size` big. +fn as_psd_layer(index: i32, bounds: [i32; 4], name: &str, size: (u32, u32)) -> Vec { + let mut opti = vec![0u8; 602]; + opti[..4].copy_from_slice(b"8BPS"); + opti[14..18].copy_from_slice(&index.to_be_bytes()); + opti[32..36].copy_from_slice(&1000u32.to_le_bytes()); + opti[36..40].copy_from_slice(&1000u32.to_le_bytes()); + for (i, v) in bounds.iter().enumerate() { + opti[78 + 4 * i..82 + 4 * i].copy_from_slice(&v.to_le_bytes()); + } + opti[344..344 + name.len()].copy_from_slice(name.as_bytes()); + let alias = br#"{"fullpath":"C:\\x\\art.psd","platform":1,"target_is_folder":false}"#.to_vec(); + mutate(&mut |c| { + if !c.is_list(b"Pin ") { + return; + } + let Body::List { children, .. } = &mut c.body else { return }; + if !children.iter().any(|k| k.is(b"opti") && k.bytes().is_some_and(|d| d.get(..4) == Some(&b"png!"[..]))) { + return; + } + // No sequence naming between the alias and the `opti`. + children.retain(|k| k.is(b"sspc") || k.is(b"opti") || k.is_list(b"Als2")); + for k in children.iter_mut() { + if k.is(b"opti") { + k.body = Body::Data(opti.clone().into()); + } else if k.is(b"sspc") { + set_data(k, 30, &size.0.to_be_bytes()); + set_data(k, 34, &size.1.to_be_bytes()); + } + } + visit(children, &mut |k| { + if k.is(b"alas") { + k.body = Body::Data(alias.clone().into()); + } + }); + }) +} + +/// #3087: an item made from one layer of a Photoshop file shows that layer, named +/// `/`; it showed the whole document, named after the file. Item as big as the layer: +/// Retain Layer Sizes; as big as the document: the layer on the document. +#[test] +fn a_photoshop_layer_item_shows_its_layer() { + for (size, layer_size) in [((48, 15), true), ((1000, 1000), false)] { + let (p, r) = run(&as_psd_layer(7, [749, 800, 764, 848], "Tëst", size), true); + r.unwrap(); + let (name, f) = p.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((i.name.clone(), f.clone())) } else { None }).unwrap(); + assert_eq!(name, "Tëst/art.psd"); + let l = f.layer.expect("the layer"); + assert_eq!((l.index, l.name.as_str(), l.layer_size, (f.width, f.height)), (7, "Tëst", layer_size, size)); + } +} + +/// A layer as big as the document but off its corner fits both framings: shown at its own bounds +/// and reported. One at the corner, or bigger than the document, reads one way and is not. An item +/// as big as neither is shown on the document and reported. +#[test] +fn a_photoshop_layer_as_big_as_the_document_off_its_corner_is_reported() { + let framing = |bounds, size| { + let (p, r) = run(&as_psd_layer(3, bounds, "Moved", size), true); + let found = losses(&r.unwrap(), LossCategory::UnsupportedItems); + let said = |what: &str| found.iter().any(|d| d.contains("'Moved/art.psd'") && d.contains(what)); + (the_footage(&p).layer.map(|l| l.layer_size), said("as big as the document"), said("neither the Photoshop layer's size")) + }; + assert_eq!(framing([5, 10, 1005, 1010], (1000, 1000)), (Some(true), true, false)); + assert_eq!(framing([0, 0, 1000, 1000], (1000, 1000)), (Some(true), false, false)); + assert_eq!(framing([9, -346, 1083, 1086], (1432, 1074)), (Some(true), false, false)); + assert_eq!(framing([749, 800, 764, 848], (1000, 1000)), (Some(false), false, false)); + // An item as big as neither the layer nor the document: shown on the document, reported. + assert_eq!(framing([5, 10, 1005, 1010], (800, 800)), (Some(false), false, true)); +} + +/// #3087: a Photoshop layer without pixels (an adjustment layer) keeps the merged image and is +/// reported; the merged image itself (index -1) is no layer. +#[test] +fn a_photoshop_layer_without_pixels_keeps_the_merged_image() { + let (p, r) = run(&as_psd_layer(4, [0; 4], "Levels 1", (1000, 1000)), true); + let found = losses(&r.unwrap(), LossCategory::UnsupportedItems); + assert!(found.iter().any(|d| d.contains("'Levels 1/art.psd'") && d.contains("without pixels")), "{found:?}"); + assert_eq!(the_footage(&p).layer, None); + let (p, _) = run(&as_psd_layer(-1, [0; 4], "", (1000, 1000)), true); + assert_eq!(the_footage(&p).layer, None); +} + /// #3087: an expression on Orientation arrives with the stored value, as on any property; it was /// dropped together with the value and reported as "not decoded". #[test] diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 532bb28c9..9105f4587 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -129,7 +129,7 @@ pub use sim2::{ CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan, wave_world_plan, }; -pub use utility::{Lut, load_lut}; +pub use utility::{Lut, check_lut_files, load_lut, lut_file, lut_generation, lut_params}; pub use vr::{equi_dir as vr_equi_dir, rotation as vr_rotation}; /// Effect categories in Effects & Presets order. diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index f0275eab1..9d5fec491 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -2,7 +2,8 @@ //! lookup tables (.cube). use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use effectcraft_color::luminance; use effectcraft_keyframe::Value; @@ -330,28 +331,165 @@ impl Lut { } } -type LutCache = Mutex>>>; +/// A LUT file's size and modification time when it was read (`None`: inline text, or a file +/// that could not be described). A file whose stamp moved is read again (#3493). +type FileStamp = Option<(u64, Option)>; -/// Load (and cache) a LUT from inline `.cube` text or a file path. +/// At most this many parsed LUTs (files and inline texts) are kept, and at most +/// [`MAX_CACHED_BYTES`] of them; the least recently used go first. +const MAX_CACHED_LUTS: usize = 64; +const MAX_CACHED_BYTES: usize = 512 << 20; + +/// At most this many LUT files are followed by [`check_lut_files`] beyond the ones whose LUT is +/// kept: the frames made with a LUT that left the cache are still dropped when its file changes. +const MAX_FOLLOWED_FILES: usize = 1024; + +/// A parsed LUT (or the failure to parse one) and what it was read from. +struct CachedLut { + stamp: FileStamp, + lut: Option>, + used: u64, + /// The parsed tables and the parameter text, counted against [`MAX_CACHED_BYTES`]. + bytes: usize, +} + +/// [`load_lut`]'s cache, by parameter text, and the stamp each LUT file was last read with. +#[derive(Default)] +struct LutCache { + map: HashMap, + files: HashMap, + clock: u64, + bytes: usize, +} + +impl LutCache { + /// Forget the LUT kept for `src`. + fn remove(&mut self, src: &str) { + if let Some(e) = self.map.remove(src) { + self.bytes = self.bytes.saturating_sub(e.bytes); + } + } + + /// Note that the file at `path` was read with `stamp`: a different stamp than the last read's + /// means it changed on disk, and frames made before may show the old LUT. + fn read_file(&mut self, path: &str, stamp: FileStamp) { + if self.files.insert(path.to_string(), stamp).is_some_and(|old| old != stamp) { + GENERATION.fetch_add(1, Ordering::AcqRel); + } + if self.files.len() > MAX_FOLLOWED_FILES { + let map = &self.map; + self.files.retain(|p, _| p.as_str() == path || map.keys().any(|src| lut_file(src) == Some(p.as_str()))); + } + } + + /// Keep at most [`MAX_CACHED_LUTS`] LUTs and [`MAX_CACHED_BYTES`], always the one for `keep`. + fn evict(&mut self, keep: &str) { + while self.map.len() > MAX_CACHED_LUTS || (self.bytes > MAX_CACHED_BYTES && self.map.len() > 1) { + let Some(oldest) = self.map.iter().filter(|(k, _)| k.as_str() != keep).min_by_key(|(_, e)| e.used).map(|(k, _)| k.clone()) else { break }; + self.remove(&oldest); + } + } +} + +/// How often a LUT file was found changed on disk ([`lut_generation`]). +static GENERATION: AtomicU64 = AtomicU64::new(0); + +fn lut_cache() -> std::sync::MutexGuard<'static, LutCache> { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner) +} + +/// The stamp of the file at `path` now: one `metadata` call. +fn file_stamp(path: &str) -> FileStamp { + std::fs::metadata(path).ok().map(|md| (md.len(), md.modified().ok())) +} + +/// The parameters (paths in the effect's group) of `effect` that [`load_lut`] reads. +pub fn lut_params(effect: &str) -> &'static [&'static str] { + match effect { + "ec.utility.applylut" => &["lut"], + "ec.color.lumetri" => &["basicCorrection/inputLutFile", "creative/lookFile"], + _ => &[], + } +} + +/// The file a LUT parameter names; `None` for inline `.cube` text or an empty parameter. +pub fn lut_file(src: &str) -> Option<&str> { + // `.cube` text always spans lines (a size header, then data rows); a path never does, even one + // whose file name contains `LUT_` (#3454). Trimmed, so a trailing newline is still a path. + let path = src.trim(); + (!path.is_empty() && !path.contains('\n')).then_some(path) +} + +/// Changes each time a LUT file [`load_lut`] read is found changed on disk (by [`load_lut`] +/// itself or [`check_lut_files`]): frames made before then may show the old LUT. +pub fn lut_generation() -> u64 { + GENERATION.load(Ordering::Acquire) +} + +/// Look at every LUT file read (one `metadata` call each) and forget the LUTs of the ones that +/// changed on disk since, so the next [`load_lut`] reads them again. Returns [`lut_generation`]. +pub fn check_lut_files() -> u64 { + let files: Vec<(String, FileStamp)> = lut_cache().files.iter().map(|(p, s)| (p.clone(), *s)).collect(); + // (stamped without the lock: a file on a slow share must not hold up renders) + let changed: Vec<(String, FileStamp, FileStamp)> = + files.into_iter().filter_map(|(path, old)| Some(file_stamp(&path)).filter(|now| *now != old).map(|now| (path, old, now))).collect(); + if !changed.is_empty() { + let mut c = lut_cache(); + for (path, old, now) in changed { + // (a render that read the file meanwhile has noted its new stamp already) + if c.files.get(&path) == Some(&old) { + let stale: Vec = c.map.keys().filter(|src| lut_file(src) == Some(path.as_str())).cloned().collect(); + for src in stale { + c.remove(&src); + } + c.read_file(&path, now); + } + } + } + lut_generation() +} + +/// Load (and cache) a LUT from inline `.cube` text or a file path. A file is stamped (one +/// `metadata` call) on every load and read again once its size or modification time moved, so a +/// file edited, created, deleted or repaired on disk is followed; inline text is its own content. +/// An edit that keeps both the size and the modification time can't be told apart. pub fn load_lut(src: &str) -> Option> { if src.trim().is_empty() { return None; } - static CACHE: OnceLock = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - if let Ok(m) = cache.lock() - && let Some(v) = m.get(src) + let file = lut_file(src); + let stamp = file.and_then(file_stamp); { - return v.clone(); + let mut c = lut_cache(); + c.clock += 1; + let now = c.clock; + if let Some(e) = c.map.get_mut(src) + && e.stamp == stamp + { + e.used = now; + return e.lut.clone(); + } } - // `.cube` text always spans lines (a size header, then data rows); a path never does, even one - // whose file name contains `LUT_` (#3454). Trimmed, as the path is, so a trailing newline is a path. - let inline = src.trim().contains('\n'); - let parsed = if inline { parse_cube(src) } else { std::fs::read_to_string(src.trim()).ok().and_then(|t| parse_cube(&t)) }.map(Arc::new); - if let Ok(mut m) = cache.lock() { - m.insert(src.to_string(), parsed.clone()); + // (read and parsed without the lock; a file written after it was stamped has a newer stamp, + // and is read again next time) + let lut = match file { + Some(path) => std::fs::read_to_string(path).ok().and_then(|t| parse_cube(&t)), + None => parse_cube(src), } - parsed + .map(Arc::new); + let tables = lut.as_ref().map_or(0, |l| l.d1.len().saturating_add(l.d3.len()).saturating_mul(std::mem::size_of::<[f32; 3]>())); + let bytes = tables.saturating_add(src.len()); + let mut c = lut_cache(); + if let Some(path) = file { + c.read_file(path, stamp); + } + c.remove(src); + let used = c.clock; + c.map.insert(src.to_string(), CachedLut { stamp, lut: lut.clone(), used, bytes }); + c.bytes = c.bytes.saturating_add(bytes); + c.evict(src); + lut } fn apply_lut(ctx: &EffectCtx, mut b: Buf) -> Buf { @@ -500,6 +638,113 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// #3493: a LUT file was read once per process; an edited or newly created file never loaded. + #[test] + fn a_lut_file_is_read_again_once_it_changes() { + const BLACK: &str = "LUT_1D_SIZE 2\n0 0 0\n0 0 0\n"; + const WHITE: &str = "LUT_1D_SIZE 2\n1 1 1\n1 1 1\n"; + let dir = std::env::temp_dir().join(format!("ec-fx-3493-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let write = |name: &str, text: &str, secs: u64| { + let path = dir.join(name); + std::fs::write(&path, text).unwrap(); + let f = std::fs::File::options().write(true).open(&path).unwrap(); + f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)).unwrap(); + path.to_string_lossy().into_owned() + }; + let at = |src: &str| load_lut(src).map(|l| l.apply([0.5; 3])); + let edited = write("edited.cube", BLACK, 1_000); + assert_eq!(at(&edited), Some([0.0; 3])); + write("edited.cube", WHITE, 2_000); + assert_eq!(at(&edited), Some([1.0; 3]), "an edited file, same size"); + let created = dir.join("created.cube").to_string_lossy().into_owned(); + assert_eq!(at(&created), None); + write("created.cube", WHITE, 1_000); + assert_eq!(at(&created), Some([1.0; 3]), "a file created after a failed load"); + let repaired = write("repaired.cube", "not a lut", 1_000); + assert_eq!(at(&repaired), None); + write("repaired.cube", WHITE, 2_000); + assert_eq!(at(&repaired), Some([1.0; 3]), "a repaired file"); + std::fs::remove_file(&repaired).unwrap(); + assert_eq!(at(&repaired), None, "a deleted file"); + // Inline text is its own content: the same text is the same LUT. + let inline = load_lut(BLACK).unwrap(); + assert!(Arc::ptr_eq(&inline, &load_lut(BLACK).unwrap())); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// A file that changed is noticed without a render loading it, and the generation that tells + /// frames made before it apart moves. + #[test] + fn check_lut_files_forgets_a_changed_file() { + let dir = std::env::temp_dir().join(format!("ec-fx-3493-check-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("grade.cube"); + std::fs::write(&path, identity_cube(2)).unwrap(); + let src = path.to_string_lossy().into_owned(); + assert!(load_lut(&src).is_some()); + let before = check_lut_files(); + assert!(lut_cache().map.contains_key(&src), "an unchanged file stays"); + std::fs::write(&path, identity_cube(3)).unwrap(); + assert!(check_lut_files() > before); + assert!(!lut_cache().map.contains_key(&src), "a changed file is read again"); + assert_eq!(load_lut(&src).map(|l| l.n3), Some(3)); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// The cache keeps at most [`MAX_CACHED_LUTS`] LUTs, the least recently used going first. + #[test] + fn the_lut_cache_is_bounded() { + // (text no other test loads: a load elsewhere would make it recently used) + let oldest = format!("TITLE \"bounded first\"\n{}", identity_cube(2)); + let first = load_lut(&oldest).unwrap(); + for i in 0..MAX_CACHED_LUTS + 8 { + let text = format!("TITLE \"bounded {i}\"\n{}", identity_cube(2)); + assert!(load_lut(&text).is_some()); + assert!(lut_cache().map.len() <= MAX_CACHED_LUTS); + } + assert!(!Arc::ptr_eq(&first, &load_lut(&oldest).unwrap()), "the oldest was let go"); + let c = lut_cache(); + assert_eq!(c.bytes, c.map.values().map(|e| e.bytes).sum::(), "the bytes kept add up"); + assert!(c.bytes <= MAX_CACHED_BYTES); + } + + /// A LUT file whose LUT left the cache is still followed: its frames are dropped when it + /// changes, though no render loads it again first. + #[test] + fn a_file_whose_lut_left_the_cache_is_still_followed() { + let dir = std::env::temp_dir().join(format!("ec-fx-3493-evicted-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("old.cube"); + std::fs::write(&path, identity_cube(2)).unwrap(); + let src = path.to_string_lossy().into_owned(); + assert!(load_lut(&src).is_some()); + for i in 0..MAX_CACHED_LUTS + 8 { + assert!(load_lut(&format!("TITLE \"evict {i}\"\n{}", identity_cube(2))).is_some()); + } + assert!(!lut_cache().map.contains_key(&src), "its LUT left the cache"); + std::fs::write(&path, identity_cube(3)).unwrap(); + check_lut_files(); + assert_eq!(lut_cache().files.get(&src), Some(&file_stamp(&src)), "the change was noticed"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Past [`MAX_FOLLOWED_FILES`], the file just read is still followed (a cache of its own: + /// the shared one's followed files belong to the other tests too). + #[test] + fn the_file_just_read_stays_followed_past_the_limit() { + let mut c = LutCache::default(); + for i in 0..MAX_FOLLOWED_FILES + 8 { + let path = format!("/followed/{i}.cube"); + c.read_file(&path, Some((1, None))); + assert!(c.files.contains_key(&path), "{i}: the file just read"); + } + assert!(c.files.len() <= MAX_FOLLOWED_FILES, "the others no longer cached are let go"); + } + #[test] fn one_d_lut_with_domain() { let lut = parse_cube("LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 2 2 2\n1 1 1\n0 0 0\n").unwrap(); diff --git a/crates/engine/src/analysis/camera_track.rs b/crates/engine/src/analysis/camera_track.rs index 29961f533..378f42dd1 100644 --- a/crates/engine/src/analysis/camera_track.rs +++ b/crates/engine/src/analysis/camera_track.rs @@ -124,8 +124,17 @@ fn str_of(g: &PropGroup, m: &str) -> String { /// What a tracker's tracks are made from: the layer's frames and Detailed Analysis. fn tracks_key(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> String { + tracks_key_of(signature(project, cid, l), g) +} + +fn tracks_key_of(signature: String, g: &PropGroup) -> String { let detailed = effectcraft_effects::warp_stab::param(&static_params(g), "advanced/detailedAnalysis").is_some_and(Value::as_bool); - format!("{}{}", signature(project, cid, l), if detailed { "+detailed" } else { "" }) + format!("{signature}{}", if detailed { "+detailed" } else { "" }) +} + +/// Whether the tracker's saved tracks are made from the layer's current frames and settings. +fn tracks_match(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> bool { + crate::warp::key_matches(project, cid, l, &str_of(g, TRACKS_KEY), |s| tracks_key_of(s, g)) } /// Parameters of an instance from static values (the tracker's settings are not animated). @@ -165,7 +174,7 @@ pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id)); for g in fx.groups().filter(|g| is_tracker(g)) { let tracked = !str_of(g, TRACKS).is_empty(); - if tracked && str_of(g, TRACKS_KEY) != tracks_key(after, *cid, l, g) { + if tracked && !tracks_match(after, *cid, l, g) { clears.push((*cid, l.id, g.uid, true)); todo.push((*cid, l.id, g.uid)); } else if tracked && str_of(g, SOLVE_KEY) != ct::solve_key(&ct::settings(&static_params(g))) { @@ -325,7 +334,7 @@ impl Session { let key = tracks_key(&self.project, comp, l, g); let settings = ct::settings(¶ms); let solve_key = ct::solve_key(&settings); - let tracks = ct::tracks(¶ms).filter(|_| str_of(g, TRACKS_KEY) == key); + let tracks = ct::tracks(¶ms).filter(|_| tracks_match(&self.project, comp, l, g)); let n = times.len(); let work = CamWork { project: self.project.clone(), diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index 246c8899c..89d3e1609 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -222,12 +222,6 @@ pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { input_key_with_signature(crate::warp::signature(p, comp, layer), layer, uid) } -/// Recognize the input key serialized before 0.23 without re-keying its frozen matte. -fn input_key_before_placed_page(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> Option { - let signature = crate::warp::signature_before_placed_page(p, comp, layer)?; - Some(input_key_with_signature(signature, layer, uid)) -} - fn input_key_with_signature(mut s: String, layer: &Layer, uid: Uid) -> String { if let Some(m) = layer.masks() { s.push_str(&format!("{m:?}")); @@ -258,7 +252,7 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid)); let saved_key = str_of(g, INPUT_KEY); // Keep a matching old key: the frozen cache itself also hashes that key. - if saved_key != key && input_key_before_placed_page(after, *cid, l, g.uid).as_deref() != Some(saved_key.as_str()) { + if !crate::warp::key_matches(after, *cid, l, &saved_key, |s| input_key_with_signature(s, l, g.uid)) { set.push((*cid, l.id, g.uid, key)); todo.push((*cid, l.id, g.uid)); } else if old.is_none_or(|o| guard_of(o) != guard_of(g)) { diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 0ab576d2c..20b88d45d 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex}; use effectcraft_effects::warp_stab::{self, ANALYSIS, ANALYSIS_KEY}; use effectcraft_keyframe::Value; -use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, PropGroup, Uid}; +use effectcraft_project::{Footage, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, PropGroup, Uid}; use effectcraft_raster::Image; use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer}; use effectcraft_time::Tick; @@ -112,7 +112,7 @@ fn fnv(s: &str) -> u64 { pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String { let src = match &layer.source { LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) { - Some(ItemKind::Footage(f)) => format!("{f:?}"), + Some(ItemKind::Footage(f)) => footage_text(f, true), Some(ItemKind::Solid(s)) => format!("{s:?}"), Some(ItemKind::Comp(c)) => format!("{}x{}@{:?}/{:?}", c.width, c.height, c.frame_rate, c.duration), _ => String::new(), @@ -122,14 +122,108 @@ pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String { signature_with_source(p, comp, layer, &src) } +/// Whether `saved`, a key an analysis of `layer` was saved with, is for the layer's current frames. +/// `key` makes such a key from a signature. A key made from the signature an earlier release wrote +/// for the same frames matches too: keep it rather than rewrite it (analysing writes today's). +pub(crate) fn key_matches(p: &Project, comp: ItemId, layer: &Layer, saved: &str, key: impl Fn(String) -> String) -> bool { + key(signature(p, comp, layer)) == saved || signature_before_placed_page(p, comp, layer).is_some_and(|s| key(s) == saved) +} + /// Before `Footage::placed_page` was added in 0.23, the persisted signature hashed the -/// same Debug representation without that last field. Only ordinary footage can match it. -pub(crate) fn signature_before_placed_page(p: &Project, comp: ItemId, layer: &Layer) -> Option { +/// same footage text without that last field. Only ordinary footage can match it. +fn signature_before_placed_page(p: &Project, comp: ItemId, layer: &Layer) -> Option { let LayerSource::Footage { item } = &layer.source else { return None }; let ItemKind::Footage(footage) = &p.item(*item)?.kind else { return None }; - let src = format!("{footage:?}"); - let old = src.strip_suffix(", placed_page: false }")?; - Some(signature_with_source(p, comp, layer, &format!("{old} }}"))) + if footage.placed_page { + return None; + } + Some(signature_with_source(p, comp, layer, &footage_text(footage, false))) +} + +/// A footage item in a signature: its fields as `Debug` wrote them in 0.23, the text saved +/// analysis keys were hashed from (without `placed_page` before 0.23). It is written out here so +/// that a new `Footage` field doesn't change every signature and drop every saved analysis. +pub(crate) fn footage_text(f: &Footage, placed_page: bool) -> String { + format!("{:?}", FootageText { f, placed_page }) +} + +struct FootageText<'a> { + f: &'a Footage, + placed_page: bool, +} + +impl std::fmt::Debug for FootageText<'_> { + fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Every field is named, so a new one doesn't compile until it is decided here. A new field + // that changes the frames goes last, written only when it isn't its default, so keys saved + // before it still match. A new field that doesn't change the frames stays out. + let Footage { + path, + kind, + width, + height, + pixel_aspect, + frame_rate, + native_rate, + duration, + has_video, + has_audio, + alpha, + premul_color, + loop_count, + codec, + missing, + sequence, + color_profile, + layer, + fields, + invert_alpha, + linear_light, + preserve_rgb, + cineon, + data, + page, + alphabetical, + start_timecode, + file_timecode, + markers, + placed_page, + } = self.f; + let mut d = out.debug_struct("Footage"); + d.field("path", path) + .field("kind", kind) + .field("width", width) + .field("height", height) + .field("pixel_aspect", pixel_aspect) + .field("frame_rate", frame_rate) + .field("native_rate", native_rate) + .field("duration", duration) + .field("has_video", has_video) + .field("has_audio", has_audio) + .field("alpha", alpha) + .field("premul_color", premul_color) + .field("loop_count", loop_count) + .field("codec", codec) + .field("missing", missing) + .field("sequence", sequence) + .field("color_profile", color_profile) + .field("layer", layer) + .field("fields", fields) + .field("invert_alpha", invert_alpha) + .field("linear_light", linear_light) + .field("preserve_rgb", preserve_rgb) + .field("cineon", cineon) + .field("data", data) + .field("page", page) + .field("alphabetical", alphabetical) + .field("start_timecode", start_timecode) + .field("file_timecode", file_timecode) + .field("markers", markers); + if self.placed_page { + d.field("placed_page", placed_page); + } + d.finish() + } } fn signature_with_source(p: &Project, comp: ItemId, layer: &Layer, src: &str) -> String { @@ -169,11 +263,10 @@ pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, if !fx.groups().any(is_warp) { continue; } - let sig = signature(after, *cid, l); let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id)); for g in fx.groups().filter(|g| is_warp(g)) { let analysed = !str_of(g, ANALYSIS).is_empty(); - if analysed && str_of(g, ANALYSIS_KEY) != sig { + if analysed && !key_matches(after, *cid, l, &str_of(g, ANALYSIS_KEY), |s| s) { clear.push((*cid, l.id, g.uid)); todo.push((*cid, l.id, g.uid)); } else if !analysed && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() { diff --git a/crates/engine/src/footage.rs b/crates/engine/src/footage.rs index b921dd128..d64afcaf7 100644 --- a/crates/engine/src/footage.rs +++ b/crates/engine/src/footage.rs @@ -1,6 +1,7 @@ pub mod doc_path; pub mod footage_check; pub mod footage_reload; +pub mod lut_reload; pub mod media_browser; pub mod media_cache; pub mod sequence; diff --git a/crates/engine/src/footage/lut_reload.rs b/crates/engine/src/footage/lut_reload.rs new file mode 100644 index 000000000..54211eac0 --- /dev/null +++ b/crates/engine/src/footage/lut_reload.rs @@ -0,0 +1,90 @@ +//! LUT files that change on disk (#3493). +//! +//! A render follows a LUT file by itself: [`effectcraft_effects::load_lut`] stamps the file and +//! reads it again once it changed, and a layer's cache key holds the stamp. What the desktop app +//! keeps of earlier renders, the frames of its viewer and RAM preview, is dropped when +//! [`effectcraft_effects::lut_generation`] moves. The [`Watcher`] looks at the LUT files in use +//! every two seconds, so an edit shows without anything rendering first, and wakes the interface +//! only when one changed. + +use std::sync::mpsc::{RecvTimeoutError, SyncSender}; +use std::time::Duration; + +/// Moves each time a LUT file in use was found changed on disk: frames rendered before may show +/// the old LUT ([`effectcraft_effects::lut_generation`]). +pub fn generation() -> u64 { + effectcraft_effects::lut_generation() +} + +/// Stamp the LUT files in use now ([`effectcraft_effects::check_lut_files`]): the ones that +/// changed are read again by the next render. Returns [`generation`]. +pub fn check() -> u64 { + effectcraft_effects::check_lut_files() +} + +/// How often the LUT files in use are stamped. +pub const INTERVAL: Duration = Duration::from_secs(2); + +/// A worker thread that stamps the LUT files in use ([`effectcraft_effects::check_lut_files`]) +/// and runs `wake` when one changed. Dropping the watcher ends the thread. +pub struct Watcher { + _stop: SyncSender<()>, +} + +impl Watcher { + /// Start the worker. `None` where there are no threads (the web app, whose LUTs don't come + /// from files) or one couldn't be started. + pub fn start(wake: impl Fn() + Send + 'static) -> Option { + Self::start_with(wake, INTERVAL) + } + + /// [`Watcher::start`] stamping every `every` (tests). + pub fn start_with(wake: impl Fn() + Send + 'static, every: Duration) -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1); + std::thread::Builder::new() + .name("lut-watch".into()) + .spawn(move || { + let mut seen = effectcraft_effects::lut_generation(); + // (ends once the watcher, the only sender, is dropped) + while let Err(RecvTimeoutError::Timeout) = rx.recv_timeout(every) { + let now = check(); + if now != seen { + seen = now; + wake(); + } + } + }) + .ok()?; + Some(Watcher { _stop: tx }) + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + /// A LUT file edited on disk wakes the interface without a render loading it. + #[test] + fn an_edited_lut_file_wakes_the_interface() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("watched.cube"); + std::fs::write(&path, "LUT_1D_SIZE 2\n0 0 0\n1 1 1\n").unwrap(); + let src = path.to_string_lossy().into_owned(); + assert!(effectcraft_effects::load_lut(&src).is_some()); + let before = effectcraft_effects::lut_generation(); + let (tx, rx) = std::sync::mpsc::channel(); + let _watcher = Watcher::start_with( + move || { + let _ = tx.send(()); + }, + Duration::from_millis(10), + ) + .unwrap(); + std::fs::write(&path, "LUT_1D_SIZE 3\n0 0 0\n0.5 0.5 0.5\n1 1 1\n").unwrap(); + rx.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(effectcraft_effects::lut_generation() > before); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f8edfbe08..0b7608756 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -61,6 +61,7 @@ pub use effectcraft_track as track; pub use footage::doc_path; pub use footage::footage_check; pub use footage::footage_reload; +pub use footage::lut_reload; pub use footage::media_browser; pub use footage::media_cache; pub use footage::sequence; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 19953053e..ab4b5b8da 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -27,8 +27,10 @@ mod key_metadata_order; mod key_time; mod keyframe_text; mod keylight; +mod legacy_analysis_keys; mod limits; mod lottie; +mod lut_files; mod m137; mod m145; mod markers; diff --git a/crates/engine/src/tests/fixtures/tracked-0.22.ecproj b/crates/engine/src/tests/fixtures/tracked-0.22.ecproj new file mode 100644 index 000000000..8e1d21b40 --- /dev/null +++ b/crates/engine/src/tests/fixtures/tracked-0.22.ecproj @@ -0,0 +1,1593 @@ +{ + "savedBy": "0.22.0", + "activeComp": 2, + "openComps": [ + 2 + ], + "schema": 2, + "settings": { + "bit_depth": "Bpc8", + "linearize": false, + "working_space": null, + "blend_linear": false, + "time_display": "Timecode", + "frame_start": 0, + "audio_sample_rate": 48000, + "gpu_acceleration": true, + "color_engine": "Adobe", + "hdr": "Clip", + "output_space": null, + "working_gamma": 2.2 + }, + "items": { + "1": { + "id": 1, + "name": "effectcraft-3456-shake.mp4", + "label": "Aqua", + "comment": "", + "parent": null, + "kind": { + "type": "Footage", + "path": "/tmp/effectcraft-3456-shake.mp4", + "kind": "Video", + "width": 160, + "height": 120, + "pixel_aspect": 1.0, + "frame_rate": { + "num": 12, + "den": 1 + }, + "native_rate": null, + "duration": 254016000000, + "has_video": true, + "has_audio": false, + "alpha": "Ignore", + "premul_color": [ + 0.0, + 0.0, + 0.0 + ], + "loop_count": 1, + "codec": "H.264 (High)", + "missing": false, + "color_profile": "Rec709" + } + }, + "2": { + "id": 2, + "name": "effectcraft-3456-shake", + "label": "Sandstone", + "comment": "", + "parent": null, + "kind": { + "type": "Comp", + "width": 160, + "height": 120, + "pixel_aspect": 1.0, + "frame_rate": { + "num": 12, + "den": 1 + }, + "duration": 254016000000, + "display_start": 0, + "background": [ + 0.0, + 0.0, + 0.0 + ], + "work_area": [ + 0, + 254016000000 + ], + "layers": [ + { + "id": 3, + "name": "effectcraft-3456-shake.mp4", + "source": { + "type": "Footage", + "item": 1 + }, + "label": "Aqua", + "comment": "", + "start_time": 0, + "in_point": 0, + "out_point": 254016000000, + "stretch": 100.0, + "switches": { + "video": true, + "audio": true, + "solo": false, + "locked": false, + "shy": false, + "collapse": false, + "quality": "Best", + "sampling": "Bilinear", + "effects": true, + "frame_blend": "Off", + "motion_blur": false, + "adjustment": false, + "three_d": false, + "guide": false + }, + "blend_mode": "Normal", + "preserve_transparency": false, + "track_matte": null, + "parent": null, + "markers": [], + "auto_orient": "Off", + "props": { + "uid": 4, + "match": "layer", + "name": "effectcraft-3456-shake.mp4", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 5, + "match": "masks", + "name": "Masks", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [] + }, + { + "node": "Group", + "uid": 6, + "match": "effects", + "name": "Effects", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 26, + "match": "ec.distort.warpstabilizer", + "name": "Warp Stabilizer", + "kind": { + "kind": "Effect", + "effect": "ec.distort.warpstabilizer" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 28, + "match": "stabilization", + "name": "Stabilization", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 27, + "match": "result", + "name": "Result", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Smooth Motion", + "No Motion" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 29, + "match": "smoothness", + "name": "Smoothness", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 30, + "match": "method", + "name": "Method", + "value": { + "t": "Enum", + "v": 3 + }, + "ui": { + "kind": "Popup", + "options": [ + "Position", + "Position, Scale, Rotation", + "Perspective", + "Subspace Warp" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 31, + "match": "preserveScale", + "name": "Preserve Scale", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 33, + "match": "borders", + "name": "Borders", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 32, + "match": "framing", + "name": "Framing", + "value": { + "t": "Enum", + "v": 2 + }, + "ui": { + "kind": "Popup", + "options": [ + "Stabilize Only", + "Stabilize, Crop", + "Stabilize, Crop, Auto-scale", + "Stabilize, Synthesize Edges" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Group", + "uid": 35, + "match": "autoScale", + "name": "Auto-scale", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 34, + "match": "maximumScale", + "name": "Maximum Scale", + "value": { + "t": "Scalar", + "v": 150.0 + }, + "ui": { + "kind": "Slider", + "min": 100.0, + "max": 1000.0, + "slider_min": 100.0, + "slider_max": 200.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 36, + "match": "actionSafeMargin", + "name": "Action-safe Margin", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 45.0, + "slider_min": 0.0, + "slider_max": 20.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Prop", + "uid": 37, + "match": "additionalScale", + "name": "Additional Scale", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 1.0, + "max": 1000.0, + "slider_min": 50.0, + "slider_max": 150.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 39, + "match": "advanced", + "name": "Advanced", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 38, + "match": "detailedAnalysis", + "name": "Detailed Analysis", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 40, + "match": "rollingShutterRipple", + "name": "Rolling Shutter Ripple", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Automatic Reduction", + "Enhanced Reduction" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 41, + "match": "cropLessSmoothMore", + "name": "Crop Less <-> Smooth More", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 42, + "match": "synthesizeInputRange", + "name": "Synthesis Input Range (seconds)", + "value": { + "t": "Scalar", + "v": 1.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 10.0, + "slider_min": 0.0, + "slider_max": 5.0, + "decimals": 2 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 43, + "match": "synthesizeEdgeFeather", + "name": "Synthesis Edge Feather", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Group", + "uid": 45, + "match": "edgeCropping", + "name": "Synthesis Edge Cropping", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 44, + "match": "left", + "name": "Left", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 50.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 46, + "match": "top", + "name": "Top", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 50.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 47, + "match": "right", + "name": "Right", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 50.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 48, + "match": "bottom", + "name": "Bottom", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 50.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Prop", + "uid": 49, + "match": "hideWarningBanner", + "name": "Hide Warning Banner", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 50, + "match": "showTrackPoints", + "name": "Show Track Points", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 51, + "match": "trackPointSize", + "name": "Track Point Size", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 200.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Prop", + "uid": 52, + "match": "analysis", + "name": "Analysis", + "value": { + "t": "Str", + "v": "{\"version\":2,\"start\":0.0,\"frameDuration\":0.08333333333333333,\"size\":[160.0,120.0],\"detailed\":false,\"frames\":[{\"t\":[0.0,0.0],\"s\":[1.0,0.0,0.0,0.0],\"h\":[1.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0],\"inliers\":0,\"features\":0},{\"t\":[-22.388798410890686,30.01868418985143],\"s\":[0.9277781961008736,-0.04422418749279422,-15.523833932787909,35.6091856999425],\"h\":[0.9277781961008736,0.04422418749279422,-15.523833932787909,-0.04422418749279422,0.9277781961008736,35.6091856999425,0.0,0.0],\"inliers\":9,\"features\":15,\"points\":[[92.3,107.9],[95.2,95.7],[92.2,103.7],[74.2,53.6],[74.3,37.4],[99.3,90.7],[82.6,41.5],[82.5,55.5],[88.5,43.5]]},{\"t\":[23.98122532957055,-1.9084399677775892],\"s\":[0.9063620953625465,-0.04409531016401132,30.797313797392007,5.599646102270199],\"h\":[0.7965841302132126,0.29995334448297317,20.168498543438005,-0.0740592055495628,1.046481410433727,-0.473260319309297,-0.002014895402579317,0.0023197947752658875],\"inliers\":10,\"features\":24,\"points\":[[118.4,97.1],[124.8,102.9],[100.5,51.5],[127.3,63.9],[127.5,45.0],[106.1,39.6],[106.2,53.7],[113.7,56.7],[52.5,35.6],[116.8,43.6]]},{\"t\":[11.687620662467452,-7.932031814223215],\"s\":[0.9951636472613027,0.0038103578382923703,12.438092774450126,-8.118641076572445],\"h\":[1.643484923435082,-0.7137632860918511,-2.5905208024072124,0.3548180400361871,0.46037059943190983,-10.538467107984474,0.004219805542554225,-0.0059212698862967],\"inliers\":9,\"features\":18,\"points\":[[132.6,73.5],[131.5,85.1],[106.5,41.5],[138.4,70.5],[143.4,74.0],[112.0,43.6],[122.5,47.5],[130.2,49.8],[149.3,58.5]]},{\"t\":[-30.310403914516613,33.907848152443364],\"s\":[1.007168616747288,0.0037069147029014813,-31.056396618707396,33.11324659331262],\"h\":[0.6198845159470996,0.0005618559126509149,-3.178267007922358,-0.13009530252511717,0.7726770220834841,43.55009577470153,-0.001720398359559105,0.00004737533435711085],\"inliers\":6,\"features\":6,\"points\":[[83.4,78.4],[111.5,91.5],[111.5,72.5],[89.0,80.3],[94.7,83.3],[92.1,67.5]]},{\"t\":[-5.583259809628714,-21.883441966142215],\"s\":[1.0031742036915483,-0.0014807972400717364,-6.0689262496146625,-22.023221544860448],\"h\":[1.1420296883900174,-0.04739597390990014,-10.605302345116598,0.09741208494601904,0.9834838974219691,-26.601790074159226,0.0009113645792746124,-0.0004613682942006241],\"inliers\":12,\"features\":26,\"points\":[[44.4,0.4],[95.7,91.4],[102.5,90.5],[96.9,82.3],[102.8,85.5],[102.6,81.2],[97.1,78.5],[127.8,90.7],[128.5,80.1],[108.6,91.2],[108.5,79.5],[105.0,77.7]]},{\"t\":[27.686733462643232,8.049769635251877],\"s\":[0.929460928303922,0.035973851378530715,35.6798165298027,9.23027450987749],\"h\":[1.008918148822829,0.1261308536278953,23.55178846805191,-0.030309790827892834,1.108836841709476,6.4896286706810855,-0.0005830225271129425,0.0013957761568033347],\"inliers\":9,\"features\":24,\"points\":[[55.5,112.5],[100.6,55.5],[100.6,69.5],[106.2,57.5],[106.1,71.6],[113.5,74.5],[76.4,61.6],[76.6,45.5],[116.6,61.6]]},{\"t\":[-2.1632428252979565,18.033956762750776],\"s\":[0.9823494564274133,-0.03858067520697906,-3.292485287213367,25.146037618301847],\"h\":[0.9579598760613574,-0.13114734456280325,2.830816183351523,0.009276438160611545,0.8429238585075404,27.816268384517308,0.00010866570427004524,-0.0010986404477924933],\"inliers\":9,\"features\":25,\"points\":[[10.2,63.8],[4.5,45.5],[53.4,60.9],[106.6,83.4],[106.6,97.6],[58.5,80.5],[71.6,85.4],[76.7,86.4],[112.4,85.5]]},{\"t\":[-30.66640840418232,-21.989852679037867],\"s\":[1.0156367307051672,0.004551071962152525,-31.763785052482092,-23.691591888755113],\"h\":[1.0156367307051672,-0.004551071962152525,-31.763785052482092,0.004551071962152525,1.0156367307051672,-23.691591888755113,0.0,0.0],\"inliers\":6,\"features\":9,\"points\":[[65.6,55.5],[68.5,58.4],[76.3,61.6],[58.5,55.9],[78.5,77.7],[43.4,48.3]]},{\"t\":[5.708740901092206,24.075281194659254],\"s\":[1.0135826886534363,-0.10515588147251018,-1.1163514746928627,27.59825643107368],\"h\":[1.1505001196625904,-0.004492526279963958,-0.5319258967829654,-0.025738957694540945,0.966887539263372,28.15190307627249,0.0007845902586792284,-0.00031935492918067597],\"inliers\":9,\"features\":24,\"points\":[[104.6,97.7],[104.8,101.6],[108.5,88.8],[103.9,89.0],[116.1,92.7],[112.4,87.8],[113.2,96.3],[47.3,72.5],[46.4,87.5]]},{\"t\":[27.779201319161004,9.926771642811133],\"s\":[0.9890321818477387,0.0013711446087583204,28.575284295787874,10.772618168795788],\"h\":[1.061498205619746,0.009950102807178602,26.602191497678987,0.03562744518371215,1.0470570180393335,7.3128067380884545,0.00040746752749711983,0.00013875206582740715],\"inliers\":19,\"features\":31,\"points\":[[101.5,91.5],[100.5,107.5],[95.4,104.6],[93.2,89.4],[62.5,94.4],[108.2,95.5],[108.2,109.3],[114.0,97.5],[89.2,102.5],[61.5,75.5],[70.5,80.5],[89.3,88.3],[114.9,111.1],[69.5,95.5],[66.5,78.5],[82.3,100.6],[76.5,82.5],[82.5,84.4],[119.1,112.0]]},{\"t\":[-18.08353148330616,-15.925984647541327],\"s\":[0.9665330472023047,-0.007772653084006622,-14.505725243709165,-11.14686987633948],\"h\":[1.396312496581232,-0.10268053165320767,-33.15101241390654,0.021275151660258673,1.173514724589782,-24.185902320422414,0.001847437675107323,-0.0003034312788189646],\"inliers\":9,\"features\":23,\"points\":[[119.3,75.5],[116.5,84.0],[121.6,82.9],[128.5,87.4],[127.6,83.0],[124.8,80.6],[128.7,71.5],[71.6,86.5],[58.4,66.5]]}],\"tracks\":[{\"id\":0,\"start\":0,\"pts\":[[121.5,16.5],[101.4,48.2],[127.2,46.6],[141.2,40.2],[111.2,74.3],[105.1,58.7],[135.1,68.5]]},{\"id\":1,\"start\":0,\"pts\":[[107.5,26.5],[85.4,56.6],[108.9,54.9],[121.0,46.9],[90.3,80.7],[82.3,64.9],[109.6,73.1],[105.7,97.1]]}]}" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 53, + "match": "analysisKey", + "name": "Analysis Key", + "value": { + "t": "Str", + "v": "f87b8df2e353c28e" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 54, + "match": "ec.perspective.cameratracker", + "name": "3D Camera Tracker", + "kind": { + "kind": "Effect", + "effect": "ec.perspective.cameratracker" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 55, + "match": "shotType", + "name": "Shot Type", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Fixed Angle of View", + "Variable Zoom", + "Specify Angle of View" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 56, + "match": "horizontalAngleOfView", + "name": "Horizontal Angle of View", + "value": { + "t": "Scalar", + "v": 40.0 + }, + "ui": { + "kind": "Slider", + "min": 1.0, + "max": 170.0, + "slider_min": 10.0, + "slider_max": 120.0, + "decimals": 1 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 57, + "match": "showTrackPoints", + "name": "Show Track Points", + "value": { + "t": "Enum", + "v": 1 + }, + "ui": { + "kind": "Popup", + "options": [ + "2D Source", + "3D Solved" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 58, + "match": "renderTrackPoints", + "name": "Render Track Points", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 59, + "match": "trackPointSize", + "name": "Track Point Size", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 1000.0, + "slider_min": 0.0, + "slider_max": 300.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 60, + "match": "targetSize", + "name": "Target Size", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 1.0, + "max": 1000.0, + "slider_min": 10.0, + "slider_max": 300.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Group", + "uid": 62, + "match": "advanced", + "name": "Advanced", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 61, + "match": "solveMethod", + "name": "Solve Method", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Auto Detect", + "Typical", + "Mostly Flat Scene", + "Tripod Pan" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 63, + "match": "methodUsed", + "name": "Method Used", + "value": { + "t": "Str", + "v": "Tripod Pan" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 64, + "match": "averageError", + "name": "Average Error", + "value": { + "t": "Scalar", + "v": 0.48427435472856495 + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 65, + "match": "detailedAnalysis", + "name": "Detailed Analysis", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 66, + "match": "lensDistortion", + "name": "Solve Lens Distortion", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 67, + "match": "undistort", + "name": "Undistort Footage", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 68, + "match": "autoDeletePoints", + "name": "Auto-delete Points Across Time", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 69, + "match": "hideWarningBanner", + "name": "Hide Warning Banner", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Prop", + "uid": 70, + "match": "tracks", + "name": "Tracks", + "value": { + "t": "Str", + "v": "{\"version\":1,\"size\":[160.0,120.0],\"start\":0.0,\"frameDuration\":0.08333333333333333,\"frames\":12,\"factor\":1.0,\"detailed\":false,\"tracks\":[{\"id\":0,\"start\":0,\"pts\":[[77.5,21.5],[63.0,42.02],[84.44,46.2],[94.51,43.46],[76.29,65.76],[77.07,73.7],[83.0,80.68],[85.69,85.86],[87.92,90.96],[89.4,99.49],[90.13,103.45],[90.97,109.78]]},{\"id\":1,\"start\":0,\"pts\":[[137.5,41.5],[125.34,65.53],[144.58,68.06],[155.96,64.9],[137.55,87.17]]},{\"id\":2,\"start\":0,\"pts\":[[103.5,9.5],[85.74,29.12],[104.73,32.21],[110.44,21.58],[92.15,43.67]]},{\"id\":3,\"start\":0,\"pts\":[[104.5,29.5],[85.9,50.12],[103.93,52.29],[109.88,48.47],[92.18,70.78]]},{\"id\":4,\"start\":0,\"pts\":[[135.5,18.5],[123.93,41.77],[143.14,45.58],[156.62,36.13],[137.86,58.99],[138.18,64.4],[142.35,72.76]]},{\"id\":5,\"start\":0,\"pts\":[[145.5,43.5],[136.49,68.62],[152.24,70.07]]},{\"id\":6,\"start\":0,\"pts\":[[68.5,16.5],[54.6,38.04],[75.81,40.97],[85.89,39.76],[67.52,62.45],[65.27,69.0]]},{\"id\":7,\"start\":0,\"pts\":[[126.5,18.5],[111.0,39.74],[128.76,42.63],[136.89,32.11],[117.68,54.39]]},{\"id\":8,\"start\":1,\"pts\":[[62.5,17.5],[85.58,22.87],[94.22,13.25],[75.42,35.02],[75.53,43.03],[82.19,51.59],[83.63,56.75],[86.42,64.01],[88.28,72.0],[89.17,76.16],[88.59,83.65]]},{\"id\":9,\"start\":1,\"pts\":[[114.5,106.5],[136.85,100.86],[147.03,92.4]]},{\"id\":10,\"start\":1,\"pts\":[[72.5,23.5],[97.31,29.7],[103.74,19.02],[85.16,40.96],[87.87,50.25],[90.99,56.84],[95.55,64.14],[94.95,69.09],[98.79,80.06],[99.53,84.17],[96.16,89.83]]},{\"id\":11,\"start\":1,\"pts\":[[105.5,56.5],[123.21,58.51],[130.67,54.61],[112.22,76.63],[107.86,82.64],[110.99,88.56],[110.29,92.35],[107.8,96.22],[105.42,105.58],[106.34,109.09],[103.49,114.69]]},{\"id\":12,\"start\":1,\"pts\":[[48.5,14.5],[74.56,20.81],[80.17,10.47],[60.5,31.52]]},{\"id\":13,\"start\":1,\"pts\":[[56.5,17.5],[79.51,23.22],[88.21,13.13],[69.38,34.7],[69.5,43.04],[76.12,50.88],[77.6,55.88],[80.34,63.05],[82.34,69.83],[83.23,74.02],[82.59,80.86]]},{\"id\":14,\"start\":1,\"pts\":[[111.5,56.5],[129.28,58.38],[136.92,54.94]]},{\"id\":15,\"start\":2,\"pts\":[[111.5,35.5],[116.71,24.74],[98.61,46.94],[95.39,52.62],[97.78,59.24]]},{\"id\":16,\"start\":2,\"pts\":[[111.5,54.5],[116.45,50.61],[98.85,72.82],[95.85,78.52],[98.15,84.77]]},{\"id\":17,\"start\":2,\"pts\":[[35.5,25.5],[51.37,25.98],[29.35,47.25],[34.79,58.33],[37.82,64.46],[35.7,68.67]]},{\"id\":18,\"start\":2,\"pts\":[[65.5,37.5],[73.16,34.37],[53.32,56.61]]},{\"id\":19,\"start\":3,\"pts\":[[139.5,91.5],[121.67,103.33],[122.29,98.68],[128.91,94.42],[130.04,88.08],[132.48,82.73],[132.69,85.66],[135.56,80.73],[133.86,80.34]]},{\"id\":20,\"start\":3,\"pts\":[[128.5,27.5],[109.77,49.81],[105.68,55.33]]},{\"id\":21,\"start\":4,\"pts\":[[132.5,108.5],[134.61,103.06],[139.18,98.97]]},{\"id\":22,\"start\":4,\"pts\":[[12.5,44.5],[15.77,54.38],[21.97,62.49],[23.19,67.31],[25.95,73.59],[32.15,76.64],[31.59,80.8],[37.46,83.52]]},{\"id\":23,\"start\":5,\"pts\":[[88.5,76.5],[91.53,82.49],[96.99,88.67],[96.31,92.35]]},{\"id\":24,\"start\":5,\"pts\":[[23.5,26.5],[34.16,36.76],[31.29,40.39]]},{\"id\":25,\"start\":5,\"pts\":[[29.5,29.5],[41.15,39.45],[37.02,43.33],[44.0,51.94]]},{\"id\":26,\"start\":5,\"pts\":[[16.5,25.5],[25.23,35.61],[24.0,39.41],[27.82,47.95],[34.62,50.88],[34.62,55.8],[39.37,61.06]]},{\"id\":27,\"start\":5,\"pts\":[[12.5,21.5],[20.78,30.91],[20.0,35.48],[23.13,42.77],[30.29,46.76],[30.38,51.79],[34.65,56.39]]},{\"id\":28,\"start\":5,\"pts\":[[115.5,82.5],[118.64,88.43],[118.09,93.19],[117.39,96.49],[114.83,106.78],[114.88,110.08]]},{\"id\":29,\"start\":5,\"pts\":[[122.5,61.5],[124.31,67.9],[123.88,72.08],[123.44,76.68],[120.82,86.9],[120.84,90.61],[117.85,97.98]]},{\"id\":30,\"start\":5,\"pts\":[[120.5,66.5],[122.86,72.98],[122.45,77.21],[121.98,81.49]]},{\"id\":31,\"start\":7,\"pts\":[[130.5,110.5],[132.07,103.93],[132.1,107.25],[134.31,101.57],[134.08,101.95]]},{\"id\":32,\"start\":7,\"pts\":[[144.5,87.5],[147.64,81.13],[147.29,84.43]]},{\"id\":33,\"start\":7,\"pts\":[[149.5,87.5],[151.62,81.15],[151.6,83.45]]},{\"id\":34,\"start\":7,\"pts\":[[141.5,109.5],[142.65,102.86],[144.73,106.45]]},{\"id\":35,\"start\":7,\"pts\":[[130.5,105.5],[132.21,97.98],[132.16,101.56]]},{\"id\":36,\"start\":7,\"pts\":[[46.5,46.5],[46.28,52.04],[53.71,58.71],[53.81,63.25]]},{\"id\":37,\"start\":7,\"pts\":[[63.5,78.5],[60.78,82.81],[61.38,87.33],[61.18,91.39],[60.13,92.98]]},{\"id\":38,\"start\":7,\"pts\":[[147.5,79.5],[148.79,73.74],[148.89,76.93],[152.13,71.83],[150.74,73.15]]},{\"id\":39,\"start\":8,\"pts\":[[110.5,73.5],[108.69,82.44],[109.4,86.46],[105.45,92.92]]},{\"id\":40,\"start\":8,\"pts\":[[37.5,75.5],[39.63,77.92],[39.24,82.09],[46.3,85.66]]},{\"id\":41,\"start\":9,\"pts\":[[72.5,92.5],[72.19,96.59],[71.1,99.0]]},{\"id\":42,\"start\":9,\"pts\":[[71.5,66.5],[71.58,70.65],[69.4,74.61]]},{\"id\":43,\"start\":9,\"pts\":[[42.5,52.5],[42.63,57.29],[48.81,63.22]]},{\"id\":44,\"start\":9,\"pts\":[[65.5,63.5],[65.9,67.71],[62.84,71.25]]},{\"id\":45,\"start\":9,\"pts\":[[54.5,84.5],[54.33,88.51],[54.49,89.47]]},{\"id\":46,\"start\":9,\"pts\":[[93.5,108.5],[94.19,112.24],[95.37,117.47]]}]}" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 71, + "match": "tracksKey", + "name": "Tracks Key", + "value": { + "t": "Str", + "v": "f87b8df2e353c28e" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 72, + "match": "solve", + "name": "Solve", + "value": { + "t": "Str", + "v": "{\"version\":1,\"size\":[160.0,120.0],\"start\":0.0,\"frameDuration\":0.08333333333333333,\"shot\":\"fixedAngle\",\"methodUsed\":\"tripodPan\",\"averageError\":0.48427435472856495,\"frames\":[{\"rot\":[0.0,0.0,0.0],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.020706432791589117,-0.008075142299053078,-0.06077723052316193],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.023015120128512003,0.005766803085062508,-0.05310541732098104],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.0170200841043793,0.014154804924310056,-0.050762146721755434],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.03356753265765158,-0.00032501028659040183,-0.05939089867356119],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.03943129932097289,0.0005848011077747351,-0.08866812978617226],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.04587385659719372,0.00556317017360105,-0.0922903070481816],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.04943028885690781,0.006586719185689197,-0.1030177435734751],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.05420648867299307,0.008981748111204299,-0.12219539780340982],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.06013167434751454,0.009061063842010806,-0.02749230328481726],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.06324873745045556,0.009255262692673143,-0.0355321639175462],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true},{\"rot\":[-0.06618656945718411,0.009319774678589094,-0.015189163276848288],\"center\":[0.0,0.0,0.0],\"focal\":1307.4062971457863,\"solved\":true}],\"points\":[{\"id\":0,\"pos\":[-0.001884543346613208,-0.029037452307042782,0.9806051370981879],\"error\":0.56470084,\"first\":0,\"last\":11},{\"id\":1,\"pos\":[0.04710854544038102,-0.015334858559503525,1.0773487523110807],\"error\":0.4844214,\"first\":0,\"last\":4},{\"id\":2,\"pos\":[0.015277830948334362,-0.04530698241035739,1.0393293228690133],\"error\":0.3392279,\"first\":0,\"last\":4},{\"id\":3,\"pos\":[0.013625157539074177,-0.028048508549162222,1.005205908477461],\"error\":0.27458376,\"first\":0,\"last\":4},{\"id\":4,\"pos\":[0.04631029390834158,-0.03435434088491794,1.0740657193502177],\"error\":0.58201736,\"first\":0,\"last\":6},{\"id\":5,\"pos\":[0.053943894461564695,-0.013610066623604573,1.082119350918587],\"error\":0.33030823,\"first\":0,\"last\":2},{\"id\":6,\"pos\":[-0.008886582549498147,-0.03423365291632946,1.0500072858529375],\"error\":0.5071384,\"first\":0,\"last\":5},{\"id\":7,\"pos\":[0.03502079415367347,-0.03650999899856251,1.0590501262399414],\"error\":0.3427207,\"first\":0,\"last\":4},{\"id\":8,\"pos\":[-0.0010752140340087664,-0.05237350807489353,0.9970760865042246],\"error\":0.35491616,\"first\":1,\"last\":11},{\"id\":9,\"pos\":[0.04045532180900739,0.011287908043101045,1.1118602867055563],\"error\":0.35821888,\"first\":1,\"last\":3},{\"id\":12,\"pos\":[-0.012247058383356772,-0.05711454082770741,1.026095977383411],\"error\":0.7576372,\"first\":1,\"last\":4},{\"id\":13,\"pos\":[-0.0057782770174717585,-0.05312155593792027,1.002454847850506],\"error\":0.543182,\"first\":1,\"last\":11},{\"id\":14,\"pos\":[0.03444063369446883,-0.02322488536327569,1.0525377480568132],\"error\":0.5363785,\"first\":1,\"last\":3},{\"id\":15,\"pos\":[0.016549051463405264,-0.04448675703801009,1.039611880351983],\"error\":0.31473064,\"first\":2,\"last\":6},{\"id\":16,\"pos\":[0.015561268129555224,-0.024177980474891878,1.0505609541603724],\"error\":0.448273,\"first\":2,\"last\":6},{\"id\":17,\"pos\":[-0.037387500298151244,-0.046978366490905726,1.0292596184739917],\"error\":0.5814284,\"first\":2,\"last\":7},{\"id\":18,\"pos\":[-0.018963958767977365,-0.03840654941654895,1.029868839109744],\"error\":0.62771547,\"first\":2,\"last\":4},{\"id\":20,\"pos\":[0.025824410036239483,-0.04221331533850395,1.051265936102142],\"error\":0.2203965,\"first\":3,\"last\":5},{\"id\":22,\"pos\":[-0.04851356310948073,-0.04922371159135405,1.0148030992016972],\"error\":0.60021263,\"first\":4,\"last\":11},{\"id\":23,\"pos\":[0.0036124978633337353,-0.027413664985930772,0.9671987963193897],\"error\":0.2763749,\"first\":5,\"last\":8},{\"id\":24,\"pos\":[-0.040597577069465214,-0.0703196502868751,1.0235859186232383],\"error\":0.20348576,\"first\":5,\"last\":7},{\"id\":26,\"pos\":[-0.04416469175733074,-0.0698384544617488,1.0211428377131564],\"error\":0.7978787,\"first\":5,\"last\":11},{\"id\":27,\"pos\":[-0.04818954744569584,-0.07416243876625056,1.013378662747375],\"error\":0.6526291,\"first\":5,\"last\":11},{\"id\":36,\"pos\":[-0.02865646311969018,-0.06261471940209673,1.0103266984710924],\"error\":0.47229937,\"first\":7,\"last\":10},{\"id\":37,\"pos\":[-0.022363582487320836,-0.038494993762751006,0.9688013952945587],\"error\":0.5830236,\"first\":7,\"last\":11},{\"id\":39,\"pos\":[0.012856414034624068,-0.040629816490058715,0.9569374661053799],\"error\":0.26666406,\"first\":8,\"last\":11},{\"id\":40,\"pos\":[-0.039487212277480116,-0.04718518517554586,0.9962321113853498],\"error\":0.48472655,\"first\":8,\"last\":11},{\"id\":41,\"pos\":[-0.014270376399443,-0.03389203058278074,0.9480752077612952],\"error\":0.79931283,\"first\":9,\"last\":11},{\"id\":42,\"pos\":[-0.014596706636024855,-0.05446828640367654,0.9808373411559306],\"error\":0.021755833,\"first\":9,\"last\":11},{\"id\":43,\"pos\":[-0.03666075088242092,-0.06654420263172509,0.9970944504474955],\"error\":0.20362298,\"first\":9,\"last\":11},{\"id\":44,\"pos\":[-0.01908451035407613,-0.057350036080083884,0.9896103813509345],\"error\":0.18076603,\"first\":9,\"last\":11},{\"id\":45,\"pos\":[-0.028179725825267236,-0.04186664641926184,0.9918891096090983],\"error\":0.24652581,\"first\":9,\"last\":11},{\"id\":46,\"pos\":[0.0010670642752641536,-0.018463010859931878,0.8064992130555431],\"error\":0.7289736,\"first\":9,\"last\":11}]}" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 73, + "match": "solveKey", + "name": "Solve Key", + "value": { + "t": "Str", + "v": "FixedAngle||Auto|[]" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 74, + "match": "deleted", + "name": "Deleted Points", + "value": { + "t": "Str", + "v": "" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 75, + "match": "camera", + "name": "Camera Layer", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + } + ] + }, + { + "node": "Group", + "uid": 15, + "match": "transform", + "name": "Transform", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 7, + "match": "anchor", + "name": "Anchor Point", + "value": { + "t": "Vec3", + "v": [ + 80.0, + 60.0, + 0.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 8, + "match": "position", + "name": "Position", + "value": { + "t": "Vec3", + "v": [ + 80.0, + 60.0, + 0.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 9, + "match": "scale", + "name": "Scale", + "value": { + "t": "Vec3", + "v": [ + 100.0, + 100.0, + 100.0 + ] + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 10, + "match": "orientation", + "name": "Orientation", + "value": { + "t": "Vec3", + "v": [ + 0.0, + 0.0, + 0.0 + ] + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 11, + "match": "rotationX", + "name": "X Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 12, + "match": "rotationY", + "name": "Y Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 13, + "match": "rotation", + "name": "Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 14, + "match": "opacity", + "name": "Opacity", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 16, + "match": "materialOptions", + "name": "Material Options", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 17, + "match": "castsShadows", + "name": "Casts Shadows", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Off", + "On", + "Only" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 18, + "match": "lightTransmission", + "name": "Light Transmission", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 19, + "match": "acceptsShadows", + "name": "Accepts Shadows", + "value": { + "t": "Enum", + "v": 1 + }, + "ui": { + "kind": "Popup", + "options": [ + "Off", + "On", + "Only" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 20, + "match": "acceptsLights", + "name": "Accepts Lights", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 21, + "match": "ambient", + "name": "Ambient", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 22, + "match": "diffuse", + "name": "Diffuse", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 23, + "match": "specularIntensity", + "name": "Specular Intensity", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 24, + "match": "specularShininess", + "name": "Specular Shininess", + "value": { + "t": "Scalar", + "v": 5.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 25, + "match": "metal", + "name": "Metal", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + } + ] + } + } + ], + "markers": [], + "shutter_angle": 180.0, + "shutter_phase": -90.0, + "motion_blur_samples": 16, + "motion_blur_adaptive_limit": 128, + "renderer": "Classic3D", + "hide_shy": false, + "enable_motion_blur": true, + "enable_frame_blending": true, + "draft_3d": false, + "preserve_frame_rate": false, + "preserve_resolution": false, + "poster_time": 0, + "global_light": { + "angle": { + "uid": 0, + "match": "globalLightAngle", + "name": "Global Light Angle", + "value": { + "t": "Scalar", + "v": 120.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + "altitude": { + "uid": 0, + "match": "globalLightAltitude", + "name": "Global Light Altitude", + "value": { + "t": "Scalar", + "v": 30.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + } + } + } + }, + "next_id": 76 +} \ No newline at end of file diff --git a/crates/engine/src/tests/fixtures/tracked-0.22.ecproj.attribution b/crates/engine/src/tests/fixtures/tracked-0.22.ecproj.attribution new file mode 100644 index 000000000..611f620f9 --- /dev/null +++ b/crates/engine/src/tests/fixtures/tracked-0.22.ecproj.attribution @@ -0,0 +1,8 @@ +asset: tracked-0.22.ecproj +title: One clip with an analysed Warp Stabilizer and 3D Camera Tracker from EffectCraft 0.22.0 +author: arnav-hiwarkar +source: original work +license: MIT OR Apache-2.0 +license-file: ../../../../../LICENSE-MIT +added: 2026-11-12 by arnav-hiwarkar +notes: Saved by the released 0.22.0 CLI after importing a generated 160x120, 12-frame shaking crop of an ffmpeg testsrc2 pattern, applying both effects and analysing them. The source path is a generic test path; the video is not needed by the tests, which only check that the saved analyses are kept or cleared. diff --git a/crates/engine/src/tests/legacy_analysis_keys.rs b/crates/engine/src/tests/legacy_analysis_keys.rs new file mode 100644 index 000000000..06a05cc77 --- /dev/null +++ b/crates/engine/src/tests/legacy_analysis_keys.rs @@ -0,0 +1,127 @@ +//! Warp Stabilizer and 3D Camera Tracker analyses saved by an earlier release keep matching +//! their footage after an edit that doesn't change it (#3456), and a real change still clears them. + +use effectcraft_effects::camera_tracker::{SOLVE, TRACKS, TRACKS_KEY}; +use effectcraft_effects::warp_stab::{ANALYSIS, ANALYSIS_KEY}; +use effectcraft_keyframe::Value; +use effectcraft_project::{ItemId, LayerId, Project}; +use serde_json::json; + +use crate::Session; + +const FOOTAGE: ItemId = ItemId(1); +const COMP: ItemId = ItemId(2); +const CLIP: LayerId = LayerId(3); +const WARP: u64 = 26; +const TRACKER: u64 = 54; + +/// The analysis (as its length and a hash: it's long) and key of the Warp Stabilizer and the +/// 3D Camera Tracker. +fn saved(s: &Session) -> [(usize, u64, String); 2] { + use std::hash::{Hash, Hasher}; + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + let get = |uid: u64, data: &str, key: &str| { + let params = crate::camera_track::static_params(layer.props.find_group(uid).unwrap()); + let text = |id: &str| match params.get(id) { + Some(Value::Str(s)) => s.clone(), + other => panic!("{id}: {other:?}"), + }; + let analysis = text(data); + let mut h = std::collections::hash_map::DefaultHasher::new(); + analysis.hash(&mut h); + (analysis.len(), h.finish(), text(key)) + }; + [get(WARP, ANALYSIS, ANALYSIS_KEY), get(TRACKER, TRACKS, TRACKS_KEY)] +} + +fn open(json: &str) -> Session { + let mut s = Session::default(); + s.replace_project(Project::from_json(json).unwrap(), None); + s.open_comp(COMP); + s +} + +#[test] +fn analyses_saved_by_0_22_survive_edits_that_keep_their_frames() { + // Saved by the released 0.22.0 CLI after analysing both effects; its footage is offline here. + let mut s = open(include_str!("fixtures/tracked-0.22.ecproj")); + let before = saved(&s); + let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap(); + for (len, _, key) in &before { + assert!(*len > 0); + // The key predates Footage::placed_page, so it isn't today's signature. + assert_ne!(key, &crate::warp::signature(&s.project, COMP, layer)); + } + + // Adding to the render queue edits the project, as `effectcraft-cli render` does. + s.execute_checked("renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})).unwrap(); + assert_eq!(saved(&s), before); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + + // Saved and reopened (by this version), the old keys are kept and still match. + let mut s = open(&s.project.to_json()); + s.execute_checked("renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})).unwrap(); + assert_eq!(saved(&s), before); + + // Other frames, from other timing or a reinterpreted footage item: both analyses are + // cleared, and undo brings them back. + let cleared = |s: &Session| saved(s).iter().all(|(len, _, key)| *len == 0 && key.is_empty()); + s.execute_checked("layer.timing", json!({"layer": CLIP.0, "out": 0.5})).unwrap(); + assert!(cleared(&s)); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + s.execute_checked("file.interpretFootage", json!({"items": [FOOTAGE.0], "invertAlpha": true})).unwrap(); + assert!(cleared(&s)); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert_eq!(saved(&s), before); + // Footage framed as a placed page has other frames too, though 0.22 had no such field. + s.edit("place page", None, |p, _| { + if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(FOOTAGE).map(|i| &mut i.kind) { + f.placed_page = true; + } + Ok(()) + }) + .unwrap(); + assert!(cleared(&s)); +} + +/// Solving again reuses the 0.22 tracks rather than tracking the (here offline) footage again. +#[test] +fn camera_tracker_solves_again_from_tracks_saved_by_0_22() { + let mut s = open(include_str!("fixtures/tracked-0.22.ecproj")); + let tracks = saved(&s)[1].clone(); + let solve = |s: &Session| { + let g = s.project.comp(COMP).unwrap().layer(CLIP).unwrap().props.find_group(TRACKER).unwrap(); + crate::camera_track::static_params(g).get(SOLVE).cloned().unwrap() + }; + // Another solve method, and back, clears the solve and keeps the tracks. + for method in [1, 0] { + s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "effects/#2/advanced/solveMethod", "value": method})).unwrap(); + } + assert_eq!(solve(&s), Value::Str(String::new())); + assert_eq!(saved(&s)[1], tracks); + s.execute_checked("camera.analyze", json!({"layer": CLIP.0, "effect": TRACKER, "wait": true})).unwrap(); + assert!(matches!(solve(&s), Value::Str(v) if !v.is_empty())); + assert_eq!(saved(&s)[1], tracks); +} + +/// The footage text analysis keys are hashed from, as 0.23 saved it. A new `Footage` field must +/// not change it (or every saved analysis is dropped again). +#[test] +fn footage_signature_text_is_pinned() { + use effectcraft_project::{Footage, FootageKind}; + let f = Footage { + path: "clip.mp4".into(), + kind: FootageKind::Video, + width: 160, + height: 120, + invert_alpha: true, + page: 2, + placed_page: true, + ..Footage::default() + }; + let fields = r#"path: "clip.mp4", kind: Video, width: 160, height: 120, pixel_aspect: 1.0, frame_rate: FrameRate { num: 30, den: 1 }, native_rate: None, duration: Tick(0), has_video: false, has_audio: false, alpha: Straight, premul_color: [0.0, 0.0, 0.0], loop_count: 1, codec: "", missing: false, sequence: [], color_profile: None, layer: None, fields: Off, invert_alpha: true, linear_light: false, preserve_rgb: false, cineon: CineonSettings { log_conversion: false, converted_black: 0.0, converted_white: 1023.0, black: 95.0, white: 685.0, gamma: 1.7, rolloff: 20.0, units: TenBit }, data: None, page: 2, alphabetical: false, start_timecode: None, file_timecode: None, markers: []"#; + assert_eq!(crate::warp::footage_text(&f, true), format!("Footage {{ {fields}, placed_page: true }}")); + assert_eq!(crate::warp::footage_text(&f, false), format!("Footage {{ {fields} }}")); +} diff --git a/crates/engine/src/tests/lut_files.rs b/crates/engine/src/tests/lut_files.rs new file mode 100644 index 000000000..57d163b36 --- /dev/null +++ b/crates/engine/src/tests/lut_files.rs @@ -0,0 +1,188 @@ +//! A LUT file that changes on disk renders again (#3493): Apply Color LUT and Lumetri's Input +//! LUT name a `.cube` file; an edit to it, or a file created, deleted or repaired, shows on the +//! next render of the same frame, from warm layer caches in memory and on disk, through a precomp +//! and a layer reading that precomp, and a frame kept on disk made with the old file is refused. + +use std::path::Path; +use std::time::{Duration, UNIX_EPOCH}; + +use serde_json::{Value, json}; + +use crate::Session; +use crate::render::RenderOpts; +use effectcraft_project::ItemId; +use effectcraft_render::disk_cache::{DiskCache, comp_content_key, frame_key}; +use effectcraft_render::reads::ReadLog; +use effectcraft_time::Tick; + +/// Every input to black, or to white (1D, two entries: the same size, so only the modification +/// time tells the two apart). +const BLACK: &str = "LUT_1D_SIZE 2\n0 0 0\n0 0 0\n"; +const WHITE: &str = "LUT_1D_SIZE 2\n1 1 1\n1 1 1\n"; +const GREY: f32 = 128.0 / 255.0; + +/// Write `text` to `path` with its modification time `secs` after the epoch. +fn write(path: &Path, text: &str, secs: u64) { + std::fs::write(path, text).unwrap(); + let f = std::fs::File::options().write(true).open(path).unwrap(); + f.set_modified(UNIX_EPOCH + Duration::from_secs(secs)).unwrap(); +} + +/// A session, its layer buffers written to a disk cache in `disk` when given. +fn session(disk: Option<&Path>) -> Session { + let mut s = Session::default(); + if let Some(dir) = disk { + s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": dir.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap(); + s.disk_cache.as_ref().unwrap().set_min_layer_ms(0); + } + s +} + +/// Comp "Grade": a grey solid graded by `effect` (`params` set on it). Comp "Shot" shows it as a +/// precomp layer, under a solid that copies the precomp's channels (Set Channels): that layer is +/// cached with what the precomp shows. Returns (Grade, Shot). +fn shot(s: &mut Session, effect: &str, params: &[(&str, Value)]) -> (ItemId, ItemId) { + s.execute_checked("comp.new", json!({"name": "Shot", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap(); + let shot = s.active_comp_id().unwrap(); + let plate = s.execute_checked("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 8, "height": 8})).unwrap()["layer"].clone(); + s.execute_checked("effect.apply", json!({"layer": plate, "effect": effect})).unwrap(); + for (path, value) in params { + s.execute_checked("prop.set", json!({"layer": plate, "path": format!("effects/#1/{path}"), "value": value})).unwrap(); + } + let pre = s.execute_checked("layer.precompose", json!({"layers": [plate], "name": "Grade"})).unwrap(); + let grade = ItemId(pre["comp"].as_u64().unwrap()); + let copy = s.execute_checked("layer.newSolid", json!({"name": "Copy", "color": "#00ff00", "width": 8, "height": 8})).unwrap()["layer"].clone(); + s.execute_checked("effect.apply", json!({"layer": copy, "effect": "Set Channels"})).unwrap(); + for i in 1..=3 { + s.execute_checked("prop.set", json!({"layer": copy, "path": format!("effects/#1/sourceLayer{i}"), "value": pre["layer"]})).unwrap(); + } + (grade, shot) +} + +/// The red and green of the centre of both comps' first frame. +fn colours(s: &Session, (grade, shot): (ItemId, ItemId)) -> [f32; 4] { + let at = |comp| { + let img = s.render(comp, Tick::ZERO, RenderOpts::default()); + img.get(i64::from(img.width / 2), i64::from(img.height / 2)) + }; + let (g, c) = (at(grade), at(shot)); + [g[0], g[1], c[0], c[1]] +} + +#[track_caller] +fn assert_all(got: [f32; 4], want: f32) { + assert!(got.iter().all(|v| (v - want).abs() < 0.01), "{got:?}, want {want}"); +} + +#[test] +fn an_edited_lut_file_renders_again_from_warm_caches() { + let dir = tempfile::tempdir().unwrap(); + let lut = dir.path().join("grade.cube"); + write(&lut, BLACK, 1_000); + let mut s = session(None); + let comps = shot(&mut s, "Apply Color LUT", &[("lut", json!(lut.to_string_lossy()))]); + assert_all(colours(&s, comps), 0.0); + let misses = s.layer_cache.stats().misses; + assert_all(colours(&s, comps), 0.0); + assert_eq!(s.layer_cache.stats().misses, misses, "an unchanged file keeps the cached layers"); + // A frame on disk made with the file names it. + let frames = DiskCache::open(dir.path().join("frames"), 1 << 30).unwrap(); + let key = frame_key(comp_content_key(&s.project, comps.1), 0, 1000, 0); + let log = ReadLog::new(); + let mut r = s.renderer(RenderOpts::default()); + r.reads = Some(&log); + let _ = r.comp_frame(comps.1, Tick::ZERO); + let files = log.into_files(); + assert!(files.get(&lut.to_string_lossy()).is_some(), "the render notes the LUT file: {files:?}"); + frames.put_frame_now(key, 1, 1, &[0; 4], &files); + assert!(frames.get_frame(key, None).is_some()); + let generation = effectcraft_effects::lut_generation(); + write(&lut, WHITE, 2_000); + assert_all(colours(&s, comps), 1.0); + assert!(effectcraft_effects::lut_generation() > generation, "the viewer's frames made before are dropped"); + assert!(frames.get_frame(key, None).is_none(), "the frame on disk made with the old file"); +} + +#[test] +fn an_edited_lut_file_is_not_served_from_the_disk_cache() { + let dir = tempfile::tempdir().unwrap(); + let (lut, disk) = (dir.path().join("grade.cube"), dir.path().join("cache")); + write(&lut, BLACK, 1_000); + let mut s = session(Some(&disk)); + let comps = shot(&mut s, "Apply Color LUT", &[("lut", json!(lut.to_string_lossy()))]); + assert_all(colours(&s, comps), 0.0); + let dc = s.disk_cache.clone().unwrap(); + dc.flush(); + assert!(dc.stats().entries > 0, "layer buffers written"); + let project = s.project.clone(); + drop((s, dc)); + // A restart on the same cache, after the file changed. + write(&lut, WHITE, 2_000); + let mut s = session(Some(&disk)); + s.project = project; + s.bump(); + assert_all(colours(&s, comps), 1.0); +} + +#[test] +fn a_lut_file_created_deleted_or_repaired_renders_again() { + let dir = tempfile::tempdir().unwrap(); + let lut = dir.path().join("later.cube"); + let mut s = session(None); + let comps = shot(&mut s, "Apply Color LUT", &[("lut", json!(lut.to_string_lossy()))]); + assert_all(colours(&s, comps), GREY); + write(&lut, WHITE, 1_000); + assert_all(colours(&s, comps), 1.0); + std::fs::remove_file(&lut).unwrap(); + assert_all(colours(&s, comps), GREY); + write(&lut, "not a LUT", 2_000); + assert_all(colours(&s, comps), GREY); + write(&lut, BLACK, 3_000); + assert_all(colours(&s, comps), 0.0); +} + +#[test] +fn lumetri_follows_its_input_lut_file() { + let dir = tempfile::tempdir().unwrap(); + let lut = dir.path().join("input.cube"); + write(&lut, BLACK, 1_000); + let mut s = session(None); + let comps = shot(&mut s, "Lumetri Color", &[("basicCorrection/inputLut", json!(1)), ("basicCorrection/inputLutFile", json!(lut.to_string_lossy()))]); + assert_all(colours(&s, comps), 0.0); + write(&lut, WHITE, 2_000); + assert_all(colours(&s, comps), 1.0); +} + +/// A precomp instance whose Essential Property names another LUT file follows that file, also +/// for a layer that reads the instance through a layer parameter. +#[test] +fn an_overridden_lut_file_renders_again() { + let dir = tempfile::tempdir().unwrap(); + let (base, overridden) = (dir.path().join("base.cube"), dir.path().join("override.cube")); + write(&base, WHITE, 1_000); + write(&overridden, BLACK, 1_000); + let mut s = session(None); + s.execute_checked("comp.new", json!({"name": "Grade", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap(); + let grade = s.active_comp_id().unwrap(); + let plate = s.execute_checked("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 8, "height": 8})).unwrap()["layer"].clone(); + s.execute_checked("effect.apply", json!({"layer": plate, "effect": "Apply Color LUT"})).unwrap(); + s.execute_checked("prop.set", json!({"layer": plate, "path": "effects/#1/lut", "value": base.to_string_lossy()})).unwrap(); + let control = s.execute_checked("essential.addProperty", json!({"layer": plate, "path": "effects/#1/lut", "name": "LUT"})).unwrap()["controls"][0].clone(); + s.execute_checked("comp.new", json!({"name": "Shot", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap(); + let shot = s.active_comp_id().unwrap(); + let instance = s.execute_checked("layer.addItem", json!({"item": grade.0})).unwrap()["layer"].clone(); + s.execute_checked("essential.set", json!({"layer": instance, "control": control, "value": overridden.to_string_lossy()})).unwrap(); + let copy = s.execute_checked("layer.newSolid", json!({"name": "Copy", "color": "#00ff00", "width": 8, "height": 8})).unwrap()["layer"].clone(); + s.execute_checked("effect.apply", json!({"layer": copy, "effect": "Set Channels"})).unwrap(); + for i in 1..=3 { + s.execute_checked("prop.set", json!({"layer": copy, "path": format!("effects/#1/sourceLayer{i}"), "value": instance})).unwrap(); + } + let centre = |s: &Session| { + let img = s.render(shot, Tick::ZERO, RenderOpts::default()); + let p = img.get(i64::from(img.width / 2), i64::from(img.height / 2)); + [p[0], p[1], p[0], p[1]] + }; + assert_all(centre(&s), 0.0); + write(&overridden, WHITE, 2_000); + assert_all(centre(&s), 1.0); +} diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index dc4216098..5e8a8845b 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -33,6 +33,8 @@ impl Dependencies<'_> { { for g in fx.groups().take(limit.unwrap_or(usize::MAX)).filter(|g| g.enabled) { self.group(ctx, layer, g, 0)?; + // A LUT file edited on disk changes the pixels without an edit (#3493). + crate::reads::lut_files(ctx, layer, g).hash(self.h); } } self.other_times = previous; @@ -169,6 +171,17 @@ impl Dependencies<'_> { ItemKind::Comp(comp) => { let time = ctx.source_time(layer); time.0.hash(self.h); + // The nested layers are walked without this instance's Essential + // Properties overrides, which the render applies: a LUT file one of + // them names is read too (#3493). + for o in crate::fx_host::essential_overrides(ctx, layer) { + if let effectcraft_keyframe::Value::Str(src) = &o.value + && let Some(path) = effectcraft_effects::lut_file(src) + { + crate::reads::ReadFile::new(path, crate::reads::FileStamp::native(path).unwrap_or(crate::reads::FileStamp::Missing)) + .hash(self.h); + } + } let nested = EvalCtx::new(ctx.project, item, comp, time); for child in &comp.layers { self.layer(&nested, child)?; diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index b1ec0ca02..900a06e2a 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -757,6 +757,15 @@ impl<'a> Renderer<'a> { return; } let Some(fx) = layer.effects() else { return }; + // The LUT files the stack reads go to this render's files: a frame on disk made with one + // that changed since is not used (#3493). + if let Some(log) = self.reads { + for g in fx.groups().take(limit).filter(|g| g.enabled) { + for f in super::reads::lut_files(ctx, layer, g) { + log.note(f); + } + } + } let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; // #659 / #1205: an authored layer's buffer spans only its painted content. Effects that diff --git a/crates/render/src/reads.rs b/crates/render/src/reads.rs index ed97e50fd..9021f1e42 100644 --- a/crates/render/src/reads.rs +++ b/crates/render/src/reads.rs @@ -65,6 +65,23 @@ impl ReadFile { } } +/// The LUT files effect `g` of `layer` reads at the context time (Apply Color LUT, Lumetri's +/// Input LUT and Look), each with its identity now. A layer's cache key holds them and a render +/// notes them, so what was made with a file that changed since is not used (#3493). +pub(crate) fn lut_files(ctx: &crate::EvalCtx, layer: &effectcraft_project::Layer, g: &effectcraft_project::PropGroup) -> Vec { + let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { return Vec::new() }; + effectcraft_effects::lut_params(effect) + .iter() + .filter_map(|param| match ctx.value(layer, g.prop(param)?) { + effectcraft_keyframe::Value::Str(src) => { + let path = effectcraft_effects::lut_file(&src)?; + Some(ReadFile::new(path, FileStamp::native(path).unwrap_or(FileStamp::Missing))) + } + _ => None, + }) + .collect() +} + /// At most this many files are listed per render or cached buffer; more mark the list /// incomplete (a comp reading more files than this for one frame is not checked file by file). pub const MAX_READ_FILES: usize = 4096; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 66387bc31..bf5964ba6 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -97,6 +97,8 @@ impl EffectcraftApp { gpu_failure_applied: None, footage_watcher: None, footage_watch_key: None, + lut_watcher: None, + lut_generation: effectcraft_engine::lut_reload::generation(), device_lost_close: None, dialog_answers: std::sync::mpsc::channel(), file_dialogs: FileDialogsUp::default(), diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index c3a5bfbf6..488b3c5ef 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -654,6 +654,23 @@ impl EffectcraftApp { ctx.request_repaint_after(std::time::Duration::from_secs(10)); } self.tick_footage_reload(ctx); + self.tick_lut_files(ctx); + } + + /// A LUT file in use changed on disk (#3493): the frames kept were rendered with the old one. + /// Renders read it again by themselves; the watcher wakes the interface when no render did. + /// The frame keys carry the generation ([`Self::frame_series`]), so the viewers show the + /// frames rendered again, and a frame still rendering with the old LUT is never asked for. + fn tick_lut_files(&mut self, ctx: &egui::Context) { + if self.lut_watcher.is_none() { + let wake = ctx.clone(); + self.lut_watcher = effectcraft_engine::lut_reload::Watcher::start(move || wake.request_repaint()); + } + let generation = effectcraft_engine::lut_reload::generation(); + if generation != self.lut_generation { + self.lut_generation = generation; + self.frames.clear(); + } } /// One background worker tracks source availability and optional content reloads. It wakes diff --git a/crates/ui-egui/src/app/viewer_frames.rs b/crates/ui-egui/src/app/viewer_frames.rs index fe3ad8abb..d7c02a99c 100644 --- a/crates/ui-egui/src/app/viewer_frames.rs +++ b/crates/ui-egui/src/app/viewer_frames.rs @@ -128,7 +128,7 @@ impl EffectcraftApp { frame: 0, scale: (scale * 1000.0).round() as u32, view: self.view_hash(comp), - opts: frames::opts_hash(&self.frame_opts(comp, scale)) ^ self.roto_model_stamp(), + opts: frames::opts_hash(&self.frame_opts(comp, scale)) ^ self.roto_model_stamp() ^ self.lut_stamp(), } } @@ -142,6 +142,13 @@ impl EffectcraftApp { h.finish() | 1 } + /// Changes when a LUT file in use changed on disk (#3493): the frame on screen and the ones + /// still rendering were made with the old LUT, so the new ones must not share their keys + /// (the viewers show a frame again only when its key changes). 0 until a LUT file changes. + fn lut_stamp(&self) -> u64 { + self.lut_generation.wrapping_mul(0x9e37_79b9_7f4a_7c15) + } + /// The frame series the viewer of `comp` shows now (its scale, view and options, for the /// comp's current content): what the cache bars count. pub fn shown_series(&self, comp: ItemId) -> FrameKey { @@ -238,3 +245,44 @@ impl EffectcraftApp { request(&self.frames, &self.render_source(), key, comp, t, opts); } } + +#[cfg(test)] +mod tests { + /// #3493: the viewer shows the frame again once the LUT file it was graded with changes, + /// though its key was otherwise the same (same project revision, frame and options). + #[test] + fn the_viewer_shows_a_lut_file_changed_on_disk() { + let dir = tempfile::tempdir().unwrap(); + let lut = dir.path().join("viewer.cube"); + let write = |text: &str, secs: u64| { + std::fs::write(&lut, text).unwrap(); + let f = std::fs::File::options().write(true).open(&lut).unwrap(); + f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)).unwrap(); + }; + write("LUT_1D_SIZE 2\n0 0 0\n0 0 0\n", 1_000); + let mut session = effectcraft_engine::Session::default(); + session.execute("comp.new", serde_json::json!({"width": 64, "height": 36, "duration": 2})).unwrap(); + session.execute("layer.newSolid", serde_json::json!({"color": "#808080"})).unwrap(); + session.execute("effect.apply", serde_json::json!({"layer": 1, "effect": "Apply Color LUT"})).unwrap(); + session.execute("prop.set", serde_json::json!({"layer": 1, "path": "effects/#1/lut", "value": lut.to_string_lossy()})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| crate::EffectcraftApp::new(session)); + let centre = |h: &egui_kittest::Harness<'_, crate::EffectcraftApp>| { + h.state().viewer_image.as_ref().map(|img| img.pixels[img.pixels.len() / 2 + img.size[0] / 2].r()) + }; + let settle = |h: &mut egui_kittest::Harness<'_, crate::EffectcraftApp>, want: u8| { + for _ in 0..400 { + h.step(); + if centre(h) == Some(want) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the viewer shows {:?}, not {want}", centre(h)); + }; + settle(&mut harness, 0); + write("LUT_1D_SIZE 2\n1 1 1\n1 1 1\n", 2_000); + // (what the app's watcher does every two seconds) + effectcraft_engine::lut_reload::check(); + settle(&mut harness, 255); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2e911ea54..699f68e0f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -373,6 +373,10 @@ pub struct EffectcraftApp { footage_watcher: Option, /// The project revision and preference the watched set was last computed for. footage_watch_key: Option<(u64, String, bool)>, + /// The worker that stamps the LUT files in use ([`Self::tick_lut_files`], #3493). + lut_watcher: Option, + /// The LUT generation the frames kept were rendered at ([`effectcraft_engine::lut_reload::generation`]). + lut_generation: u64, /// The graphics device was lost and the window is closing (see `close_after_device_loss`); /// `true` keeps crash recovery for the next launch. device_lost_close: Option, diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 038ed5e15..2e34757bf 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -608,9 +608,8 @@ pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) { ui.data_mut(|d| d.insert_temp(told, dock)); acts.push(Act::Resize(id, dock)); } - let content = rect; - egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| { - let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover()); + let window = vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32); + panel_scroll(ui, ("scriptui-panel", id), rect.size(), window, |ui, r| { draw_children(app, ui, r.min, id, &w.root, &mut acts); mouse_events(ui, r.min, r.intersect(ui.clip_rect()), id, &w.root, !modal, &mut acts); }); @@ -619,6 +618,36 @@ pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) { perform(app, &ctx, acts); } +/// The scroll area a docked ScriptUI panel shows its `window`-sized window in, at most `dock` +/// big, with the app's scroll style (solid bars, #269). `body` draws the window in the rect it is +/// given. +fn panel_scroll( + ui: &mut egui::Ui, + salt: impl std::hash::Hash + std::fmt::Debug, + dock: egui::Vec2, + window: egui::Vec2, + body: impl FnOnce(&mut egui::Ui, Rect) -> R, +) -> egui::scroll_area::ScrollAreaOutput { + egui::ScrollArea::both().id_salt(salt).max_width(dock.x).max_height(dock.y).scroll_bar_visibility(panel_bars(dock, window)).show(ui, |ui| { + let (r, _) = ui.allocate_exact_size(window, Sense::hover()); + body(ui, r) + }) +} + +/// Whether a docked panel's scroll bars may show: never while its window fits the dock. +/// +/// The fit is decided from the window and the dock, not from what the scroll area has left. A +/// solid bar, once shown, takes its width from the viewport, so a window that exactly fills its +/// dock was "too large" for the narrower viewport and its bars never went away (#2796). The fit +/// rounds the dock up to the whole point, as egui's scroll area does, because the script is told +/// its dock's size rounded (#2585) and its window can be a fraction of a point bigger. A window +/// that overflows only one way still gets both bars: the first one's width hides the edge of a +/// window laid out at the dock's full size, and the second one scrolls to it. +fn panel_bars(dock: egui::Vec2, window: egui::Vec2) -> egui::scroll_area::ScrollBarVisibility { + use egui::scroll_area::ScrollBarVisibility; + if window.x <= dock.x.ceil() && window.y <= dock.y.ceil() { ScrollBarVisibility::AlwaysHidden } else { ScrollBarVisibility::VisibleWhenNeeded } +} + /// Tab title of a script panel. pub fn panel_title(app: &EffectcraftApp, id: u32) -> Option { app.session.script_ui.window(id).map(|w| if w.title.is_empty() { w.script.clone() } else { w.title.clone() }) @@ -640,6 +669,70 @@ pub fn sync_panels(app: &mut EffectcraftApp) { mod tests { use super::*; + const DOCK: egui::Vec2 = egui::vec2(300.0, 200.0); + + /// Twelve frames of a ScriptUI panel docked in `dock`, with the app's solid scroll bars + /// (#269): its window is `first` big on the first frame and `then` big after that. The last + /// frame's scroll output (viewport and content size). + fn panel_frames(dock: egui::Vec2, first: egui::Vec2, then: egui::Vec2) -> (Rect, egui::Vec2) { + let ctx = egui::Context::default(); + ctx.all_styles_mut(|s| s.spacing.scroll = egui::style::ScrollStyle::solid()); + let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0)); + let mut last = (Rect::NOTHING, egui::Vec2::ZERO); + for frame in 0..12 { + let window = if frame == 0 { first } else { then }; + let input = egui::RawInput { screen_rect: Some(screen), time: Some(f64::from(frame) * 0.1), predicted_dt: 0.1, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + let mut panel = ui.new_child(UiBuilder::new().max_rect(Rect::from_min_size(egui::Pos2::ZERO, dock))); + let o = panel_scroll(&mut panel, "panel-test", dock, window, |_, _| ()); + last = (o.inner_rect, o.content_size); + }); + // Nothing paints these frames, and egui expects their texture uploads to be consumed. + out.textures_delta.clear(); + } + last + } + + /// #2796: a solid bar, once shown, took its width from the viewport, so a window that exactly + /// fills its dock stayed "too large" for what was left and kept its bars for good. A window + /// that overflowed for one frame (a dock dragged smaller before the script learns its size) + /// and then fits gets the whole dock back. + #[test] + fn a_panel_whose_window_fits_hides_its_bars() { + let (viewport, _) = panel_frames(DOCK, DOCK + vec2(40.0, 40.0), DOCK); + assert_eq!(viewport.size(), DOCK, "the bars go away once the window fits"); + } + + /// The script is told its dock's size rounded (#2585), so in a dock a fraction of a pixel + /// smaller its window is that fraction bigger. That still fits. + #[test] + fn a_panel_in_a_fractional_dock_hides_its_bars() { + let dock = vec2(299.6, 199.6); + let (viewport, _) = panel_frames(dock, dock + vec2(40.0, 40.0), DOCK); + // egui snaps rects to 1/32 of a point; a bar would take 10 points. + assert!((viewport.size() - dock).length() < 0.05, "a window within the whole pixel fits ({viewport:?})"); + } + + /// #269 holds: a window bigger than its dock keeps solid bars that take their room from the + /// panel, and scrolls. + #[test] + fn a_panel_whose_window_is_too_big_keeps_its_solid_bars() { + let big = DOCK + vec2(120.0, 80.0); + let (viewport, content) = panel_frames(DOCK, big, big); + let bar = egui::style::ScrollStyle::solid().allocated_width(); + assert_eq!(viewport.size(), DOCK - vec2(bar, bar), "solid bars on both axes"); + assert_eq!(content, big, "the whole window is scrollable"); + } + + #[test] + fn panel_bars_only_show_for_a_window_bigger_than_its_dock() { + use egui::scroll_area::ScrollBarVisibility::{AlwaysHidden, VisibleWhenNeeded}; + assert_eq!(panel_bars(DOCK, DOCK), AlwaysHidden); + assert_eq!(panel_bars(vec2(299.4, 199.4), DOCK), AlwaysHidden); + assert_eq!(panel_bars(DOCK, DOCK + vec2(1.0, 0.0)), VisibleWhenNeeded); + assert_eq!(panel_bars(DOCK, DOCK + vec2(0.0, 1.0)), VisibleWhenNeeded); + } + /// Script colours are sRGB, not linear: `#2B2B2B` drew as RGB 114 when read as linear light. #[test] fn draw_colours_are_srgb() { diff --git a/docs/aep-format.md b/docs/aep-format.md index 8830a869e..0b73dcfd5 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -1,6 +1,6 @@ # After Effects project format (`.aep`) -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-07 · **Change:** Puppet (`ADBE FreePin3`, 3.7): the property tree from an external scripting listing, **?** until a byte sample exists (#3093); before that: footage: an image sequence without a filename prefix stores only the suffix `Utf8`, and one imported with Force alphabetical order stores its file list in `LIST StVc` / `StVS` (section 3, footage); before that: versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** footage: the Photoshop layer an item shows and its document's size (`opti` tag `8BPS`, section 3.3, issue 3087); before that: Puppet (`ADBE FreePin3`, 3.7): the property tree from an external scripting listing, **?** until a byte sample exists (#3093); before that: footage: an image sequence without a filename prefix stores only the suffix `Utf8`, and one imported with Force alphabetical order stores its file list in `LIST StVc` / `StVS` (section 3, footage); before that: versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026 What `crates/aep` ([`effectcraft-aep`](../crates/aep/README.md)) reads and writes, and the evidence for each statement. Everything here was observed on project files we generated ourselves; no @@ -263,6 +263,7 @@ property of `L` after 229 and 230). The corpus manifest calls the classic render | footage size | `Pin /sspc` | 30, 34 | u32, u32 | width, height in pixels | 000 (1920 x 1080), 022 (100 x 100 null), 126 (8 x 8), 211, 212, 259 | V | | footage name | `Pin /opti` | 26 (solid), 58 (file) | ASCII, NUL terminated | the item's own `Utf8` name is empty for footage | 000 (`L`), 022 (`Null 1`), 126 (`test8x8.png`), 211 (`S100`), 259 (`Blue`) | V | | **solid colour** | `Pin /opti` | 10, 14, 18, 22 | 4 x f32 | ARGB, 0..1: red [1, 0, 0] is `1, 1, 0, 0`, blue [0, 0, 1] is `1, 0, 0, 1` | 000, 259 | V | +| **Photoshop layer** | `Pin /opti` (tag `8BPS`) | 14; 32, 36; 78, 82, 86, 90; 344 | i32 BE; 2 x u32 **LE**; 4 x i32 **LE**; UTF-8, NUL terminated | an item made from one layer of a `.psd`: the layer record's index in the file (bottom of the stack = 0; -1 is the merged image), the document's two sides (which is the width: **?**, the one sample is square), the layer's bounds top, left, bottom, right in document pixels (all 0 for a layer without pixels, such as an adjustment layer) and the layer's name. The item's size (`sspc` 30 / 34) is the bounds' size with Retain Layer Sizes, else the document's. `Footage::psd_layer` | the project of issue 3087: ten layers, each index, bounds and name equal to the `.psd`'s layer record at that position (`Layer 1` `[9, -346, 1083, 1086]`, item 1432 x 1074; `test` `[749, 800, 764, 848]`, item 48 x 15; 32 and 36 hold 1000, 1000 in every record, the `.psd`'s size) | V | | file path | `Pin /Als2/alas` | key `fullpath` | JSON string | alias JSON of a file footage item | 126 (the fixture holds a neutral path) | V | | precomp linkage | `Layr/ldta` | 40 | u32 | the layer's source id is the id of the precomp's item | 124 | V | diff --git a/docs/aep-import.md b/docs/aep-import.md index e76468962..9d44006b2 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** an effect without a counterpart arrives as a placeholder with its parameters, values, keys and expressions; it renders nothing and is still reported (#3087); before that: the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss; an expression on Orientation is imported with its value or keys (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** an effect without a counterpart arrives as a placeholder with its parameters, values, keys and expressions; it renders nothing and is still reported (#3087); before that: an item made from one layer of a Photoshop file shows that layer (#3087); before that: the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss; an expression on Orientation is imported with its value or keys (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -70,7 +70,7 @@ outside the sampled ones keeps the current value and is reported as `project set | Shape layers | groups (nested, with anchor, position, scale, skew, skew axis, rotation, opacity), rectangle (size, position, roundness), ellipse (size, position), polystar (star or polygon, points, position, rotation, radii, roundness), bezier path (vertices and tangents in layer pixels, path keys), fill (colour, opacity, fill rule), stroke (colour, opacity, width, cap, join, miter limit), gradient fill and stroke (linear or radial, start and end point; the default white-to-black colours), trim paths (start, end, offset, trim multiple shapes), round corners (radius), merge paths (mode; 707, 708), offset paths (amount, line join, miter limit, copies, copy offset; 710; After Effects offsets copy *i* by Amount x (*i* + Copy Offset), default 1, EffectCraft by Amount x (*i* + 1 + copyOffset), so the copy offset arrives 1 less, also its keys; an expression on it is kept and reported; 826-828, 828 renders equal to After Effects on every grid point), repeater (copies, offset, composite, transform; 709), in file order, with keyframes and expressions, the operators' defaults as After Effects reads them back (731); the layer anchor in pixels; 238-242, 330-333, 451-484, 519, 705-710, 731 | | Markers | layer and composition markers: time (layer time for layer markers, exact frames), duration (1/600 s units, 509, 510), comment, chapter, URL, frame target, label, cue point (name, event or navigation, parameters); 122, 123, 249, 347-349, 509, 510 | | Time Remapping | an enabled remap becomes the `timeRemap` property (source seconds, keys in layer time with their interpolation and ease, expression), first in the layer's properties as Layer > Time > Enable Time Remapping puts it; 319, 354 | -| Footage | a link to the stored path; **missing-footage placeholder** when the path does not exist where the import runs (listed in `missingFootage`), a normal link when it does. The importer never opens footage. **An image sequence** (its alias is the folder) becomes a sequence of its files, named from the stored numbering (`shot_0101.exr` .. `shot_0103.exr` in 603, `seq_001.png` .. in 353), at its native frame rate; the first file is the footage's path and the one checked. Sequences and clips take the stored native frame rate and duration (603, 606). A sequence of more than 100 000 frames, or beyond a million file names in one import, links only its first file and is reported | +| Footage | a link to the stored path; **missing-footage placeholder** when the path does not exist where the import runs (listed in `missingFootage`), a normal link when it does. The importer never opens footage. **An image sequence** (its alias is the folder) becomes a sequence of its files, named from the stored numbering (`shot_0101.exr` .. `shot_0103.exr` in 603, `seq_001.png` .. in 353), at its native frame rate; the first file is the footage's path and the one checked. Sequences and clips take the stored native frame rate and duration (603, 606). A sequence of more than 100 000 frames, or beyond a million file names in one import, links only its first file and is reported. **One layer of a Photoshop file** shows that layer (`Footage::layer`: its index and name; at its own size with Retain Layer Sizes, else on the document) and is named `/` unless the item has a name; a layer without pixels (an adjustment layer) keeps the merged image and is reported; so is a layer as big as the document but not at its corner, which the item's size does not tell apart (shown at its own size), and an item as big as neither the layer nor the document (shown on the document) | Units, the "absent = default" rule and the keyframe templates are the reader's ([aep-format.md](aep-format.md), sections 3.3–3.6). The importer converts them to EffectCraft's: @@ -363,7 +363,7 @@ for the `unlisted` ones. The categories: | `parent_links` | a parent that was not imported (a skipped camera, a missing layer) or that forms a loop | | `track_mattes` | a track matte of unknown mode, whose matte layer was not imported or does not exist, a layer matted by itself, or a matte of the older "layer above" form on the top layer | | `unsupported_layers` | an unknown layer type, a layer without a usable source, a precomposition loop (the layer is cut) | -| `unsupported_items` | a project item of an unknown kind | +| `unsupported_items` | a project item of an unknown kind; an item made from a Photoshop layer without pixels (it shows the merged image), or from one as big as the document but not at its corner (shown at its own size), or an item as big as neither the layer nor the document (shown on the document) | | `damaged_data` | a header, key or value that could not be read, numbers that are not finite or beyond ±1e9 (default used); an unreadable text document (the text layer is imported with empty text), damaged style runs | `limitations` states what the reader cannot see at all, so it can be neither imported nor counted: