diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 4499e3fdc..2a8144a5b 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -29,7 +29,7 @@ permissions: jobs: windows-arm64: # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 with: app-name: effectcraft app-dir: EffectCraft diff --git a/Cargo.lock b/Cargo.lock index 04548afbd..a48f2d44b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1623,6 +1623,7 @@ name = "effectcraft-media" version = "0.18.0" dependencies = [ "craft-docpath", + "effectcraft-color", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", diff --git a/README.md b/README.md index c1f895176..601f268b0 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,10 @@ a few places where it matters to us: without a plugin. - **A project file you can read.** Projects are versioned JSON (`.ecproj`), so they diff cleanly in version control. -- **Everything is scriptable.** Every menu item, timeline drag and property edit goes through one - command registry, so the same actions are reachable from a command line, a JSON control - channel and an MCP server for agents. +- **Everything is scriptable.** Menu actions, timeline edits and property changes are exposed as + commands. Engine commands are available through the CLI, JSON control channel and MCP; + UI-only commands use the frontend dispatcher and can be invoked through the control channel + or an attached MCP bridge. - **No FFmpeg.** Video and audio decoding and encoding are pure Rust: FilmCraft's codecs and EffectCraft's own VP9, AV1, HEVC and Opus encoders. @@ -173,8 +174,10 @@ cannot express; File ▸ Import ▸ Lottie… opens one as a new composition. ## Built for agents -Everything you can do from a menu is a command with an id, and agents can reach every one of -them: +Engine commands are available through the CLI, JSON control channel and MCP. UI-only commands +need the running app's frontend dispatcher: complete menu access requires the app's control +channel or an attached MCP bridge. Headless MCP sessions expose supported engine commands, +but not UI-only commands or bridge-only `ui_*` tools. - **MCP server:** `effectcraft-cli mcp` speaks the Model Context Protocol over stdio, headless or bridged to the running app (`--bridge 9877`). Tools cover commands, the project and property diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs index da78ae116..d5de8f687 100644 --- a/crates/automation/tests/qa/compositing.rs +++ b/crates/automation/tests/qa/compositing.rs @@ -35,7 +35,7 @@ fn import_footage(qa: &mut Qa) -> (Value, Value) { // Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸ // Photoshop Layers) from a small comp. let seq = write_sequence(qa); - let imp = qa.exec("file.import", json!({"paths": [seq]})); + let imp = qa.exec("file.import", json!({"paths": [seq], "sequence": true})); let seq_item = imp["items"][0].clone(); let project = qa.tool("get_project", json!({})); let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone(); diff --git a/crates/automation/tests/qa/tracking.rs b/crates/automation/tests/qa/tracking.rs index 837872658..ee6bbb2e9 100644 --- a/crates/automation/tests/qa/tracking.rs +++ b/crates/automation/tests/qa/tracking.rs @@ -52,7 +52,7 @@ fn shake_offset(f: u32) -> (i64, i64) { /// Import a sequence at 12 fps and make a comp of it; returns (comp, layer). fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) { - let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone(); + let item = qa.exec("file.import", json!({"paths": [first], "sequence": true}))["items"][0].clone(); qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12})); let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone(); diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index cd9153d66..2aab70309 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -206,6 +206,12 @@ pub fn reverses(id: &str) -> bool { id == "ec.audio.backwards" } +/// Does each output sample depend only on the input sample at the same time (no state), so the +/// mixer may run the effect in short pieces with parameters animated between them? +pub fn per_sample(id: &str) -> bool { + matches!(id, "ec.audio.stereomixer" | "ec.audio.backwards") +} + /// Seconds of input history the effect needs before a block to reach the state it would have /// had when processing continuously. pub fn preroll(id: &str, params: &Params) -> f64 { diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 08241107c..dc65c3255 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -7,6 +7,11 @@ //! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through //! (EXtractoR still maps the layer's own R, G, B and A). //! +//! 3D Layer Instance (Create 3D Layer Instance's own effect, Layer ▸ Create) is not an aux +//! reader: it replaces the layer's pixels with a referenced layer's render — for a 3D model +//! layer, its render on its own through the comp's camera — so 2D effects can be applied to a +//! 3D layer's render. +//! //! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer //! pixel's layer position, so render scale and layer padding are respected. @@ -582,6 +587,19 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// A 2D copy of a referenced layer (Create 3D Layer Instance's solid): the layer's pixels +/// replaced with the referenced layer's render, alpha included — the copy is transparent +/// where the referenced layer isn't. A 3D model or primitive layer is rendered on its own +/// through the comp's camera, as After Effects renders 3D layers; another layer is read with +/// its masks and effects. With no layer chosen the effect passes the layer through, as a layer +/// parameter with nothing chosen does. +fn instance_3d(ctx: &EffectCtx, mut b: Buf) -> Buf { + if let Some(o) = ctx.layer_param("layer", true) { + b.img = crate::util::fit_layer(ctx, &b, &o, false); + } + b +} + pub fn specs() -> Vec { let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text); vec![ @@ -682,6 +700,7 @@ pub fn specs() -> Vec { ], identifier, ), + spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d), ] } diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs index 4fc84440e..e69c2a6aa 100644 --- a/crates/effects/src/channel3d/tests.rs +++ b/crates/effects/src/channel3d/tests.rs @@ -373,3 +373,29 @@ fn channel_extract_antialias_averages_sub_pixels() { let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host); assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5)); } + +#[test] +fn instance3d_replaces_the_pixels_with_the_referenced_layer() { + // Create 3D Layer Instance's copy: the chosen layer's render takes the place of the layer's + // own pixels, alpha included — the copy is transparent where the referenced layer isn't — + // and with no layer chosen the layer passes through. + struct Host(Image); + impl EffectHost for Host { + fn layer(&self, id: u64, _: bool) -> Option { + (id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + let solid = || Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]); + let mut render = Image::new(4, 4); + render.set(1, 1, [1.0, 0.0, 0.0, 1.0]); + let env = EffectEnv { host: Some(&Host(render)), ..Default::default() }; + let out = run_fx("ec.3d.instance3d", &[("layer", Value::Layer(Some(7)))], solid(), 0.0, env); + assert_eq!(out.img.get(1, 1), [1.0, 0.0, 0.0, 1.0], "the referenced layer's pixel"); + assert_eq!(out.img.get(3, 3), [0.0; 4], "transparent where the referenced layer isn't"); + // No layer chosen: the layer's own pixels. + let out = run_fx("ec.3d.instance3d", &[], solid(), 0.0, EffectEnv::default()); + assert_eq!(out.img.data, solid().data); +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 56e56bb61..117506807 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -312,15 +312,16 @@ impl Buf { self.offset[0] += l as f64; self.offset[1] += t as f64; } - /// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`]. + /// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`] and as + /// clamped by [`Image::effective_pad`]. pub fn pad(&mut self, pad: u32) { - let pad = pad_within_budget(self.img.width, self.img.height, pad); - if pad == 0 { + let eff = self.img.effective_pad(pad_within_budget(self.img.width, self.img.height, pad)); + if eff == 0 { return; } - self.img = self.img.padded(pad); - self.offset[0] += pad as f64; - self.offset[1] += pad as f64; + self.img = self.img.padded(eff); + self.offset[0] += eff as f64; + self.offset[1] += eff as f64; } } @@ -1384,6 +1385,20 @@ mod tests { assert_eq!(out.img.data, img.data); } + #[test] + fn pad_offset_follows_the_clamped_pad() { + // Image::padded clamps a hostile pad to keep each side within MAX_PADDED_SIDE; the offset + // must move by the pad actually applied, or the content drifts from its layer position. + let mut img = Image::new(29_990, 2); + img.set(0, 0, [1.0, 0.0, 0.0, 1.0]); + let mut buf = Buf { img, offset: [3.0, 4.0], scale: 1.0 }; + buf.pad(u32::MAX); + // Width binds: (MAX_PADDED_SIDE - 29 990) / 2 = 5. + assert_eq!(buf.offset, [8.0, 9.0]); + let (x, y) = buf.to_px([-3.0, -4.0]); + assert_eq!(buf.img.get(x as i64, y as i64), [1.0, 0.0, 0.0, 1.0]); + } + #[test] #[ignore] fn dump_registry() { diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs index 434cdd8af..fe04f882b 100644 --- a/crates/engine/src/analysis/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -207,7 +207,7 @@ pub(crate) fn source_frame( t: Tick, expr: Option<&dyn ExprHost>, ) -> Option<(Arc, [f64; 2])> { - let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None }; + let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] }; if let effectcraft_project::LayerSource::Footage { item } = &layer.source && let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind) && f.has_video diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 850d38ada..6c4b02c87 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -1,14 +1,17 @@ //! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text, -//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage). +//! Create Masks from Text, Create Shapes from Vector Layer (SVG footage) and Create 3D Layer +//! Instance (a 2D copy of a 3D model layer that 2D effects can be applied to). use effectcraft_color::Label; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ItemKind, LayerSource, Solid}; use serde_json::{Value, json}; use super::{CommandSpec, bad, has_layers, layers_p, str_p}; use crate::{EngineError, Result, Session, cmd}; -const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector"; +const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d"; fn create(s: &mut Session, p: &Value) -> Result { let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?; @@ -19,6 +22,69 @@ fn create(s: &mut Session, p: &Value) -> Result { let time = s.time(); let mut created = vec![]; let mut skipped = vec![]; + // All the selected models in one edit: one undo (or redo) removes (or restores) every + // instance at once, as one command in the History panel. + if op == "instance3d" { + // After Effects' Create 3D Layer Instance (Layer ▸ Create): 3D model layers take + // no masks or effects, so a comp-sized solid goes at the top of the layer stack + // with a 3D Layer Instance effect referencing the model — a 2D copy of its render, + // through the comp's camera, that 2D effects can be applied to. The model is switched + // off, as After Effects does (back on to keep its shadow). + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let mut eligible = vec![]; + for lid in &lids { + match comp.layer(*lid) { + Some(src) if src.source.is_model() => eligible.push((*lid, src.clone())), + Some(src) => skipped.push(format!("{}: not a 3D model layer", src.name)), + None => {} + } + } + if !eligible.is_empty() { + let ids = s.edit("Create 3D Layer Instance", None, |proj, st| { + let mut ids = vec![]; + for (lid, src) in &eligible { + let name = proj.comp(cid).ok_or(EngineError::NoComp)?.unique_layer_name(&format!("{} Instance", src.name)); + let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder)); + let sid = proj.add_item( + &name, + src.label, + Some(folder), + ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }), + ); + let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None); + // A soloed source keeps its Solo switch with video off, and any_solo hides + // the non-soloed copy: the copy takes the Solo over to stay visible. + l.switches.solo = src.switches.solo; + // A 3D Layer Instance effect referencing the 3D layer: the solid shows its + // render (the 2D copy). + let fx = effectcraft_effects::find("ec.3d.instance3d").ok_or_else(|| bad("layer.create", "the 3D Layer Instance effect is missing"))?; + let mut g = effectcraft_effects::instantiate(fx, &mut Ids(&mut proj.next_id), fx.name, [comp.width as f64, comp.height as f64]); + if let Some(pr) = g.get_mut("layer") { + pr.value = KV::Layer(Some(lid.0)); + } + if let Some(fxg) = l.props.sub_mut("effects") { + fxg.children.push(g.into()); + } + // The model is switched off (After Effects turns the referenced layer off); + // the copies go at the top of the stack, selected. + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + if let Some(m) = c.layers.iter_mut().find(|x| x.id == *lid) { + m.switches.video = false; + } + let id = l.id; + c.layers.insert(0, l); + ids.push(id); + } + st.selected_layers = ids.clone(); + Ok(ids) + })?; + created.extend(ids.iter().map(|i| i.0)); + } + if created.is_empty() && !skipped.is_empty() { + return Err(bad("layer.create", skipped.join("; "))); + } + return Ok(json!({"layers": created, "skipped": skipped})); + } for lid in lids { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let Some(src) = comp.layer(lid).cloned() else { continue }; @@ -198,5 +264,5 @@ fn insert_above( } pub fn specs() -> Vec { - vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)] + vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector|instance3d, layers?}", has_layers, create)] } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index ab847ab87..30c027deb 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -146,6 +146,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { st.selected_layers = created.clone(); Ok(created) })?; + // Duplicating selects the copies, so an open text edit on a now-deselected layer ends + // (#2226): typing must not keep editing an unselected layer. + if s.state.text_edit.as_ref().is_some_and(|e| !s.state.selected_layers.contains(&e.layer)) { + super::text_edit::end_edit(s, &Value::Null)?; + } Ok(json!(new.iter().map(|l| l.0).collect::>())) } diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index 003993fde..32b0aaa2b 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -136,7 +136,16 @@ fn warning(s: &mut Session, p: &Value) -> Result { let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?; let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))})) } @@ -160,7 +169,16 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { .filter(|pr| pr.ui == effectcraft_project::ParamUi::Color) .ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?; let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); @@ -288,7 +306,16 @@ impl CryptoProbe { let comp = r.project.comp(cid).ok_or("no composition")?; let layer = comp.layer(lid).ok_or("no such layer")?; let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?; - let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: r.project, + comp_id: cid, + comp, + time, + expr: r.expr, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?; @@ -351,7 +378,16 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?; let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?; let time = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let frame = probe_frame(&ctx, layer, pos); let o = s.render_opts(Default::default()); let prefs = (o.nested_switches, o.draft_shadows); @@ -503,7 +539,16 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result { let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) }; let (cid, lid, ..) = key.at; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string(); let driven = prop.expr.as_ref().is_some_and(|e| e.enabled); let (aux, layer_name) = (b.aux.clone(), b.layer.clone()); diff --git a/crates/engine/src/cmd/expr_tools.rs b/crates/engine/src/cmd/expr_tools.rs index fae74a5b5..cb39077af 100644 --- a/crates/engine/src/cmd/expr_tools.rs +++ b/crates/engine/src/cmd/expr_tools.rs @@ -163,6 +163,8 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"), ("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"), ("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"), + ("bezier(t, value1, value2, [x1, y1, x2, y2])", "bezier(t, value1, value2, [x1, y1, x2, y2])"), + ("bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])", "bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])"), ], ), ( diff --git a/crates/engine/src/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs index 1e57d4b5c..59a824208 100644 --- a/crates/engine/src/cmd/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -878,10 +878,34 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string(); let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?; let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?; - // A numbered still brings its image sequence, as on import (`sequence: false` for one frame). - let opts = SequenceOptions::from_params(p); + // Replacing a still keeps it a still; replacing a sequence keeps bringing the new file's run. + // `sequence`/`alphabetical` override that (#2073). + let previous = match s.project.item(item).map(|it| &it.kind) { + Some(ItemKind::Footage(f)) if f.kind == FootageKind::Sequence => (true, true, f.alphabetical), + _ => (false, false, false), + }; + // Only `sequence` decides whether the item keeps its kind; `alphabetical` is a sequence + // modifier, not a request to make one. + let inherited = b_p(p, "sequence").is_none(); + let mut opts = SequenceOptions::from_params(p); + if inherited { + (opts.sequence, opts.folders) = (previous.0, previous.1); + if previous.0 && b_p(p, "alphabetical").is_none() { + opts.alphabetical = previous.2; + } + } let src = crate::sequence::source_of(s.services.as_ref(), &path, opts); - let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + // A single-file source comes back as a still; an inherited sequence kind keeps it a + // one-frame sequence, so a later default replacement still brings numbered runs (#2073). + if inherited && previous.0 && matches!(src, crate::sequence::Source::File(_)) && f.kind == FootageKind::Still { + f.kind = FootageKind::Sequence; + f.sequence = vec![f.path.clone()]; + f.alphabetical = opts.alphabetical; + f.frame_rate = s.sequence_rate(); + f.native_rate = None; + f.sync_sequence_duration(); + } let name = src.name(); // Replacing a missing file relinks the other missing files that moved with it (as After Effects // does when you locate one). @@ -1093,7 +1117,7 @@ pub fn specs() -> Vec { "File...", ["File", "Replace Footage"], Some("Cmd+H"), - "{path, item?, sequence?: bool (default true: a numbered still brings its image sequence), alphabetical?: bool}", + "{path, item?, sequence?: bool (a numbered still brings its image sequence; default: the item keeps its kind, so a still stays a still and a sequence stays a sequence), alphabetical?: bool}", has_footage_selection, replace_footage ), diff --git a/crates/engine/src/cmd/focus.rs b/crates/engine/src/cmd/focus.rs index 013672a38..356ce1a07 100644 --- a/crates/engine/src/cmd/focus.rs +++ b/crates/engine/src/cmd/focus.rs @@ -78,6 +78,12 @@ fn set_to_layer(s: &mut Session, p: &Value) -> Result { let c = "camera.setFocusToLayer"; let (cid, cam, target) = refs(s, p, c, true)?; let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?; + // Only a finite depth can be stored: JSON has no infinity or NaN, so the project could no + // longer be saved. Extreme but finite positions can still produce one, and `d <= 0.0` is + // false for NaN. + if !d.is_finite() { + return Err(bad(c, "can't measure the distance to the layer (the camera or layer position is out of range)")); + } if d <= 0.0 { return Err(bad(c, "the layer is behind the camera")); } diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index dc6298b85..a6f49abf8 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -9,10 +9,20 @@ pub(crate) struct ParentFix { rotation: f64, /// Multiply Scale. scale: [f64; 2], - /// 3D AV layers retain their world-space anchor, including Z. + /// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also + /// re-express their Point of Interest. position_3d: Option, - /// A uniform relative parent transform can preserve static Orientation and Scale. - pose_3d: Option<([f64; 3], f64)>, + /// A uniform relative parent transform re-expresses static Orientation and Scale. + pose_3d: Option, +} + +/// A uniform relative parent transform, as it applies to a 3D layer's Orientation and Scale. +#[derive(Clone, Copy)] +struct Pose3d { + /// Rotation that Orientation is re-expressed under: `Orientation' = correction * Orientation`. + correction: effectcraft_geom::Mat4, + /// Uniform scale factor, multiplied into Scale (negative for a mirrored parent). + factor: f64, } impl ParentFix { @@ -22,7 +32,7 @@ impl ParentFix { if let Some(m) = self.position_3d { // Spatial Position speed measures distance along its path. Its parent-space // unit changes even when the child's Orientation cannot be re-expressed. - let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs()); + let position_speed = uniform_rotation(m).map(|(_, factor)| factor.abs()); let map_position = |v: &KV| { let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3())); KV::Vec3([q.x, q.y, q.z]) @@ -44,7 +54,8 @@ impl ParentFix { } } } - if let Some(pr) = tr.get_mut("position") { + // Position and a camera's or light's Point of Interest are both points in the parent's space. + let map_point = |pr: &mut effectcraft_project::Property| { pr.value = map_position(&pr.value); for key in &mut pr.keys { key.value = map_position(&key.value); @@ -64,34 +75,19 @@ impl ParentFix { } } } + }; + if let Some(pr) = tr.get_mut("position") { + map_point(pr); } - // Cameras and lights aim with Point of Interest: move it by the same parent-space - // change, so a two-node rig keeps its world frame (#1010, #1020). if let Some(pr) = tr.get_mut("poi") { - pr.value = map_position(&pr.value); - for key in &mut pr.keys { - key.value = map_position(&key.value); - for tangent in [&mut key.spatial_in, &mut key.spatial_out] { - let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent)); - *tangent = [v.x, v.y, v.z]; - } - } - if let Some(factor) = position_speed - .filter(|_| pr.spatial) - .filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite())) - { - for key in &mut pr.keys { - for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { - ease.speed *= factor; - } - } - } + map_point(pr); } - if let Some((orientation, factor)) = self.pose_3d { + if let Some(pose) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { - pr.value = KV::Vec3(orientation); + reexpress_orientation(pose.correction, pr); } if let Some(pr) = tr.get_mut("scale") { + let factor = pose.factor; let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor)); pr.value = scaled(&pr.value); for key in &mut pr.keys { @@ -154,13 +150,10 @@ impl ParentFix { } } -/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal -/// rotation to static Orientation using the existing Rz * Ry * Rx order. -fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> { - use effectcraft_geom::{Mat4, Vec3, vec3}; - if !orientation.iter().all(|v| v.is_finite()) { - return None; - } +/// The rotation and signed scale factor of a uniform parent transform's linear part (negative for a +/// mirrored parent). `None` for a non-uniform or degenerate transform. +fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4, f64)> { + use effectcraft_geom::{Mat4, vec3}; let a = m.0; let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])]; let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z); @@ -179,15 +172,188 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti rotation.0[row][col] = a[row][col] / factor; } } - if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { - return Some((orientation, factor)); - } - let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; + Some((rotation, factor)) +} + +/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation. +fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option { + let m = correction.0; + let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9; + about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees()) +} + +/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock. +fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] { // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. let cy = r[0][0].hypot(r[1][0]); let y = (-r[2][0]).atan2(cy); let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; - Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) + [x.to_degrees(), y.to_degrees(), z.to_degrees()] +} + +/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the +/// `(x + 180, 180 - y, z + 180)` form when that is nearer. +fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) }; + let natural = near(euler); + let flipped = near([euler[0] + 180.0, 180.0 - euler[1], euler[2] + 180.0]); + let distance = |v: [f64; 3]| v.iter().zip(reference.iter()).map(|(a, b)| (a - b).powi(2)).sum::(); + if distance(flipped) < distance(natural) { flipped } else { natural } +} + +/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`. +fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o)); + nearest_euler(euler_zyx(world.0), reference) +} + +/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys. +const BAKE_TOLERANCE_DEGREES: f64 = 0.05; +/// How many times a segment may be halved while baking, and the most keys a property may end up with. +const BAKE_MAX_DEPTH: u32 = 10; +const BAKE_MAX_KEYS: usize = 4096; + +/// The angle (degrees) between two rotations. +fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 { + let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum(); + ((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees() +} + +/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend +/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out` +/// never grows past `budget` keys: what the whole property may still add, not what one segment may. +fn bake_between( + correction: effectcraft_geom::Mat4, + authored: &effectcraft_project::Property, + (ta, ea): (Tick, [f64; 3]), + (tb, eb): (Tick, [f64; 3]), + depth: u32, + budget: usize, + out: &mut Vec, +) { + use effectcraft_geom::{Mat4, Vec3}; + if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget { + return; + } + let tm = Tick(ta.0 + (tb.0 - ta.0) / 2); + let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0]; + let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3())); + // Symmetric easing crosses the straight blend at its midpoint. Check both sides too before + // accepting a segment whose original easing will be replaced with a linear blend. + let quarter = (tb.0 - ta.0) / 4; + if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| { + let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64; + let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u); + let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3())); + rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES + }) { + return; + } + let em = nearest_euler(euler_zyx(exact.0), blend); + bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out); + if out.len() >= budget { + return; + } + out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em))); + bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out); +} + +/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the +/// value and every key. For any other correction a static value is converted directly; keys are +/// converted, and keys are added between them wherever the Euler blend between two keys would drift +/// from the exact rotation (the segments become linear, with the original easing baked in). +fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { + if let Some(turn) = z_only_turn(correction) { + // No turn (an identity correction) leaves every authored value as it is. + if turn.abs() <= 1e-12 { + return; + } + let shift = |value: &KV| { + let [x, y, z] = value.as_vec3(); + KV::Vec3([x, y, z + turn]) + }; + pr.value = shift(&pr.value); + for key in &mut pr.keys { + key.value = shift(&key.value); + } + return; + } + let value = pr.value.as_vec3(); + pr.value = KV::Vec3(reexpress_euler(correction, value, value)); + if pr.keys.is_empty() { + return; + } + let authored = pr.clone(); + // Each key keeps the authored change from the previous key on top of its converted value. + let mut converted: Vec<[f64; 3]> = vec![]; + let mut prev: Option<([f64; 3], [f64; 3])> = None; + for key in &authored.keys { + let o = key.value.as_vec3(); + let reference = match prev { + Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], + None => o, + }; + let n = reexpress_euler(correction, o, reference); + converted.push(n); + prev = Some((o, n)); + } + use effectcraft_keyframe::Interp; + let mut keys: Vec = vec![]; + let mut after_baked_segment = false; + for (i, key) in authored.keys.iter().enumerate() { + let mut k = key.clone(); + k.value = KV::Vec3(converted[i]); + if after_baked_segment { + k.in_interp = Interp::Linear; + k.in_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + } + after_baked_segment = false; + let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold); + let mut between = vec![]; + if let Some(next) = segment { + // What the whole property may still add: the keys it will have already (the ones written + // and the authored ones still to come) count against the cap. + let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i); + bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between); + // With the cap reached the segment can't be checked or subdivided: it keeps its authored easing between + // the converted keys, rather than becoming a straight blend that was never checked against the original. + if between.len() >= budget { + between.clear(); + // An eased segment, or one that gained keys, becomes a run of straight blends. + } else if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear { + k.out_interp = Interp::Linear; + k.out_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + after_baked_segment = true; + } + } + keys.push(k); + keys.extend(between); + } + pr.keys = keys; +} + +/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is +/// `rotation * aim`, so the roll is re-expressed against that aim. `None` for a degenerate aim. +fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option { + use effectcraft_geom::vec3; + use effectcraft_render::three_d::camera::basis_rotation; + let [x, y, z] = aim; + let length = (x * x + y * y + z * z).sqrt(); + if !length.is_finite() || length <= 1e-9 { + return None; + } + let dir = vec3(x / length, y / length, z / length); + let turned = rotation.apply_vec(dir); + // A view along the up axis has no roll to keep, and its basis is undefined. + if 1.0 - dir.y * dir.y < 1e-12 || 1.0 - turned.y * turned.y < 1e-12 { + return None; + } + let up = vec3(0.0, 1.0, 0.0); + Some(basis_rotation(turned, up).transpose() * rotation * basis_rotation(dir, up)) } /// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position, @@ -196,8 +362,16 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti /// time. pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ectx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ectx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; let space = |id: Option| -> effectcraft_geom::Mat3 { id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) }; @@ -235,31 +409,58 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op }) }; if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { - // Cameras and lights compensate like 3D layers (Position, and Point of Interest for - // two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no - // Scale, so `pose_3d` below stays `None` and Orientation is kept as authored. for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) { let m = inv * space_3d(layer.parent); + // A one-node camera or light has no Scale to carry a mirror, and its frame (forward and + // down) would come out reversed: compensate none of it, so Position and Orientation stay + // as authored instead of half-following the parent. + let rig = layer.is_camera() || layer.is_light(); + if rig && !effectcraft_render::three_d::camera::is_two_node(layer) && uniform_rotation(m).is_some_and(|(_, factor)| factor <= 0.0) { + continue; + } if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { let orientation = tr.get("orientation")?; - let scale = tr.get("scale")?; - // Animated Euler Orientation and auto-orient cannot be re-expressed by - // prepending a constant Euler value. Expression-driven values stay authored. - if !orientation.keys.is_empty() - || orientation.expr.is_some() - || scale.expr.is_some() - || layer.auto_orient != effectcraft_project::AutoOrient::Off + // Cameras and lights have no Scale; their Orientation is re-expressed alone. + let scale = tr.get("scale"); + // Expression-driven values stay authored. Auto-orient replaces Orientation, except + // a two-node rig, whose roll is re-expressed against its new aim. + let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer); + if orientation.expr.is_some() + || scale.is_some_and(|scale| scale.expr.is_some()) + || (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node) { return None; } - let pose = uniform_parent_pose(m, orientation.value.as_vec3())?; - let factor = pose.1; - let values_finite = std::iter::once(&scale.value) - .chain(scale.keys.iter().map(|key| &key.value)) - .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); - let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite()); - (values_finite && speeds_finite).then_some(pose) + let (rotation, factor) = uniform_rotation(m)?; + let correction = if two_node { + // A mirrored parent flips the aim, which this correction does not model. + if factor <= 0.0 { + return None; + } + let (w, h) = (comp.width as f64, comp.height as f64); + let pos = ectx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]); + let poi = ectx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]); + aim_correction(rotation, [poi[0] - pos[0], poi[1] - pos[1], poi[2] - pos[2]])? + } else { + rotation + }; + let orientation_finite = std::iter::once(&orientation.value) + .chain(orientation.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| v.is_finite())); + if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { + return None; + } + if let Some(scale) = scale { + let values_finite = std::iter::once(&scale.value) + .chain(scale.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); + let speeds_finite = scale.keys.iter().all(|key| key.in_ease.iter().chain(&key.out_ease).all(|ease| (ease.speed * factor).is_finite())); + if !values_finite || !speeds_finite { + return None; + } + } + Some(Pose3d { correction, factor }) }); fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d }); } diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index 8b75dcbc8..eb0f610f9 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -74,14 +74,39 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { let op = str_p(p, "op").unwrap_or("reset").to_string(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = s.time(); - let sizes: Vec<(LayerId, (u32, u32))> = - comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect(); + // A shape or text layer has no source item, so source_size is (0, 0): the fit ops size it by + // its painted bounds at the current time instead, and anchor at the bounds' centre — content + // bounds live in layer space, where a shape is centred on the origin and text sits above its + // baseline, so (w/2, h/2) would push it off-centre (#2198). Reset and the other ops keep the + // source size. + let ectx = effectcraft_render::EvalCtx { + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + ..effectcraft_render::EvalCtx::new(&s.project, cid, &comp, t) + }; + let fitting = matches!(op.as_str(), "fit" | "fitWidth" | "fitHeight"); + let sizes: Vec<(LayerId, [f64; 2], [f64; 2])> = comp + .layers + .iter() + .filter(|l| ids.contains(&l.id)) + .map(|l| { + let (w, h) = effectcraft_render::source_size(&s.project, l); + if fitting && (w == 0 || h == 0) { + match effectcraft_render::content_bounds(&ectx, l).filter(|b| b.iter().all(|v| v.is_finite()) && b[2] > b[0] && b[3] > b[1]) { + Some(b) => (l.id, [b[2] - b[0], b[3] - b[1]], [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0]), + None => (l.id, [0.0; 2], [0.0; 2]), + } + } else { + (l.id, [w as f64, h as f64], [w as f64 / 2.0, h as f64 / 2.0]) + } + }) + .collect(); s.edit("Transform", None, |proj, _| { - for (lid, (w, h)) in &sizes { + for (lid, size, anchor) in &sizes { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); let (cw, ch) = (comp.width as f64, comp.height as f64); - let (w, h) = (*w as f64, *h as f64); + let (w, h) = (size[0], size[1]); let Some(tr) = l.transform_mut() else { continue }; let set = |tr: &mut PropGroup, m: &str, v: KV| { if let Some(pr) = tr.get_mut(m) { @@ -105,11 +130,15 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { "fitHeight" => (ch / h, ch / h), _ => (cw / w, ch / h), }; + // A tiny shape's bounds can overflow the scale; refuse rather than write infinity. + if !(sx * 100.0).is_finite() || !(sy * 100.0).is_finite() { + return Err(EngineError::Other(format!("layer {} is too small to fit to the composition", lid.0))); + } let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d); let (sz, az) = (z("scale", 100.0), z("anchor", 0.0)); set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz])); update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]); - set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az])); + set(tr, "anchor", KV::Vec3([anchor[0], anchor[1], az])); } "flipH" | "flipV" => { let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]); diff --git a/crates/engine/src/cmd/layer_time.rs b/crates/engine/src/cmd/layer_time.rs index d98bfdea7..368e280c6 100644 --- a/crates/engine/src/cmd/layer_time.rs +++ b/crates/engine/src/cmd/layer_time.rs @@ -185,7 +185,16 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result { return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer")); } // What each layer shows now (honouring an existing remap) — evaluated before the edit. - let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ectx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: cti, + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; let now: Vec<(effectcraft_project::LayerId, f64)> = comp .layers .iter() diff --git a/crates/engine/src/cmd/markers.rs b/crates/engine/src/cmd/markers.rs index 4c979619e..3530eaa4b 100644 --- a/crates/engine/src/cmd/markers.rs +++ b/crates/engine/src/cmd/markers.rs @@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the -/// layer (layer time is the nested comp's time), skipping ones already there. +/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers, or the +/// markers a footage file carries (a WAV's cue points), onto the layer (layer time is the source's +/// time), skipping ones already there. fn update_from_source(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let src: Vec<(LayerId, Vec)> = { @@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { let l = comp.layer(*id)?; match &l.source { LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())), + LayerSource::Footage { item } => match &s.project.item(*item)?.kind { + effectcraft_project::ItemKind::Footage(f) => Some((*id, f.markers.clone())), + _ => None, + }, _ => None, } }) .collect() }; if src.is_empty() { - return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer")); + return Err(bad("layer.updateMarkersFromSource", "select a precomposition or footage layer")); } let n = s.edit("Update Markers From Source", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; @@ -323,8 +328,11 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { if l.markers_locked { continue; } + // One set of the times already on the layer: a file with thousands of markers must not + // scan the layer's list for each of them. + let mut times: std::collections::HashSet<_> = l.markers.iter().map(|x| x.time).collect(); for m in ms { - if !l.markers.iter().any(|x| x.time == m.time) { + if times.insert(m.time) { l.markers.push(m.clone()); n += 1; } @@ -339,10 +347,10 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> { has_layers(s)?; let c = s.active_comp().ok_or("no composition")?; - if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) { + if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })) { Ok(()) } else { - Err("select a precomposition layer".into()) + Err("select a precomposition or footage layer".into()) } } diff --git a/crates/engine/src/cmd/path_nulls.rs b/crates/engine/src/cmd/path_nulls.rs index bb74ee77c..93d71cd67 100644 --- a/crates/engine/src/cmd/path_nulls.rs +++ b/crates/engine/src/cmd/path_nulls.rs @@ -78,7 +78,16 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result

Result Res let fd = comp.frame_duration(); let mut t = layer.in_point; while t < layer.out_point { - let ctx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp: &comp, + time: t, + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr))); t += fd; } diff --git a/crates/engine/src/cmd/puppet.rs b/crates/engine/src/cmd/puppet.rs index 320e1d415..1d080e773 100644 --- a/crates/engine/src/cmd/puppet.rs +++ b/crates/engine/src/cmd/puppet.rs @@ -38,7 +38,8 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) let fx = layer.effects()?; let index = fx.groups().position(|g| g.uid == fx_uid)?; let g = fx.groups().nth(index)?; - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index 079c9bd30..00e078372 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -619,7 +619,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result { let (w, h) = item.settings.output_size(comp); let mut crop_only = om.clone(); crop_only.resize.enabled = false; - let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0)); + let (cw, ch) = crop_only.frame_size(w, h, item.settings.effective_scale(comp)); om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000); } let post = match str_p(p, "postRenderAction") { diff --git a/crates/engine/src/cmd/rig3d.rs b/crates/engine/src/cmd/rig3d.rs index f034fa641..5b17fcffe 100644 --- a/crates/engine/src/cmd/rig3d.rs +++ b/crates/engine/src/cmd/rig3d.rs @@ -48,7 +48,16 @@ impl Env { Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() } } fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> { - EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) } + EvalCtx { + project: proj, + comp_id: cid, + comp, + time: self.time, + expr: self.expr.as_deref(), + footage: Some(self.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + } } } diff --git a/crates/engine/src/cmd/text_anim.rs b/crates/engine/src/cmd/text_anim.rs index 92ab23f17..296dc4c37 100644 --- a/crates/engine/src/cmd/text_anim.rs +++ b/crates/engine/src/cmd/text_anim.rs @@ -142,6 +142,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { } let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?; let auid = animator_uid(s, &layer, p, C)?; + let mut index = 0; s.edit("Add Text Animator Property", None, |proj, _| { let mut next = proj.next_id; let mut ids = Ids(&mut next); @@ -151,10 +152,13 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { for k in &kinds { add_props(&mut ids, anim, k, three); } + // The requested property's 1-based position in the Properties group. A companion (such as + // Tracking Type) is added before it, so the first new child is not always the one asked for. + index = kinds.first().and_then(|k| anim.sub("properties")?.children.iter().position(|n| n.match_id() == k.as_str())).map_or(0, |i| i + 1); proj.next_id = next; Ok(()) })?; - Ok(json!({"animator": auid})) + Ok(json!({"animator": auid, "index": index})) } pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs index e143690d3..1cf470ebe 100644 --- a/crates/engine/src/cmd/three_d.rs +++ b/crates/engine/src/cmd/three_d.rs @@ -413,7 +413,8 @@ fn get_3d(s: &mut Session, p: &Value) -> Result { let (w, h) = (comp.width as f64, comp.height as f64); let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default(); let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx)); let f = cam.forward(); let lights: Vec = comp @@ -555,7 +556,8 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { /// through each layer's world matrix; cameras and lights contribute their position). fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let mut out = vec![]; for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { let m = ctx.world_matrix(l); diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index 75fd0f936..267985e37 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -81,6 +81,8 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { nf.codec = probed.codec; nf.has_video = probed.has_video; nf.has_audio = probed.has_audio; + // The markers the file carries now (a WAV's cue points), for Update Markers From Source. + nf.markers = probed.markers; if matches!(f.kind, FootageKind::Video | FootageKind::Audio) { nf.duration = probed.duration; // Interpret Footage ▸ Conform to frame rate stays; the file's own rate is refreshed. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 25237493c..25d05b903 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -15,6 +15,7 @@ mod frame_export; mod fuzz_open; mod history; mod import_project; +mod instance3d; mod key_metadata_order; mod key_time; mod keyframe_text; diff --git a/crates/engine/src/tests/crypto_pick.rs b/crates/engine/src/tests/crypto_pick.rs index eacae55af..2e61f57a1 100644 --- a/crates/engine/src/tests/crypto_pick.rs +++ b/crates/engine/src/tests/crypto_pick.rs @@ -190,7 +190,16 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap(); let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap()); let layer = &comp.layers[0]; - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap(); let (px, py) = buf.to_px([x, y]); let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0]; diff --git a/crates/engine/src/tests/essential.rs b/crates/engine/src/tests/essential.rs index 191416b9d..db5bf0751 100644 --- a/crates/engine/src/tests/essential.rs +++ b/crates/engine/src/tests/essential.rs @@ -367,6 +367,128 @@ fn media_replacement_swaps_footage_per_instance() { assert!(center(&s, main)[0] > 0.99); } +/// #1747: a collapsed precomp with an Essential Property override must keep drawing its nested +/// layers past the precomp's frame instead of falling back to a flattened, clipped frame. +#[test] +fn collapsed_precomp_applies_property_overrides_without_clipping() { + struct AddY; + impl effectcraft_render::ExprHost for AddY { + fn eval( + &self, + _: &effectcraft_render::EvalCtx, + _: &effectcraft_project::Layer, + _: &effectcraft_project::Property, + value: &effectcraft_keyframe::Value, + ) -> Result { + Ok(match value { + effectcraft_keyframe::Value::Vec3([x, y, z]) => effectcraft_keyframe::Value::Vec3([*x, y + 100.0, *z]), + _ => value.clone(), + }) + } + } + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": box_, "path": "transform/position", "value": [200, 50, 0]})).unwrap(); + // The source property's expression still runs after the instance override. + s.execute("prop.setExpression", json!({"layer": box_, "path": "transform/position", "expression": "value + [0, 100, 0]"})).unwrap(); + let position = s.execute("essential.addProperty", json!({"layer": box_, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + let other = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [instance, other], "switch": "collapse", "value": true})).unwrap(); + s.expr = Some(std::sync::Arc::new(AddY)); + s.execute("layer.select", json!({"layers": [instance]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -120, 0]})).unwrap(); + s.execute("layer.select", json!({"layers": [other]})).unwrap(); + s.execute("essential.set", json!({"layer": other, "control": position, "value": [200, 220, 0]})).unwrap(); + let img = s.render(outer, Tick::ZERO, RenderOpts::default()); + // The expression adds 100 px: the overrides give inner y = -20 and 320. The first maps to + // outer y = 130 (the precomp is centred at 300) and its 100 px box spans 80..179, 70 px above + // the instance's frame (150..449), where a flattened precomp would stop. The second maps to + // 470 and spans 420..519, past the bottom. + assert!(img.get(200, 100)[3] > 0.99, "the override draws above the nested frame: {:?}", img.get(200, 100)); + assert!(img.get(200, 90)[3] > 0.99, "the box reaches the top of its overridden bounds"); + assert!(img.get(200, 500)[3] > 0.99, "the other instance keeps its own override: {:?}", img.get(200, 500)); + assert!(img.get(200, 200)[3] < 0.01, "nothing is left between the instances"); +} + +/// #1747: two collapsed instances of the same comp keep their own source-affecting property +/// overrides in the layer cache (the nested layers' ids and static values are identical). +#[test] +fn collapsed_instances_keep_distinct_property_overrides_in_the_cache() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap(); + let card = s.active_comp_id().unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap(); + let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + let main = s.active_comp_id().unwrap(); + let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "collapse", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [60, 50, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [140, 50, 0]})).unwrap(); + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + s.execute("essential.set", json!({"layer": a, "control": color, "value": [1.0, 0.0, 0.0, 1.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + s.execute("essential.set", json!({"layer": b, "control": color, "value": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + let img = s.render(main, Tick::ZERO, RenderOpts::default()); + let left = img.get(60, 50); + let right = img.get(140, 50); + assert!(left[0] > 0.99 && left[2] < 0.01, "left instance is red: {left:?}"); + assert!(right[2] > 0.99 && right[0] < 0.01, "right instance is blue: {right:?}"); +} + +/// #1747 with Media Replacement: the replacement footage and the position override both apply +/// while the precomp stays collapsed, so the box is blue and still draws above the frame. +#[test] +fn collapsed_precomp_applies_media_replacement_without_clipping() { + struct Colors; + impl crate::render::FootageSource for Colors { + fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option> { + let c = if f.path.contains("blue") { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }; + Some(std::sync::Arc::new(effectcraft_raster::Image::filled(100, 100, c))) + } + } + let mut s = Session { footage: std::sync::Arc::new(Colors), ..Default::default() }; + let footage = |path: &str| effectcraft_project::Footage { + path: path.into(), + kind: effectcraft_project::FootageKind::Still, + width: 100, + height: 100, + has_video: true, + ..Default::default() + }; + let (red, blue) = s + .edit("test", None, |proj, _| { + let red = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("red.png"))); + let blue = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("blue.png"))); + Ok((red, blue)) + }) + .unwrap(); + s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + let clip = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": clip, "path": "transform/position", "value": [200, 50, 0]})).unwrap(); + let media = s.execute("essential.addMedia", json!({"layer": clip})).unwrap()["control"].as_u64().unwrap(); + let position = s.execute("essential.addProperty", json!({"layer": clip, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [instance], "switch": "collapse", "value": true})).unwrap(); + s.execute("layer.select", json!({"layers": [instance]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -20, 0]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": media, "item": blue.0})).unwrap(); + let img = s.render(outer, Tick::ZERO, RenderOpts::default()); + let above = img.get(200, 100); + assert!(above[2] > 0.99 && above[0] < 0.01, "the replacement media is blue above the frame: {above:?}"); + assert!(img.get(200, 200)[3] < 0.01, "the collapsed box is not clipped at the nested frame"); +} + /// Responsive Design — Time: stretching a precomp keeps its protected regions at their /// original speed; the unprotected parts absorb the stretch. #[test] diff --git a/crates/engine/src/tests/essential_more.rs b/crates/engine/src/tests/essential_more.rs index a8d8dc808..ea4aa42e5 100644 --- a/crates/engine/src/tests/essential_more.rs +++ b/crates/engine/src/tests/essential_more.rs @@ -2,6 +2,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::essential::{self, ControlType}; +use effectcraft_time::Tick; use serde_json::json; use crate::{Event, Session}; @@ -102,3 +103,33 @@ fn open_in_essential_graphics_sets_the_primary_comp() { ); assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A"); } + +/// #1747: through a collapsed precomp, a Text override and a Font override of the same Source +/// Text both apply: the Font merges into the overridden text, as `essential::with_overrides` +/// does on the flattened path, instead of one entry replacing the other. +#[test] +fn collapsed_instance_merges_text_and_font_overrides() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let title = s.active_comp_id().unwrap(); + let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + let body = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap(); + let font = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let main = s.active_comp_id().unwrap(); + let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [inst], "switch": "collapse", "value": true})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": body, "value": "Bye"})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": font, "value": {"font": "Serif Test", "size": 40}})).unwrap(); + // Evaluate the nested text in the collapsed instance's context, as the renderer does. + let r = effectcraft_render::Renderer::new(&s.project, &*s.footage, crate::render::RenderOpts::default()); + let ctx = effectcraft_render::EvalCtx::new(&s.project, main, s.project.comp(main).unwrap(), Tick::ZERO); + let layer = ctx.comp.layer(effectcraft_project::LayerId(inst)).unwrap(); + let collapsed = r.collapsed(&ctx, layer).expect("the instance collapses"); + let (_, nctx) = r.collapse_into(&ctx, layer, &collapsed, 1.0).expect("a nested context"); + let src = nctx.comp.layer(effectcraft_project::LayerId(text)).unwrap(); + match nctx.value(src, src.props.prop("text/sourceText").unwrap()) { + KV::Text(d) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)), + o => panic!("{o:?}"), + } +} diff --git a/crates/engine/src/tests/instance3d.rs b/crates/engine/src/tests/instance3d.rs new file mode 100644 index 000000000..48f4b7730 --- /dev/null +++ b/crates/engine/src/tests/instance3d.rs @@ -0,0 +1,257 @@ +//! Create 3D Layer Instance (Layer ▸ Create): a comp-sized solid at the top of the layer stack +//! with a 3D Layer Instance effect referencing the selected 3D model layer — a 2D copy of its +//! render that 2D effects can be applied to (behaviour, pixels, undo/redo, serde). + +use std::sync::Arc; + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, Project}; +use effectcraft_render::{FootageSource, RenderOpts}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::json; + +use crate::{Importer, Session}; + +fn session() -> Session { + let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() }; + s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap(); + s +} + +fn cid(s: &Session) -> ItemId { + s.active_comp_id().unwrap() +} + +fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer { + s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone() +} + +fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] { + img.data[(y * img.width + x) as usize] +} + +fn close(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image, tol: f32) -> bool { + a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol)) +} + +struct DiskModels; + +impl FootageSource for DiskModels { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn model(&self, _: ItemId, f: &Footage) -> Option> { + let bytes = std::fs::read(&f.path).ok()?; + effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new) + } +} + +struct ModelImporter; + +impl Importer for ModelImporter { + fn probe(&self, path: &str) -> Result { + effectcraft_model::format_of(path).ok_or("not a model")?; + Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() }) + } +} + +/// A red triangle in the Z = 0 plane (buffer embedded as a data URI). +const TRIANGLE: &str = r#"{ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}], + "materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}], + "buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]}, + {"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3} + ] +}"#; + +fn triangle_model(s: &mut Session) -> u64 { + let dir = std::env::temp_dir().join(format!("ec-instance3d-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Triangle.gltf"); + std::fs::write(&path, TRIANGLE).unwrap(); + s.execute("layer.newModel", json!({"path": path.to_string_lossy(), "name": "Triangle"})).unwrap()["layer"].as_u64().unwrap() +} + +#[test] +fn instance3d_creates_a_2d_copy_of_the_model() { + let mut s = session(); + let model = triangle_model(&mut s); + // The model renders through the comp's camera. + let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default()); + assert!(px(&before, 320, 180)[3] > 0.9, "the model covers the centre: {:?}", px(&before, 320, 180)); + assert!(px(&before, 4, 4)[3] < 0.1, "the comp is transparent around it: {:?}", px(&before, 4, 4)); + // Create 3D Layer Instance. + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap(); + let solid = r["layers"][0].as_u64().unwrap(); + let comp = cid(&s); + let l = layer_in(&s, comp, solid); + assert_eq!(l.name, "Triangle Instance"); + assert_eq!(s.active_comp().unwrap().layers[0].id.0, solid, "at the top of the layer stack"); + // A 3D Layer Instance effect referencing the 3D layer. + let g = l.effects().unwrap().groups().next().unwrap().clone(); + assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.3d.instance3d"), "{:?}", g.kind); + assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model))); + // The model is switched off, the copy selected. + assert!(!layer_in(&s, comp, model).switches.video, "the referenced 3D layer is off, as After Effects does"); + assert_eq!(s.state.selected_layers, vec![LayerId(solid)]); + // The comp renders the same through the copy: a 2D copy of the 3D layer, transparent + // around it. + let after = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert!(close(&before, &after, 2e-2), "the copy shows the model:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180)); + assert!(px(&after, 320, 180)[3] > 0.9 && px(&after, 4, 4)[3] < 0.1, "{:?} {:?}", px(&after, 320, 180), px(&after, 4, 4)); + // Undo removes the copy and switches the model back on; redo returns it. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_none()); + assert!(layer_in(&s, comp, model).switches.video); + s.execute("edit.redo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_some()); + assert!(!layer_in(&s, comp, model).switches.video); + // Serde round-trips the layer parameter. + let j = serde_json::to_string(&*s.project).unwrap(); + let back: Project = serde_json::from_str(&j).unwrap(); + assert_eq!(back, *s.project); + // 2D effects can be applied to the copy (the model layer itself takes none): Fill in blue + // recolours the triangle. + s.execute("effect.apply", json!({"layer": solid, "effect": "ec.generate.fill"})).unwrap(); + s.execute("prop.set", json!({"layer": solid, "path": "effects/#2/color", "value": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + let with_fx = s.render(comp, Tick::ZERO, RenderOpts::default()); + let p = px(&with_fx, 320, 180); + assert!(p[2] > 0.9 && p[0] < 0.1, "the fill recolours the copy: {p:?}"); +} + +#[test] +fn instance3d_also_takes_3d_primitives() { + let mut s = session(); + let prim = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 200, "height": 200, "depth": 200})).unwrap()["layer"].as_u64().unwrap(); + let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default()); + let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [prim]})).unwrap(); + let solid = r["layers"][0].as_u64().unwrap(); + let l = layer_in(&s, cid(&s), solid); + assert_eq!(l.name, "Cube Instance"); + assert!(!layer_in(&s, cid(&s), prim).switches.video); + let after = s.render(cid(&s), Tick::ZERO, RenderOpts::default()); + assert!(close(&before, &after, 2e-2), "the copy shows the cube"); +} + +#[test] +fn instance3d_needs_a_3d_layer() { + let mut s = session(); + let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + let err = s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [solid]})); + assert!(err.is_err()); + let text = s.execute("layer.newText", json!({"text": "hi"})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err()); + // A 3D-switched text layer is not a model layer either (it has 2D pixels of its own). + s.execute("layer.setSwitch", json!({"layers": [text], "switch": "threeD", "value": true})).unwrap(); + assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err()); +} + +#[test] +fn instance3d_creates_every_selected_copy_in_one_undo() { + let mut s = session(); + let a = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [a, b]})).unwrap(); + let copies = r["layers"].as_array().unwrap(); + assert_eq!(copies.len(), 2); + let (ia, ib) = (copies[0].as_u64().unwrap(), copies[1].as_u64().unwrap()); + let comp = cid(&s); + assert_ne!(ia, ib); + assert_ne!(layer_in(&s, comp, ia).name, layer_in(&s, comp, ib).name, "each copy has a name of its own"); + // Both sources are off, both copies selected, above their sources in the stack. + assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video); + let mut sel = s.state.selected_layers.clone(); + let mut want = vec![LayerId(ia), LayerId(ib)]; + sel.sort_by_key(|l| l.0); + want.sort_by_key(|l| l.0); + assert_eq!(sel, want); + let c = s.active_comp().unwrap(); + let pos = |id: u64| c.layers.iter().position(|l| l.id.0 == id).unwrap(); + assert!(pos(ia) < pos(a) && pos(ib) < pos(b), "the copies sit above their sources"); + // One undo removes both copies and switches both models back on; one redo returns them. + s.execute("edit.undo", json!({})).unwrap(); + let c = s.active_comp().unwrap(); + assert!(c.layer(LayerId(ia)).is_none() && c.layer(LayerId(ib)).is_none(), "one undo removes every instance"); + assert!(layer_in(&s, comp, a).switches.video && layer_in(&s, comp, b).switches.video); + s.execute("edit.redo", json!({})).unwrap(); + let c = s.active_comp().unwrap(); + assert!(c.layer(LayerId(ia)).is_some() && c.layer(LayerId(ib)).is_some(), "one redo returns every instance"); + assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video); +} + +#[test] +fn instance3d_in_a_mixed_selection_only_takes_the_models() { + let mut s = session(); + let model = triangle_model(&mut s); + let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [model, solid]})).unwrap(); + let copies = r["layers"].as_array().unwrap(); + assert_eq!(copies.len(), 1, "only the model gets a copy: {r}"); + let copy = copies[0].as_u64().unwrap(); + let comp = cid(&s); + assert!(!layer_in(&s, comp, model).switches.video, "the model is switched off"); + assert!(layer_in(&s, comp, solid).switches.video, "the plain solid is untouched"); + let skipped = r["skipped"].as_array().unwrap(); + assert_eq!(skipped.len(), 1); + assert!(skipped[0].as_str().unwrap().contains("not a 3D model layer"), "{skipped:?}"); + // The copy references the model, not the solid. + let l = layer_in(&s, comp, copy); + let g = l.effects().unwrap().groups().next().unwrap().clone(); + assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model))); +} + +#[test] +fn instance3d_shows_nothing_outside_the_source_layer_range() { + let mut s = session(); + let model = triangle_model(&mut s); + // The model's range ends at 1 s; the comp runs to 2 s. + let comp = cid(&s); + s.edit("trim", None, |p, _| { + p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().out_point = Tick::from_seconds_f64(1.0); + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap(); + let _copy = r["layers"][0].as_u64().unwrap(); + // Inside the model's range the copy shows it, transparent around it. + let inside = s.render(comp, Tick::from_seconds_f64(0.5), RenderOpts::default()); + assert!(px(&inside, 320, 180)[3] > 0.9, "the copy shows the model: {:?}", px(&inside, 320, 180)); + // After its out point the source has nothing to read: the copy is transparent. + let outside = s.render(comp, Tick::from_seconds_f64(1.5), RenderOpts::default()); + assert!(outside.data.iter().all(|p| p[3] < 1e-3), "transparent after the source's out point: {:?}", px(&outside, 320, 180)); +} + +#[test] +fn instance3d_keeps_the_copy_visible_when_the_source_is_soloed() { + let mut s = session(); + let model = triangle_model(&mut s); + let comp = cid(&s); + // Solo the model, then create the instance: any_solo hides non-soloed layers and the + // source's video goes off, so the copy has to take the Solo over. + s.edit("solo", None, |p, _| { + p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().switches.solo = true; + Ok(()) + }) + .unwrap(); + let before = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert!(px(&before, 320, 180)[3] > 0.9, "the soloed model shows: {:?}", px(&before, 320, 180)); + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap(); + let solid = r["layers"][0].as_u64().unwrap(); + assert!(layer_in(&s, comp, solid).switches.solo, "the copy takes the source's Solo switch"); + assert!(!layer_in(&s, comp, model).switches.video, "the source keeps its video off"); + let after = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert!(px(&after, 320, 180)[3] > 0.9, "the soloed copy shows the model: {:?}", px(&after, 320, 180)); + assert!(close(&before, &after, 2e-2), "the comp renders the same through the copy:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180)); +} diff --git a/crates/engine/src/tests/menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs index eb99d809f..93f81736f 100644 --- a/crates/engine/src/tests/menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -1103,6 +1103,66 @@ fn transform_commands_keep_depth_and_follow_separated_dimensions() { assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre"); } +/// The layer's painted bounds mapped to comp space — what the viewer shows. +fn seen_bounds(s: &Session, lid: u64) -> [f64; 4] { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap().clone(); + let layer = comp.layer(effectcraft_project::LayerId(lid)).unwrap(); + let ectx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time()); + let b = effectcraft_render::content_bounds(&ectx, layer).expect("painted bounds"); + let m = ectx.layer_to_comp(layer).0; + let corners = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]; + let pts: Vec<[f64; 2]> = corners + .iter() + .map(|p| { + let q = m.apply(effectcraft_geom::vec2(p[0], p[1])); + [q.x, q.y] + }) + .collect(); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in &pts { + x0 = x0.min(p[0]); + y0 = y0.min(p[1]); + x1 = x1.max(p[0]); + y1 = y1.max(p[1]); + } + [x0, y0, x1, y1] +} + +#[test] +fn fit_to_comp_fills_the_comp_with_shape_and_text_bounds() { + let mut s = comp(); + let close = |x: f64, y: f64| (x - y).abs() < 1e-6; + // A shape or text layer has no source item: fit sizes it by its painted bounds, anchored at + // the bounds' centre, so what the viewer shows fills the frame (#2198). + let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [180, 80]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap(); + let seen = seen_bounds(&s, a); + assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the rect's painted bounds fill the comp: {seen:?}"); + + let t = s.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.transform", json!({"layers": [t], "op": "fit"})).unwrap(); + let seen = seen_bounds(&s, t); + assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the text's painted bounds fill the comp: {seen:?}"); + + // fitWidth spans the comp's width and centres vertically. + for lid in [a, t] { + s.execute("layer.transform", json!({"layers": [lid], "op": "fitWidth"})).unwrap(); + let seen = seen_bounds(&s, lid); + assert!(close(seen[0], 0.0) && close(seen[2], 640.0), "fitWidth spans the comp width: {seen:?}"); + assert!(close((seen[1] + seen[3]) / 2.0, 180.0), "fitWidth centres vertically: {seen:?}"); + } +} + +#[test] +fn fit_to_comp_refuses_a_scale_that_overflows() { + let mut s = comp(); + let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [1e-305, 80]})).unwrap()["layer"].as_u64().unwrap(); + let before = s.project.clone(); + assert!(s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).is_err(), "an infinite scale is refused"); + assert_eq!(s.project, before, "nothing was written"); +} + #[test] fn duplicated_and_pasted_layers_keep_their_links() { let mut s = comp(); diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index d801a743a..cf2518447 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use effectcraft_geom::{Vec3, vec3}; use effectcraft_keyframe::Value as KV; use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project}; -use effectcraft_render::three_d::camera::layer_frame; +use effectcraft_render::three_d::camera::{is_two_node, layer_frame}; use effectcraft_render::{EvalCtx, FootageSource, RenderOpts}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -40,7 +40,7 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { fn frame(s: &Session, id: u64) -> (Vec3, Vec3) { let c = cid(s); let comp = s.project.comp(c).unwrap(); - let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()); (e, f) } @@ -217,8 +217,189 @@ fn control_light_with_camera_follows_and_releases() { assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err()); } +/// Parenting keeps a camera where it is (After Effects' Parent & Link): Position and Point of +/// Interest are re-expressed in the null's space, so the frame does not move. +#[test] +fn parented_camera_keeps_its_frame_under_an_offset_null() { + let mut s = session(); + let cam = camera(&mut s); + let (eye0, f0) = frame(&s, cam); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, cam); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}"); + // The null's origin is Position minus its default anchor (50, 50): (50, 10) in the comp. + let c = layer_in(&s, cid(&s), cam); + assert!(close3(v3(&c, "transform/position"), [270.0, 170.0, -800.0], 1e-6), "{:?}", v3(&c, "transform/position")); + assert!(close3(v3(&c, "transform/poi"), [270.0, 170.0, 0.0], 1e-6), "{:?}", v3(&c, "transform/poi")); + // Moving the null carries the camera and keeps its aim. + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [140, 90, 0]})).unwrap(); + let (eye2, f2) = frame(&s, cam); + assert!((eye2 - (eye0 + vec3(40.0, 30.0, 0.0))).length() < 1e-6, "{eye2:?}"); + assert!((f2 - f0).length() < 1e-6, "{f2:?}"); +} + +/// World (eye, forward, down) of a layer in the active comp at time `t`. +fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) { + let c = cid(s); + let comp = s.project.comp(c).unwrap(); + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; + layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()) +} + +/// Whether two frames agree on eye, forward and down. +fn frames_match(a: (Vec3, Vec3, Vec3), b: (Vec3, Vec3, Vec3)) -> bool { + (a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-6 && (a.2 - b.2).length() < 1e-6 +} + +/// A 3D null off-centre in the comp, with an Orientation and a Z rotation. +fn rotated_null(s: &mut Session, orientation: [f64; 3], turn: f64) -> u64 { + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [nul], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 40]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/orientation", "value": orientation})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": turn})).unwrap(); + nul +} + +/// A two-node camera under a rotated null keeps its eye, aim and roll: its Orientation is +/// re-expressed against the new aim, so Up does not drift. +#[test] +fn two_node_camera_under_rotated_null_keeps_its_roll() { + let mut s = session(); + let cam = camera(&mut s); + s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 25})).unwrap(); + assert!(is_two_node(&layer_in(&s, cid(&s), cam))); + let before = frame_at(&s, cam, Tick::ZERO); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let after = frame_at(&s, cam, Tick::ZERO); + assert!(frames_match(before, after), "{before:?} {after:?}"); +} + +#[test] +fn parented_point_light_keeps_its_direction_under_a_rotated_null() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let (eye0, f0) = frame(&s, light); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": 30})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, light); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); +} + +/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at +/// every time, between the keys too. +#[test] +fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction"); + } +} + +/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are +/// added between them: the light keeps its direction within a small angle at every time. +#[test] +fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { + { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.5, json!([50.0, 118.0, 74.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let times = [0.0, 0.2, 0.5, 0.9, 1.3, 1.5].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + // 0.05 degrees is about 9e-4 of a radian on a unit vector. + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } + assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two"); + } +} + +/// A one-node light parented under a mirrored null (Z scale -100%) keeps the Position and +/// Orientation it was authored with: it has no Scale to carry the mirror, so none of it is +/// compensated rather than reversing its frame (a half-compensated light would point backwards). +#[test] +fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let before = layer_in(&s, cid(&s), light); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 0.0); + s.execute("prop.set", json!({"layer": nul, "path": "transform/scale", "value": [100, 100, -100]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let after = layer_in(&s, cid(&s), light); + assert_eq!(after.parent, Some(LayerId(nul))); + assert_eq!(v3(&after, "transform/position"), v3(&before, "transform/position")); + assert_eq!(v3(&after, "transform/orientation"), v3(&before, "transform/orientation")); +} + +/// The cap on keys added by baking is for the whole Orientation property, not for each segment: +/// many strongly eased segments under a tilted parent stay within it. +#[test] +fn baked_orientation_keys_are_capped_across_the_whole_property() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + let count = 120; + for i in 0..count { + let swing = if i % 2 == 0 { json!([0.0, 0.0, 0.0]) } else { json!([170.0, 60.0, 120.0]) }; + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": i as f64 * 0.05, "value": swing})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + let keys: Vec<_> = (0..count).map(|i| json!({"layer": light, "prop": uid, "time": i as f64 * 0.05})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len(); + assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment"); + // the segments the cap left unchecked keep their authored easing instead of turning into straight blends + let orientation = layer_in(&s, cid(&s), light); + let keys = &orientation.props.prop("transform/orientation").unwrap().keys; + let last = keys.iter().rev().nth(1).expect("the penultimate key"); + assert_ne!(last.out_interp, effectcraft_keyframe::Interp::Linear, "the last segment keeps its authored easing"); +} + // ---------------------------------------------------------------- from 3D model +/// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points. +#[test] +fn eased_orientation_under_x_parent_keeps_motion_between_keys() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([0.0, 0.0, 0.0])), (1.0, json!([90.0, 0.0, 0.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + s.execute("keys.select", json!({"keys": [{"layer": light, "prop": uid, "time": 0.0}, {"layer": light, "prop": uid, "time": 1.0}]})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let times: Vec<_> = (0..=100).map(|i| Tick::from_seconds_f64(i as f64 / 100.0)).collect(); + let before: Vec<_> = times.iter().map(|t| frame_at(&s, light, *t)).collect(); + let parent = rotated_null(&mut s, [30.0, 0.0, 0.0], 0.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": parent})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } +} + struct DiskModels; impl FootageSource for DiskModels { diff --git a/crates/engine/src/tests/sequence.rs b/crates/engine/src/tests/sequence.rs index f2849a6be..cf6968e3f 100644 --- a/crates/engine/src/tests/sequence.rs +++ b/crates/engine/src/tests/sequence.rs @@ -182,14 +182,58 @@ fn interpret_footage_start_timecode_relabels_source_time() { } #[test] -fn replace_footage_and_reload_keep_sequences() { +fn replace_footage_keeps_a_still_a_still() { let dir = folder(&RUN); let mut s = session(); let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": false})).unwrap()["items"][0].clone(); s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + // Replacing with a numbered still does not silently turn the item into a sequence (#2073). s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap(); let (name, f) = footage(&s, &item); - assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0001-0005].png", 4)); + assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still)); + // JSON null is unspecified, not "on": the still stays a still. + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": null})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still)); + // Alphabetical is a sequence modifier, not a request to make one. + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "alphabetical": true})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still)); + // Asking for the run still brings it. + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": true})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4)); +} + +#[test] +fn replace_footage_keeps_a_one_frame_run_a_sequence() { + let dir = folder(&["shot_0001.png", "shot_0002.png", "solo_0007.png", "cover.png"]); + let mut s = session(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone(); + s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + // A numbered file with no other frame in its run is still the replacement for a sequence. + s.execute("file.replaceFootage", json!({"path": at(&dir, "solo_0007.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("solo_0007.png", FootageKind::Sequence, 1)); + // A single-file source keeps the sequence kind too. + s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("cover.png", FootageKind::Sequence, 1)); + // An explicit `sequence: false` still asks for one frame. + s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png"), "sequence": false})).unwrap(); + let (_, f) = footage(&s, &item); + assert_eq!((f.kind, f.sequence.len()), (FootageKind::Still, 0)); +} + +#[test] +fn replace_footage_and_reload_keep_sequences() { + let dir = folder(&RUN); + let mut s = session(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone(); + s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4)); // Reload finds a new frame and keeps the interpretation. s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "startTimecode": 2})).unwrap(); std::fs::write(at(&dir, "shot_0003.png"), b"").unwrap(); diff --git a/crates/engine/src/tests/tail.rs b/crates/engine/src/tests/tail.rs index 2710365eb..90cfd1bfc 100644 --- a/crates/engine/src/tests/tail.rs +++ b/crates/engine/src/tests/tail.rs @@ -421,6 +421,70 @@ fn camera_focus_commands_undo_and_serde() { assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok()); } +/// Set Focus Distance to Layer: degenerate targets (behind the camera, exactly at it) are clean +/// errors, and a depth-of-field frame renders right after the command. +#[test] +fn camera_set_focus_to_layer_degenerate_targets_and_dof_frame() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [200, 150, -500]})).unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": true})).unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + // Behind the camera (which now sits at z = −500 looking towards +z). + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -1000]})).unwrap(); + let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string(); + assert!(e.contains("behind the camera"), "{e}"); + // Exactly at the camera: depth 0, still a clean error. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -500]})).unwrap(); + assert!(s.execute("camera.setFocusToLayer", json!({})).is_err()); + // In front: the command works, and the depth-of-field frame renders with finite pixels. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 400]})).unwrap(); + let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap(); + assert!((d - 900.0).abs() < 1e-6, "{d}"); + // An out-of-focus 3D layer, which must visibly blur, and a 0%-scaled one, whose pixels cover + // ~0 screen pixels and so turn an ordinary defocus into a radius of ~1e9 buffer pixels. + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/aperture", "value": 120})).unwrap(); + let blur = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [blur], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": blur, "prop": "position", "value": [200, 150, 1200]})).unwrap(); + let invis = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [invis], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": invis, "prop": "position", "value": [200, 150, 1600]})).unwrap(); + s.execute("layer.setTransform", json!({"layer": invis, "prop": "scale", "value": [0.0, 0.0, 0.0]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + assert!(img.data.iter().all(|p| p.iter().all(|c| c.is_finite())), "non-finite pixels after setFocusToLayer"); + // The DoF frame actually blurred (not a no-op blur): the same frame with DoF off differs. + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": false})).unwrap(); + let plain = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + assert_ne!(img.data, plain.data, "the out-of-focus layer should have blurred"); +} + +/// Set Focus Distance to Layer fails cleanly on a depth that isn't finite. Every stored number +/// here is finite, but their difference overflows f64 to infinity; JSON has no infinity, so +/// storing it would leave a project that can't be saved. +#[test] +fn camera_set_focus_to_layer_rejects_non_finite_depth() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200.0, 150.0, 1e308]})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTransform", json!({"layer": cam, "prop": "position", "value": [200.0, 150.0, -1e308]})).unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string(); + assert!(e.contains("out of range"), "{e}"); + let after = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + assert!((after - before).abs() < 1e-9, "a non-finite focus distance was written: {after}"); + // The project still round-trips through serde (a stored infinity would come back as null). + let back = roundtrip(&s); + let f = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().value.as_f64(); + assert!((f - before).abs() < 1e-9, "serde focus distance: {f}"); +} + /// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches. #[test] fn numeric_string_resolves_as_id() { diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index c48cff7e3..7a04ea155 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -199,6 +199,20 @@ fn typing_deleting_and_caret_movement() { assert!(s.state.text_edit.is_none()); } +/// Duplicating during a text edit moves the selection to the copy: the edit on the original ends +/// (#2226), as in After Effects — typing must not keep editing an unselected layer. +#[test] +fn duplicate_during_a_text_edit_ends_the_edit() { + let (mut s, t) = session("Hello"); + s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap(); + let copy = s.execute("edit.duplicate", json!({"layers": [t]})).unwrap()[0].as_u64().unwrap(); + assert_ne!(copy, t, "the duplicate is created"); + assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "the copy is selected"); + assert!(s.state.text_edit.is_none(), "the edit ended with the selection move (#2226)"); + assert_eq!(doc(&s, t).text, "Hello", "the original keeps its text"); + assert_eq!(doc(&s, copy).text, "Hello", "and the copy carries it"); +} + #[test] fn caret_style_applies_to_the_next_typed_text() { let (mut s, t) = session("ab"); @@ -928,11 +942,19 @@ fn fonts_lists_bundled_and_installed_families() { let r = s.execute("text.fonts", json!({"query": "noto ser", "rescan": true})).unwrap(); assert_eq!(r["families"][0]["family"], "Noto Serif"); // The bundled face is Regular; the OS can also provide Bold, Italic, or other faces. - // Compare against the independent directory inventory rather than assuming an empty OS. - let mut expected_styles = std::collections::BTreeSet::from(["Regular".to_string()]); - expected_styles.extend(installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")).map(|face| face.style.clone())); + // Model `add_source()` on the independent directory inventory: a system face whose family + // and primary style duplicate an earlier face is skipped, with its named instances. + let mut expected_styles = std::collections::BTreeSet::from(["regular".to_string()]); + let mut retained = std::collections::BTreeSet::from([("noto serif".to_string(), "regular".to_string())]); + for face in installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")) { + if retained.insert((face.family.to_ascii_lowercase(), face.style.to_ascii_lowercase())) { + expected_styles.insert(face.style.to_ascii_lowercase()); + expected_styles.extend(face.instances.iter().map(|i| i.style.to_ascii_lowercase())); + } + } let styles: Vec = r["families"][0]["styles"].as_array().unwrap().iter().map(|style| style.as_str().unwrap().to_string()).collect(); - assert_eq!(styles.iter().cloned().collect::>(), expected_styles); + // `families()` keeps the first spelling of a style that differs only in case. + assert_eq!(styles.iter().map(|style| style.to_ascii_lowercase()).collect::>(), expected_styles); assert_eq!(styles.len(), expected_styles.len(), "style menu has no duplicates"); assert!(r["families"].as_array().unwrap().iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("noto ser"))); // The style menus offer a family's own styles. diff --git a/crates/engine/tests/it/parenting_3d_position.rs b/crates/engine/tests/it/parenting_3d_position.rs index f9dd9ae3f..61440e273 100644 --- a/crates/engine/tests/it/parenting_3d_position.rs +++ b/crates/engine/tests/it/parenting_3d_position.rs @@ -410,12 +410,12 @@ fn parenting_static_orientation_is_exact_just_off_gimbal_lock() { fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { for animated in [true, false] { let (mut s, p, c) = fixture(true, true); + // A non-uniform parent has no uniform pose to re-express, animated or not. + set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); if animated { for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); } - } else { - set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); } let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap(); let orientation = layer.props.prop("transform/orientation").unwrap().clone(); @@ -430,6 +430,27 @@ fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_bound } } +/// Keyed Orientation under a uniform parent is re-expressed at every key, so the child's world pose +/// and pixels stay put at every time (the parent is Z-only, so the paths between keys stay exact too). +#[test] +fn parenting_uniform_parent_re_expresses_keyed_orientation() { + for factor in [120.0, -120.0] { + let (mut s, p, c) = fixture(true, false); + set(&mut s, p, "transform/rotation", json!(42.0)); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); + } + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + let images = times.map(|t| cpu(&s, t)); + parent(&mut s, c, Some(p), true); + let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); + let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max); + assert!(error < 1e-8 && pixels < 2e-5, "keyed Orientation under a uniform parent (factor {factor}): corners {error}, pixels {pixels}"); + } +} + /// Position's temporal speed is measured along its spatial path, in parent-space units. #[test] fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() { diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index fd1ace60b..1b69a87bd 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -376,7 +376,7 @@ async fn gif_export(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_ let end = (i + batch).min(st.total); let frames: Vec> = job .frames(comp, (i..end).collect(), |_, img| { - let mut px = job.pixels(&img, comp, job.output.channels, w, h); + let mut px = job.pixels_with(&img, comp, job.output.channels, w, h, false); gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10) }) .await; diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index b185c3345..c639ebad1 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -4,10 +4,8 @@ use std::sync::Mutex; -use effectcraft_project::render_queue::{ - AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path, -}; -use effectcraft_project::{Comp, Project}; +use effectcraft_project::render_queue::{AlphaMode, Channels, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path}; +use effectcraft_project::{BitDepth, Comp, Project}; use effectcraft_raster::Image; use effectcraft_render::{Backend, LayerCache, RenderOpts, Renderer}; use effectcraft_time::Tick; @@ -220,6 +218,9 @@ impl<'a> Cx<'a> { pub fn new(job: &'a Job<'a>) -> Cx<'a> { let mut project = job.project.clone(); apply_overrides(&mut project, job); + // Render Settings ▸ Color Depth overrides the project's depth for the whole render (every + // layer and effect quantises at it), not just the finished frame (#2022). + project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth); let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new())); let cache = LayerCache::default(); // With deferred readbacks, buffers computed from a missed pass's placeholders are not kept. @@ -240,7 +241,7 @@ impl<'a> Cx<'a> { // compositors differ slightly, so a per-frame choice made renders differ run to run. let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu }; let opts = RenderOpts { - scale: s.resolution.clamp(0.01, 4.0), + scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)), motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll, guides: s.guide_layers == CurrentOrOff::Current, draft: s.quality == RenderQuality::Draft, @@ -360,14 +361,6 @@ impl<'a> Cx<'a> { if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) } } }; - let mut img = img; - // Color Depth: renders run at the project's depth; another depth quantises the frame. - let depth = self.job.settings.color_depth; - if depth != ColorDepth::Current - && let Some(levels) = depth.resolve(self.project.settings.bit_depth).levels() - { - img.quantize(levels); - } let img = self.crop_resize(img); if let Some(ft) = &self.frame_times { ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64())); @@ -384,12 +377,14 @@ impl<'a> Cx<'a> { return None; } let FrameSample::Progressive(t) = self.job.settings.sample(comp, i) else { return None }; - let depth = self.job.settings.color_depth; - if depth != ColorDepth::Current && depth.resolve(self.project.settings.bit_depth).levels().is_some() { + // Above 8 bpc the colour is dithered on its way to 8 bits (`Cx::pixels`), which the + // accelerator's conversion doesn't do: those frames render as usual so H.264 doesn't + // band (#2022). The project's depth already includes Render Settings ▸ Color Depth. + if self.project.settings.bit_depth != BitDepth::Bpc8 { return None; } let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.job.settings.effective_scale(comp); let size = |n: u32| ((n as f64 * scale).round() as u32).max(1); if om.resize.enabled || (size(comp.width), size(comp.height)) != (w, h) || om.crop.edges(w, h, scale) != (0, 0, 0, 0) { return None; @@ -435,7 +430,7 @@ impl<'a> Cx<'a> { /// Output Module ▸ Crop, then Resize. fn crop_resize(&self, img: Image) -> Image { let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0)); let (t, l, b, r) = om.crop.edges(img.width, img.height, scale); let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) }; if !om.resize.enabled { @@ -453,12 +448,24 @@ impl<'a> Cx<'a> { /// 8-bit RGBA of the requested channels cropped/padded to `w`×`h` (codec rounding): RGB over /// the comp background (opaque), RGB + Alpha straight or premultiplied, or the alpha matte. + /// When the render runs above 8 bpc, the colour is dithered on its way to 8 bits, as After + /// Effects dithers colours converted down to 8 bpc for rendering and export, so gradients + /// rendered at 16 or 32 bpc don't band (#2022). An 8 bpc render has no finer values to + /// dither, so its output stays byte for byte what it was. pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec { + let dither = self.project.settings.bit_depth != BitDepth::Bpc8; + self.pixels_with(img, comp, channels, w, h, dither) + } + + /// [`Cx::pixels`], dithering the colour or not (a GIF palette gains nothing from dither noise). + pub fn pixels_with(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, dither: bool) -> Vec { let px = match channels { + Channels::Rgb if dither => img.to_rgba8_over_dithered(comp.background), Channels::Rgb => img.to_rgba8_over(comp.background), - Channels::Rgba => match self.job.output.alpha_mode { - AlphaMode::Straight => img.to_rgba8(), - AlphaMode::Premultiplied => premultiplied8(img), + Channels::Rgba => match (self.job.output.alpha_mode, dither) { + (AlphaMode::Straight, true) => img.to_rgba8_dithered(), + (AlphaMode::Straight, false) => img.to_rgba8(), + (AlphaMode::Premultiplied, _) => premultiplied8(img, dither), }, Channels::Alpha => img .data @@ -619,15 +626,22 @@ fn bicubic(src: &Image, w: u32, h: u32) -> Image { out } -/// Premultiplied 8-bit RGBA (colour matted with black). -fn premultiplied8(img: &Image) -> Vec { - img.data - .iter() - .flat_map(|p| { - let a = p[3].clamp(0.0, 1.0); - [0, 1, 2].map(|c| (p[c].clamp(0.0, a) * 255.0 + 0.5) as u8).into_iter().chain([(a * 255.0 + 0.5) as u8]) - }) - .collect() +/// Premultiplied 8-bit RGBA (colour matted with black), the colour dithered when `dither`. +fn premultiplied8(img: &Image, dither: bool) -> Vec { + let w = img.width.max(1) as usize; + let mut out = Vec::with_capacity(img.data.len() * 4); + for (i, p) in img.data.iter().enumerate() { + let a = p[3].clamp(0.0, 1.0); + let a8 = (a * 255.0 + 0.5) as u8; + let (x, y) = ((i % w) as u32, (i / w) as u32); + for c in 0..3 { + let v = p[c].clamp(0.0, a); + // Dither may round up past the rounded alpha; premultiplied colour never exceeds it. + out.push(if dither { effectcraft_raster::dither_u8(v, x, y).min(a8) } else { (v * 255.0 + 0.5) as u8 }); + } + out.push(a8); + } + out } #[cfg(test)] @@ -636,6 +650,19 @@ mod tests { use rayon::prelude::*; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + /// Dither may round a colour up while alpha rounds down; premultiplied colour stays at or + /// below the written alpha at every pixel. + #[test] + fn dithered_premultiplied_colour_never_exceeds_alpha() { + let v = 100.25 / 255.0; + let img = Image::filled(64, 64, [v, v, v, v]); + let px = premultiplied8(&img, true); + assert!(px.as_chunks::<4>().0.iter().all(|p| p[..3].iter().all(|c| *c <= p[3])), "a colour byte is above its alpha"); + assert!(px.as_chunks::<4>().0.iter().all(|p| p[3] == 100)); + let plain = premultiplied8(&img, false); + assert!(plain.as_chunks::<4>().0.iter().all(|p| *p == [100, 100, 100, 100])); + } + /// Frames `0..n` through [`map_frames`] with `cap`, recording the most running at once. fn run_capped(n: u64, cap: Option) -> (Vec, usize) { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/export/tests/it/banding.rs b/crates/export/tests/it/banding.rs new file mode 100644 index 000000000..3f99f1eb6 --- /dev/null +++ b/crates/export/tests/it/banding.rs @@ -0,0 +1,156 @@ +//! Gradients exported at 16 bpc (or with a 16 bpc Color Depth override) don't band (#2022): the +//! float frame is dithered on its way to 8 bits, and the Render Settings' Color Depth reaches the +//! render itself. + +use std::sync::{Arc, Mutex}; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::*; +use effectcraft_export::{Job, JobOptions, Sink, export}; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, LayerSource, Project, Solid}; +use effectcraft_render::NoFootage; +use effectcraft_time::{FrameRate, Tick}; + +const W: u32 = 1024; +const H: u32 = 16; +/// The ramp runs from this grey to `HI` across the comp: about 25 eight-bit codes in 1024 px, +/// so plain rounding makes bands about 40 px wide. +const LO: f64 = 0.20; +const HI: f64 = 0.30; +/// Width of the column blocks whose mean is compared with the ideal ramp. +const WINDOW: u32 = 8; + +/// A grey horizontal ramp over a comp of the given project depth. +fn ramp_project(depth: BitDepth) -> (Project, ItemId) { + let mut p = Project::default(); + p.settings.bit_depth = depth; + let mut comp = Comp::new(W, H, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + comp.background = [0.0, 0.0, 0.0]; + let sid = p.add_item("Ramp", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: W, height: H, pixel_aspect: 1.0 })); + let mut layer = build::layer(&mut p, &comp, "Ramp", LayerSource::Solid { item: sid }, (W, H), None); + let spec = effectcraft_effects::find("ec.generate.gradientramp").expect("gradient ramp effect"); + let mut next = p.next_id; + let mut group = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]); + p.next_id = next; + for (k, v) in [ + ("start", Value::Vec2([0.0, 0.0])), + ("end", Value::Vec2([W as f64, 0.0])), + ("startColor", Value::Color([LO, LO, LO, 1.0])), + ("endColor", Value::Color([HI, HI, HI, 1.0])), + ] { + group.get_mut(k).expect("ramp parameter").value = v; + } + layer.props.sub_mut("effects").expect("effects group").children.push(group.into()); + comp.layers.push(layer); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid) +} + +/// Export frame 0 as PNG with the Render Settings' Color Depth `depth` and return the grey of +/// every row. +fn export_rows(p: &Project, cid: ItemId, depth: ColorDepth) -> Vec> { + let files: Arc)>>> = Default::default(); + let f = files.clone(); + let sink: Box = Box::new(move |path: &str, data: Vec| f.lock().unwrap().push((path.to_string(), data))); + let settings = + RenderSettings { time_span: TimeSpan::Custom { start: Tick::ZERO, end: Tick::from_seconds_f64(0.04) }, color_depth: depth, ..Default::default() }; + let om = OutputModule::for_format(OutputFormat::PngSequence); + let job = Job { + project: p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &om, + path: "/banding/f_[#####].png", + sink: Some(&*sink), + options: JobOptions::default(), + nested_switches: true, + }; + export(&job, &mut |_| true).expect("export"); + let files = files.lock().unwrap(); + let (_, png) = files.iter().find(|(path, _)| path.ends_with(".png")).expect("a PNG"); + let img = image::load_from_memory(png).expect("png").to_rgba8(); + (0..H).map(|y| (0..W).map(|x| img.get_pixel(x, y).0[1]).collect()).collect() +} + +/// The ideal ramp at column `x`, in 8-bit codes. +fn ideal(x: f64) -> f64 { + 255.0 * (LO + (HI - LO) * (x + 0.5) / W as f64) +} + +/// Longest run of equal neighbours in the row: a band's width. +fn longest_run(row: &[u8]) -> usize { + let (mut best, mut run) = (1, 1); + for w in row.windows(2) { + run = if w[0] == w[1] { run + 1 } else { 1 }; + best = best.max(run); + } + best +} + +/// Horizontal neighbours with different values, over all rows: ~25 per row when every band is a +/// flat step, hundreds when the steps are dithered. +fn transitions(rows: &[Vec]) -> usize { + rows.iter().map(|r| r.windows(2).filter(|w| w[0] != w[1]).count()).sum() +} + +/// Largest difference, in 8-bit codes, between the mean of a `WINDOW`-column block over all rows +/// and the ideal ramp's mean over it. Plain rounding leaves up to ~0.4 codes of it beside a step +/// edge; dither averages it away (its noise on a mean of `WINDOW * H` samples is under 0.05). +fn worst_window_error(rows: &[Vec]) -> f64 { + (0..W / WINDOW) + .map(|i| { + let (x0, x1) = ((i * WINDOW) as usize, ((i + 1) * WINDOW) as usize); + let sum: f64 = rows.iter().flat_map(|r| &r[x0..x1]).map(|&v| v as f64).sum(); + let mean = sum / (rows.len() * (x1 - x0)) as f64; + (mean - ideal((x0 + x1) as f64 / 2.0 - 1.0)).abs() + }) + .fold(0.0, f64::max) +} + +#[test] +fn a_16_bpc_gradient_export_is_dithered() { + let (p, cid) = ramp_project(BitDepth::Bpc16); + let rows = export_rows(&p, cid, ColorDepth::Current); + let (steps, err) = (transitions(&rows), worst_window_error(&rows)); + eprintln!("16 bpc project: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes"); + // Undithered there are ~25 flat steps per row (400 transitions) and the block means are off + // by up to ~0.4 codes. + assert!(steps > 2000, "the steps survive: {steps} transitions"); + assert!(err < 0.25, "the local mean is off by {err} codes"); + // Dither never moves a value a whole code from the ideal ramp. + for row in &rows { + for (x, &v) in row.iter().enumerate() { + let want = ideal(x as f64); + assert!((v as f64 - want).abs() < 1.05, "x {x}: {v} vs {want}"); + } + } +} + +#[test] +fn the_export_is_the_same_every_time() { + let (p, cid) = ramp_project(BitDepth::Bpc16); + assert_eq!(export_rows(&p, cid, ColorDepth::Current), export_rows(&p, cid, ColorDepth::Current)); +} + +#[test] +fn the_render_color_depth_reaches_the_render() { + // An 8 bpc project renders every layer at 8 bits: flat steps, nothing for the output to dither. + let (p, cid) = ramp_project(BitDepth::Bpc8); + let banded = export_rows(&p, cid, ColorDepth::Current); + assert!(longest_run(&banded[0]) >= 30, "an 8 bpc render is quantised before export"); + assert!(transitions(&banded) < 16 * 30); + // The Render Settings' 16 bpc depth overrides the project's: the render keeps its precision + // until the final conversion, which dithers. + let deep = export_rows(&p, cid, ColorDepth::Bpc16); + let (steps, err) = (transitions(&deep), worst_window_error(&deep)); + eprintln!("8 bpc project, 16 bpc render: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes"); + assert!(steps > 2000 && err < 0.25, "a 16 bpc render still bands: {steps} transitions, error {err}"); + // And 8 bpc on a 16 bpc project renders at 8 bits. + let (p16, cid16) = ramp_project(BitDepth::Bpc16); + assert_eq!(export_rows(&p16, cid16, ColorDepth::Bpc8), banded); +} diff --git a/crates/export/tests/it/h264_gpu_yuv.rs b/crates/export/tests/it/h264_gpu_yuv.rs index 59e670779..0f99a7b54 100644 --- a/crates/export/tests/it/h264_gpu_yuv.rs +++ b/crates/export/tests/it/h264_gpu_yuv.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use effectcraft_color::Label; -use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::render_queue::{ColorDepth, OutputFormat, OutputModule, RenderSettings, TimeSpan}; use effectcraft_export::{Job, export}; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build}; use effectcraft_raster::Image; @@ -147,6 +147,20 @@ fn frames_the_accelerator_cannot_take_fall_back_to_rgba() { assert!(a == c); } +/// Above 8 bpc the colour is dithered on its way to 8 bits, which the accelerator's planes +/// don't do: those frames take the RGBA path, so an H.264 export doesn't band (#2022). +#[test] +fn renders_above_8_bpc_skip_the_planes() { + let d = out_dir("h264-gpu-yuv-16bpc"); + let (p, cid) = project(); + let s = RenderSettings { time_span: TimeSpan::LengthOfComp, color_depth: ColorDepth::Bpc16, ..Default::default() }; + let yuv = Fake::new(true, false, usize::MAX); + let a = run(&p, cid, Some(&yuv), &s, &d.join("yuv.mp4")); + let c = run(&p, cid, None, &s, &d.join("cpu.mp4")); + assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 0, "no planes asked for above 8 bpc"); + assert!(a == c); +} + /// With deferred readbacks the export keeps rendering frames in passes (`Cx::frames`), never /// through the blocking per-frame path the planes use. #[test] diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs index 0064ba8b7..88398a0e8 100644 --- a/crates/export/tests/it/main.rs +++ b/crates/export/tests/it/main.rs @@ -1,6 +1,7 @@ //! The crate's integration tests, one binary (each module is a former `tests/.rs`). mod audio_sample_positions; +mod banding; mod h264_gpu_yuv; mod hevc_av1_roundtrip; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index a25fbc4f0..fa2993f97 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -10,7 +10,7 @@ use effectcraft_geom::{Mat3, Mat4}; use effectcraft_keyframe::Value; use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property}; -use effectcraft_render::{EvalCtx, source_size}; +use effectcraft_render::EvalCtx; use effectcraft_time::Tick; /// A name-or-index argument (`layer("A")`, `layer(2)`, `effect(1)`). @@ -299,7 +299,17 @@ impl<'a> Resolver<'a> { } fn ctx_at(&self, c: u64, t: f64) -> Option> { let comp = self.comp(c)?; - Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr, footage: self.ctx.footage }) + let same_comp = c == self.ctx.comp_id.0; + Some(EvalCtx { + project: self.ctx.project, + comp_id: ItemId(c), + comp, + time: Tick::from_seconds_f64(t), + expr: self.ctx.expr, + footage: self.ctx.footage, + property_overrides: if same_comp { self.ctx.property_overrides } else { &[] }, + media_overrides: if same_comp { self.ctx.media_overrides } else { &[] }, + }) } fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> { let layer = self.layer(c, l)?; @@ -372,7 +382,7 @@ impl<'a> Resolver<'a> { Req::LayerInfo { comp, layer } => { let c = self.comp(*comp)?; let l = self.layer(*comp, *layer)?; - let (mut w, mut h) = source_size(project, l); + let (mut w, mut h) = self.ctx_at(*comp, self.ctx.time.seconds())?.source_size(l); if w == 0 || h == 0 { (w, h) = (c.width, c.height); } @@ -467,7 +477,7 @@ impl<'a> Resolver<'a> { } } _ => { - let (w, h) = source_size(project, l); + let (w, h) = ctx.source_size(l); [0.0, 0.0, w as f64, h as f64] } }; @@ -501,7 +511,12 @@ impl<'a> Resolver<'a> { Req::SourceInfo { comp, layer } => { let c = self.comp(*comp)?; let l = self.layer(*comp, *layer)?; - let it = project.item(l.source.item()?)?; + let ctx = self.ctx_at(*comp, self.ctx.time.seconds())?; + let item = match &l.source { + LayerSource::Footage { item } => ctx.media_item(l, *item), + _ => l.source.item()?, + }; + let it = project.item(item)?; let (kind, aspect, frame, duration) = match &it.kind { ItemKind::Comp(nc) => ("comp", nc.pixel_aspect, nc.frame_duration().seconds(), nc.duration.seconds()), ItemKind::Footage(f) => ("footage", f.pixel_aspect, f.frame_rate.frame_duration().seconds(), f.duration.seconds()), diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index c77af5b37..9e78d250f 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -17,7 +17,8 @@ //! `sourceRectAtTime`), property methods (`valueAtTime`, `velocityAtTime`, `speedAtTime`, `key`, //! `nearestKey`, `numKeys`, `wiggle`, `temporalWiggle`, `smooth`, `loopIn/Out[Duration]`) and the //! global helpers (`linear`/`ease`/`easeIn`/`easeOut`, `random`/`gaussRandom`/`seedRandom`/ -//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`). +//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`), +//! plus `bezier`, a CSS-style cubic-Bezier easing helper that After Effects does not have. //! * **Determinism**: `wiggle` and `random` are seeded by layer index, property uid (and time / //! `seedRandom`), so frames render identically on every thread and platform. //! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read @@ -162,7 +163,16 @@ fn evaluate_out( return Err("Error: expressions reference each other too deeply (circular reference?)".into()); } budget_left()?; - let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage }; + let ctx = EvalCtx { + project: ctx.project, + comp_id: ctx.comp_id, + comp: ctx.comp, + time: ctx.time, + expr: Some(host), + footage: ctx.footage, + property_overrides: ctx.property_overrides, + media_overrides: ctx.media_overrides, + }; let comp = ctx.comp_id.0; let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid)); let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } }; diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index c2ca16736..8fe812590 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -207,6 +207,60 @@ function ease() { return __interp(__ease, arguments); } function easeIn() { return __interp(__easeIn, arguments); } function easeOut() { return __interp(__easeOut, arguments); } +// Cubic-Bezier easing (the CSS cubic-bezier() shape): [x1, y1, x2, y2], with x1 / x2 in 0..1 +// and y1 / y2 free (overshoot included). Solves x(u) = x by Newton–Raphson, then by bisection +// when Newton leaves 0..1 or the derivative vanishes, and returns y(u). A curve that is not four +// finite numbers is linear, never an error. +function __cubicBezier(curve) { + var x1, y1, x2, y2; + if (Array.isArray(curve) && curve.length === 4) { + x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3]; + } + if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' || + !isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) { + return __lin; + } + x1 = Math.max(0, Math.min(1, x1)); + x2 = Math.max(0, Math.min(1, x2)); + function bx(u) { var v = 1 - u; return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u; } + function dbx(u) { var v = 1 - u; return 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2); } + function by(u) { var v = 1 - u; return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u; } + function dby(u) { var v = 1 - u; return 3 * v * v * y1 + 6 * v * u * (y2 - y1) + 3 * u * u * (1 - y2); } + return function (x) { + if (x <= 0) return 0; + if (x >= 1) return 1; + var u = x; + for (var i = 0; i < 8; i++) { + var f = bx(u) - x; + var d = dbx(u); + if (Math.abs(d) < 1e-12) break; + // Stop when the *value* is accurate: near the endpoints dy/dx is steep, so a residual in x + // alone can still mean a visible error in y (f * dy/dx estimates it). + if (Math.abs(f * dby(u) / d) < 1e-9) return by(u); + var n = u - f / d; + if (!(n >= 0 && n <= 1)) break; + u = n; + } + var lo = 0, hi = 1; + for (var j = 0; j < 60; j++) { + var mid = (lo + hi) / 2; + if (bx(mid) < x) lo = mid; else hi = mid; + } + return by((lo + hi) / 2); + }; +} + +// bezier(t, tMin, tMax, value1, value2, curve), or bezier(t, value1, value2, curve): the curve is +// the sixth / fourth argument. Read it by position, not from the end: when it is missing or +// unusable, the interpolation falls back to linear without consuming the last value. +function bezier() { + var a = arguments, n = a.length; + if (n >= 6) return __interp(__cubicBezier(a[5]), [a[0], a[1], a[2], a[3], a[4]]); + if (n === 5) return __interp(__lin, [a[0], a[1], a[2], a[3], a[4]]); + if (n === 4) return __interp(__cubicBezier(a[3]), [a[0], a[1], a[2]]); + return __interp(__lin, [a[0], a[1], a[2]]); +} + // --------------------------------------------------------------------------------------------- // Random numbers and noise. diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index cf13f02f8..55720a327 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -314,8 +314,78 @@ fn interpolation_with_arrays() { assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]); } -// ---- object model ----------------------------------------------------------------------------- +#[test] +fn cubic_bezier_curves() { + let f = fx(); + // ease(t, …) is cubic-bezier(1/3, 0, 2/3, 1): x(u) = u and y(u) = 3u² - 2u³. + assert_close!(f.num("bezier(0.25, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), f.num("ease(0.25, 0, 1, 0, 100)", 0.0)); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), 50.0); + // Symmetric ease-in-out: slower at both ends, faster in the middle. + let early = f.num("bezier(0.25, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0); + let late = f.num("bezier(0.75, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0); + assert!(early < 25.0 && late > 75.0, "ease-in-out: {early}, {late}"); + // Control points on the diagonal are linear. + assert_close!(f.num("bezier(0.3, 0, 1, 0, 100, [0, 0, 1, 1])", 0.0), 30.0); + // t is clamped to tMin..tMax; tMin == tMax behaves like the other helpers. + assert_close!(f.num("bezier(-1, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 0.0); + assert_close!(f.num("bezier(2, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0); + assert_close!(f.num("bezier(5, 2, 2, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0); +} +#[test] +fn cubic_bezier_short_form_arrays_and_fallbacks() { + let f = fx(); + // Short form: no tMin / tMax. + assert_close!(f.num("bezier(0.25, 10, 20, [0, 0, 1, 1])", 0.0), 12.5); + assert_eq!(f.pos("bezier(time, 0, 1, [0, 0], [100, 50], [0.42, 0, 0.58, 1])", 1.0), [100.0, 50.0, 0.0]); + // y outside 0..1 overshoots, as CSS allows. + let over = f.num("bezier(0.5, 0, 1, 0, 100, [0.5, 2, 0.5, 2])", 0.0); + assert!(over > 100.0, "overshoot: {over}"); + // A curve that is not four finite numbers falls back to linear instead of throwing. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0); + // A missing or unusable curve must not consume the last value: read the curve by position. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100)", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 100)", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 100, [1, 0])", 0.0), 50.0); + let v = f.ev(f.shape, "contents/#1/contents/fill/color", "bezier(0.5, 0, 1, [0, 0, 0, 1], [1, 1, 1, 1])", 0.0).unwrap(); + assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0])); + // Exactly four values are a curve; extra ones mean the curve is malformed, so linear. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 0])", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 2, 3])", 0.0), 50.0); + // x control points outside 0..1 are clamped, as CSS requires. + let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0); + assert!(v.is_finite(), "clamped curve: {v}"); +} + +#[test] +fn cubic_bezier_is_accurate_near_the_endpoints() { + /// The value the curve reaches at `x`, by bisection on its monotone x(u). + fn reference(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 { + let bx = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * x1 + 3.0 * (1.0 - u) * u * u * x2 + u * u * u; + let by = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * y1 + 3.0 * (1.0 - u) * u * u * y2 + u * u * u; + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..100 { + let mid = 0.5 * (lo + hi); + if bx(mid) < x { + lo = mid; + } else { + hi = mid; + } + } + by(0.5 * (lo + hi)) + } + // Steep curves: a residual in x is not a residual in y near the endpoints (dy/dx is in the + // thousands), so the solver stops on the estimated y error instead. + let f = fx(); + for (c, x) in [([0.0, 1000.0, 1.0, 1.0], 1e-6), ([0.0, 100.0, 1.0, 0.0], 1e-9), ([0.8, -500.0, 1.0, 1.0], 0.999999)] { + let got = f.num(&format!("bezier({x}, 0, 1, [{}, {}, {}, {}])", c[0], c[1], c[2], c[3]), 0.0); + let want = reference(c[0], c[1], c[2], c[3], x); + assert!((got - want).abs() < 1e-6, "curve {c:?} at {x}: {got} vs {want}"); + } +} + +// ---- object model ----------------------------------------------------------------------------- #[test] fn comp_attributes() { let f = fx(); @@ -908,6 +978,49 @@ fn source_rect_at_time() { assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}"); } +#[test] +fn source_dimensions_follow_media_replacement() { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let footage = |p: &mut Project, name: &str, width, height| { + p.add_item( + name, + Label::SeaFoam, + None, + ItemKind::Footage(effectcraft_project::Footage { + path: name.into(), + kind: effectcraft_project::FootageKind::Still, + width, + height, + has_video: true, + ..Default::default() + }), + ) + }; + let original = footage(&mut p, "original.png", 100, 80); + let replacement = footage(&mut p, "replacement.png", 30, 40); + let mut l = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: original }, (100, 80), None); + l.props.prop_mut(ROT).unwrap().expr = + Some(Expression { text: "thisLayer.width + thisLayer.height + thisLayer.source.width + sourceRectAtTime().width".into(), enabled: true }); + let lid = l.id; + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + let l = c.layer(lid).unwrap(); + let media = effectcraft_render::MediaOverride::new(lid, replacement); + let ctx = EvalCtx { + project: &p, + comp_id: cid, + comp: c, + time: Tick::ZERO, + expr: Some(&Expressions), + footage: None, + property_overrides: &[], + media_overrides: &[media], + }; + assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0)); +} + // ---- colour ----------------------------------------------------------------------------------- #[test] @@ -1115,7 +1228,16 @@ fn text_expression_selector_values() { let sel_of = |p: &Project| { let c = p.comp(cid).unwrap(); let l = c.layer(lid).unwrap(); - let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: c, + time: Tick::ZERO, + expr: Some(&Expressions), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let doc = effectcraft_render::text::source_text(&ctx, l).unwrap(); let lay = effectcraft_text::layout_doc(&doc); let anim = l.props.group("text/animators/#1").unwrap(); diff --git a/crates/expr/src/tests/fuzz.rs b/crates/expr/src/tests/fuzz.rs index 1bfaf8c16..37b28c415 100644 --- a/crates/expr/src/tests/fuzz.rs +++ b/crates/expr/src/tests/fuzz.rs @@ -93,6 +93,8 @@ const SEEDS: &[&str] = &[ "linear(time, 0, 1, 0, 100)", "ease(time, 0, 2, [0, 0], [100, 50])", "easeIn(time, 0, 1, 0, 360) + easeOut(time, 1, 2, 0, 10)", + "bezier(time, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", + "bezier(time, [0, 0], [100, 50], [0.25, 0.1, 0.25, 1])", "clamp(value * 2, 0, 100)", "length([0, 0], [3, 4]) + dot([1, 0], [0, 1]) + cross([1, 0, 0], [0, 1, 0])[2]", "normalize([3, 4])[0] * degreesToRadians(180)", diff --git a/crates/gpu/src/bokeh.rs b/crates/gpu/src/bokeh.rs index 39dc0bdea..a3f05bf12 100644 --- a/crates/gpu/src/bokeh.rs +++ b/crates/gpu/src/bokeh.rs @@ -10,8 +10,9 @@ use crate::context::{Enc, GpuImage, Params}; /// Blur `src` (an unpadded plane buffer) as `d` says. Returns the padded, blurred image. pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option { - let (w, h) = (src.width + 2 * d.pad, src.height + 2 * d.pad); - if !e.g.fits(w + 1, h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { + // Saturating, so an oversized padding is refused by `fits` below instead of wrapping around. + let (w, h) = (src.width.saturating_add(d.pad.saturating_mul(2)), src.height.saturating_add(d.pad.saturating_mul(2))); + if !e.g.fits(w.saturating_add(1), h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { return None; } let padded = e.image(w, h); diff --git a/crates/gpu/src/tests/adv3d.rs b/crates/gpu/src/tests/adv3d.rs index 21422aa6b..f09798119 100644 --- a/crates/gpu/src/tests/adv3d.rs +++ b/crates/gpu/src/tests/adv3d.rs @@ -96,7 +96,16 @@ pub(crate) fn scene_project() -> (Project, ItemId) { pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene { let r = Renderer::new(p, &NoFootage, RenderOpts::default()); - let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height)) } @@ -277,7 +286,16 @@ pub(crate) fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) { } pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) { - let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); (ctx, run) } diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index 78a6857fb..d4d7a62a1 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -89,7 +89,7 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut i = j; } else { match r.collapsed(ctx, visible[i]) { - Some(item) => draw_collapsed(e, r, ctx, visible[i], item, canvas)?, + Some(collapsed) => draw_collapsed(e, r, ctx, visible[i], &collapsed, canvas)?, None => draw_layer(e, r, ctx, visible[i], canvas)?, } i += 1; @@ -187,19 +187,26 @@ fn draw_split(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, split: &effectcraft_rend Some(cv) } -fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut GpuImage) -> Option<()> { +fn draw_collapsed<'a>( + e: &mut Enc, + r: &Renderer<'a>, + ctx: &EvalCtx<'a>, + layer: &'a Layer, + collapsed: &effectcraft_render::Collapsed, + canvas: &mut GpuImage, +) -> Option<()> { let opacity = r.layer_opacity(ctx, layer); if opacity <= 0.0 { return Some(()); } let matte = r.track_matte(ctx, layer).is_some(); if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency { - if let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, opacity) { + if let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, opacity) { draw_comp(e, &sub, &nctx, canvas)?; } return Some(()); } - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, 1.0) else { return Some(()) }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, 1.0) else { return Some(()) }; let mut iso = e.zeros(canvas.width, canvas.height); draw_comp(e, &sub, &nctx, &mut iso)?; composite_iso(e, r, ctx, layer, iso, canvas, opacity) diff --git a/crates/host/src/tests.rs b/crates/host/src/tests.rs index a73f15937..3114c2ddb 100644 --- a/crates/host/src/tests.rs +++ b/crates/host/src/tests.rs @@ -269,7 +269,16 @@ fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_eng let cid = s.active_comp_id().unwrap(); let comp = s.project.comp(cid).unwrap(); let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap(); - let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: Default::default(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; ctx.value(layer, layer.props.prop(path).unwrap()) } @@ -523,8 +532,16 @@ fn stereo_rig_expressions_follow_the_master_camera() { let v3 = |s: &effectcraft_engine::Session, path: &str| { let comp = s.project.comp(left).unwrap(); let layer = &comp.layers[0]; - let ctx = - effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: left, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: left, + comp, + time: Default::default(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3() }; let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-6); diff --git a/crates/host/tests/it/main.rs b/crates/host/tests/it/main.rs index 1e60e5ab7..826363edb 100644 --- a/crates/host/tests/it/main.rs +++ b/crates/host/tests/it/main.rs @@ -12,4 +12,5 @@ mod relink; mod tga_import; mod timeline_interop; mod vector_import; +mod wav_markers; mod webm_metadata; diff --git a/crates/host/tests/it/wav_markers.rs b/crates/host/tests/it/wav_markers.rs new file mode 100644 index 000000000..98d2685dc --- /dev/null +++ b/crates/host/tests/it/wav_markers.rs @@ -0,0 +1,126 @@ +//! Markers a WAV file carries, through the fully wired session (media import included): the +//! layer made from the file starts with them, and Update Markers From Source copies them again, +//! through its bound `layers` parameter as well as the selection, as one undo step. + +use effectcraft_engine::Session; +use effectcraft_project::LayerSource; +use serde_json::json; + +/// The comments of a layer's markers, in time order. +fn names(s: &mut Session, layer: u64) -> Vec { + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + list.as_array().unwrap().iter().map(|m| m["comment"].as_str().unwrap().to_string()).collect() +} + +/// A one-second mono 8 kHz WAV with the named cue points `(id, sample, name)` (a `cue ` chunk and a +/// `LIST` `adtl`). +fn marker_wav(points: &[(u32, u32, &str)]) -> Vec { + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + let mut fmt = Vec::new(); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(8000u32.to_le_bytes()); + fmt.extend(16000u32.to_le_bytes()); + fmt.extend(2u16.to_le_bytes()); + fmt.extend(16u16.to_le_bytes()); + let mut cue = (points.len() as u32).to_le_bytes().to_vec(); + let mut adtl = b"adtl".to_vec(); + for &(id, at, name) in points { + for v in [id, 0] { + cue.extend(v.to_le_bytes()); + } + cue.extend(b"data"); + for v in [0, 0, at] { + cue.extend(u32::to_le_bytes(v)); + } + let mut labl = id.to_le_bytes().to_vec(); + labl.extend(name.as_bytes()); + labl.push(0); + adtl.extend(chunk(b"labl", &labl)); + } + let mut body = b"WAVE".to_vec(); + for c in [chunk(b"fmt ", &fmt), chunk(b"data", &[0; 16000]), chunk(b"cue ", &cue), chunk(b"LIST", &adtl)] { + body.extend(c); + } + let mut file = b"RIFF".to_vec(); + file.extend((body.len() as u32).to_le_bytes()); + file.extend(body); + file +} + +/// A WAV's cue points become markers on the layer made from it, named as in the file, and +/// Update Markers From Source brings back one that was deleted (through the fully wired session, +/// media import included). +#[test] +fn a_wav_layer_starts_with_the_markers_in_its_file() { + let mut s = effectcraft_host::session(); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap(); + let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-markers-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cues.wav"); + std::fs::write(&path, marker_wav(&[(1, 0, "Intro"), (2, 4000, "Middle")])).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let layer = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Footage { .. })).unwrap().id.0; + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + let marks: Vec<(String, f64)> = + list.as_array().unwrap().iter().map(|m| (m["comment"].as_str().unwrap().to_string(), m["time"].as_f64().unwrap())).collect(); + assert_eq!(marks, [("Intro".to_string(), 0.0), ("Middle".to_string(), 0.5)]); + s.execute_checked("markers.delete", json!({"layer": layer, "index": 0})).unwrap(); + assert_eq!(names(&mut s, layer), ["Middle"]); + s.execute_checked("layer.select", json!({"layers": [layer]})).unwrap(); + // The bound `layers` parameter (what a script or the menu's own binding passes) brings the marker back... + let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap(); + assert_eq!(r["added"], 1); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // ...as one undo step: undo removes it again, redo restores it. + assert!(s.undo()); + assert_eq!(names(&mut s, layer), ["Middle"]); + assert!(s.redo()); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // The selection route (no parameter) finds nothing more to add. + assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 0); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Reload Footage re-reads the markers the file carries: a cue point added to the file after the +/// import is available to Update Markers From Source (and a layer already made keeps its own). +#[test] +fn reloading_a_wav_picks_up_cue_points_added_to_the_file() { + let mut s = effectcraft_host::session(); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap(); + let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-reload-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cues.wav"); + std::fs::write(&path, marker_wav(&[(1, 0, "Intro")])).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let (layer, item) = s + .active_comp() + .unwrap() + .layers + .iter() + .find_map(|l| match l.source { + LayerSource::Footage { item } => Some((l.id.0, item.0)), + _ => None, + }) + .unwrap(); + assert_eq!(names(&mut s, layer), ["Intro"]); + // The file gains a cue point; the layer does not change until asked. + std::fs::write(&path, marker_wav(&[(1, 0, "Intro"), (2, 4000, "Added later")])).unwrap(); + let r = s.execute_checked("file.reloadFootage", json!({"items": [item]})).unwrap(); + assert_eq!(r["reloaded"], 1, "{r}"); + assert_eq!(names(&mut s, layer), ["Intro"]); + let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap(); + assert_eq!(r["added"], 1); + assert_eq!(names(&mut s, layer), ["Intro", "Added later"]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 0ef01d831..52a375fef 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] craft-docpath = { workspace = true } effectcraft-time = { workspace = true } +effectcraft-color = { workspace = true } effectcraft-project = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-render = { workspace = true } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 306f8d860..946e0d12a 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -25,6 +25,7 @@ pub use layered::vector_doc; mod pool; mod probe; mod stream; +mod wav_markers; mod webp_anim; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 8197cd61f..f695ff0d0 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -107,8 +107,11 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let turn = video_turn(&bytes); let rate = video_rate(&bytes); + // A WAV's cue points and XMP markers become the footage's markers. + let markers = crate::wav_markers::read(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; let mut f = footage_from_info(path, src.info()); + f.markers = markers; turn_footage(&mut f, turn); correct_rate(&mut f, rate); Ok(f) diff --git a/crates/media/src/wav_markers.rs b/crates/media/src/wav_markers.rs new file mode 100644 index 000000000..ca40824b6 --- /dev/null +++ b/crates/media/src/wav_markers.rs @@ -0,0 +1,517 @@ +//! Markers a WAV file carries, read from its RIFF chunks. +//! +//! Two encodings are read and merged: +//! - **Cue points** (the standard): a `cue ` chunk with one point per marker, named by `labl` (or +//! `note`) sub-chunks of a `LIST` `adtl` chunk, and given a length by `ltxt`. +//! - **XMP** (Adobe's tools): an `_PMX` chunk holding an XMP packet whose `xmpDM:markers` list has +//! one entry per marker (`xmpDM:startTime`, `xmpDM:duration`, `xmpDM:name`), counted in frames +//! of `xmpDM:frameRate` (`f44100` for samples at 44.1 kHz). +//! +//! RF64 files (WAVs over 4 GB) carry a `0xFFFFFFFF` size for a chunk too big for 32 bits and give its real size in a +//! `ds64` chunk; those sizes are resolved so the chunks after the audio are still found. +//! +//! The file is untrusted input: every read is bounds-checked, sizes use checked arithmetic, and +//! the number of markers, of `adtl` sub-chunks looked at and the size of the XMP packet are capped. + +use std::collections::{HashMap, HashSet}; + +use effectcraft_color::Label; +use effectcraft_project::Marker; +use effectcraft_time::Tick; + +/// Most markers read from one file. +pub const MAX_MARKERS: usize = 10_000; +/// Largest XMP packet read (bytes). +const MAX_XMP: usize = 4 << 20; +/// Most `adtl` sub-chunks (`labl`, `note`, `ltxt`) looked at in one file: a few per cue point is plenty. +const MAX_ADTL_RECORDS: usize = 4 * MAX_MARKERS; +/// Most entries read from an RF64 file's `ds64` size table. +const MAX_DS64_TABLE: usize = 1_024; + +fn u32_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes(s.try_into().ok()?)) +} + +fn u64_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(8)?)?; + Some(u64::from_le_bytes(s.try_into().ok()?)) +} + +/// The text of a NUL-terminated label (the rest of the chunk), lossily decoded. +fn label(b: &[u8]) -> String { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim().to_string() +} + +fn secs(frames: f64, rate: f64) -> Tick { + if rate > 0.0 && frames.is_finite() { Tick::from_seconds_f64((frames / rate).max(0.0)) } else { Tick::ZERO } +} + +/// Whether `bytes` start a RIFF/WAVE file. +pub fn is_wav(bytes: &[u8]) -> bool { + matches!(bytes.get(0..4), Some(b"RIFF" | b"RF64")) && bytes.get(8..12) == Some(b"WAVE") +} + +/// The sizes an RF64 file's `ds64` chunk gives for chunks too big for the 32-bit size field. +struct Ds64 { + /// The `data` chunk's size. + data: u64, + /// Other chunks: `(id, size)`. + table: Vec<([u8; 4], u64)>, +} + +impl Ds64 { + /// The real size of chunk `id`, when the file says. + fn size_of(&self, id: &[u8; 4]) -> Option { + if id == b"data" { Some(self.data) } else { self.table.iter().find(|(t, _)| t == id).map(|(_, size)| *size) } + } +} + +/// The `ds64` chunk of an RF64 file (`RF64`, size, `WAVE`, then `ds64`); `None` for any other file or one whose +/// `ds64` doesn't fit. +fn ds64(bytes: &[u8]) -> Option { + if bytes.get(0..4) != Some(b"RF64") || bytes.get(12..16) != Some(b"ds64") { + return None; + } + let size = usize::try_from(u32_at(bytes, 16)?).ok()?; + let body = bytes.get(20..20usize.checked_add(size)?)?; + // riff size (8), data size (8), sample count (8), table length (4), then `(id, size)` entries of 12 bytes + let data = u64_at(body, 8)?; + let entries = usize::try_from(u32_at(body, 24)?).ok()?; + let mut table = Vec::new(); + for i in 0..entries.min(MAX_DS64_TABLE) { + let at = 28usize.checked_add(i.checked_mul(12)?)?; + let id = <[u8; 4]>::try_from(body.get(at..at.checked_add(4)?)?).ok()?; + table.push((id, u64_at(body, at + 4)?)); + } + Some(Ds64 { data, table }) +} + +/// The chunks of a WAVE file as `(id, body)`, stopping at the first one that doesn't fit. +fn chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + let big = ds64(bytes); + let mut out = Vec::new(); + let mut at = 12usize; + while let Some(head) = bytes.get(at..at.saturating_add(8)) { + let (Ok(id), Some(field)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + // In an RF64 file a size field of 0xFFFFFFFF stands for the size `ds64` gives. + let size = match &big { + Some(d) if field == u32::MAX => d.size_of(&id).unwrap_or(u64::from(field)), + _ => u64::from(field), + }; + let start = at.saturating_add(8); + let Some(end) = usize::try_from(size).ok().and_then(|n| start.checked_add(n)) else { break }; + let Some(body) = bytes.get(start..end) else { break }; + out.push((id, body)); + // Chunks are padded to an even size. + at = end.saturating_add(end & 1); + } + out +} + +/// The markers `bytes` carry, in time order (empty for anything that isn't a WAV with markers). +pub fn read(bytes: &[u8]) -> Vec { + if !is_wav(bytes) { + return Vec::new(); + } + let chunks = chunks(bytes); + let rate = chunks.iter().find(|(id, _)| id == b"fmt ").and_then(|(_, b)| u32_at(b, 4)).map_or(0.0, f64::from); + let mut out = cue_markers(&chunks, rate); + if let Some((_, xmp)) = chunks.iter().find(|(id, _)| id == b"_PMX") { + // Tools that write both encodings repeat each marker. + let mut seen: HashSet<(String, Tick)> = out.iter().map(|o| (o.comment.clone(), o.time)).collect(); + for m in xmp_markers(xmp, rate) { + if seen.insert((m.comment.clone(), m.time)) { + out.push(m); + } + } + } + out.truncate(MAX_MARKERS); + out.sort_by_key(|m| m.time); + out +} + +/// Cue points named and sized by the `adtl` list. +fn cue_markers(chunks: &[([u8; 4], &[u8])], rate: f64) -> Vec { + let Some((_, cue)) = chunks.iter().find(|(id, _)| id == b"cue ") else { return Vec::new() }; + let count = (u32_at(cue, 0).unwrap_or(0) as usize).min(MAX_MARKERS); + // The cue points first: `(id, offset in samples)`. + let mut points = Vec::new(); + for i in 0..count { + let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; + let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; + points.push((id, offset)); + } + // Only the names and lengths of those ids are kept, so a hostile `adtl` list cannot grow them past the cue count, + // and each record is a lookup, not a scan. + let wanted: HashSet = points.iter().map(|(id, _)| *id).collect(); + let mut names: HashMap = HashMap::new(); + let mut lengths: HashMap = HashMap::new(); + let mut records = 0usize; + 'lists: for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { + let mut at = 4usize; + while let Some(head) = list.get(at..at.saturating_add(8)) { + let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let start = at.saturating_add(8); + let Some(end) = start.checked_add(size as usize) else { break }; + let Some(body) = list.get(start..end) else { break }; + records += 1; + if records > MAX_ADTL_RECORDS { + break 'lists; + } + if let Some(cue_id) = u32_at(body, 0).filter(|c| wanted.contains(c)) { + match &id { + // A `labl` is the name; a `note` only when there is none. + b"labl" => { + names.insert(cue_id, label(body.get(4..).unwrap_or_default())); + } + b"note" => { + names.entry(cue_id).or_insert_with(|| label(body.get(4..).unwrap_or_default())); + } + // The first `ltxt` of a cue point gives its length. + b"ltxt" => { + lengths.entry(cue_id).or_insert_with(|| u32_at(body, 4).unwrap_or(0)); + } + _ => {} + } + } + at = end.saturating_add(end & 1); + } + } + points + .into_iter() + .map(|(id, offset)| { + let comment = names.get(&id).cloned().unwrap_or_default(); + let length = lengths.get(&id).copied().unwrap_or(0); + Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() } + }) + .collect() +} + +/// The text of `name` in an XMP entry, as an attribute (`name="v"`) or an element +/// (`v`). +fn xmp_value(entry: &str, name: &str) -> Option { + let attr = format!("{name}=\""); + if let Some(i) = entry.find(&attr) { + let rest = entry.get(i + attr.len()..)?; + return Some(unescape(rest.get(..rest.find('"')?)?)); + } + let open = format!("<{name}>"); + let i = entry.find(&open)?; + let rest = entry.get(i + open.len()..)?; + Some(unescape(rest.get(..rest.find('<')?)?)) +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i..).unwrap_or_default(); + let Some(semi) = tail.find(';').filter(|&n| n <= 10) else { + out.push('&'); + rest = tail.get(1..).unwrap_or_default(); + continue; + }; + let ent = tail.get(1..semi).unwrap_or_default(); + match ent { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + _ => { + let code = ent.strip_prefix("#x").and_then(|h| u32::from_str_radix(h, 16).ok()).or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok())); + match code.and_then(char::from_u32) { + Some(c) => out.push(c), + None => out.push_str(tail.get(..=semi).unwrap_or_default()), + } + } + } + rest = tail.get(semi + 1..).unwrap_or_default(); + } + out.push_str(rest); + out.trim().to_string() +} + +/// A frame rate as XMP writes it: `f44100`, or `f30000s1001` (rate over scale). +fn xmp_rate(text: &str) -> Option { + let t = text.trim().strip_prefix('f')?; + let (num, den) = t.split_once('s').unwrap_or((t, "1")); + let (n, d): (f64, f64) = (num.parse().ok()?, den.parse().ok()?); + (n > 0.0 && d > 0.0).then_some(n / d) +} + +/// The markers of an XMP packet's `xmpDM:markers` entries. +fn xmp_markers(packet: &[u8], file_rate: f64) -> Vec { + let text = String::from_utf8_lossy(packet.get(..packet.len().min(MAX_XMP)).unwrap_or_default()); + let mut out = Vec::new(); + // The frame rate belongs to the track an entry follows. + let mut rate = file_rate; + for entry in text.split("().ok()) else { continue }; + let length = xmp_value(entry, "xmpDM:duration").and_then(|v| v.trim().parse::().ok()).unwrap_or(0.0); + let comment = xmp_value(entry, "xmpDM:name").unwrap_or_default(); + out.push(Marker { time: secs(start, rate), duration: secs(length, rate), comment, label: Label::None, ..Default::default() }); + if out.len() >= MAX_MARKERS { + break; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + fn fmt(rate: u32) -> Vec { + let mut b = Vec::new(); + b.extend(1u16.to_le_bytes()); // PCM + b.extend(1u16.to_le_bytes()); // mono + b.extend(rate.to_le_bytes()); + b.extend((rate * 2).to_le_bytes()); + b.extend(2u16.to_le_bytes()); + b.extend(16u16.to_le_bytes()); + chunk(b"fmt ", &b) + } + + fn wav(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RIFF".to_vec(); + v.extend((body.len() as u32 + 4).to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + fn cue(points: &[(u32, u32)]) -> Vec { + let mut b = (points.len() as u32).to_le_bytes().to_vec(); + for (id, offset) in points { + b.extend(id.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(b"data"); + b.extend(0u32.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(offset.to_le_bytes()); + } + chunk(b"cue ", &b) + } + + fn labl(id: &[u8; 4], cue_id: u32, text: &str) -> Vec { + let mut b = cue_id.to_le_bytes().to_vec(); + b.extend(text.as_bytes()); + b.push(0); + chunk(id, &b) + } + + fn adtl(subs: &[Vec]) -> Vec { + let mut b = b"adtl".to_vec(); + b.extend(subs.iter().flatten()); + chunk(b"LIST", &b) + } + + #[test] + fn cue_points_are_named_by_the_adtl_list_and_timed_in_samples() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 8]), + cue(&[(2, 88200), (1, 22050)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 2); + assert_eq!((m[0].comment.as_str(), m[0].time), ("SEQ01_Prologue", Tick::from_seconds_f64(0.5))); + assert_eq!((m[1].comment.as_str(), m[1].time), ("SEQ02_Vallee_P1_Agriculture", Tick::from_seconds_f64(2.0))); + } + + #[test] + fn a_note_names_a_cue_only_when_it_has_no_label_and_ltxt_gives_a_length() { + let mut ltxt = 1u32.to_le_bytes().to_vec(); + ltxt.extend(44100u32.to_le_bytes()); + ltxt.extend([0; 16]); + let file = wav(&[ + fmt(44100), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"note", 1, "from note"), chunk(b"ltxt", <xt), labl(b"labl", 2, "named"), labl(b"note", 2, "ignored")]), + ]); + let m = read(&file); + assert_eq!((m[0].comment.as_str(), m[0].duration), ("from note", Tick::from_seconds_f64(1.0))); + assert_eq!(m[1].comment, "named"); + } + + const XMP_ELEMENTS: &str = r#"MarkersCuef44100441000One & Two13230022050Three"#; + + #[test] + fn xmp_markers_are_read_as_elements_in_frames_of_the_track_rate() { + let file = wav(&[fmt(48000), chunk(b"_PMX", XMP_ELEMENTS.as_bytes())]); + let m = read(&file); + assert_eq!(m.len(), 2); + // 44100 frames at f44100 is one second, whatever the file's own rate. + assert_eq!((m[0].comment.as_str(), m[0].time), ("One & Two", Tick::from_seconds_f64(1.0))); + assert_eq!((m[1].comment.as_str(), m[1].time, m[1].duration), ("Three", Tick::from_seconds_f64(3.0), Tick::from_seconds_f64(0.5))); + } + + #[test] + fn xmp_markers_are_read_as_attributes_and_use_the_file_rate_without_a_track_rate() { + let xmp = r#""#; + let m = read(&wav(&[fmt(48000), chunk(b"_PMX", xmp.as_bytes())])); + assert_eq!((m[0].comment.as_str(), m[0].time), ("Attr é", Tick::from_seconds_f64(0.5))); + } + + #[test] + fn a_marker_in_both_encodings_is_listed_once() { + let xmp = r#""#; + let file = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Same")]), chunk(b"_PMX", xmp.as_bytes())]); + let names: Vec = read(&file).into_iter().map(|m| m.comment).collect(); + assert_eq!(names, ["Same", "Only XMP"]); + } + + #[test] + fn importing_a_wav_puts_its_markers_on_the_footage() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 4 * 44100]), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let f = crate::probe::probe_bytes("markers.wav", file.into()).expect("probe"); + assert!(f.has_audio); + let names: Vec<&str> = f.markers.iter().map(|m| m.comment.as_str()).collect(); + assert_eq!(names, ["SEQ01_Prologue", "SEQ02_Vallee_P1_Agriculture"]); + assert_eq!(f.markers[1].time, Tick::from_seconds_f64(1.0)); + } + + #[test] + fn damaged_or_hostile_files_read_as_far_as_they_go_without_panicking() { + assert!(read(b"").is_empty()); + assert!(read(b"RIFF").is_empty()); + assert!(read(b"not a wav file at all").is_empty()); + let good = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + // Every truncation is safe. + for n in 0..good.len() { + let _ = read(&good[..n]); + } + // A cue count far beyond the chunk, and chunk sizes past the end of the file. + let mut huge = 0xFFFF_FFFFu32.to_le_bytes().to_vec(); + huge.extend([0; 24]); + let m = read(&wav(&[fmt(44100), chunk(b"cue ", &huge)])); + assert!(m.len() <= 1); + let mut lying = good.clone(); + lying.extend(b"LIST\xff\xff\xff\xffadtl"); + assert_eq!(read(&lying).len(), 1); + // No sample rate: times are zero, not a division by zero. + let m = read(&wav(&[cue(&[(1, 44100)])])); + assert_eq!(m[0].time, Tick::ZERO); + } + + /// A chunk whose size field says `field` (an RF64 file's `0xFFFFFFFF`) while its body is `body`. + fn chunk_with_size_field(id: &[u8; 4], field: u32, body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend(field.to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + /// An RF64 `ds64` chunk: the `data` chunk's size and a table of other chunks' sizes. + fn ds64_chunk(data_size: u64, table: &[([u8; 4], u64)]) -> Vec { + let mut b = 0u64.to_le_bytes().to_vec(); // riff size (not used) + b.extend(data_size.to_le_bytes()); + b.extend(0u64.to_le_bytes()); // sample count + b.extend((table.len() as u32).to_le_bytes()); + for (id, size) in table { + b.extend(id); + b.extend(size.to_le_bytes()); + } + chunk(b"ds64", &b) + } + + fn rf64(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RF64".to_vec(); + v.extend(u32::MAX.to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + #[test] + fn rf64_markers_after_a_data_chunk_of_sentinel_size_are_read() { + let audio = [0u8; 8]; + let file = rf64(&[ + ds64_chunk(audio.len() as u64, &[]), + fmt(44100), + chunk_with_size_field(b"data", u32::MAX, &audio), + cue(&[(1, 44100)]), + adtl(&[labl(b"labl", 1, "After the audio")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 1); + assert_eq!((m[0].comment.as_str(), m[0].time), ("After the audio", Tick::from_seconds_f64(1.0))); + } + + #[test] + fn rf64_sizes_of_other_chunks_come_from_the_ds64_table() { + let junk = [7u8; 6]; + let tail = || [cue(&[(1, 8000)]), adtl(&[labl(b"labl", 1, "Found")])].concat(); + let with_entry = rf64(&[ds64_chunk(0, &[(*b"junk", junk.len() as u64)]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert_eq!(read(&with_entry)[0].comment, "Found"); + // Without the table entry the sentinel is all there is, and what follows is not reached. + let without = rf64(&[ds64_chunk(0, &[]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert!(read(&without).is_empty()); + } + + #[test] + fn hostile_rf64_size_tables_read_as_far_as_they_go_without_panicking() { + // sizes that overflow when added to an offset + for data in [u64::MAX, u64::MAX - 7, 1 << 63] { + let file = rf64(&[ds64_chunk(data, &[(*b"junk", u64::MAX)]), fmt(8000), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 0)])]); + assert!(read(&file).is_empty()); + } + // a table that claims far more entries than its chunk holds + let mut liar = ds64_chunk(0, &[]); + liar[8 + 24..8 + 28].copy_from_slice(&u32::MAX.to_le_bytes()); + let _ = read(&rf64(&[liar, fmt(8000), cue(&[(1, 0)])])); + // every truncation of a good RF64 file is safe + let good = + rf64(&[ds64_chunk(4, &[]), fmt(44100), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + assert_eq!(read(&good).len(), 1); + for n in 0..good.len() { + let _ = read(&good[..n]); + } + } + + #[test] + fn a_huge_adtl_list_is_read_in_linear_time_and_only_for_the_cue_points_that_exist() { + // Records for ids that have no cue point are skipped, not stored, and the number looked at is capped. + let junk = |n: u32| (1000..1000 + n).map(|id| labl(b"labl", id, "n")).collect::>(); + let started = std::time::Instant::now(); + // the real label comes first: read + let mut subs = vec![labl(b"labl", 1, "Real")]; + subs.extend(junk(100_000)); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!(m[0].comment, "Real"); + // it comes after more records than are looked at: not read, and the marker is still there + let mut subs = junk(MAX_ADTL_RECORDS as u32 + 10); + subs.push(labl(b"labl", 1, "Too late")); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!((m.len(), m[0].comment.as_str()), (1, "")); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + } +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index b78e3d8c1..95ed262d4 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -881,6 +881,14 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource _ => {} } } + // A footage layer starts with the markers its file carries (layer time is footage time). + let footage_markers = match &source { + LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) { + Some(crate::ItemKind::Footage(f)) => f.markers.clone(), + _ => vec![], + }, + _ => vec![], + }; let has_audio = matches!(&source, LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(crate::ItemKind::Footage(f)) if f.has_audio)) || matches!(source, LayerSource::Comp { .. }); if has_audio { @@ -910,7 +918,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource preserve_transparency: false, track_matte: None, parent: None, - markers: vec![], + markers: footage_markers, markers_locked: false, environment: false, environment_background: false, diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index 391b1e17c..486bc25f7 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -1,4 +1,4 @@ -use super::settings::yes; +use super::settings::{Marker, yes}; use super::{ColorSpace, Deserialize, FrameRate, Serialize, Tick}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -115,6 +115,10 @@ pub struct Footage { /// timecode isn't read). It only relabels source time, as in After Effects. #[serde(default, skip_serializing_if = "Option::is_none")] pub start_timecode: Option, + /// Markers the file carries (a WAV's cue points and XMP markers), at footage time. A layer + /// made from the footage starts with them, and Update Markers From Source copies new ones. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub markers: Vec, } fn is_default(v: &T) -> bool { @@ -150,6 +154,7 @@ impl Default for Footage { page: 0, alphabetical: false, start_timecode: None, + markers: vec![], } } } diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 777b7e289..1a829630c 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -442,9 +442,18 @@ impl RenderSettings { pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick { self.rate(comp).tick_of(self.first_frame(comp) + i as i64) } - pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + pub fn effective_scale(&self, comp: &Comp) -> f64 { let s = self.resolution.clamp(0.01, 4.0); - (((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1)) + let max = f64::from(crate::limits::MAX_SIZE); + s.min(max / f64::from(comp.width.max(1))).min(max / f64::from(comp.height.max(1))) + } + pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + let s = self.effective_scale(comp); + // The scale is clamped so neither side exceeds the comp limit, keeping the aspect + // ratio: a 30 000 px side at 4x renders at 1x instead of cropping to a corner. + let w = ((comp.width as f64 * s).round() as u32).max(1); + let h = ((comp.height as f64 * s).round() as u32).max(1); + (w, h) } pub fn resolution_label(&self) -> &'static str { match self.resolution { @@ -779,15 +788,18 @@ impl Crop { if self.use_roi && let Some([x, y, rw, rh]) = self.roi { - // The quad arrives unvalidated (control channel or a hand-edited project): - // clamp like the sibling edge fields (plus/minus 20 000) and subtract - // without overflow. - let conv = |v: f64| v.round().clamp(-20_000.0, 20_000.0) as i32; - let l = conv(x * scale); - let t = conv(y * scale); - let r = (w as i32).saturating_sub(conv((x + rw) * scale)); - let b = (h as i32).saturating_sub(conv((y + rh) * scale)); - return (t, l, b, r); + // The quad arrives unvalidated (control channel or a hand-edited project). As in + // After Effects, the region of interest lies inside the frame: each converted edge + // is clamped to [0, w] / [0, h] (inf clamps to a side, NaN converts to 0), and the + // far edge never passes the near one. Every edge then lies inside the frame, so the + // crop can't be larger than the frame and no subtraction can overflow. + let (wi, hi) = (i32::try_from(w).unwrap_or(i32::MAX), i32::try_from(h).unwrap_or(i32::MAX)); + let conv = |v: f64, side: i32| v.round().clamp(0.0, f64::from(side)) as i32; + let l = conv(x * scale, wi); + let t = conv(y * scale, hi); + let r = conv((x + rw) * scale, wi).max(l); + let b = conv((y + rh) * scale, hi).max(t); + return (t, l, hi - b, wi - r); } (self.top, self.left, self.bottom, self.right) } @@ -1126,7 +1138,7 @@ impl OutputModule { /// rounding). pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) { let (w, h) = settings.output_size(comp); - let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0)); + let (w, h) = self.frame_size(w, h, settings.effective_scale(comp)); self.format.coded_size(w, h) } /// Every setting as `(label, value)` lines (render logs). @@ -1659,8 +1671,8 @@ mod tests { #[test] fn hostile_roi_cannot_overflow_crop_math() { - // `-1e12` saturates to `i32::MIN` on conversion: `w as i32 - l` must saturate, - // not overflow, and still yield a usable (degenerate) frame size. + // `-1e12` is clamped into the frame on conversion: it must not overflow and must still + // yield a usable (degenerate) frame size. let c = Comp::new(1920, 1080, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); let rs = RenderSettings::default(); let mut m = OutputModule::for_format(OutputFormat::PngSequence); @@ -1673,6 +1685,66 @@ mod tests { assert_eq!(m.output_size(&c, &half), (400, 200)); } + #[test] + fn output_size_never_exceeds_the_comp_limit() { + // The effective scale is clamped so neither side exceeds the limit (exporter + // `w * h * 4` would overflow at 120 000 px sides); the pipeline renders at the + // same scale, so it never requests a frame larger than this reports. + let rs = RenderSettings { resolution: 4.0, ..Default::default() }; + let m = OutputModule::for_format(OutputFormat::PngSequence); + let square = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&square), 1.0); + assert_eq!(rs.output_size(&square), (30_000, 30_000)); + assert_eq!(m.output_size(&square, &rs), (30_000, 30_000)); + // Wide comps keep their aspect ratio instead of clamping each side on its own. + let wide = Comp::new(30_000, 15_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&wide), 1.0); + assert_eq!(rs.output_size(&wide), (30_000, 15_000)); + assert_eq!(m.output_size(&wide, &rs), (30_000, 15_000)); + // A mid-size comp renders at the largest scale that still fits. + let mid = Comp::new(20_000, 10_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(mid.width as f64 * rs.effective_scale(&mid), 30_000.0); + assert_eq!(rs.output_size(&mid), (30_000, 15_000)); + } + + #[test] + fn valid_wide_roi_endpoints_are_preserved() { + // A supported 30 000 x 100 frame at scale 1: ROI [10000, 0, 20000, 100] spans x 10 000..30 000 + // and must give 20 000 x 100 (the absolute right edge 30 000 exceeds the old +/-20 000 clamp). + let c = Comp::new(30_000, 100, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + let rs = RenderSettings::default(); + let mut m = OutputModule::for_format(OutputFormat::PngSequence); + m.crop = Crop { enabled: true, use_roi: true, roi: Some([10_000.0, 0.0, 20_000.0, 100.0]), ..Default::default() }; + assert_eq!(m.output_size(&c, &rs), (20_000, 100)); + assert_eq!(m.crop.edges(30_000, 100, 1.0), (0, 10_000, 0, 0)); + // Scale 4.0 pushes a valid edge to 120 000 px (30 000 x 4); the left edge 40 000 and the + // right edge 120 000 are both beyond 20 000 and must survive. + assert_eq!(m.crop.edges(120_000, 400, 4.0), (0, 40_000, 0, 0)); + assert_eq!(m.frame_size(120_000, 400, 4.0), (80_000, 400)); + // Hostile quads stay inside the frame: never empty, never larger than the frame, so the + // export can't be asked for an unbounded crop (an ROI reaching far outside the comp + // used to pad the frame instead). + let (fw, fh) = (1920, 1080); + let hostile = [ + [1e300, 1e300, 1e300, 1e300], + [-1e300, -1e300, 1e300, 1e300], + [-1e6, -1e6, 2e6 + 1920.0, 2e6 + 1080.0], + [-1e12, -1e12, -1e12, -1e12], + [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::NAN], + [f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY], + ]; + for roi in hostile { + m.crop.roi = Some(roi); + let (t, l, b, r) = m.crop.edges(fw, fh, 1.0); + assert!([t, l, b, r].iter().all(|e| *e >= 0), "{roi:?}: an edge pads the frame: {:?}", (t, l, b, r)); + let (w, h) = m.frame_size(fw, fh, 1.0); + assert!((1..=fw).contains(&w) && (1..=fh).contains(&h), "{roi:?}: {w}x{h} is outside 1x1..={fw}x{fh}"); + } + // An ROI covering more than the frame is the whole frame. + m.crop.roi = Some([-1e6, -1e6, 2e6 + 1920.0, 2e6 + 1080.0]); + assert_eq!(m.frame_size(fw, fh, 1.0), (fw, fh)); + } + #[test] fn uncompressed_quicktime_format() { let f = OutputFormat::from_name("uncompressed").unwrap(); diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 9f2e4e755..fd2fd183b 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -23,6 +23,11 @@ pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_wa pub type Px = [f32; 4]; +/// Maximum padded side in pixels — mirrors the project crate's `limits::MAX_SIZE` (30 000; +/// raster sits below it in the layering and may not depend on it, so the two must stay equal). +/// A padded side beyond the largest supported comp side has no render use. +pub const MAX_PADDED_SIDE: u32 = 30_000; + /// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up to `levels` steps (`inv` = 1 / levels): /// one pixel of an 8/16 bpc buffer. Values are non-negative after the clamp, so the integer /// conversion rounds like `round()` and vectorises. @@ -161,13 +166,28 @@ impl Image { } o } - /// Copy with `pad` transparent pixels on every side. + /// The pad [`Image::padded`] applies for a requested `pad`: clamped so neither output side + /// exceeds [`MAX_PADDED_SIDE`]. At most `(MAX_PADDED_SIDE - side) / 2`, so + /// `side + 2 · pad ≤ MAX_PADDED_SIDE` and overflow is unreachable by construction (a side + /// already past the cap gets pad 0). Callers that track where the pixels went (a buffer + /// offset) must move by this, not by the requested pad. + pub fn effective_pad(&self, pad: u32) -> u32 { + pad.min(MAX_PADDED_SIDE.saturating_sub(self.width) / 2).min(MAX_PADDED_SIDE.saturating_sub(self.height) / 2) + } + /// Copy with [`Image::effective_pad`] transparent pixels on every side. + /// + /// The clamp keeps the output within the largest image the engine supports (a max-size comp + /// frame), so an unbounded pad cannot overflow or size a buffer past that. It is not a memory + /// budget: callers that pad by a derived, possibly large amount bound the area themselves + /// (`DOF_PAD_BUDGET` in render's `three_d/compose.rs`, `fits()` on the GPU path) or reject an + /// oversized pad first (`padded_size`); legitimate bounded pads are byte-identical. pub fn padded(&self, pad: u32) -> Image { - let mut out = Image::new(self.width + 2 * pad, self.height + 2 * pad); + let eff = self.effective_pad(pad); + let mut out = Image::new(self.width + 2 * eff, self.height + 2 * eff); let ow = out.width as usize; for y in 0..self.height as usize { let src = &self.data[y * self.width as usize..(y + 1) * self.width as usize]; - let o = (y + pad as usize) * ow + pad as usize; + let o = (y + eff as usize) * ow + eff as usize; out.data[o..o + self.width as usize].copy_from_slice(src); } out @@ -222,7 +242,7 @@ impl Image { } /// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored). pub fn to_rgba8(&self) -> Vec { - self.map_rgba8(|o, p| { + self.map_rgba8(|o, p, _, _| { let a = p[3].clamp(0.0, 1.0); let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 }; for c in 0..3 { @@ -231,9 +251,20 @@ impl Image { o[3] = (a * 255.0 + 0.5) as u8; }) } + /// [`Image::to_rgba8`] with the colour dithered ([`dither_u8`]); alpha is rounded. + pub fn to_rgba8_dithered(&self) -> Vec { + self.map_rgba8(|o, p, x, y| { + let a = p[3].clamp(0.0, 1.0); + let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 }; + for c in 0..3 { + o[c] = dither_u8(p[c] * inv, x, y); + } + o[3] = (a * 255.0 + 0.5) as u8; + }) + } /// Composite over an opaque background colour and return opaque 8-bit RGBA. pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec { - self.map_rgba8(|o, p| { + self.map_rgba8(|o, p, _, _| { let k = 1.0 - p[3].clamp(0.0, 1.0); for c in 0..3 { o[c] = ((p[c] + bg[c] * k).clamp(0.0, 1.0) * 255.0 + 0.5) as u8; @@ -241,13 +272,23 @@ impl Image { o[3] = 255; }) } - /// 8-bit RGBA with each pixel written by `f`, row-parallel. - fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px) + Sync) -> Vec { + /// [`Image::to_rgba8_over`] with the colour dithered ([`dither_u8`]). + pub fn to_rgba8_over_dithered(&self, bg: [f32; 3]) -> Vec { + self.map_rgba8(|o, p, x, y| { + let k = 1.0 - p[3].clamp(0.0, 1.0); + for c in 0..3 { + o[c] = dither_u8(p[c] + bg[c] * k, x, y); + } + o[3] = 255; + }) + } + /// 8-bit RGBA with each pixel written by `f` (given its x and y), row-parallel. + fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px, u32, u32) + Sync) -> Vec { let mut out = vec![0u8; self.data.len() * 4]; let w = self.width.max(1) as usize; - out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).for_each(|(o, row)| { - for (o, p) in o.as_chunks_mut::<4>().0.iter_mut().zip(row) { - f(o, p); + out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).enumerate().for_each(|(y, (o, row))| { + for (x, (o, p)) in o.as_chunks_mut::<4>().0.iter_mut().zip(row).enumerate() { + f(o, p, x as u32, y as u32); } }); out @@ -322,6 +363,22 @@ pub fn hash_noise(x: u32, y: u32, seed: u32) -> f32 { (h & 0x00ff_ffff) as f32 / 16_777_216.0 } +/// Seed of the output dither: fixed, so a frame converts to the same bytes every time. +const DITHER_SEED: u32 = 0x5bd1_e995; + +/// A colour value in 0..1 as an 8-bit code, dithered by pixel position (#2022): the value is +/// scaled to 0..255 and rounded up or down against a per-pixel hash threshold, so the codes of +/// a smooth gradient average to the gradient instead of stepping in bands. A value already on +/// an 8-bit code (anything rendered at 8 bpc) comes out unchanged. Deterministic: depends only +/// on the value and the pixel position, never on a clock or a global generator. +#[inline] +pub fn dither_u8(v: f32, x: u32, y: u32) -> u8 { + let s = v.clamp(0.0, 1.0) * 255.0; + // The threshold stays inside (1e-4, 1 - 1e-4) so float error on an exact code can't flip it. + let t = hash_noise(x, y, DITHER_SEED) * (1.0 - 2e-4) + 1e-4; + (s + t).floor() as u8 +} + /// A single-channel coverage mask. #[derive(Clone, Debug, Default, PartialEq)] pub struct Mask { @@ -346,6 +403,35 @@ impl Mask { mod tests { use super::*; + #[test] + fn dither_keeps_exact_codes_and_averages_the_rest() { + // A value on an 8-bit code is that code at every position. + for code in 0..=255u32 { + let v = code as f32 / 255.0; + for (x, y) in [(0, 0), (1, 0), (17, 3), (1919, 1079), (4095, 4095)] { + assert_eq!(dither_u8(v, x, y) as u32, code, "code {code} at ({x}, {y})"); + } + } + // Out-of-range and NaN values saturate instead of wrapping. + assert_eq!((dither_u8(-3.0, 5, 5), dither_u8(7.0, 5, 5), dither_u8(f32::NAN, 5, 5)), (0, 255, 0)); + // A value between two codes lands on both, in proportion: the mean over a block is the value. + let v = (100.0 + 0.3) / 255.0; + let n = 64u32; + let sum: u32 = (0..n * n).map(|i| dither_u8(v, i % n, i / n) as u32).sum(); + let mean = sum as f32 / (n * n) as f32; + assert!((mean - 100.3).abs() < 0.03, "{mean}"); + // Deterministic. + assert_eq!(dither_u8(v, 9, 4), dither_u8(v, 9, 4)); + } + + #[test] + fn dithered_conversions_match_the_plain_ones_on_exact_codes() { + let src: Vec = vec![255, 128, 0, 255, 10, 20, 30, 255, 0, 0, 0, 255, 1, 2, 3, 255]; + let img = Image::from_rgba8(2, 2, &src); + assert_eq!(img.to_rgba8_dithered(), img.to_rgba8()); + assert_eq!(img.to_rgba8_over_dithered([0.0; 3]), img.to_rgba8_over([0.0; 3])); + } + #[test] fn rgba8_roundtrip() { let src: Vec = vec![255, 128, 0, 255, 10, 20, 30, 128, 0, 0, 0, 0, 1, 2, 3, 4]; @@ -384,4 +470,29 @@ mod tests { assert!((0.0..1.0).contains(&n)); } } + + #[test] + fn padded_bounds_hostile_pad_at_the_api() { + // Fail-before (#2092 class): before the clamp, a caller passing a hostile pad sized an + // oversized buffer here — the very OOM this guard exists to stop, now caught in padded(). + // (Wide-flat source so the clamped-to-cap output stays a test-sized allocation.) + let mut img = Image::new(29_990, 2); + img.set(29_989, 1, [1.0, 0.0, 0.0, 1.0]); + let out = img.padded(u32::MAX); + // Width binds first: eff = (MAX_PADDED_SIDE - 29 990) / 2 = 5. + assert_eq!((out.width, out.height), (MAX_PADDED_SIDE, 12)); + // The source lands intact at the clamped offset, not the requested one. + assert_eq!(out.get(29_994, 6), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(out.get(0, 0), [0.0; 4]); // clamped pad region stays transparent + } + + #[test] + fn padded_leaves_legit_pads_untouched() { + // Anti-over-clamp: an ordinary pad must round exactly, byte for byte. + let mut img = Image::new(8, 6); + img.set(3, 2, [1.0, 0.0, 0.0, 1.0]); + let out = img.padded(4); + assert_eq!((out.width, out.height), (16, 14)); + assert_eq!(out.get(7, 6), [1.0, 0.0, 0.0, 1.0]); // (3+4, 2+4) + } } diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index accc1fc54..4f93e41d7 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -184,8 +184,9 @@ impl<'a> Renderer<'a> { pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { match &layer.source { LayerSource::Footage { item } => { - let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None }; - let a = self.footage.aux(*item, f, ctx.source_time(layer))?; + let item = ctx.media_item(layer, *item); + let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None }; + let a = self.footage.aux(item, f, ctx.source_time(layer))?; // Aux pixels per layer pixel (the layer is the footage at its nominal size). The // planes are only copied when that changes the scale. let scale = a.width as f64 / f.width as f64; @@ -332,8 +333,8 @@ impl<'a> Renderer<'a> { } /// Draw a collapsed precomp layer into `canvas` on the CPU. - pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) { - self.draw_collapsed(ctx, layer, item, canvas, false); + pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, canvas: &mut Image) { + self.draw_collapsed(ctx, layer, collapsed, canvas, false); } /// The layer's finished buffers (styles included) in the blending space. diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 66b54b6d0..ee19d8b25 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -4,10 +4,13 @@ //! (Effect > Audio, see [`effectcraft_effects::audio_fx`]) run on each layer's audio before //! its levels; Backwards plays the layer's [in, out) span reversed. //! -//! Callers mix in short blocks (one video frame, or an audio device buffer). Effects and levels -//! are evaluated once per block. To make the result independent of block boundaries, layer -//! effects are run from a cleared state over a pre-roll of earlier audio and only the block's -//! tail is kept — so preview playback and export produce identical samples. +//! Callers mix in short blocks (one video frame, an audio device buffer, or a second of export). +//! Effect parameters are evaluated once per block. Animated Audio Levels, and animated effects +//! that work sample by sample (Stereo Mixer), are evaluated on a grid of comp samples every +//! millisecond instead, so keyframed fades ramp smoothly whatever the block size. To make the +//! result independent of block boundaries, layer effects are run from a cleared state over a +//! pre-roll of earlier audio and only the block's tail is kept — so preview playback and export +//! produce identical samples. //! //! Time-stretched layers are resampled nearest-sample; reversed (negative stretch) layers are //! silent for now. @@ -85,6 +88,20 @@ fn units_ceil(t: Tick, rate: u32) -> i64 { (n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0)) as i64 } +/// Samples between evaluations of animated levels and per-sample effects: one millisecond, on +/// a grid of comp sample indices so the result does not depend on where a block starts. +fn param_step(rate: u32) -> i64 { + i64::from(rate / 1000).max(1) +} + +/// Does any property of the group change over time? +fn animated(g: &effectcraft_project::PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => p.is_animated() || p.has_expression(), + Node::Group(sub) => animated(sub), + }) +} + fn sample_tick(i: i64, rate: u32) -> Tick { // Keep the requested sample when the rate does not divide the tick clock. let t = Tick::from_units(i, i64::from(rate)); @@ -189,21 +206,70 @@ impl MixEnv<'_> { /// `i0 .. i0 + n`. fn layer_audio(&self, l: &Layer, i0: i64, n: usize) -> Vec { let t = sample_tick(i0, self.rate); - let fx: Vec<(&str, Params)> = audio_effect_groups(l).map(|(id, g)| (id, self.params(l, g, t))).collect(); + let fx: Vec<(&str, &effectcraft_project::PropGroup, Params)> = audio_effect_groups(l).map(|(id, g)| (id, g, self.params(l, g, t))).collect(); if fx.is_empty() { return self.layer_raw(l, i0, n, false); } - let reversed = fx.iter().any(|(id, _)| audio_fx::reverses(id)); - let pre_s: f64 = fx.iter().map(|(id, p)| audio_fx::preroll(id, p)).sum(); + let reversed = fx.iter().any(|(id, _, _)| audio_fx::reverses(id)); + let pre_s: f64 = fx.iter().map(|(id, _, p)| audio_fx::preroll(id, p)).sum(); let pre = (pre_s * self.rate as f64).ceil() as usize; - let mut buf = self.layer_raw(l, i0 - pre as i64, n + pre, reversed); - let start = l.layer_time(sample_tick(i0 - pre as i64, self.rate)).seconds(); - for (id, p) in &fx { - audio_fx::process(id, p, &mut buf, self.rate, start); + let b0 = i0 - pre as i64; + let mut buf = self.layer_raw(l, b0, n + pre, reversed); + let start = l.layer_time(sample_tick(b0, self.rate)).seconds(); + for (id, g, p) in &fx { + if audio_fx::per_sample(id) && animated(g) { + self.process_stepped(l, id, g, &mut buf, b0); + } else { + audio_fx::process(id, p, &mut buf, self.rate, start); + } } buf.drain(..pre * 2); buf } + + /// Run per-sample effect `id` over `buf` (comp samples from `b0`) in pieces of the parameter + /// grid, each with the parameters at its first sample. + fn process_stepped(&self, l: &Layer, id: &str, g: &effectcraft_project::PropGroup, buf: &mut [f32], b0: i64) { + let step = param_step(self.rate); + let frames = buf.len() / 2; + let mut k = 0usize; + while k < frames { + let i = b0.saturating_add(k as i64); + let next = i.div_euclid(step).saturating_add(1).saturating_mul(step); + let end = usize::try_from(next.saturating_sub(b0)).unwrap_or(frames).clamp(k + 1, frames); + let t = sample_tick(i, self.rate); + if let Some(piece) = buf.get_mut(k * 2..end * 2) { + audio_fx::process(id, &self.params(l, g, t), piece, self.rate, l.layer_time(t).seconds()); + } + k = end; + } + } + + /// Linear gains (left, right) of the layer's Audio Levels for comp samples `i0 .. i0 + n`. + /// Animated levels are evaluated on the parameter grid and interpolated in dB in between. + fn level_gains(&self, l: &Layer, i0: i64, n: usize) -> Vec<[f32; 2]> { + let Some(pr) = l.props.sub("audio").and_then(|g| g.get("levels")) else { return vec![[1.0, 1.0]; n] }; + let db = |i: i64| self.ctx(sample_tick(i, self.rate)).value(l, pr).as_vec2(); + let gain = |[a, b]: [f64; 2]| [db_to_gain(a), db_to_gain(b)]; + if !pr.is_animated() && !pr.has_expression() { + return vec![gain(db(i0)); n]; + } + let step = param_step(self.rate); + let end = i0.saturating_add(n as i64); + let mut out = Vec::with_capacity(n); + let mut k = i0.div_euclid(step).saturating_mul(step); + let mut lo = db(k); + while k < end { + let hi = db(k.saturating_add(step)); + for i in k.max(i0)..k.saturating_add(step).min(end) { + let f = (i - k) as f64 / step as f64; + out.push(gain([lo[0] + (hi[0] - lo[0]) * f, lo[1] + (hi[1] - lo[1]) * f])); + } + k = k.saturating_add(step); + lo = hi; + } + out + } } /// Shared inputs for composition audio mixing, including nested compositions. @@ -234,25 +300,18 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut if a >= b { continue; } - let t_a = sample_tick(s0 + a as i64, rate); - let (ll, lr) = levels(&env, l, t_a); - if ll == 0.0 && lr == 0.0 { + let gains = env.level_gains(l, s0 + a as i64, b - a); + if gains.iter().all(|g| *g == [0.0, 0.0]) { continue; } let buf = env.layer_audio(l, s0 + a as i64, b - a); - for (i, f) in buf.as_chunks::<2>().0.iter().enumerate() { - out[(a + i) * 2] += f[0] * ll; - out[(a + i) * 2 + 1] += f[1] * lr; + for (i, (f, g)) in buf.as_chunks::<2>().0.iter().zip(&gains).enumerate() { + out[(a + i) * 2] += f[0] * g[0]; + out[(a + i) * 2 + 1] += f[1] * g[1]; } } } -fn levels(env: &MixEnv, l: &Layer, t: Tick) -> (f32, f32) { - let Some(g) = l.props.sub("audio") else { return (1.0, 1.0) }; - let [a, b] = env.ctx(t).v2(l, g, "levels", [0.0, 0.0]); - (db_to_gain(a), db_to_gain(b)) -} - /// A min/max peak summary of interleaved stereo audio for waveform drawing: one entry per /// `bin` sample frames, `[min L, max L, min R, max R]`. pub fn peak_summary(samples: &[f32], bin: usize) -> Vec<[f32; 4]> { diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 28d2a8a89..1c7aadc55 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -479,6 +479,19 @@ fn group_has(g: &PropGroup, match_id: &str) -> bool { /// Feed every property of `g` that can change over time (keyframes or expression) as its value /// at the context time. The static structure is hashed separately. +/// Mix the collapsed-instance overrides that target `id` into a cache key. They replace values +/// without editing the layer, so a key without them would reuse another instance's buffer. +fn hash_overrides(h: &mut KeyHasher, ctx: &EvalCtx, id: effectcraft_project::LayerId) { + for o in ctx.property_overrides.iter().filter(|o| o.layer == id) { + o.prop.hash(h); + o.font.hash(h); + hash_debug(h, &o.value); + } + for m in ctx.media_overrides.iter().filter(|m| m.layer == id) { + m.item.hash(h); + } +} + fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { for c in &g.children { match c { @@ -605,8 +618,9 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo hash_debug(&mut h, s); } LayerSource::Text | LayerSource::Shape => {} - LayerSource::Footage { item } if footage || still_footage(ctx, *item) => { - let it = ctx.project.item(*item)?; + LayerSource::Footage { item } if footage || still_footage(ctx, ctx.media_item(layer, *item)) => { + let item = ctx.media_item(layer, *item); + let it = ctx.project.item(item)?; let ItemKind::Footage(f) = &it.kind else { return None }; hash_debug(&mut h, f); // A proxy (and its Use Proxy switch) changes the pixels. @@ -629,6 +643,10 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo scale.to_bits().hash(&mut h); draft.hash(&mut h); layer.id.hash(&mut h); + // Collapsed-instance overrides replace values without editing the nested layer, so mix them + // into the key: a Font override, effect parameter or any other source-affecting override + // must not reuse another instance's processed buffer. + hash_overrides(&mut h, ctx, layer.id); hash_debug(&mut h, &layer.source); // (Audio, Lock and Shy don't change pixels.) hash_debug(&mut h, &layer.switches.pixels()); diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 5deffa4db..1a550f9c7 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -3,7 +3,7 @@ //! cyclic/over-budget graphs or expressions sampled at unknown times. use super::{KeyHasher, hash_debug, hash_values}; use crate::EvalCtx; -use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup}; +use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Node, PropGroup}; use std::hash::Hash; pub(super) fn hash(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, effects: Option) -> Option<()> { @@ -63,11 +63,68 @@ impl Dependencies<'_> { Some(()) } + /// What a referenced 3D model or primitive layer's solo render depends on: the layer itself + /// (geometry, materials, switches, in/out), its animated values and transform at the time, + /// the mesh item, the comp's camera (with its depth of field) and its lights at the time + /// (the lights are off in draft 3D, and the renderer setting changes the pixels), the model's + /// layer time while it plays an animation clip, and the in/out gate (outside the range the + /// read is transparent). An Environment light's image can be any layer's render: with one on + /// the read is not cached. Comp size and render scale are in the owner's key already. + fn soloed_model(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { + self.remaining = self.remaining.checked_sub(1)?; + // Expressions sampled at other times: not cacheable, as in the ordinary path. + if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) { + return None; + } + hash_debug(self.h, layer); + hash_values(self.h, ctx, layer, &layer.props); + hash_debug(self.h, &ctx.world_matrix(layer)); + if let Some(item) = layer.source.item() { + hash_debug(self.h, &ctx.project.item(item)); + } + // The solo render goes through the comp's camera and lights. The camera's depth of + // field blurs it (focus, aperture, iris, highlights) — none of that is in the + // projection hashed above. + hash_debug(self.h, &ctx.camera()); + hash_debug(self.h, &crate::three_d::active_camera(ctx).dof); + hash_debug(self.h, &crate::three_d::light::lights_at(ctx)); + hash_debug(self.h, &ctx.comp.draft_3d); + hash_debug(self.h, &ctx.comp.renderer); + // An Environment light's image lights Advanced 3D models (its source can be any + // layer's render, or any environment layer) — too open-ended to hash: while one is + // on (and lighting: Draft 3D lights nothing), don't cache the read. + if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D + && !ctx.comp.draft_3d + && ctx.comp.layers.iter().any(|l| { + l.is_light() && l.switches.video && l.is_active_at(ctx.time) && matches!(l.source, LayerSource::Light { kind: LightKind::Environment }) + }) + { + return None; + } + // A model with an animation clip selected is posed at its layer time (Animation Speed + // and Looping are static, hashed above): every time is its own entry. Without one the + // pose is the same at every time: keep the static key. + if layer.props.sub("geometryOptions").map_or(0, |g| ctx.e(layer, g, "animation")) != 0 { + layer.layer_time(ctx.time).0.hash(self.h); + } + // Outside the layer's in/out range the read is transparent: fold the gate in, so a + // static model renders once inside it and again when it leaves. + layer.is_active_at(ctx.time).hash(self.h); + Some(()) + } + fn layer(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { let id = (ctx.comp_id, layer.id); if self.path.len() > crate::MAX_FX_DEPTH || self.path.contains(&id) { return None; } + // A referenced 3D model or primitive layer renders through the comp's camera and lights + // (an effect layer parameter reads it that way — Create 3D Layer Instance): hash what + // moves its pixels, so the owner keeps a cache entry that changes when any of it does. + if layer.source.is_model() { + self.soloed_model(ctx, layer)?; + return Some(()); + } self.remaining = self.remaining.checked_sub(1)?; if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) { return None; @@ -76,6 +133,9 @@ impl Dependencies<'_> { // Include transforms/parents as well as pixels: selected-layer anchors and // continuously rasterized sources can depend on them. hash_debug(self.h, layer); + // A referenced layer's static values can be overridden by the collapsed instance without + // editing it (`hash_values` only sees animated ones), so the owner's key needs them too. + super::hash_overrides(self.h, ctx, layer.id); hash_values(self.h, ctx, layer, &layer.props); hash_debug(self.h, &ctx.world_matrix(layer)); // A cached owner never runs this layer's effects, so the comp camera and lights its @@ -88,6 +148,10 @@ impl Dependencies<'_> { } match layer.source { LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => { + let item = match layer.source { + LayerSource::Footage { .. } => ctx.media_item(layer, item), + _ => item, + }; let source = ctx.project.item(item); hash_debug(self.h, &source); if let Some(source) = source { diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 14249900f..81fe45d6c 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -39,17 +39,33 @@ pub struct EvalCtx<'a> { /// Footage frames for expressions that read rendered pixels (`sampleImage`): the renderer's /// own source while rendering; `None` renders footage as transparent. pub footage: Option<&'a dyn crate::FootageSource>, + /// Property values overridden by the Essential Properties of a collapsed precomp instance. + pub property_overrides: &'a [crate::PropertyOverride], + /// Media Replacement values overridden by the Essential Properties of a collapsed precomp + /// instance. + pub media_overrides: &'a [crate::MediaOverride], } impl<'a> EvalCtx<'a> { pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> { - EvalCtx { project, comp_id, comp, time, expr: None, footage: None } + EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[] } } pub fn at(&self, time: Tick) -> EvalCtx<'a> { EvalCtx { time, ..*self } } /// Value of a property at the context time (keyframes, then expression). pub fn value(&self, layer: &Layer, prop: &Property) -> Value { + // A collapsed precomp instance's Essential Property override replaces the base value; + // its expression (if any) still runs, and sees the overrides of this context. + if let Some(o) = self.property_overrides.iter().rev().find(|o| o.layer == layer.id && o.prop == prop.uid) { + let v = o.value(layer, prop, self.time); + if prop.has_expression() + && let Some(h) = self.expr + { + return h.eval(self, layer, prop, &v).unwrap_or(v); + } + return v; + } // Separated Position reads as the combination of X/Y/Z Position. if prop.match_id == "position" && let Some(tr) = layer.transform() @@ -145,13 +161,15 @@ impl<'a> EvalCtx<'a> { /// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps /// with another pixel aspect) is stretched horizontally by this factor in the comp. pub fn par_ratio(&self, layer: &Layer) -> f64 { - let par = match &layer.source { - LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) { - Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect, - Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect, - Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect, - _ => 1.0, - }, + let item = match &layer.source { + LayerSource::Footage { item } => Some(self.media_item(layer, *item)), + LayerSource::Solid { item } | LayerSource::Comp { item } => Some(*item), + _ => None, + }; + let par = match item.and_then(|item| self.project.item(item)).map(|i| &i.kind) { + Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect, + Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect, + Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect, _ => 1.0, }; let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 }; @@ -237,9 +255,27 @@ impl<'a> EvalCtx<'a> { (Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]]), 0.0) } } + /// The footage item a layer uses, honouring this instance's Media Replacement override. + pub fn media_item(&self, layer: &Layer, item: ItemId) -> ItemId { + self.media_overrides.iter().rev().find(|m| m.layer == layer.id).map_or(item, |m| m.item) + } + + /// Size of a layer's source in layer pixels, honouring Media Replacement. + pub fn source_size(&self, layer: &Layer) -> (u32, u32) { + let item = match &layer.source { + LayerSource::Footage { item } => Some(self.media_item(layer, *item)), + LayerSource::Comp { item } | LayerSource::Solid { item } => Some(*item), + _ => None, + }; + match layer.source { + LayerSource::Null => (100, 100), + _ => item.and_then(|item| self.project.item(item)).and_then(|i| i.dimensions()).unwrap_or((0, 0)), + } + } + /// [`effect_bounds`] of a layer of this comp. pub fn effect_bounds(&self, layer: &Layer) -> ([f64; 2], [f64; 2]) { - effect_bounds(self.project, self.comp, layer) + effect_bounds_of(self.comp, self.source_size(layer)) } /// Effect space (see [`effect_bounds`]) → comp matrix. pub fn effect_to_comp(&self, layer: &Layer) -> Mat3 { @@ -248,6 +284,20 @@ impl<'a> EvalCtx<'a> { } } +impl crate::PropertyOverride { + fn value(&self, layer: &Layer, prop: &Property, time: Tick) -> Value { + if self.font { + // A Font control merges only the font into the target text, as + // `essential::with_overrides` does. + let mut value = prop.value_at(layer.layer_time(time)); + effectcraft_project::essential::merge_font(&mut value, &self.value); + value + } else { + self.value.clone() + } + } +} + /// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and /// clamped to the comp, in seconds. pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> { @@ -321,7 +371,11 @@ pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option /// surrounds. Effects work in **effect space**, measured from that corner (After Effects' layer /// space for effect points: a shape layer's default effect point is its bounds' centre). pub fn effect_bounds(project: &Project, comp: &Comp, layer: &Layer) -> ([f64; 2], [f64; 2]) { - match source_size(project, layer) { + effect_bounds_of(comp, source_size(project, layer)) +} + +fn effect_bounds_of(comp: &Comp, size: (u32, u32)) -> ([f64; 2], [f64; 2]) { + match size { (0, _) => { let (w, h) = (comp.width as f64, comp.height as f64); ([w, h], [-w / 2.0, -h / 2.0]) diff --git a/crates/render/src/frames.rs b/crates/render/src/frames.rs index 7dd0fc0de..3024dfc52 100644 --- a/crates/render/src/frames.rs +++ b/crates/render/src/frames.rs @@ -1,6 +1,6 @@ use super::footage::NoFootage; use super::opts::RenderOpts; -use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text}; +use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text}; /// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. /// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even / @@ -132,7 +132,7 @@ pub fn content_bounds(ctx: &EvalCtx, layer: &effectcraft_project::Layer) -> Opti LayerSource::Shape => layer.props.sub("contents").and_then(|c| shapes::content_bounds(ctx, layer, c)).map(|r| [r.x0, r.y0, r.x1, r.y1]), LayerSource::Camera | LayerSource::Light { .. } => None, _ => { - let (w, h) = source_size(ctx.project, layer); + let (w, h) = ctx.source_size(layer); (w > 0 && h > 0).then_some([0.0, 0.0, w as f64, h as f64]) } } diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs index a0c3e7490..65bafe002 100644 --- a/crates/render/src/fx_host.rs +++ b/crates/render/src/fx_host.rs @@ -72,6 +72,14 @@ impl EffectHost for FxHost<'_, '_, '_> { return None; } let sub = self.r.nested(); + // A 3D model or primitive layer has no 2D source of its own: an effect referencing one + // reads it as After Effects does — the layer rendered on its own through the comp's + // camera (Create 3D Layer Instance's effect), in the layer's effect space (the + // comp, centred on the layer's anchor). + if other.source.is_model() { + let buf = sub.solo_3d(self.ctx, other); + return Some(self.pixels(other, buf, false)); + } // The cached layer buffer is shared; effects get their own copy. let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? }; Some(self.pixels(other, buf, true)) @@ -90,12 +98,13 @@ impl EffectHost for FxHost<'_, '_, '_> { fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option> { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; let LayerSource::Footage { item } = &other.source else { return None }; - let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None }; + let item = self.ctx.media_item(other, *item); + let ItemKind::Footage(f) = &self.r.project.item(item)?.kind else { return None }; if !f.has_audio { return None; } let st = other.layer_time(Tick::from_seconds_f64(start)); - self.r.footage.audio(*item, f, st, frames, rate) + self.r.footage.audio(item, f, st, frames, rate) } fn self_at(&self, layer_time: f64, effects: usize) -> Option { diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index cac2add43..ce0a2dcde 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -2,7 +2,7 @@ use super::frames::adaptive_samples; use super::opts::{LayerTiming, Through}; use super::{ Arc, BlendMode, Buf, Comp, EvalCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, Layer, LayerSource, Mat3, - MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, source_size, styles, vec2, + MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, styles, vec2, }; /// Blend policy for placing a layer, including optional output-depth quantization. @@ -52,7 +52,7 @@ impl<'a> Renderer<'a> { } } let t0 = web_time::Instant::now(); - let size = source_size(self.project, layer); + let size = ctx.source_size(layer); let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; let st = styles::render(ctx, layer, &content, layer_size); if let Some(t) = timing { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0552a44d4..0c2f11e4c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -60,6 +60,7 @@ pub use layers::{Placement, StyledLayer}; pub(crate) use opts::Collapse3d; pub use opts::{LayerTiming, RenderOpts}; use opts::{MAX_THROUGH, Through}; +pub use pipeline::{Collapsed, MediaOverride, PropertyOverride}; pub struct Renderer<'a> { pub project: &'a Project, diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 71ad426ce..d51344923 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -7,6 +7,8 @@ use super::{ Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId, ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2, }; +use effectcraft_keyframe::Value; +use effectcraft_project::LayerId; /// Coordinate space and stack range shared by CPU and accelerated effect evaluation. #[derive(Clone, Copy)] @@ -16,6 +18,88 @@ pub(crate) struct EffectStack { pub origin: [f64; 2], } +/// A property value an Essential Properties override gives a layer of a collapsed precomp. +#[derive(Clone, Debug)] +pub struct PropertyOverride { + pub(crate) layer: LayerId, + pub(crate) prop: u64, + pub(crate) value: Value, + /// A Font control changes only the font of the target text, not its text. + pub(crate) font: bool, +} + +/// A Media Replacement an Essential Properties override gives a footage layer of a collapsed +/// precomp. +#[derive(Clone, Copy, Debug)] +pub struct MediaOverride { + pub(crate) layer: LayerId, + pub(crate) item: ItemId, +} + +impl MediaOverride { + /// A Media Replacement of `layer`'s footage by `item`. + pub fn new(layer: LayerId, item: ItemId) -> Self { + MediaOverride { layer, item } + } +} + +/// A collapsed precomp layer: the nested comp and the instance overrides that apply to it. +pub struct Collapsed { + pub item: ItemId, + pub(crate) property_overrides: Vec, + pub(crate) media_overrides: Vec, +} + +/// The property and media overrides of a collapsed precomp layer. `None` when its Essential +/// Properties group or the source comp's definitions are missing (the flattened path then +/// preserves the old behaviour). +fn collapsed_instance(ctx: &EvalCtx, layer: &Layer, item: ItemId) -> Option<(Vec, Vec)> { + use effectcraft_project::essential::{self, ControlType, EgKind}; + let over = essential::overridden(layer); + if over.is_empty() { + return Some((vec![], vec![])); + } + let g = essential::group(layer)?; + let eg = ctx.project.comp(item)?.essential.as_ref()?; + let mut property = vec![]; + let mut fonts: Vec = vec![]; + let mut media = vec![]; + g.walk("", &mut |_, p| { + if !over.contains(&p.uid) { + return; + } + let Some(control) = essential::control_of(&p.match_id) else { return }; + let Some(ctl) = eg.resolve(control) else { return }; + let value = ctx.value(layer, p); + match &ctl.kind { + EgKind::Property { .. } => { + let font = ctl.as_type == Some(ControlType::Font); + for (layer, prop) in ctl.kind.targets() { + let o = PropertyOverride { layer, prop, value: value.clone(), font }; + if font { fonts.push(o) } else { property.push(o) } + } + } + EgKind::Media { layer: target } => { + let Value::Scalar(id) = value else { return }; + let item = ItemId(id.max(0.0) as u64); + if matches!(ctx.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) { + media.push(MediaOverride { layer: *target, item }); + } + } + _ => {} + } + }); + // A Font control merges into the text its instance shows, after a whole-value (Text) override + // of the same property, as `essential::with_overrides` does: keep one entry per property. + for f in fonts { + match property.iter_mut().rev().find(|o| o.layer == f.layer && o.prop == f.prop) { + Some(o) => effectcraft_project::essential::merge_font(&mut o.value, &f.value), + None => property.push(f), + } + } + Some((property, media)) +} + impl<'a> Renderer<'a> { pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { Renderer { @@ -122,8 +206,10 @@ impl<'a> Renderer<'a> { /// The nested comp of a precomp layer whose transformations collapse into this comp: the /// Collapse Transformations switch is on and the layer has no masks, effects or layer - /// styles (those force a flattened render of the precomp, as in After Effects). - pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + /// styles (those force a flattened render of the precomp, as in After Effects). Essential + /// Properties overrides travel with it and are applied as the nested layers are evaluated, + /// so the nested comp is not rasterised at its frame. + pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 { return None; } @@ -140,24 +226,37 @@ impl<'a> Renderer<'a> { if styles::active(ctx, layer) { return None; } - // Instance overrides (Essential Properties) need the nested comp rendered on its own. - if !effectcraft_project::essential::overridden(layer).is_empty() { + // A composition proxy or frame blending is drawn by the flattened path (`precomp_source`); + // collapsing renders the nested comp directly and would ignore both. + if self.proxy_for(*item).is_some() || frame_blend_mode(ctx, layer) != FrameBlend::Off { return None; } - Some(*item) + let (property_overrides, media_overrides) = collapsed_instance(ctx, layer, *item)?; + Some(Collapsed { item: *item, property_overrides, media_overrides }) } /// A renderer and context that draw a collapsed precomp's layers straight into this comp: /// their transforms are concatenated with the precomp layer's (one resample, no /// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their /// 3D layers use this comp's camera and lights. - pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> { + pub fn collapse_into<'p>(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &'p Collapsed, opacity: f32) -> Option<(Renderer<'p>, EvalCtx<'p>)> + where + 'a: 'p, + { + let item = collapsed.item; let nc = self.project.comp(item)?; let t = ctx.nested_time(layer); if !nc.covers(t) { return None; } - let nctx = EvalCtx { comp_id: item, comp: nc, time: t, ..*ctx }; + let nctx = EvalCtx { + comp_id: item, + comp: nc, + time: t, + property_overrides: &collapsed.property_overrides, + media_overrides: &collapsed.media_overrides, + ..*ctx + }; let (l2c, _) = ctx.layer_to_comp(layer); let outer = Some(self.outer.map_or(l2c, |o| o * l2c)); let world = ctx.world_matrix(layer); @@ -189,19 +288,19 @@ impl<'a> Renderer<'a> { /// that then takes the precomp layer's track matte, Preserve Transparency and blend mode. /// Returns the region left to re-quantise (nested layers drawn straight into `canvas` /// quantise their own footprints). - pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image, blank: bool) -> Region { + pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &Collapsed, canvas: &mut Image, blank: bool) -> Region { let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; if opacity <= 0.0 { return Region::Empty; } let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency { - if let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, opacity) { + if let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, opacity) { sub.draw_comp(&nctx, canvas, blank); } return Region::Empty; } - let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return Region::Empty }; + let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, 1.0) else { return Region::Empty }; let mut iso = Image::new(canvas.width, canvas.height); sub.draw_comp(&nctx, &mut iso, true); self.composite_iso(ctx, layer, iso, canvas, opacity); @@ -230,6 +329,7 @@ impl<'a> Renderer<'a> { let continuous = match layer.source { LayerSource::Text | LayerSource::Shape => true, LayerSource::Footage { item } => { + let item = ctx.media_item(layer, item); layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)) } _ => false, @@ -267,7 +367,7 @@ impl<'a> Renderer<'a> { } pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { - EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage) } + EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage), property_overrides: &[], media_overrides: &[] } } /// This renderer with another accelerator (or none): the passes of @@ -406,7 +506,7 @@ impl<'a> Renderer<'a> { blank = false; } else { let changed = match self.collapsed(ctx, visible[i]) { - Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas, blank), + Some(collapsed) => self.draw_collapsed(ctx, visible[i], &collapsed, canvas, blank), None => self.draw_layer(ctx, visible[i], canvas, blank), }; i += 1; @@ -637,7 +737,8 @@ impl<'a> Renderer<'a> { Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: bs }) } LayerSource::Footage { item } => { - let it = self.project.item(*item)?; + let item = ctx.media_item(layer, *item); + let it = self.project.item(item)?; let ItemKind::Footage(f) = &it.kind else { return None }; if !f.has_video { return None; @@ -645,7 +746,7 @@ impl<'a> Renderer<'a> { // Continuously rasterised vector footage: drawn at the on-screen scale. if layer.switches.collapse && is_vector_footage(f) { let k = self.raster_scale(ctx, layer); - if let Some(img) = self.footage.vector_frame(*item, f, k) { + if let Some(img) = self.footage.vector_frame(item, f, k) { let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k }; if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); @@ -655,10 +756,10 @@ impl<'a> Renderer<'a> { } let lt = ctx.source_time(layer); // The proxy is decoded instead, at its own size, and fills the footage's frame. - let proxy = self.proxy_for(*item); + let proxy = self.proxy_for(item); let pf = proxy.unwrap_or(f); let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none()); - let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?; + let img = self.footage_frame(ctx, layer, item, pf, lt, size)?; Some(self.footage_buf(&img, pf, f.width, f.height)) } LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), @@ -836,6 +937,30 @@ impl<'a> Renderer<'a> { self.source(ctx, layer) } + /// A 3D model or primitive layer rendered on its own, through the comp's camera: what an + /// effect layer parameter reads when it references one, as After Effects, which renders 3D + /// layers through the active camera. The comp's pixels at this render's scale; the layer's + /// blend mode, track matte and video switch don't apply (a source read, not a composite — + /// the solo clone of a 3D track matte, see `three_d::adv`). + pub(crate) fn solo_3d(&self, ctx: &EvalCtx, layer: &Layer) -> Buf { + let s = self.opts.scale; + let w = ((ctx.comp.width as f64 * s).ceil() as u32).max(1); + let h = ((ctx.comp.height as f64 * s).ceil() as u32).max(1); + let mut canvas = Image::new(w, h); + // Outside the layer's in/out points there is nothing to read: the copy is transparent + // there, like any other layer reference (the 3D pipelines don't gate on the range). + if !layer.is_active_at(ctx.time) { + return Buf { img: canvas, offset: [0.0; 2], scale: s }; + } + let mut solo = layer.clone(); + solo.track_matte = None; + solo.blend_mode = effectcraft_color::BlendMode::Normal; + solo.preserve_transparency = false; + solo.switches.video = true; + three_d::compose::draw_run(self, ctx, &[&solo], &mut canvas); + Buf { img: canvas, offset: [0.0; 2], scale: s } + } + /// Layer pixels after source → masks → effects (no layer styles). pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { self.content_buf_timed(ctx, layer, None).map(|(b, _)| b) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index f04bd4be2..38baa8f3e 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1276,3 +1276,194 @@ fn workers_rendering_a_layer_at_once_share_one_buffer() { assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer"); assert_eq!(cache.stats().entries, 1); } + +// ------------------------------------------------------------------------------ 3D layer instances + +/// A red triangle in the Z = 0 plane (the engine instance3d tests' glTF, buffer embedded as a +/// data URI), parsed in memory: a FootageSource that knows one model item. +const TRIANGLE: &str = r#"{ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}], + "materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}], + "buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]}, + {"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3} + ] +}"#; + +struct SoloModels; + +impl crate::FootageSource for SoloModels { + fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option> { + None + } + fn model(&self, _: ItemId, f: &effectcraft_project::Footage) -> Option> { + effectcraft_model::load(&f.path, TRIANGLE.as_bytes(), &|_| None).ok().map(std::sync::Arc::new) + } +} + +/// A red triangle 3D model, a camera panned right by `pan` px and, on top of the stack, the +/// copy: a solid with a 3D Layer Instance effect referencing the model. +fn instance_scene(pan: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + // Models are Advanced 3D (layer.newModel switches a Classic comp over). + p.comp_mut(cid).unwrap().renderer = effectcraft_project::Renderer::Advanced3D; + let mid = p.add_item( + "Triangle", + Label::Red, + None, + ItemKind::Footage(effectcraft_project::Footage { + path: "Triangle.gltf".into(), + kind: effectcraft_project::FootageKind::Model, + frame_rate: FrameRate::FPS_30, + codec: "glTF".into(), + ..Default::default() + }), + ); + let model = build::layer(&mut p, &comp, "Triangle", LayerSource::Model { item: mid }, (200, 100), None); + let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None); + for path in ["transform/position", "transform/poi"] { + let pr = cam.props.prop_mut(path).unwrap(); + let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; + pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); + } + let mut owner = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + add_effect_200(&mut p, &mut owner, "ec.3d.instance3d", &[("layer", Value::Layer(Some(model.id.0)))]); + p.comp_mut(cid).unwrap().layers = vec![cam, owner, model]; + (p, cid) +} + +fn render_models(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { + let mut r = crate::Renderer::new(p, &SoloModels, crate::RenderOpts::default()); + r.cache = cache; + r.comp_frame(cid, t) +} + +/// The coverage-weighted centre of the triangle (x): where its drawn pixels are, lit or not. +fn cover_x(img: &crate::Image) -> f32 { + let (mut m, mut sx) = (0.0, 0.0); + for y in 0..img.height { + for x in 0..img.width { + let a = img.get(x as i64, y as i64)[3]; + m += a; + sx += a * x as f32; + } + } + assert!(m > 1.0, "the triangle is drawn"); + sx / m +} + +/// Create 3D Layer Instance: the copy (the owner) is cached, and its key covers what the +/// referenced model's solo render depends on — the camera, and the model's in/out gate. +#[test] +fn an_instance_of_a_model_is_cached_and_follows_the_camera() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = instance_scene(0.0); + let (q, _) = instance_scene(40.0); + let key_at = |p: &Project, t: Tick| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_ne!(key_at(&p, t), key_at(&q, t), "moving the camera changes the copy's key"); + // The same layer at a later time, outside its in/out range: the read turns transparent. + let mut out = p.clone(); + out.comp_mut(cid).unwrap().layers[2].out_point = Tick::from_seconds_f64(0.5); + assert_ne!(key_at(&out, Tick::from_seconds_f64(0.25)), key_at(&out, t), "leaving the model's in/out range changes the copy's key"); + // Two renders of one time: the second is served from the cache. + let cache = crate::LayerCache::default(); + let here = render_models(&p, cid, t, Some(&cache)); + let again = render_models(&p, cid, t, Some(&cache)); + assert_same(&again, &render_models(&p, cid, t, None), "the second render is the cached one"); + assert!(cache.stats().hits > 0, "the copy was served from the cache"); + // Panning the camera: the copy (and the model) move with it. + let panned = render_models(&q, cid, t, Some(&cache)); + assert_same(&panned, &render_models(&q, cid, t, None), "cached render after the camera moved"); + let (a, b) = (cover_x(&here), cover_x(&panned)); + assert!(b < a - 20.0, "panning the camera right moves the copy left: {a} -> {b}"); +} + +/// The camera's depth of field blurs the solo render (focus, aperture, iris, highlights) — +/// none of it is in the projection matrix: changing any of it must change the copy's key, or +/// it would serve the un-blurred pixels. +#[test] +fn the_copy_key_covers_the_cameras_depth_of_field() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = instance_scene(0.0); + let set_dof = |p: &Project, on: bool, focus: f64, aperture: f64| { + let mut q = p.clone(); + let cam = &mut q.comp_mut(cid).unwrap().layers[0]; + for (path, v) in + [("cameraOptions/dof", Value::Bool(on)), ("cameraOptions/focusDistance", Value::Scalar(focus)), ("cameraOptions/aperture", Value::Scalar(aperture))] + { + cam.props.prop_mut(path).unwrap().value = v; + } + q + }; + let key_at = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + let ka = key_at(&set_dof(&p, true, 500.0, 20.0)); + let kb = key_at(&set_dof(&p, true, 500.0, 40.0)); // aperture + let kc = key_at(&set_dof(&p, true, 700.0, 20.0)); // focus + let kd = key_at(&set_dof(&p, false, 500.0, 20.0)); // the switch off + assert!(ka != kb && ka != kc && ka != kd && kb != kc, "aperture, focus and the switch each change the key"); +} + +/// A model with an animation clip selected is posed at its layer time: every time is its own +/// entry. Without one (the default) the pose is the same at every time: one key inside the +/// in/out range, as before. +#[test] +fn the_copy_key_covers_an_animated_models_layer_time() { + let (p, cid) = instance_scene(0.0); + let (t0, t1) = (Tick::from_seconds_f64(0.5), Tick::from_seconds_f64(1.0)); + let key_at = |p: &Project, t: Tick| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_eq!(key_at(&p, t0), key_at(&p, t1), "a model without a clip keeps one key across times"); + let mut q = p.clone(); + q.comp_mut(cid).unwrap().layers[2].props.prop_mut("geometryOptions/animation").unwrap().value = Value::Enum(1); + assert_ne!(key_at(&q, t0), key_at(&q, t1), "an animated model's key follows its layer time"); + // Two times and back: each is rendered fresh, none served another time's pixels. + let cache = crate::LayerCache::default(); + let first = render_models(&q, cid, t0, Some(&cache)); + let second = render_models(&q, cid, t1, Some(&cache)); + assert_eq!(cache.stats().hits, 0, "the second time is not served the first time's pixels"); + assert_same(&second, &render_models(&q, cid, t1, None), "the second time is a fresh render"); + let again = render_models(&q, cid, t0, Some(&cache)); + assert_eq!(cache.stats().hits, 1, "the first time is served on the way back"); + assert_same(&again, &first, "the way back is the first time's pixels"); +} + +/// An Environment light's image lights Advanced 3D models — its source can be any layer's +/// render: with one on, the read is not cached, so changes to it cannot reuse stale pixels. +#[test] +fn an_environment_light_makes_the_copy_uncached() { + let (p, cid) = instance_scene(0.0); + let mut q = p.clone(); + let comp = q.comp(cid).unwrap().clone(); + let env = build::layer(&mut q, &comp, "Env", LayerSource::Light { kind: effectcraft_project::LightKind::Environment }, (200, 100), None); + q.comp_mut(cid).unwrap().layers.insert(0, env); + let t = Tick::from_seconds_f64(1.0); + let key_at = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[2], 1.0, false, false) + }; + assert!(key_at(&q).is_none(), "with an Environment light the read is not cached"); + let cache = crate::LayerCache::default(); + let a = render_models(&q, cid, t, Some(&cache)); + let b = render_models(&q, cid, t, Some(&cache)); + assert_same(&a, &b, "two fresh renders agree"); + assert_eq!(cache.stats().hits, 0, "nothing is served from the cache"); + // The light off (not lighting): the cache comes back. + q.comp_mut(cid).unwrap().layers[0].switches.video = false; + assert!(key_at(&q).is_some(), "the light off, the read is cached again"); +} diff --git a/crates/render/src/tests/audio_fx.rs b/crates/render/src/tests/audio_fx.rs index aced8208e..242a5a5e2 100644 --- a/crates/render/src/tests/audio_fx.rs +++ b/crates/render/src/tests/audio_fx.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use effectcraft_keyframe::Value; +use effectcraft_keyframe::{Keyframe, Value}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid}; use effectcraft_time::{FrameRate, Tick}; @@ -148,6 +148,42 @@ fn stereo_mixer_pans() { assert!(m.chunks(2).all(|f| f[0].abs() < 1e-6 && (f[1] - 0.5).abs() < 1e-6), "{:?}", &m[..4]); } +/// Key `prop` of layer 0 (`effect`: in its first effect, else in Audio) from `a` at 0 s to `b` at 1 s. +fn key(p: &mut Project, cid: ItemId, effect: bool, prop: &str, a: Value, b: Value) { + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + let g = if effect { l.props.sub_mut("effects").unwrap().children[0].as_group_mut().unwrap() } else { l.props.sub_mut("audio").unwrap() }; + g.get_mut(prop).unwrap().keys = vec![Keyframe::new(Tick::ZERO, a), Keyframe::new(Tick::from_seconds_f64(1.0), b)]; +} + +/// Left samples of a mix of constant 0.5 audio over the layer's 1 s, in blocks of `block` samples. +fn left_ramp(p: &Project, cid: ItemId, block: usize) -> Vec { + fn half(_: f64) -> (f32, f32) { + (0.5, 0.5) + } + let mut m = vec![]; + for k in 0..SR as usize / block { + m.extend(mix(p, &Signal(half), cid, (k * block) as f64 / SR as f64, block)); + } + m.iter().step_by(2).copied().collect() +} + +/// Keyframed Audio Levels and Stereo Mixer levels ramp smoothly inside a block (export mixes one +/// second at a time), instead of holding the value at the block's first sample. +#[test] +fn animated_levels_ramp_within_a_block() { + let (mut levels, lc) = comp_with(None, &[], false); + key(&mut levels, lc, false, "levels", Value::Vec2([-60.0, -60.0]), Value::Vec2([0.0, 0.0])); + let (mut mixer, mc) = comp_with(Some("ec.audio.stereomixer"), &[], false); + key(&mut mixer, mc, true, "leftLevel", Value::Scalar(0.0), Value::Scalar(100.0)); + for (name, p, cid, mid) in [("levels", &levels, lc, 0.5 * 10f32.powf(-1.5)), ("stereo mixer", &mixer, mc, 0.25)] { + let one = left_ramp(p, cid, SR as usize); + assert!(one.windows(2).all(|w| w[1] >= w[0]), "{name}: not monotonic"); + assert!(one[100] < one[4000] && one[4000] < one[7900], "{name}: {} {} {}", one[100], one[4000], one[7900]); + assert!((one[4000] / mid - 1.0).abs() < 0.05, "{name}: {} at 0.5 s, want {mid}", one[4000]); + assert_eq!(one, left_ramp(p, cid, 320), "{name}: block size changes the result"); + } +} + #[test] fn tone_makes_a_solid_audible_at_its_frequency() { let mut vals: Vec<(&str, Value)> = vec![("frequency1", Value::Scalar(500.0))]; diff --git a/crates/render/src/tests/collapse.rs b/crates/render/src/tests/collapse.rs index 797960033..eb340a4ca 100644 --- a/crates/render/src/tests/collapse.rs +++ b/crates/render/src/tests/collapse.rs @@ -3,7 +3,7 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::{TextDoc, Value}; use effectcraft_project::build::{self, Ids}; -use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; +use effectcraft_project::{BitDepth, Comp, FrameBlend, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; use effectcraft_time::{FrameRate, Tick}; use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer, render_frame}; @@ -110,6 +110,66 @@ fn masks_on_a_collapsed_layer_force_a_flattened_render() { assert!(max_diff(&a, &b) < 1e-4, "{}", max_diff(&a, &b)); } +/// #1747: the flattened path honours a composition proxy and frame blending; a collapsed layer +/// renders the nested comp directly and ignores both, so such a layer must not collapse. +#[test] +fn proxy_and_frame_blending_keep_a_precomp_on_the_flattened_path() { + let (mut p, cid) = scene(true, false); + let id = p.comp(cid).unwrap().layers[0].id; + let collapses = |p: &Project| { + let r = Renderer::new(p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(p, cid, p.comp(cid).unwrap(), Tick::ZERO); + r.collapsed(&ctx, ctx.comp.layer(id).unwrap()).is_some() + }; + assert!(collapses(&p), "a plain precomp collapses"); + // Frame blending needs both the comp's switch and the layer's. + p.comp_mut(cid).unwrap().enable_frame_blending = true; + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::FrameMix; + assert!(!collapses(&p), "frame blending keeps the flattened path"); + p.comp_mut(cid).unwrap().enable_frame_blending = false; + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off; + // A composition proxy stands in for the precomp's pixels. + let LayerSource::Comp { item } = p.comp(cid).unwrap().layers[0].source else { panic!("a precomp layer") }; + let proxy = + effectcraft_project::Proxy { footage: effectcraft_project::Footage { has_video: true, width: 32, height: 32, ..Default::default() }, enabled: true }; + p.item_mut(item).unwrap().proxy = Some(Box::new(proxy)); + assert!(!collapses(&p), "a proxy keeps the flattened path"); +} + +/// #1747: an effect on B that reads A must not reuse its cached buffer when two collapsed +/// instances give A different static overrides (`hash_values` only sees animated values). +#[test] +fn referenced_layer_overrides_change_the_owner_cache_key() { + let mut p = project(); + let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let a = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [30.0, 50.0, 0.0]); + let aid = a.id; + let mut b = solid(&mut p, &comp, [1.0, 1.0, 1.0], (100, 100), [50.0, 50.0, 0.0]); + let bid = b.id; + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap(); + let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [100.0, 100.0]); + fx.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(a.id.0)); + p.next_id = next; + b.props.sub_mut("effects").unwrap().children.push(fx.into()); + let mut c = comp; + c.layers = vec![b, a]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + let comp = p.comp(cid).unwrap(); + let b = comp.layer(bid).unwrap(); + let opacity = comp.layer(aid).unwrap().props.prop("transform/opacity").unwrap().uid; + let key = |value: f64| { + let overrides = [crate::PropertyOverride { layer: aid, prop: opacity, value: Value::Scalar(value), font: false }]; + let ctx = + EvalCtx { project: &p, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &overrides, media_overrides: &[] }; + crate::cache::layer_key(&ctx, b, 1.0, false, false) + }; + let (opaque, half) = (key(100.0), key(50.0)); + assert!(opaque.is_some(), "the owner is cacheable"); + assert_ne!(opaque, half, "A's static override changes B's key"); +} + #[test] fn collapsed_3d_layers_join_the_parent_scene() { // Parent: a red 3D solid at z = 0 above (in the stack) a collapsed 3D precomp holding a diff --git a/crates/render/src/tests/time.rs b/crates/render/src/tests/time.rs index 8739140de..e9599f70b 100644 --- a/crates/render/src/tests/time.rs +++ b/crates/render/src/tests/time.rs @@ -134,7 +134,16 @@ fn input_keys_ignore_later_effects() { } let key = |p: &Project| { let comp = p.comp(cid).unwrap(); - let ctx = crate::EvalCtx { project: p, comp_id: cid, comp, time: Tick::from_seconds_f64(0.5), expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: p, + comp_id: cid, + comp, + time: Tick::from_seconds_f64(0.5), + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &comp.layers[0]; (crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2)) }; diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index a97272434..2162439ec 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -857,8 +857,8 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() { return; } - if let Some(item) = r.collapsed(ctx, layer) { - collapsed_layer(r, ctx, layer, item, b); + if let Some(collapsed) = r.collapsed(ctx, layer) { + collapsed_layer(r, ctx, layer, &collapsed, b); } else if layer.source.is_model() { model_layer(r, ctx, layer, b); } else if matches!(layer.source, LayerSource::Text | LayerSource::Shape) @@ -874,9 +874,9 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu /// (meshes, extrusions, cards) placed by the precomp layer's world transform, so they /// intersect, occlude and shadow the parent's layers. Nested 2D layers lie on the precomp /// layer's plane; nested lights and cameras are ignored (the parent's light the scene). -fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: effectcraft_project::ItemId, b: &mut Builder) { +fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, collapsed: &crate::Collapsed, b: &mut Builder) { let op = ctx.opacity(layer) as f32 * r.opacity_mul(); - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return }; let saved = b.outer; b.outer = saved * ctx.world_matrix(layer); let t = nctx.time; diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 6e626ad0d..627e4e291 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -370,7 +370,16 @@ fn extruded_text_renders_as_a_mesh() { set(l, "geometryOptions/bevelStyle", Value::Enum(1)); set(l, "transform/rotationY", Value::Scalar(40.0)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -409,7 +418,16 @@ fn shape_surface_items_colour_the_extruded_surfaces() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0)); set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -445,7 +463,16 @@ fn text_animators_colour_the_extruded_surfaces() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0)); set(l, "geometryOptions/bevelStyle", Value::Enum(1)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -643,7 +670,16 @@ fn extruded_strokes_render_as_bevelled_meshes() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(20.0)); set(l, "geometryOptions/bevelStyle", Value::Enum(1)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); diff --git a/crates/render/src/three_d/bokeh.rs b/crates/render/src/three_d/bokeh.rs index 12bcbcf37..098f095d0 100644 --- a/crates/render/src/three_d/bokeh.rs +++ b/crates/render/src/three_d/bokeh.rs @@ -289,16 +289,24 @@ pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image { return bokeh_blur(img, iris, levels[0] as f64); } let (lo, hi, n) = (levels[0], levels[levels.len() - 1], levels.len()); - let blurred: Vec = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); + // A pixel blends only the two levels around its radius, so the levels are made in order and + // only that pair is held: peak memory stays a few images whatever the level count, rather + // than one image per level. Each level's blur is parallel over its rows. let mut out = Image::new(img.width, img.height); - out.data.par_iter_mut().enumerate().for_each(|(i, o)| { - let r = radius[i]; - let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); - let k = (f.floor() as usize).min(n - 2); - let t = f - k as f32; - let (a, b) = (blurred[k].data[i], blurred[k + 1].data[i]); - *o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); - }); + let mut below = bokeh_blur(img, iris, lo as f64); + for (k, &r) in levels.iter().enumerate().skip(1) { + let above = bokeh_blur(img, iris, r as f64); + out.data.par_iter_mut().enumerate().for_each(|(i, o)| { + let f = ((radius[i] - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); + let kk = (f.floor() as usize).min(n - 2); + if kk + 1 == k { + let t = f - kk as f32; + let (a, b) = (below.data[i], above.data[i]); + *o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); + } + }); + below = above; + } out } diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 5af8859d5..a927e2ceb 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -37,7 +37,8 @@ pub struct Dof { impl Dof { /// Circle-of-confusion diameter (comp pixels) for a point at camera depth `z`. pub fn coc(&self, z: f64) -> f64 { - if z <= NEAR || self.focus <= 0.0 { + // No defocus can be measured against a non-finite focus or depth, so it counts as in focus. + if !z.is_finite() || z <= NEAR || !self.focus.is_finite() || self.focus <= 0.0 { return 0.0; } self.aperture * (z - self.focus).abs() / z * self.blur_level @@ -360,22 +361,27 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState { let (eye, fwd, down) = layer_frame(ctx, cam); let opts = cam.props.sub("cameraOptions"); let zoom = opts.map(|g| ctx.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0).max(1e-3); - let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| Dof { - focus: ctx.f(cam, g, "focusDistance", zoom), - aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), - blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, - iris: super::bokeh::Iris { - sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), - rotation: ctx.f(cam, g, "irisRotation", 0.0), - roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, - aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), - fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, - }, - highlight: super::bokeh::Highlight { - gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, - threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, - saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, - }, + let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| { + // A non-finite Focus Distance would make every blur radius non-finite; use the value the + // property defaults to (the zoom plane) instead. + let focus = ctx.f(cam, g, "focusDistance", zoom); + Dof { + focus: if focus.is_finite() { focus } else { zoom }, + aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), + blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, + iris: super::bokeh::Iris { + sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), + rotation: ctx.f(cam, g, "irisRotation", 0.0), + roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, + aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), + fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, + }, + highlight: super::bokeh::Highlight { + gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, + threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, + saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, + }, + } }); CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof } } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 38ac5b962..d203cfa71 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use effectcraft_color::{BlendMode, blend_pixel}; use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, Mat4, Vec3, vec3}; -use effectcraft_project::{Layer, LightKind, MatteKind}; +use effectcraft_project::{Layer, LightKind, MatteKind, limits}; use effectcraft_raster::{Image, Px, hash_noise}; use effectcraft_time::Tick; use rayon::prelude::*; @@ -195,13 +195,13 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { } /// Prepare a layer (buffer, geometry, material). `None` when it shows nothing. -pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { +pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { let buf = r.blend_layer_buf(ctx, layer)?; prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf, local: Mat4::IDENTITY }) } /// Prepare a layer as planes: one per character for per-character 3D text, else one. -pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { +pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { if crate::text::per_char_3d(ctx, layer) { return crate::text::per_char_planes(ctx, layer, r.opts.scale) .into_iter() @@ -218,7 +218,7 @@ struct PlaneInput { } /// Prepare the plane's geometry, motion samples and material. -fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option> { +fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option> { let PlaneInput { mut buf, local } = plane; if buf.img.is_empty() { return None; @@ -287,12 +287,45 @@ fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: us } /// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from -/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there. -fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec { - dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale) +/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there, at +/// most `cap` ([`dof_radius_cap`]). +fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf, cap: f64) -> Vec { + dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale, cap) } -fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec { +/// Pixels a padded blur buffer may have: the ~16 million layer buffers are held to +/// ([`crate::Renderer::raster_scale`]), or twice the buffer when that is larger, so a big buffer +/// can still be blurred. The blur holds a few images of this size at once whatever its level +/// count ([`super::bokeh::progressive_blur`]), so blurring a small plane costs no more memory than +/// blurring a large layer. +const DOF_PAD_BUDGET: f64 = 16.0e6; + +/// Comp pixels per buffer pixel below which a plane isn't blurred. Such a plane covers almost no +/// screen area (a 0%-scaled layer, one at an extreme distance), while its radius in buffer +/// pixels grows without bound as it shrinks: blurring it would cost the most for the least. +const DOF_MIN_SCREEN_SCALE: f64 = 1e-3; + +/// The largest blur radius (buffer pixels) of a `w`×`h` buffer: the one whose padded copy just +/// fits [`DOF_PAD_BUDGET`]. The circle of confusion has no bound of its own (a focus distance far +/// beyond the scene, a near-zero layer scale), so memory sets the limit; ordinary bokeh stays far +/// below it and is never clamped. +pub(super) fn dof_radius_cap(dof: &super::camera::Dof, w: u32, h: u32) -> f64 { + let (w, h) = (w as f64, h as f64); + let budget = DOF_PAD_BUDGET.max(2.0 * w * h); + // (w + 2p)·(h + 2p) = budget, solved for the padding p. + let pad = (((w - h) * (w - h) + 4.0 * budget).sqrt() - (w + h)) / 4.0; + ((pad - 2.0) / iris_stretch(dof)).max(0.0) +} + +pub(super) fn dof_radii_at( + cam: &CameraState, + dof: &super::camera::Dof, + world: Mat4, + (w, h): (usize, usize), + offset: [f64; 2], + scale: f64, + cap: f64, +) -> Vec { let s = scale.max(1e-9); let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0))); @@ -306,31 +339,48 @@ fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h) if z <= super::camera::NEAR { continue; } - // coc · ½ / (zoom / z · sx) · scale - *o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32; + // coc · ½ / (zoom / z · sx) · scale. Infinity clamps to the cap like any large radius, + // so the blur keeps growing with defocus; only NaN, which has no defocus, stays sharp. + let r = dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s; + *o = if r.is_nan() || cam.zoom * sx / (z * s) < DOF_MIN_SCREEN_SCALE { 0.0 } else { r.min(cap) as f32 }; } }); out } +/// How much the iris' aspect ratio stretches a blur radius along its longer axis. +fn iris_stretch(dof: &super::camera::Dof) -> f64 { + let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt(); + a.max(1.0 / a) +} + /// The padding [`dof_blur`] gives a buffer whose largest blur radius is `rmax`. fn dof_pad(dof: &super::camera::Dof, rmax: f64) -> u32 { - let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt()); - (rmax * a).ceil() as u32 + 2 + ((rmax * iris_stretch(dof)).ceil() as u32).saturating_add(2) +} + +/// The size of `img` padded by `pad` on every side, or `None` past [`limits::MAX_SIZE`] on a side: +/// [`DOF_PAD_BUDGET`] bounds the area, so a long thin buffer could still outgrow a side. +fn padded_size(img: &Image, pad: u32) -> Option<(u32, u32)> { + let grow = |n: u32| n.saturating_add(pad.saturating_mul(2)); + let (w, h) = (grow(img.width), grow(img.height)); + (w <= limits::MAX_SIZE && h <= limits::MAX_SIZE).then_some((w, h)) } /// [`dof_blur`] planned for an accelerator: the padding, radius map and levels, and the padded /// buffer's layer-space bounds. `None` when nothing blurs. fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Option<(PlaneDof, [f64; 4])> { - let radii = dof_radii(cam, dof, world, buf); + let cap = dof_radius_cap(dof, buf.img.width, buf.img.height); + let radii = dof_radii(cam, dof, world, buf, cap); let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; if rmax <= 0.3 { return None; } let pad = dof_pad(dof, rmax); - let (w, h) = (buf.img.width as usize + 2 * pad as usize, buf.img.height as usize + 2 * pad as usize); + let (wp, hp) = padded_size(&buf.img, pad)?; + let (w, h) = (wp as usize, hp as usize); let offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64]; - let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale); + let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale, cap); let levels = super::bokeh::blur_levels(&radius)?; let s = buf.scale.max(1e-9); let bounds = [-offset[0] / s, -offset[1] / s, (w as f64 - offset[0]) / s, (h as f64 - offset[1]) / s]; @@ -339,16 +389,20 @@ fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) /// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth. fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc) -> Arc { - let radii = dof_radii(cam, dof, world, &buf); + let cap = dof_radius_cap(dof, buf.img.width, buf.img.height); + let radii = dof_radii(cam, dof, world, &buf, cap); let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; if rmax <= 0.3 { return buf; } let pad = dof_pad(dof, rmax); + if padded_size(&buf.img, pad).is_none() { + return buf; + } let mut padded = buf.img.padded(pad); super::bokeh::boost_highlights(&mut padded, &dof.highlight); let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale }; - let pr = dof_radii(cam, dof, world, &pbuf); + let pr = dof_radii(cam, dof, world, &pbuf, cap); // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale }) } @@ -449,8 +503,8 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, .flat_map_iter(|(i, l)| { // Stack order with room for a collapsed precomp's layers in between. let i = i * 1024; - if let Some(item) = r.collapsed(ctx, l) { - return collapsed_items(r, ctx, l, item, i, out); + if let Some(collapsed) = r.collapsed(ctx, l) { + return collapsed_items(r, ctx, l, &collapsed, i, out); } let mut its = prepare_all(r, ctx, l, i, out, true); if !its.is_empty() { @@ -483,20 +537,13 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, /// 3D space (world = precomp layer's world × nested world), depth-sorted with the run, so they /// intersect the parent's 3D layers; nested 2D layers lie on the precomp layer's plane in stack /// order. (Nested adjustment layers are skipped here.) -fn collapsed_items<'a>( - r: &Renderer<'a>, - ctx: &EvalCtx<'a>, - layer: &'a Layer, - item: effectcraft_project::ItemId, - order: usize, - out: (u32, u32), -) -> Vec> { +fn collapsed_items<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, order: usize, out: (u32, u32)) -> Vec> { let op = ctx.opacity(layer) as f32 * r.opacity_mul(); - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return vec![] }; let t = nctx.time; - let any_solo = nctx.comp.any_solo(); - let nested: Vec<&'a Layer> = nctx - .comp + let Some(nc) = r.project.comp(collapsed.item) else { return vec![] }; + let any_solo = nc.any_solo(); + let nested: Vec<&'a Layer> = nc .layers .iter() .rev() @@ -863,7 +910,7 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh if nv[2] > 0.0 { nv = nv.map(|v| -v); } - let (sw, sh) = crate::source_size(r.project, it.layer); + let (sw, sh) = ctx.source_size(it.layer); let size = if sw == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [sw as f64, sh as f64] }; (number(it.layer), source_id(it.layer), crypto_hash(&it.layer.name), crypto_hash(&source_name(it.layer)), nv, size) }) diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs index 5e20b4635..026929623 100644 --- a/crates/render/src/three_d/tests.rs +++ b/crates/render/src/three_d/tests.rs @@ -13,10 +13,15 @@ const W: u32 = 200; const H: u32 = 100; fn setup() -> (Project, ItemId, Comp) { + setup_sized(W, H) +} + +/// [`setup`] with a `w`×`h` comp. +fn setup_sized(w: u32, h: u32) -> (Project, ItemId, Comp) { let mut p = Project::default(); // Exact float maths; 8/16 bpc quantisation has its own tests (tests_color). p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; - let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); (p, cid, comp) } @@ -374,7 +379,7 @@ fn shadow_diffusion_is_a_soft_ramp_not_copies() { // ---------------------------------------------------------------- cameras fn camera(p: &mut Project, comp: &Comp) -> Layer { - build::layer(p, comp, "Camera", LayerSource::Camera, (W, H), None) + build::layer(p, comp, "Camera", LayerSource::Camera, (comp.width, comp.height), None) } #[test] @@ -450,9 +455,155 @@ fn depth_of_field_blurs_out_of_focus_layers() { assert!(b.get(66, 50)[3] > 0.01); } +/// A non-finite Focus Distance doesn't reach the blur: the camera focuses on the property's +/// default plane (`zoom`) instead. +#[test] +fn dof_falls_back_when_focus_distance_is_not_finite() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(bad)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let cp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO); + let cs = crate::three_d::camera::layer_camera(&ctx, &cp.layers[0]); + let f = cs.dof.unwrap().focus; + assert!((f - zoom()).abs() < 1e-9, "{bad}: evaluated to {f}, expected the fallback to zoom {}", zoom()); + // The z = 0 plane is in focus: hard edge, finite pixels. + let img = render(&p, cid); + assert!(img.get(71, 50)[3] > 0.99 && img.get(69, 50)[3] < 0.01, "{bad}: the edge should be hard"); + assert!(img.data.iter().all(|px| px.iter().all(|c| c.is_finite())), "{bad}: non-finite pixels"); + } +} + +/// The Classic 3D blur stays bounded for degenerate inputs: a 0%-scaled 3D layer off the focal +/// plane (e.g. the first frame of a scale-in) or a huge but finite Focus Distance asks for a +/// radius of billions of buffer pixels, whose padded buffer can't be allocated. +#[test] +fn dof_blur_radius_is_bounded_for_degenerate_inputs() { + let scene = |focus: f64, scale: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + render(&p, cid) + }; + let finite = |img: &effectcraft_raster::Image| img.data.iter().all(|px| px.iter().all(|c| c.is_finite())); + // A 0%-scaled layer 10 px off the focal plane: an ordinary defocus times 1/sx (sx = 1e-9). + assert!(finite(&scene(zoom() + 10.0, 0.0)), "zero-scaled: non-finite pixels"); + // Huge but finite Focus Distances (typed in, or a large expression result): the blur is as + // large as the buffer budget allows, never refused (which would render the layer sharp). + // 1e307 overflows the circle of confusion itself to infinity: still the largest blur. + for focus in [1.0e12, 1.0e307] { + let img = scene(focus, 100.0); + assert!(finite(&img), "focus {focus:e}: non-finite pixels"); + assert!(img.get(100, 50)[3] < 0.5, "focus {focus:e}: the layer should be spread out, not sharp ({})", img.get(100, 50)[3]); + } +} + +/// The blur radius map of a plane: a 0%-scaled plane is degenerate on screen and isn't blurred; +/// any other radius stays within the buffer budget's cap. +#[test] +fn dof_radii_skip_degenerate_planes_and_respect_the_cap() { + let radii = |focus: f64, scale: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let cp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO); + let cs = super::camera::layer_camera(&ctx, &cp.layers[0]); + let dof = cs.dof.unwrap(); + let cap = super::compose::dof_radius_cap(&dof, 60, 60); + (super::compose::dof_radii_at(&cs, &dof, ctx.world_matrix(&cp.layers[1]), (60, 60), [0.0; 2], 1.0, cap), cap) + }; + let (r, _) = radii(zoom() + 10.0, 0.0); + assert!(r.iter().all(|&r| r == 0.0), "a 0%-scaled plane is not blurred"); + let (r, cap) = radii(1.0e12, 100.0); + assert!(cap > 1000.0, "a 60×60 buffer may blur by well over 1000 px: {cap}"); + assert!(r.iter().all(|&r| r as f64 <= cap && r as f64 > cap - 1.0), "huge focus: every radius at the cap {cap}"); +} + +/// Two layers that look the same on screen (400×100 at 100%, 1600×400 at 25%) at the same depth +/// blur by the same amount on screen: the radius bound must not depend on the layer's scale. +#[test] +fn dof_blur_is_independent_of_layer_scale() { + let ramp = |w: u32, h: u32, scale: f64| { + let (mut p, cid, comp) = setup_sized(800, 400); + let zoom = 800.0 * 50.0 / 36.0; + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0)); + // coc = aperture·|z − f| / z: a 40 px comp radius on the z = 0 plane (depth = zoom). + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom + 2.0 * 40.0 * zoom / 200.0)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], w, h, [400.0, 200.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let img = render(&p, cid); + soft_count(&img, (0..400).map(|x| (x, 200))) + }; + let (full, quarter) = (ramp(400, 100, 100.0), ramp(1600, 400, 25.0)); + assert!(full > 60, "the out-of-focus edge should be soft: {full}"); + assert!(full.abs_diff(quarter) <= 2, "same on-screen layer, same depth: {full} px at 100% vs {quarter} px at 25%"); +} + +/// The same frame at Full and Half resolution shows the same blur (in comp pixels). The radius +/// (~100 px) is large so that any bound that depends on the resolution would show. +#[test] +fn dof_blur_matches_across_render_resolutions() { + let (mut p, cid, comp) = setup_sized(800, 400); + let zoom = 800.0 * 50.0 / 36.0; + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(2.0 * zoom)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 100, [400.0, 200.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let ramp = |scale: f64| { + let img = render_frame(&p, cid, Tick::ZERO, scale); + let (w, h) = (img.width as i64, img.height as i64); + soft_count(&img, (0..w / 2).map(|x| (x, h / 2))) as f64 / scale + }; + let (full, half) = (ramp(1.0), ramp(0.5)); + assert!(full > 150.0, "a ~100 px radius must not be clamped: {full} comp px"); + assert!((full - half).abs() <= 4.0, "Full {full} vs Half {half} comp px"); +} + +/// `Dof::coc` treats a non-finite focus or depth as in focus. +#[test] +fn dof_coc_is_zero_for_non_finite_inputs() { + let d = |focus: f64| super::camera::Dof { focus, aperture: 50.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() }; + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(d(bad).coc(500.0), 0.0, "focus {bad}"); + assert_eq!(d(400.0).coc(bad), 0.0, "depth {bad}"); + } + assert!(d(400.0).coc(500.0) > 0.0); +} + +/// Pixels among `pts` whose alpha is strictly between 0.05 and 0.95. +fn soft_count(img: &effectcraft_raster::Image, pts: impl Iterator) -> usize { + pts.filter(|&(x, y)| (0.05..0.95).contains(&img.get(x, y)[3])).count() +} + /// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95. fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize { - (0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count() + soft_count(img, (0..img.height as i64).map(|y| (x, y))) } #[test] diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index b64aaab37..3dd717aa8 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -90,6 +90,44 @@ pub fn stroke_dash(name: &str) -> Option<&'static str> { const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")]; +/// Animator property ids and their stable After Effects scripting names. Shared by lookup, +/// `.matchName`, and `addProperty` so the same property has one name in every direction. +pub const TEXT_ANIMATOR_PROPERTIES: &[(&str, &str)] = &[ + ("anchor", "ADBE Text Anchor Point 3D"), + ("position", "ADBE Text Position 3D"), + ("scale", "ADBE Text Scale 3D"), + ("skew", "ADBE Text Skew"), + ("rotation", "ADBE Text Rotation"), + ("opacity", "ADBE Text Opacity"), + ("fillColor", "ADBE Text Fill Color"), + ("fillHue", "ADBE Text Fill Hue"), + ("fillSaturation", "ADBE Text Fill Saturation"), + ("fillBrightness", "ADBE Text Fill Brightness"), + ("fillOpacity", "ADBE Text Fill Opacity"), + ("strokeColor", "ADBE Text Stroke Color"), + ("strokeHue", "ADBE Text Stroke Hue"), + ("strokeSaturation", "ADBE Text Stroke Saturation"), + ("strokeBrightness", "ADBE Text Stroke Brightness"), + ("strokeOpacity", "ADBE Text Stroke Opacity"), + ("strokeWidth", "ADBE Text Stroke Width"), + ("tracking", "ADBE Text Tracking Amount"), + ("lineAnchor", "ADBE Text Line Anchor"), + ("lineSpacing", "ADBE Text Line Spacing"), + ("characterOffset", "ADBE Text Character Offset"), + ("characterValue", "ADBE Text Character Replace"), + ("blur", "ADBE Text Blur"), +]; + +/// Companion properties that exist below an animator but are not kinds `addProperty` accepts: +/// they come with Tracking (Track Type), Skew (Skew Axis) and per-character 3D Rotation +/// (X and Y Rotation). Lookup and `.matchName` know them; `addProperty` does not. +pub const TEXT_ANIMATOR_COMPANIONS: &[(&str, &str)] = &[ + ("trackingType", "ADBE Text Track Type"), + ("skewAxis", "ADBE Text Skew Axis"), + ("rotationX", "ADBE Text Rotation X"), + ("rotationY", "ADBE Text Rotation Y"), +]; + const TEXT_SELECTORS: &[(&str, &str)] = &[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")]; @@ -487,6 +525,9 @@ pub fn node(chain: &[&Node]) -> String { "text" if pid == "animators" => "ADBE Text Animator".into(), "text" if pid == "selectors" => find(TEXT_SELECTORS, id).map(str::to_string).unwrap_or_else(own), "text" if pid == "rangeSelector" => find(TEXT_RANGE_SELECTOR, id).map(str::to_string).unwrap_or_else(own), + "text" if pid == "properties" && chain.len() == 5 && chain.get(1).is_some_and(|n| n.match_id() == "animators") => { + find(TEXT_ANIMATOR_PROPERTIES, id).or_else(|| find(TEXT_ANIMATOR_COMPANIONS, id)).map(str::to_string).unwrap_or_else(own) + } "text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own), "cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own), "lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own), @@ -537,6 +578,30 @@ mod tests { assert_eq!(shape_kind("nope"), None); } + #[test] + fn text_animator_property_names_are_canonical_and_scoped() { + let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); + let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties")); + for (id, name) in TEXT_ANIMATOR_PROPERTIES { + let prop = n(id); + assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name); + assert_eq!(node(&[&properties, &prop]), *id, "an unrelated Properties group keeps its own ids"); + } + } + + /// #1063: the companions an animator property brings (Track Type, Skew Axis, X / Y Rotation) + /// have their After Effects names too, though `addProperty` does not accept them. + #[test] + fn text_animator_companions_have_after_effects_names() { + let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); + let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties")); + for (id, name) in TEXT_ANIMATOR_COMPANIONS { + let prop = n(id); + assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name); + assert!(!TEXT_ANIMATOR_PROPERTIES.iter().any(|(k, m)| k == id || m == name), "{id} is not an addProperty kind"); + } + } + #[test] fn text_range_selector_names() { let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 23356c064..258a6de2d 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -743,36 +743,12 @@ fn resolve_add(s: &Session, a: &J) -> R { /// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match /// name or a display name. fn text_animator_property(name: &str) -> Option<&'static str> { - const AE: &[(&str, &str)] = &[ - ("ADBE Text Anchor Point 3D", "anchor"), - ("ADBE Text Position 3D", "position"), - ("ADBE Text Scale 3D", "scale"), - ("ADBE Text Skew", "skew"), - ("ADBE Text Rotation", "rotation"), - ("ADBE Text Opacity", "opacity"), - ("ADBE Text Fill Color", "fillColor"), - ("ADBE Text Fill Hue", "fillHue"), - ("ADBE Text Fill Saturation", "fillSaturation"), - ("ADBE Text Fill Brightness", "fillBrightness"), - ("ADBE Text Fill Opacity", "fillOpacity"), - ("ADBE Text Stroke Color", "strokeColor"), - ("ADBE Text Stroke Hue", "strokeHue"), - ("ADBE Text Stroke Saturation", "strokeSaturation"), - ("ADBE Text Stroke Brightness", "strokeBrightness"), - ("ADBE Text Stroke Opacity", "strokeOpacity"), - ("ADBE Text Stroke Width", "strokeWidth"), - ("ADBE Text Tracking Amount", "tracking"), - ("ADBE Text Line Anchor", "lineAnchor"), - ("ADBE Text Line Spacing", "lineSpacing"), - ("ADBE Text Character Offset", "characterOffset"), - ("ADBE Text Character Replace", "characterValue"), - ("ADBE Text Blur", "blur"), - ]; - if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { + let table = matchnames::TEXT_ANIMATOR_PROPERTIES; + if let Some((k, _)) = table.iter().find(|(_, m)| m.eq_ignore_ascii_case(name)) { return Some(k); } let n = loose(name); - AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) + table.iter().map(|(k, _)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) } /// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f012ce128..a33714314 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -734,9 +734,8 @@ PropertyGroup.prototype.addProperty = function (name) { } else if (r.kind === "animator") { uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator; } else if (r.kind === "animatorProperty") { - var before = this.numProperties; - __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); - return this.property(before + 1); + var added = __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); + return this.property(added.index); } else if (r.kind === "selector") { var n0 = this.numProperties; __call("layer.addTextSelector", { comp: L.__comp, layer: L.__id, animator: r.animator, kind: r.selector }); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index a2f674d0a..875885c11 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1719,6 +1719,50 @@ fn layer_property_resolves_transform_and_source_text_shortcuts() { assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0)); } +/// A text animator property added by its After Effects name is the one asked for, not its Tracking +/// Type companion, and it can be found again by that name (#1063). +#[test] +fn scripted_text_animator_property_is_the_one_asked_for() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var a = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator"); + var props = a.property("ADBE Text Animator Properties"); + var added = props.addProperty("ADBE Text Tracking Amount"); + added.setValue(20); + [added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name, + added.matchName, props.property(added.matchName).propertyIndex] + "#, + ); + assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount", "ADBE Text Tracking Amount", 2])); +} + +/// The companion properties added with Tracking and Skew are found by their After Effects names, +/// but cannot be added on their own (#1063). +#[test] +fn text_animator_companion_properties_resolve_by_name() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var props = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator").property("ADBE Text Animator Properties"); + props.addProperty("ADBE Text Tracking Amount"); + props.addProperty("ADBE Text Skew"); + var type = props.property("ADBE Text Track Type"); + var axis = props.property("ADBE Text Skew Axis"); + var added = ""; + try { props.addProperty("ADBE Text Track Type"); added = "added"; } catch (e) { added = "refused"; } + [type ? type.matchName : null, axis ? axis.matchName : null, added] + "#, + ); + assert_eq!(o.result, json!(["ADBE Text Track Type", "ADBE Text Skew Axis", "refused"])); +} + /// #1936: an array whose length doesn't match the property's dimension is an error in After /// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal. #[test] diff --git a/crates/script/src/tests/ui_more.rs b/crates/script/src/tests/ui_more.rs index 228a58016..483d686ef 100644 --- a/crates/script/src/tests/ui_more.rs +++ b/crates/script/src/tests/ui_more.rs @@ -222,7 +222,16 @@ fn position_now(s: &Session, layer: u64) -> [f64; 2] { let cid = s.active_comp_id().unwrap(); let comp = s.project.comp(cid).unwrap(); let l = comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap(); - let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components(); [v[0], v[1]] } diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index a973335f4..a31a17124 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -707,14 +707,28 @@ const JAPANESE_FAMILIES: &[&str] = &[ ]; /// Han ideograph families for Simplified Chinese, in the order the platforms' own fallback tables /// prefer them (Windows, macOS, then the Linux/Noto names). Missing ones cost one name lookup each. -const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = - &["Microsoft YaHei", "SimSun", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]; +/// `Hiragino Sans GB`, `Heiti SC` and `Songti SC` are the Simplified faces a macOS install without +/// the on-demand PingFang has (macOS 27 on an English install ships them, not PingFang): without +/// them Chinese Han text fell through to the Japanese face and drew Japanese glyph forms (#453). +const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = &[ + "Microsoft YaHei", + "SimSun", + "PingFang SC", + "Hiragino Sans GB", + "Heiti SC", + "Songti SC", + "STSong", + "Noto Sans CJK SC", + "Noto Sans SC", + "Source Han Sans SC", + "WenQuanYi Micro Hei", +]; /// The same for Traditional Chinese. The two scripts share almost all of their code points but not /// the glyph forms (`东` against `東`'s neighbours, `直`, `骨`), so one list for both drew a /// Traditional interface with Simplified forms - the faces do not differ in *coverage*, which is /// what the fallback search checks, so the script has to pick the list. const TRADITIONAL_CHINESE_FAMILIES: &[&str] = - &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; + &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Songti TC", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"]; /// The platforms' plain text families for the other scripts the bundled fonts lack, in the same /// order (Windows, macOS, then the Linux/Noto names). diff --git a/crates/text/src/fonts/tests.rs b/crates/text/src/fonts/tests.rs index 079a001e0..9a6ba365a 100644 --- a/crates/text/src/fonts/tests.rs +++ b/crates/text/src/fonts/tests.rs @@ -1,5 +1,14 @@ use super::*; +/// Run `test` again in a child process with `variable` set. The fixture faces below go into the +/// process-global font database and its fallback caches, so only a process that runs one such test +/// on its own can use them. +#[cfg(not(target_arch = "wasm32"))] +fn run_in_child(variable: &str, test: &str) { + let output = std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact", test, "--nocapture"]).env(variable, "1").output().unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); +} + /// Concurrent language requests must neither choose another context's font nor reuse its /// cached family order. Synthetic fonts in a child process keep this independent of the host /// font installation and of other tests that exercise the process-global database/locale. @@ -8,12 +17,7 @@ use super::*; fn cjk_requests_keep_their_language_and_cache_entries() { const CHILD: &str = "EFFECTCRAFT_TEST_ISOLATED_CJK"; if std::env::var_os(CHILD).is_none() { - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "fonts::tests::cjk_requests_keep_their_language_and_cache_entries", "--nocapture"]) - .env(CHILD, "1") - .output() - .unwrap(); - assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + run_in_child(CHILD, "fonts::tests::cjk_requests_keep_their_language_and_cache_entries"); return; } let families = [("ja", "Yu Gothic"), ("zh-hans", "Microsoft YaHei"), ("zh-hant", "Microsoft JhengHei"), ("ko", "Malgun Gothic")]; @@ -71,6 +75,52 @@ fn cjk_requests_keep_their_language_and_cache_entries() { } } +/// #453: with a Chinese interface, a Han ideograph that both a Chinese and a Japanese face cover +/// must reach the installed Simplified Chinese family first, or Chinese text in a composition is +/// drawn with Japanese glyph forms. Fixture faces named after the macOS families keep this +/// independent of the host's fonts and of rendering. Before the fix the Simplified list named none +/// of the macOS faces a stock install has, so the walk below reached the Japanese face - the +/// regression this guards. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn zh_hans_han_fallback_puts_the_chinese_family_first() { + const CHILD: &str = "EFFECTCRAFT_TEST_ZH_HANS_ORDER"; + if std::env::var_os(CHILD).is_none() { + run_in_child(CHILD, "fonts::tests::zh_hans_han_fallback_puts_the_chinese_family_first"); + return; + } + let mut ids = Vec::new(); + { + let mut d = db().write().unwrap(); + d.scanned = true; // This test process uses only bundled and synthetic fonts. + // The Japanese face is added first, so scan order cannot be what picks the Chinese one. + for family in ["Hiragino Sans", "Hiragino Sans GB"] { + let mut data = test_font(family); + let table = data[12..12 + 8 * 16].as_chunks::<16>().0.iter().find(|t| &t[..4] == b"cmap").unwrap(); + let cmap = u32::from_be_bytes(table[8..12].try_into().unwrap()) as usize + 12; + // Map 页 to the fixture's triangle glyph instead of its private-use range. + for (offset, value) in [(16, '页' as u16), (24, '页' as u16), (30, 2u16.wrapping_sub('页' as u16))] { + data[cmap + offset..cmap + offset + 2].copy_from_slice(&value.to_be_bytes()); + } + let data = Arc::new(data); + let names = read_faces_bytes(&data).into_iter().next().unwrap(); + ids.push(push(&mut d, info_from(names, FaceData::Shared(data), "test"))); + } + } + let &[japanese, chinese] = ids.as_slice() else { panic!("two fixture faces expected: {ids:?}") }; + let base = resolve("Inter", "Regular").face; + assert!(face(japanese).has_char('页') && face(chinese).has_char('页')); + // The fallback list itself, before any coverage check: the Chinese family comes first for + // zh-hans and second for ja, whether or not either face can draw the probe. + let zh_hans: Vec = family_faces(FallbackScript::Han, &face(base), CjkLocale::Simplified).to_vec(); + assert_eq!(zh_hans, [chinese, japanese], "zh-hans tries the Chinese family first"); + let ja: Vec = family_faces(FallbackScript::Han, &face(base), CjkLocale::Japanese).to_vec(); + assert_eq!(ja, [japanese, chinese], "ja keeps the Japanese family first"); + // ...and the face a layout actually gets for the shared ideograph. + assert_eq!(face(fallback_for_locale('页', base, "zh-hans")).info.family, "Hiragino Sans GB"); + assert_eq!(face(fallback_for_locale('页', base, "ja")).info.family, "Hiragino Sans"); +} + #[test] fn extra_font_dirs_come_from_a_path_list() { assert!(extra_dirs(None).is_empty()); @@ -93,24 +143,47 @@ fn bundled_families_and_resolution() { assert!(!b.synth_bold); let faces = all_faces(); assert!(faces.iter().any(|f| f.info.family == "Noto Serif" && f.info.style == "Regular" && f.info.origin == "bundled")); - // A real installed Bold Italic takes priority. Partial installed families can instead - // supply the nearest weight/slant, so neither synthesis flag is fixed by the OS. + // A real installed Bold Italic — the face's own style, or a named instance of a variable + // face — takes priority. Partial installed families can instead supply the nearest + // weight/slant, so neither synthesis flag is fixed by the OS. for family in ["Noto Serif", "Inter"] { let resolved = resolve(family, "Bold Italic"); let selected = face(resolved.face); assert!(selected.info.family.eq_ignore_ascii_case(family)); assert!(!resolved.missing); let candidates: Vec<_> = faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(family)).collect(); - if let Some(exact) = candidates.iter().find(|f| f.info.style.eq_ignore_ascii_case("Bold Italic")) { + let exact = candidates + .iter() + .find(|f| f.info.style.eq_ignore_ascii_case("Bold Italic")) + .or_else(|| candidates.iter().find(|f| f.info.instances.iter().any(|i| i.style.eq_ignore_ascii_case("Bold Italic")))); + if let Some(exact) = exact { assert_eq!(resolved.face, exact.id, "real style wins for {family}"); + let expected_instance = if exact.info.style.eq_ignore_ascii_case("Bold Italic") { + None + } else { + exact.info.instances.iter().position(|i| i.style.eq_ignore_ascii_case("Bold Italic")).and_then(|i| u16::try_from(i).ok()) + }; + assert_eq!(resolved.instance, expected_instance, "exact style instance for {family}"); assert!(!resolved.synth_bold && !resolved.synth_italic); } else { - // Italic mismatch dominates any supported font weight distance. - let distance = |f: &Face| u32::from(f.info.weight.abs_diff(700)) + if f.info.italic { 0 } else { 1_000 }; - let nearest = candidates.iter().map(|f| distance(f)).min().unwrap(); - assert_eq!(distance(&selected), nearest, "closest available style for {family}"); - assert_eq!(resolved.synth_bold, selected.info.weight <= 500); - assert_eq!(resolved.synth_italic, !selected.info.italic); + // Italic mismatch dominates any supported font weight distance. Named instances + // resolve at their effective weight and slant, not the face's defaults. + let selected_instance = resolved.instance.and_then(|i| selected.info.instances.get(usize::from(i))); + let selected_weight = selected_instance.map_or(selected.info.weight, |i| i.weight(selected.info.weight)); + let selected_italic = selected_instance.map_or(selected.info.italic, |i| i.italic(selected.info.italic)); + let distance = |weight: u16, italic: bool| u32::from(weight.abs_diff(700)) + if italic { 0 } else { 1_000 }; + let nearest = candidates + .iter() + .flat_map(|f| { + std::iter::once((f.info.weight, f.info.italic)) + .chain(f.info.instances.iter().map(move |i| (i.weight(f.info.weight), i.italic(f.info.italic)))) + }) + .map(|(weight, italic)| distance(weight, italic)) + .min() + .unwrap(); + assert_eq!(distance(selected_weight, selected_italic), nearest, "closest available style for {family}"); + assert_eq!(resolved.synth_bold, selected_weight <= 500); + assert_eq!(resolved.synth_italic, !selected_italic); } } } @@ -164,6 +237,7 @@ fn variable_named_instances_resolve_and_list() { ("Regular", 400.0, Some("EffectCraftInstanceTest-Regular")), ("Black", 900.0, Some("EffectCraftInstanceTest-Black")), ("Extra Bold", 800.0, None), + ("Bold Italic", 700.0, Some("EffectCraftInstanceTest-BoldItalic")), ]; assert_eq!(add_font_data(crate::sfnt::test_font::variable(FAMILY, &instances, true)).len(), 1); let wght = |r: &Resolved| instance_variations(r); @@ -185,10 +259,15 @@ fn variable_named_instances_resolve_and_list() { assert_eq!(wght(&ps), vec![("wght".to_string(), 900.0)]); assert_eq!(wght(&resolve("EffectCraftInstanceTest-ExtraBold", "")), vec![("wght".to_string(), 800.0)]); assert_eq!(wght(&resolve("effectcraft instance test extra bold", "")), vec![("wght".to_string(), 800.0)]); + // A named instance keeps its own index, so layout applies its axis values. + let bold_italic = resolve(FAMILY, "Bold Italic"); + assert!(!bold_italic.missing && !bold_italic.synth_bold && !bold_italic.synth_italic); + assert_eq!((bold_italic.face, bold_italic.instance), (black.face, Some(4))); + assert_eq!(wght(&bold_italic), vec![("wght".to_string(), 700.0)]); // The font menu lists every instance, in weight order. let fams = families(); let styles = &fams.iter().find(|(f, _)| f == FAMILY).unwrap().1; - assert_eq!(styles, &["Light", "Regular", "Extra Bold", "Black"]); + assert_eq!(styles, &["Light", "Regular", "Extra Bold", "Black", "Bold Italic"]); // Static fonts are untouched. assert_eq!(resolve("Inter", "Bold").instance, None); } diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 0ad6c3706..8710f1f9a 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -94,9 +94,11 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: )); } app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label()); + // The drawn tooltip asks the catalog; the automation label above keeps the English name. + let label = crate::i18n::tr(cur.label()); let tip = match cur.shortcut() { - Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)), - None => cur.label().to_string(), + Some(s) => format!("{} ({})", label, crate::menus::shortcut_text(s)), + None => label.to_string(), }; let resp = resp.on_hover_text(tip); let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); @@ -145,7 +147,7 @@ fn slot_flyout(app: &mut EffectcraftApp, ui: &mut egui::Ui, si: usize, slot: &[T let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; egui::Popup::menu(resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| { for tool in slot.iter() { - let row = ui.selectable_label(app.ui.tool == *tool, tool.label()); + let row = ui.selectable_label(app.ui.tool == *tool, crate::i18n::tr(tool.label())); app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label()); if row.clicked() { app.ui.tool = *tool; diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 52c600711..0e767f5ac 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -143,6 +143,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.3d.fog3d", "Fog 3D", "雾 3D"), ("ec.3d.identifier", "IDentifier", "IDentifier"), ("ec.3d.idmatte", "ID Matte", "ID 遮罩"), + ("ec.3d.instance3d", "3D Layer Instance", "3D 图层实例"), ("ec.audio.backwards", "Backwards", "倒放"), ("ec.audio.basstreble", "Bass & Treble", "低音和高音"), ("ec.audio.compressor", "Compressor", "压缩程序"), @@ -802,6 +803,7 @@ const GEORGIAN: &[Row] = &[ ("ec.3d.fog3d", "Fog 3D", "3D ნისლი"), ("ec.3d.identifier", "IDentifier", "IDentifier"), ("ec.3d.idmatte", "ID Matte", "ID მატი"), + ("ec.3d.instance3d", "3D Layer Instance", "3D Layer Instance"), ("ec.audio.backwards", "Backwards", "უკუღმა"), ("ec.audio.basstreble", "Bass & Treble", "ბასი და ტრებლი"), ("ec.audio.compressor", "Compressor", "კომპრესორი"), diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 804ed8545..061090871 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1643,6 +1643,49 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "总偏移"), ("Per Layer", "每个图层"), ("Track", "跟踪"), + ("Selection Tool", "选择工具"), + ("Hand Tool", "抓手工具"), + ("Zoom Tool", "缩放工具"), + ("Orbit Around Cursor Tool", "绕行摄像机工具"), + ("Pan Under Cursor Tool", "平移摄像机工具"), + ("Dolly Towards Cursor Tool", "推拉摄像机工具"), + ("Rotation Tool", "旋转工具"), + ("Pan Behind (Anchor Point) Tool", "向后平移工具"), + ("Rectangle Tool", "矩形工具"), + ("Rounded Rectangle Tool", "圆角矩形工具"), + ("Ellipse Tool", "椭圆工具"), + ("Polygon Tool", "多边形工具"), + ("Star Tool", "星形工具"), + ("Pen Tool", "钢笔工具"), + ("Add Vertex Tool", "添加顶点工具"), + ("Delete Vertex Tool", "删除顶点工具"), + ("Convert Vertex Tool", "转换顶点工具"), + ("Mask Feather Tool", "蒙版羽化工具"), + ("Horizontal Type Tool", "横排文字工具"), + ("Vertical Type Tool", "直排文字工具"), + ("Brush Tool", "画笔工具"), + ("Clone Stamp Tool", "仿制图章工具"), + ("Eraser Tool", "橡皮擦工具"), + ("Roto Brush Tool", "Roto 笔刷工具"), + ("Refine Edge Tool", "调整边缘工具"), + ("Object Matte Tool", "对象遮罩工具"), + ("Puppet Position Pin Tool", "人偶位置控点工具"), + ("Puppet Starch Pin Tool", "人偶扑粉控点工具"), + ("Puppet Bend Pin Tool", "人偶弯曲控点工具"), + ("Puppet Advanced Pin Tool", "人偶高级控点工具"), + ("Puppet Overlap Pin Tool", "人偶重叠控点工具"), + ("Shortcut", "快捷键"), + ("Range", "范围"), + ("Play From", "从以下位置播放"), + ("Frame Rate", "帧速率"), + ("Resolution", "分辨率"), + ("Overlays", "叠加"), + ("Layer Controls", "图层控制"), + ("Loop", "循环"), + ("Cache Before Playback", "播放前缓存"), + ("Pre-roll", "预卷"), + ("Post-roll", "后卷"), + ("If caching, play cached frames", "缓存时播放已缓存帧"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -2330,6 +2373,49 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "總偏移"), ("Per Layer", "每個圖層"), ("Track", "追蹤"), + ("Selection Tool", "選取工具"), + ("Hand Tool", "手形工具"), + ("Zoom Tool", "縮放工具"), + ("Orbit Around Cursor Tool", "繞行攝影機工具"), + ("Pan Under Cursor Tool", "平移攝影機工具"), + ("Dolly Towards Cursor Tool", "推拉攝影機工具"), + ("Rotation Tool", "旋轉工具"), + ("Pan Behind (Anchor Point) Tool", "向後平移工具"), + ("Rectangle Tool", "矩形工具"), + ("Rounded Rectangle Tool", "圓角矩形工具"), + ("Ellipse Tool", "橢圓工具"), + ("Polygon Tool", "多邊形工具"), + ("Star Tool", "星形工具"), + ("Pen Tool", "筆型工具"), + ("Add Vertex Tool", "添加頂點工具"), + ("Delete Vertex Tool", "刪除頂點工具"), + ("Convert Vertex Tool", "轉換頂點工具"), + ("Mask Feather Tool", "遮色片羽化工具"), + ("Horizontal Type Tool", "橫排文字工具"), + ("Vertical Type Tool", "垂直文字工具"), + ("Brush Tool", "筆刷工具"), + ("Clone Stamp Tool", "仿製圖章工具"), + ("Eraser Tool", "橡皮擦工具"), + ("Roto Brush Tool", "Roto 筆刷工具"), + ("Refine Edge Tool", "調整邊緣工具"), + ("Object Matte Tool", "物件遮色片工具"), + ("Puppet Position Pin Tool", "人偶位置控點工具"), + ("Puppet Starch Pin Tool", "人偶撲粉控點工具"), + ("Puppet Bend Pin Tool", "人偶彎曲控點工具"), + ("Puppet Advanced Pin Tool", "人偶高級控點工具"), + ("Puppet Overlap Pin Tool", "人偶重疊控點工具"), + ("Shortcut", "快速鍵"), + ("Range", "範圍"), + ("Play From", "從下列位置播放"), + ("Frame Rate", "影格速率"), + ("Resolution", "解析度"), + ("Overlays", "覆疊"), + ("Layer Controls", "圖層控制"), + ("Loop", "循環"), + ("Cache Before Playback", "播放前快取"), + ("Pre-roll", "預卷"), + ("Post-roll", "後卷"), + ("If caching, play cached frames", "快取時播放已快取影格"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -5745,6 +5831,7 @@ mod tests { ("menus/frontend.rs", include_str!("../menus/frontend.rs")), ("menus/invoke.rs", include_str!("../menus/invoke.rs")), ("menus/shortcuts.rs", include_str!("../menus/shortcuts.rs")), + ("state.rs", include_str!("../state.rs")), ("prefs_live.rs", include_str!("../prefs_live.rs")), ("widgets.rs", include_str!("../widgets.rs")), ("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")), @@ -5822,6 +5909,55 @@ mod tests { rows.iter().find(|(source, _)| *source == en).map_or(en, |(_, translated)| *translated) } + #[test] + fn the_toolbar_asks_the_catalog_for_the_tool_names() { + let header = include_str!("../header.rs"); + for call in ["crate::i18n::tr(tool.label())", "crate::i18n::tr(cur.label())"] { + assert!(header.contains(call), "header.rs no longer draws {call}"); + } + assert!( + header.contains("app.auto.add(&format!(\"tools.select.{tool:?}\"), row.rect, tool.label())"), + "the automation label must stay the English tool name" + ); + } + + #[test] + fn the_preview_panel_labels_are_translated() { + // The Preview panel draws these from a local closure, a check table and a roll tuple, none of + // which the call-shape scan sees; they had no rows at all. + for key in [ + "Shortcut", + "Range", + "Play From", + "Frame Rate", + "Resolution", + "Overlays", + "Layer Controls", + "Loop", + "Cache Before Playback", + "Pre-roll", + "Post-roll", + "If caching, play cached frames", + ] { + for (name, table) in [("Simplified", SIMPLIFIED_CHINESE), ("Traditional", TRADITIONAL_CHINESE)] { + let value = table.iter().find(|(k, _)| *k == key).map(|(_, v)| *v); + assert!(value.is_some_and(|v| !v.is_empty()), "{key} has no {name} row"); + } + } + // A row is not a translation until the panel asks for it: the labels drawn one by one carry + // `tr(..)`, and the table and the roll tuple wrap the text they read at the call. + let source = include_str!("../panels/misc.rs"); + for literal in ["Shortcut", "Range", "Play From", "Frame Rate", "Resolution", "Full Screen", "If caching, play cached frames"] { + assert!(source.contains(&format!("tr(\"{literal}\")")), "{literal} is drawn without asking the catalog"); + } + for call in ["check_row(app, ui, &p, pos2(cx, cy), key, tr(text), on)", "Align2::LEFT_CENTER, tr(text),"] { + assert!(source.contains(call), "{call} no longer asks the catalog"); + } + for table in ["\"Overlays\"", "\"Layer Controls\"", "\"Loop\"", "\"Cache Before Playback\"", "\"Pre-roll\"", "\"Post-roll\""] { + assert!(source.contains(table), "{table} is not in the panel"); + } + } + /// A row that translates nothing, repeats a source string, or changes the number of values the /// English source carries is a bug in the catalog itself. #[test] diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 8fee44a72..d7b5d56ca 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -119,7 +119,8 @@ pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool { } } if e.command == "edit.clear" && app.ui.focused == PanelKind::Project { - return app.session.is_enabled("project.delete"); + // An open text edit stands down: Clear must not delete the comp being typed into (#2227). + return app.session.state.text_edit.is_none() && app.session.is_enabled("project.delete"); } if e.command == "edit.clear" && app.ui.focused == PanelKind::RenderQueue { return !app.session.is_rendering() && !app.session.project.render_queue.is_empty(); diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index b266623f3..23065067f 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -151,7 +151,16 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI let s = &app.session; let (Some(cid), Some(comp)) = (s.active_comp_id(), s.active_comp()) else { return d }; let Some(l) = comp.layer(layer) else { return d }; - let ectx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None }; + let ectx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let (l2c, _) = crate::panels::viewer::l2c(&ectx, l); l2c.inverse().map_or(d, |inv| { let v = inv.apply_vec(vec2(d[0], d[1])); @@ -223,8 +232,13 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V return run_engine(app, ctx, "edit.pasteSvg", json!({"svg": svg})); } } - // Clear follows panel focus, including the Edit menu and keyboard shortcut. + // Clear follows panel focus, including the Edit menu and keyboard shortcut. An open text + // edit stands down: the user is working in a composition, and the Project-panel redirect + // would delete it without asking when nothing uses it (#2227). if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project { + if app.session.state.text_edit.is_some() { + return Ok(Value::Null); + } // Through `invoke`, so deleting items that compositions use still asks first (M3.16). return invoke(app, ctx, "project.delete", json!({})); } diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 39cdee76c..12f3e0057 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -166,7 +166,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: app.session.time(), + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim); p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator)); diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index 157288b94..6d742dc4e 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -111,6 +111,7 @@ pub const COMPLETIONS: &[&str] = &[ "ease", "easeIn", "easeOut", + "bezier", "clamp", "random", "gaussRandom", diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index e982d5bde..8e4f3502f 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -104,7 +104,16 @@ fn render_tex( match cached { Some(c) if c.key == key => Some(c), _ => { - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time, + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); r.cache = Some(&app.session.layer_cache); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index dfa9a42e0..65e188c90 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -61,7 +61,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut yy = y + 46.0; // Shortcut. - label(yy, "Shortcut"); + label(yy, tr("Shortcut")); let names: Vec = PreviewShortcut::ALL.iter().map(|s| s.label().to_string()).collect(); if let Some(i) = dropdown_row( app, @@ -91,14 +91,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let row = if i < 4 { i / 2 } else { i - 2 }; let cx = x0 + col as f32 * 110.0 - 2.0; let cy = yy + row as f32 * 22.0; - if check_row(app, ui, &p, pos2(cx, cy), key, text, on) { + if check_row(app, ui, &p, pos2(cx, cy), key, tr(text), on) { changes.push(json!({"shortcut": current.id(), key: !on})); } } yy += 4.0 * 22.0 + 6.0; // Range (and the pre/post-roll of Play Around Current Time). - label(yy, "Range"); + label(yy, tr("Range")); let ranges: Vec = PreviewRange::ALL.iter().map(|r| r.label().to_string()).collect(); let range_ids = ["workArea", "workAreaExtended", "entireDuration", "aroundCurrentTime"]; if let Some(i) = dropdown_row( @@ -116,7 +116,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if o.range == PreviewRange::AroundCurrentTime { for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() { let lx = xv + k as f32 * 100.0; - p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim); + p.text(pos2(lx, yy), Align2::LEFT_CENTER, tr(text), Tokens::ui(11.5), t.text_dim); let (r, nv, _) = widgets::hot_number_at( ui, pos2(lx + 52.0, yy - 9.0), @@ -134,7 +134,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Play From. - label(yy, "Play From"); + label(yy, tr("Play From")); let froms: Vec = PlayFrom::ALL.iter().map(|f| f.label().to_string()).collect(); if let Some(i) = dropdown_row( app, @@ -151,7 +151,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Frame Rate, Skip, Resolution. let comp_fps = app.session.active_comp().map(|c| c.frame_rate.as_f64()); - label(yy, "Frame Rate"); + label(yy, tr("Frame Rate")); const RATES: [f64; 12] = [8.0, 10.0, 12.0, 12.5, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0]; let auto = format!("({:.2}) Auto", comp_fps.unwrap_or(0.0)); let mut rates = vec![auto.clone()]; @@ -180,7 +180,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32})); } yy += 24.0; - label(yy, "Resolution"); + label(yy, tr("Resolution")); let ress: Vec = PreviewResolution::ALL.iter().map(|r| r.label().to_string()).collect(); let res_ids = ["auto", "full", "half", "third", "quarter", "custom"]; if let Some(i) = dropdown_row( @@ -209,7 +209,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } yy += 24.0; - if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", "Full Screen", o.full_screen) { + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", tr("Full Screen"), o.full_screen) { changes.push(json!({"shortcut": current.id(), "fullScreen": !o.full_screen})); } yy += 24.0; @@ -217,7 +217,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // What stopping does. p.text(pos2(x0, yy), Align2::LEFT_CENTER, tr_args("On ({}) stop:", &[&(current.label())]), Tokens::ui(11.5), t.text_dim); yy += 20.0; - if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", "If caching, play cached frames", o.play_cached_frames) { + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", tr("If caching, play cached frames"), o.play_cached_frames) { changes.push(json!({"shortcut": current.id(), "playCachedFrames": !o.play_cached_frames})); } yy += 22.0; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index b10604122..a7563a6ff 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -70,7 +70,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let project = app.session.project.clone(); let expr = app.session.expr.clone(); let now = app.session.time(); - let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &project, + comp_id: cid, + comp: &comp, + time: now, + expr: expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let x0 = rect.min.x + PAD; let w = rect.width() - 2.0 * PAD; let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect); diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index 0d9eefe45..ca3c56ac5 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -69,7 +69,16 @@ pub fn draw_overlay( let ov = match cached { Some(c) if c.key == key => Some(c), _ => { - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time, + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); r.cache = Some(&app.session.layer_cache); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 6a36fbbc9..2ded3b3e3 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -57,7 +57,16 @@ pub fn text_target(app: &EffectcraftApp) -> Option { .iter() .filter_map(|id| comp.layer(*id)) .find(|l| matches!(l.source, effectcraft_engine::project::LayerSource::Text))?; - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time: app.session.time(), + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None }) } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7ae50ac0d..80faef91a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1557,7 +1557,7 @@ fn replaceable_item(it: &effectcraft_engine::project::Item) -> bool { /// The Alt/Option-drag target: the layer under the pointer can take this item as its source, and /// the item wouldn't put the composition inside itself. -fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool { +pub(crate) fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool { app.session.project.item(item).is_some_and(replaceable_item) && !app.session.project.comp_contains(item, cid) && comp @@ -1865,7 +1865,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { pick_whip_edge_scroll(app, g); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: g.time, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: g.time, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut rw = begin_rows(app, ui, tl); let g = &mut rw; draw_rows(app, ui, g, &ectx); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 2e3dc9be0..c9386f154 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -178,7 +178,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { phase_frame(app, &mut v); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: v.time, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: v.time, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; phase_guides(app, &ectx, &mut v); phase_layers(app, ui, &ectx, &mut v); phase_scene(app, &ectx, &mut v); @@ -1812,7 +1821,7 @@ use geometry::{ pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad}; use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius}; use shapes::{create_shape, draw_shape_ghost, shape_drag}; -pub(crate) use shapes::{layer_at, layers_at, pick, selectable_layers}; +pub(crate) use shapes::{layer_at, layer_at_including_locked, layers_at, pick, selectable_layers}; #[cfg(test)] use tools::constrain_45; use tools::{add_motion_key, axis_at, axis_drag, drag_axes, draw_rigs, parent_world_inverse, pen_cursor, pen_cursor_icon, pen_press, select_motion_key}; diff --git a/crates/ui-egui/src/panels/viewer/shapes.rs b/crates/ui-egui/src/panels/viewer/shapes.rs index 395219a05..0cd579883 100644 --- a/crates/ui-egui/src/panels/viewer/shapes.rs +++ b/crates/ui-egui/src/panels/viewer/shapes.rs @@ -4,14 +4,17 @@ use super::{ pos2, }; -/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. +/// Visible layers eligible for viewer hit tests, locked or not, in front-to-back order. /// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After /// Effects), even when hidden, locked or outside its in-out range. -pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { +fn hit_testable_layers(comp: &Comp, time: Tick) -> impl Iterator { let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); - comp.layers - .iter() - .filter(move |l| l.is_active_at(time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) + comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) +} + +/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. +pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { + hit_testable_layers(comp, time).filter(|l| !l.switches.locked) } /// Selectable layers under a comp point, front to back. @@ -24,6 +27,18 @@ pub(crate) fn layer_at<'a>(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Laye layers_at(ectx, cpt).next() } +/// Topmost visible layer under a comp point, locked or not. An Alt-drop uses it so a locked layer in +/// front is refused rather than skipped, which would replace the layer behind it. +pub(crate) fn layer_at_including_locked<'a>(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Layer> { + // A layer scaled to nothing has a degenerate quad that `point_in_quad` accepts everywhere. + hit_testable_layers(ectx.comp, ectx.time).find(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| quad_area(&q) > 1e-9 && point_in_quad(cpt, &q))) +} + +/// Area of a quad (shoelace formula); zero when its corners coincide or are collinear. +fn quad_area(q: &[[f64; 2]; 4]) -> f64 { + (0..4).map(|i| q[i][0] * q[(i + 1) % 4][1] - q[(i + 1) % 4][0] * q[i][1]).sum::().abs() / 2.0 +} + /// The layer a press at a comp point acts on, selected (Shift toggles the topmost layer). As in /// After Effects, a selected layer under the point comes before the layers in front of it, so it /// can be dragged where they cover it; otherwise the topmost layer (#230). @@ -202,6 +217,8 @@ pub(super) fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: time: app.session.time(), expr: None, footage: None, + property_overrides: &[], + media_overrides: &[], }; let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY); let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0)); diff --git a/crates/ui-egui/src/panels/viewer/view.rs b/crates/ui-egui/src/panels/viewer/view.rs index 30b3f4d00..59e3e513d 100644 --- a/crates/ui-egui/src/panels/viewer/view.rs +++ b/crates/ui-egui/src/panels/viewer/view.rs @@ -44,7 +44,16 @@ pub(super) fn aux_views( // layout shows them), evaluated on a snapshot of the project. let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp, time: t, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp, + time: t, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let selected = app.session.state.selected_layers.clone(); let mut picked: Option<(usize, View3D)> = None; for (i, (r, v)) in aux.into_iter().enumerate() { diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 44364a49d..ac56d9acb 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -1,7 +1,8 @@ //! Dropping onto the Composition viewer: footage, comps and solids from the //! Project panel and files from the Media Browser become layers centred where they are dropped //! (#85), and an effect or animation preset from Effects & Presets goes on the layer under the -//! pointer, outlined while it is dragged (#88). Agents do the same with `layer.addItem {position}`, +//! pointer, outlined while it is dragged (#88). Alt/Option-dropping a Project item replaces the source of +//! the layer under the pointer instead, as in the Timeline (#2173). Agents do the same with `layer.addItem {position}`, //! `mediaBrowser.import {position}`, `effect.apply {layers}` and the preset commands. use effectcraft_engine::project::{ItemId, ItemKind}; @@ -10,7 +11,7 @@ use egui::{Rect, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; use super::DragPayload; -use super::viewer::{ViewerMap, layer_at, layer_quad}; +use super::viewer::{ViewerMap, layer_at, layer_at_including_locked, layer_quad}; use crate::EffectcraftApp; /// While something is dragged over the viewer, show where it goes; drop it on release. @@ -23,6 +24,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(map.area)) else { return }; let stroke = Stroke::new(2.0, app.tokens.accent); let at = map.to_comp(ptr); + // Alt/Option-drop of a Project item replaces the source of the layer under the pointer (After + // Effects), as in the Timeline; it never adds a layer. + let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), DragPayload::Item(_)); let actions: Vec<(&str, Value)> = match payload.as_ref() { DragPayload::Effect(effect) => layer_at(ectx, at) .map(|l| { @@ -42,6 +46,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain }) .into_iter() .collect(), + DragPayload::Item(item) if alt_item => alt_replace(app, ectx, painter, map, stroke, at, *item).into_iter().collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { @@ -55,7 +60,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain DragPayload::Property { .. } => vec![], }; // Layer drops outline the target layer; the others outline the whole viewer. - let layer_drop = matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); + let layer_drop = alt_item || matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); if !layer_drop && !actions.is_empty() { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } @@ -80,3 +85,21 @@ fn frame_size(kind: &ItemKind, comp_pixel_aspect: f64) -> Option<[f32; 2]> { let stretch = if comp_pixel_aspect > 0.0 && pixel_aspect > 0.0 { pixel_aspect / comp_pixel_aspect } else { 1.0 }; (w > 0 && h > 0).then_some([(w as f64 * stretch) as f32, h as f32]) } + +/// The `layer.replaceSource` for an Alt/Option-drop of Project item `item` at comp point `at`: the +/// topmost layer there, outlined, if it can take the item as its source (a locked one cannot). +fn alt_replace( + app: &EffectcraftApp, + ectx: &EvalCtx, + painter: &egui::Painter, + map: &ViewerMap, + stroke: Stroke, + at: [f64; 2], + item: u64, +) -> Option<(&'static str, Value)> { + let layer = layer_at_including_locked(ectx, at).filter(|l| super::timeline::replace_source_target(app, ectx.comp_id, ItemId(item), l.id, ectx.comp))?; + if let Some((_, q, _)) = layer_quad(ectx, layer) { + painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); + } + Some(("layer.replaceSource", json!({"layers": [layer.id.0], "item": item, "fixExpressions": false}))) +} diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index e405bf777..ec8db35cc 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -124,6 +124,44 @@ fn alt_dragging_a_project_item_replaces_the_layer_source() { assert_eq!(layer.name, "Top", "the layer keeps its name"); } +/// Alt/Option-dropping a Project item on the Composition viewer replaces the source of the layer +/// under the pointer, as in the Timeline, instead of adding a layer (#2173). +#[test] +fn alt_dropping_a_project_item_on_the_viewer_replaces_the_layer_source() { + use effectcraft_engine::project::{ItemId, LayerId, LayerSource}; + let (mut h, clip) = harness(); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap(); + // The three solids overlap completely, so the topmost one is under the pointer. + let (top, middle, bottom) = (layer_id(&h, "Top"), layer_id(&h, "Middle"), layer_id(&h, "Bottom")); + let source = |h: &Harness<'_, EffectcraftApp>, id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().source.clone(); + let (middle_before, bottom_before) = (source(&h, middle), source(&h, bottom)); + let before = stack(&h); + drag(&mut h, item, to, Modifiers::ALT); + assert_eq!(stack(&h), before, "no layer is added"); + assert_eq!(source(&h, top), LayerSource::Comp { item: ItemId(clip) }, "the layer under the pointer takes the new source"); + assert_eq!(source(&h, middle), middle_before, "the layer below it keeps its source"); + assert_eq!(source(&h, bottom), bottom_before, "the bottom layer keeps its source"); +} + +/// A locked layer under the pointer refuses the Alt-drop; the layer behind it is not replaced +/// instead (#2173). +#[test] +fn alt_dropping_on_a_locked_layer_in_the_viewer_replaces_nothing() { + use effectcraft_engine::project::LayerId; + let (mut h, clip) = harness(); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap(); + let top = layer_id(&h, "Top"); + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [top], "switch": "locked", "value": true})).unwrap(); + h.run_steps(3); + let sources = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().map(|l| l.source.clone()).collect::>(); + let before = sources(&h); + drag(&mut h, item, to, Modifiers::ALT); + assert_eq!(sources(&h), before, "no layer takes the new source"); + assert!(h.state().session.active_comp().unwrap().layer(LayerId(top)).unwrap().switches.locked); +} + /// Ctrl+Alt+/ (Cmd+Opt+/ on macOS) replaces every selected layer's source with the Project panel /// selection (#991). #[test] diff --git a/crates/ui-egui/tests/it/ui_project_delete.rs b/crates/ui-egui/tests/it/ui_project_delete.rs index f217c29db..2a395cf4b 100644 --- a/crates/ui-egui/tests/it/ui_project_delete.rs +++ b/crates/ui-egui/tests/it/ui_project_delete.rs @@ -364,3 +364,29 @@ fn a_cut_is_read_with_the_modifiers_where_it_is_in_the_frame() { assert_eq!(h.state().session.project.item(solid).is_none(), deletes, "case {n}: {events:?} after {before:?}"); } } + +/// An open text edit stands down the Project-panel Clear redirect: the composition being typed +/// into must not be deleted out from under it, with no confirmation when nothing uses it (#2227). +#[test] +fn clear_with_the_project_focused_stands_down_during_a_text_edit() { + let (mut h, _, _) = harness(); + let working = h.state().session.active_comp_id().unwrap(); + // the composition being typed into is also the Project panel's selection, as when it was opened + click(&mut h, &format!("project.item.{}.name", working.0), Modifiers::NONE); + let text = h.state_mut().session.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("text.edit", json!({"layer": text, "created": true})).unwrap(); + h.state_mut().ui.focused = PanelKind::Project; + h.run_steps(2); + assert!(h.state().session.state.project_selection.contains(&working), "the comp is the project selection"); + let project = h.state().session.project.clone(); + + // Edit > Clear (the menu, or the control channel) dispatches through `invoke` + let ctx = h.ctx.clone(); + let cleared = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.clear", json!({})); + h.run_steps(3); + + cleared.expect("Clear stands down during a text edit"); + assert!(h.state().session.project.item(working).is_some(), "the composition being typed into survives"); + assert_eq!(h.state().session.project, project, "nothing was deleted"); + assert!(h.state().session.state.text_edit.is_some(), "the text edit stays open"); +} diff --git a/docs/agents.md b/docs/agents.md index 8414346f7..172e5c4c6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -272,10 +272,10 @@ build real projects through these interfaces; they are worked examples of everyt then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. -* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole - numbered run as one item (several picked frames: that range; a folder: each run in it; - `"sequence": false`: one still; dropped files, `"drag": true`, import as stills unless - `"sequence": true`, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files +* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"], "sequence": true}` imports the + file's whole numbered run as one item (several picked frames: that range; a folder: each run in + it, also without `sequence`). Without `"sequence": true` a picked or dropped numbered file imports + as one still, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new diff --git a/docs/architecture.md b/docs/architecture.md index 4ffdd9dd7..4fad74486 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -401,7 +401,10 @@ hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion, its nested layers straight into the parent with concatenated transforms (one resample), so nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's -3D run. Masks, effects or styles force a flattened render. Text layers are always rasterised at +3D run. Essential Properties overrides (properties, Font and Media Replacement) travel with the +collapsed context and are applied while the nested layers are evaluated, so they do not force a +flattened render or clip at the precomp's frame. Masks, effects or styles force a flattened +render. Text layers are always rasterised at their on-screen scale, as text is vector in After Effects; on shape layers and vector footage the switch is Continuously Rasterize and does the same. Quality: Draft samples nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, @@ -468,8 +471,10 @@ effects crate, hence the `effects → track` edge. `essential` field lists its exposed controls (properties, Media Replacement, groups, comments). Every precomp layer of such a comp gets an Essential Properties group (`GroupKind::Essential`, children `eg`), kept in step by `Session::edit`; editing a child overrides it for -that instance. The renderer draws an instance with overrides from a copy of the project with the -overridden source properties set (`essential::with_overrides`). Templates are `.ectemplate` +that instance. A collapsed instance carries its property, Font and Media Replacement overrides +into the nested render context (`Collapsed`, `EvalCtx::{property_overrides, media_overrides}`); +other instances are drawn from a copy of the project with the overridden source properties set +(`essential::with_overrides`). Templates are `.ectemplate` ZIPs: `manifest.json` (controls), `project.ecproj` (the comp and its dependencies), `media/…` and `poster.png`; importing offsets every id past the project's (`essential::offset_ids`). **Responsive Design — Time**: a time-stretched precomp maps time piecewise so its protected diff --git a/docs/effects.md b/docs/effects.md index b8214e438..c6fb40922 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 307 effects with 3032 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 308 effects with 3033 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 280 effects also run on the GPU compositor with identical results. -- **32**: 307 effects process 32-bit float (HDR, overbright) pixels without clamping. -- **Status**: 307 implemented in full, 0 partial (what is missing is listed). +- **32**: 308 effects process 32-bit float (HDR, overbright) pixels without clamping. +- **Status**: 308 implemented in full, 0 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -16,6 +16,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | 3D Channel Extract | `ec.3d.channelextract` | 6 | GPU | 32 | Implemented | +| 3D Layer Instance | `ec.3d.instance3d` | 1 | | 32 | Implemented | | Cryptomatte | `ec.3d.cryptomatte` | 4 | GPU | 32 | Implemented | | Depth Matte | `ec.3d.depthmatte` | 3 | GPU | 32 | Implemented | | Depth of Field | `ec.3d.depthoffield` | 4 | GPU | 32 | Implemented | diff --git a/docs/footage.md b/docs/footage.md index bd99cbeb2..f23bcdcc7 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -129,9 +129,10 @@ including files overwritten at the same path and byte size. and 3D models are not sources, and a composition can't be nested in itself. Alt-dragging onto a layer is one layer at a time, as in After Effects. Agents: `layer.replaceSource {"item": id, "layers": [ids]}`. -- **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered - file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, - "sequence": false}` for one frame). +- **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. A still stays a + still, and a sequence item keeps bringing the new numbered file's run; pass + `sequence: true` to bring a numbered still's whole run (`file.replaceFootage {"path": …, + "sequence": true}`), or `sequence: false` for one frame. - **Replace with Precomposition (used N times)**, right-clicking a footage item or solid in the Project panel, puts the item in a new composition of its size, pixel aspect and frame rate, in the same folder, and switches every layer in every composition diff --git a/docs/gaps.md b/docs/gaps.md index 904c1d4e6..c851fe0ff 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -67,7 +67,7 @@ are grouped by area; the issue tracker has the rest. | 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | | 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; UI and GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | | 10 | **Colour pipeline differences** | #340 ProRes 4444 tagged bt709 but sRGB-encoded (open); ACES 2.0 / OCIO 2.5 configs (26.5) missing; now closed: #494 32-bpc blend modes, #495 and #750 Working Gamma 2.4, #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | -| 11 | **Text rendering and layout** | Open: #453 CJK text in comps, #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | +| 11 | **Text rendering and layout** | #453 CJK text in comps: the Han fallback now reaches the installed Chinese faces; open: #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | | 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (fb5a5e9a; #315 closed); #307 reopened (the viewer's renderer button now shows and switches the renderer, #645); Z position (#489) closed | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) |