Merge main into #1387; clean

This commit is contained in:
bflatastic
2026-10-30 05:46:52 +07:00
124 changed files with 4777 additions and 432 deletions
+1 -1
View File
@@ -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
Generated
+1
View File
@@ -1623,6 +1623,7 @@ name = "effectcraft-media"
version = "0.18.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
"effectcraft-model",
"effectcraft-pdf",
"effectcraft-project",
+11 -8
View File
@@ -20,7 +20,7 @@
<p align="center">
A free, open-source compositor: compositions, layers,
keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
keyframes, 308 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
Windows and Linux, and in the browser. Young, moving fast, and already usable.
</p>
@@ -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.
@@ -126,7 +127,7 @@ or into the Timeline, between layers and at the time you point to.
## Effects
306 built-in effects across its categories, each with its parameter names, order and
308 built-in effects across its categories, each with its parameter names, order and
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
@@ -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
@@ -228,7 +231,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
## Where it stands
EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set
exists, including 306 effects, but breadth is not the same as full maturity across every edge
exists, including 308 effects, but breadth is not the same as full maturity across every edge
case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended
for production client work. In particular:
+6
View File
@@ -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 {
+14
View File
@@ -182,6 +182,20 @@ mod tests {
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
}
/// The README quotes the number of effects (#2565): every count it gives is the registry's.
#[test]
fn readme_effect_counts_are_current() {
let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md")).unwrap_or_default();
let words: Vec<&str> = readme.split_whitespace().collect();
let counts: Vec<&str> = words
.windows(3)
.filter(|w| w[0].parse::<u32>().is_ok() && (w[1].starts_with("effects") || (w[1] == "built-in" && w[2].starts_with("effects"))))
.map(|w| w[0])
.collect();
let n = super::registry().len().to_string();
assert!(!counts.is_empty() && counts.iter().all(|c| *c == n), "README.md says {counts:?} effects; the registry has {n}");
}
#[test]
fn cpu_only_controls_keep_effects_off_the_gpu() {
use effectcraft_keyframe::Value;
+19
View File
@@ -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<EffectSpec> {
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<EffectSpec> {
],
identifier,
),
spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d),
]
}
+26
View File
@@ -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<LayerPixels> {
(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<Vec<f32>> {
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);
}
+53 -5
View File
@@ -312,17 +312,34 @@ impl Buf {
self.offset[0] += l as f64;
self.offset[1] += t as f64;
}
/// Grow by `pad` transparent pixels on every side.
/// 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) {
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;
}
}
/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within
/// 16 Mpx (256 MiB of f32 RGBA) or four times its own area, whichever is larger: a small layer
/// far from an effect's centre can't ask for gigabytes (#2261). Shared with the GPU path.
pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 {
const MIN_BUDGET: f64 = 16_777_216.0;
let (w, h) = (f64::from(w), f64::from(h));
let budget = MIN_BUDGET.max(4.0 * w * h);
if (w + 2.0 * f64::from(pad)) * (h + 2.0 * f64::from(pad)) <= budget {
return pad;
}
// (w + 2p)(h + 2p) = budget, solved for p.
let p = (((w - h).powi(2) + 4.0 * budget).sqrt() - (w + h)) / 4.0;
(p.floor().max(0.0) as u32).min(pad)
}
/// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`:
/// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its
/// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp
@@ -1344,6 +1361,23 @@ pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, en
mod tests {
use super::*;
/// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx;
/// big layers may still grow to four times their area, and small pads are untouched.
#[test]
fn padded_buffers_stay_within_the_budget() {
let area = |w: u32, h: u32, p: u32| (u64::from(w) + 2 * u64::from(p)) * (u64::from(h) + 2 * u64::from(p));
let far = radial_fast_pad((6500.0, 6500.0), 20, 20, 0.8);
assert_eq!(far, 4096, "the effect asks for its full pad");
let mut b = Buf { img: Image::new(20, 20), offset: [0.0; 2], scale: 1.0 };
b.pad(far);
assert!(area(20, 20, 0) < area(b.img.width, b.img.height, 0) && area(b.img.width, b.img.height, 0) <= 1 << 24, "{}x{}", b.img.width, b.img.height);
assert_eq!(b.offset, [f64::from((b.img.width - 20) / 2); 2], "the content keeps its place");
let p = pad_within_budget(3840, 2160, 4096);
assert!(p > 1000 && area(3840, 2160, p) <= 4 * 3840 * 2160 && area(3840, 2160, p + 1) > 4 * 3840 * 2160);
assert_eq!(pad_within_budget(640, 360, 200), 200);
assert_eq!(pad_within_budget(20, 20, 0), 0);
}
#[test]
fn run_fx_passthrough() {
let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]);
@@ -1351,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() {
+1 -1
View File
@@ -207,7 +207,7 @@ pub(crate) fn source_frame(
t: Tick,
expr: Option<&dyn ExprHost>,
) -> Option<(Arc<Image>, [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
+69 -3
View File
@@ -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<Value> {
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<Value> {
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<CommandSpec> {
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)]
}
+5
View File
@@ -146,6 +146,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>()))
}
+50 -5
View File
@@ -136,7 +136,16 @@ fn warning(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
.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<Hit> {
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());
+2
View File
@@ -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])"),
],
),
(
+28 -4
View File
@@ -878,10 +878,34 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
"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
),
+6
View File
@@ -78,6 +78,12 @@ fn set_to_layer(s: &mut Session, p: &Value) -> Result<Value> {
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"));
}
+2 -2
View File
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text",
["Layer", "New"],
Some("Cmd+Alt+Shift+T"),
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)",
has_comp,
new_text
),
@@ -192,7 +192,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Text",
[],
None,
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}",
has_layers,
set_text
),
+259 -58
View File
@@ -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<effectcraft_geom::Mat4>,
/// 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<Pose3d>,
}
/// 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<f64> {
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::<f64>();
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<effectcraft_keyframe::Keyframe>,
) {
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<effectcraft_keyframe::Keyframe> = 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<effectcraft_geom::Mat4> {
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<LayerId>) -> Vec<ParentFix> {
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<LayerId>| -> 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 });
}
+34 -5
View File
@@ -74,14 +74,39 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
"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]);
+10 -1
View File
@@ -185,7 +185,16 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
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()
+14 -6
View File
@@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
let (cid, ids) = layers_p(s, p)?;
let src: Vec<(LayerId, Vec<Marker>)> = {
@@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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())
}
}
+20 -3
View File
@@ -78,7 +78,16 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<P
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let pr = layer.props.find(uid).ok_or_else(|| bad(cmd, "no such property"))?;
let t = s.time();
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, 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,
time: t,
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let KV::Path(path) = ctx.value(layer, pr) else { return Err(bad(cmd, "that property is not a path")) };
if path.vertices.is_empty() {
return Err(bad(cmd, "the path has no vertices"));
@@ -147,8 +156,16 @@ fn pin_targets(s: &Session, p: &Value, cmd: &str) -> Result<Option<(ItemId, Vec<
};
let cid = pins.first().map(|x| x.0).ok_or_else(|| bad(cmd, "no puppet pins"))?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx =
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 ctx = 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 mut out = vec![];
for (_, lid, uid) in pins {
let Some(layer) = comp.layer(lid) else { continue };
+10 -2
View File
@@ -1285,8 +1285,16 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> 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;
}
+2 -1
View File
@@ -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);
+1 -1
View File
@@ -619,7 +619,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
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") {
+10 -1
View File
@@ -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: &[],
}
}
}
+5 -1
View File
@@ -142,6 +142,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result<Value> {
}
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<Value> {
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<Value> {
+4 -2
View File
@@ -413,7 +413,8 @@ fn get_3d(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> = comp
@@ -555,7 +556,8 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
/// through each layer's world matrix; cameras and lights contribute their position).
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
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);
@@ -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.
+1
View File
@@ -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;
+10 -1
View File
@@ -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];
+122
View File
@@ -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<effectcraft_keyframe::Value, String> {
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<std::sync::Arc<effectcraft_raster::Image>> {
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]
+31
View File
@@ -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:?}"),
}
}
+257
View File
@@ -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<Arc<effectcraft_raster::Image>> {
None
}
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
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<Footage, String> {
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));
}
+60
View File
@@ -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();
+183 -2
View File
@@ -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 {
+46 -2
View File
@@ -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();
+3
View File
@@ -476,6 +476,9 @@ fn set_text_paragraph_fill_and_leading() {
s.execute("layer.setText", json!({"layer": t, "hScale": 120, "vScale": 80, "baselineShift": 4, "smallCaps": true, "strokeOverFill": true})).unwrap();
let d = doc(&s);
assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true));
s.execute("layer.setText", json!({"layer": t, "lineJoin": "bevel"})).unwrap();
assert_eq!(doc(&s).line_join, effectcraft_keyframe::LineJoin::Bevel);
assert!(s.execute("layer.setText", json!({"layer": t, "lineJoin": "square"})).is_err());
}
#[test]
+64
View File
@@ -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() {
+26 -4
View File
@@ -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<String> = r["families"][0]["styles"].as_array().unwrap().iter().map(|style| style.as_str().unwrap().to_string()).collect();
assert_eq!(styles.iter().cloned().collect::<std::collections::BTreeSet<_>>(), 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::<std::collections::BTreeSet<_>>(), 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.
@@ -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() {
+1 -1
View File
@@ -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<gif::Frame<'static>> = 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;
+56 -29
View File
@@ -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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<usize>) -> (Vec<u64>, usize) {
use std::sync::atomic::{AtomicUsize, Ordering};
+156
View File
@@ -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<Vec<u8>> {
let files: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
let f = files.clone();
let sink: Box<Sink> = Box::new(move |path: &str, data: Vec<u8>| 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<u8>]) -> 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<u8>]) -> 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);
}
+15 -1
View File
@@ -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]
+1
View File
@@ -1,6 +1,7 @@
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
mod audio_sample_positions;
mod banding;
mod h264_gpu_yuv;
mod hevc_av1_roundtrip;
#[cfg(not(target_arch = "wasm32"))]
+20 -5
View File
@@ -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<EvalCtx<'a>> {
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()),
+12 -2
View File
@@ -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() } };
+54
View File
@@ -208,6 +208,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.
+1
View File
@@ -677,6 +677,7 @@ pub fn styled_doc(v: &serde_json::Value, own: &effectcraft_keyframe::TextDoc) ->
let p0 = src.para(0);
d.set_paras(vec![p0; d.para_count()]);
d.stroke_over_fill = src.stroke_over_fill;
d.line_join = src.line_join;
}
}
for op in v.get("ops").and_then(|o| o.as_array()).into_iter().flatten() {
+124 -2
View File
@@ -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]
@@ -1117,7 +1230,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();
+2
View File
@@ -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)",
+3 -2
View File
@@ -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<GpuImage> {
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);
+1
View File
@@ -46,6 +46,7 @@ impl GBuf {
/// Buf::pad: grow by `pad` transparent pixels on every side.
pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> {
let pad = effectcraft_effects::pad_within_budget(self.img.width, self.img.height, pad);
if pad == 0 {
return Some(());
}
+20 -2
View File
@@ -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)
}
+11 -4
View File
@@ -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)
+20 -3
View File
@@ -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);
+1
View File
@@ -12,4 +12,5 @@ mod relink;
mod tga_import;
mod timeline_interop;
mod vector_import;
mod wav_markers;
mod webm_metadata;
+126
View File
@@ -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<String> {
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<u8> {
fn chunk(id: &[u8; 4], body: &[u8]) -> Vec<u8> {
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);
}
+1 -1
View File
@@ -15,7 +15,7 @@ pub mod value;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun};
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, LineJoin, OpenType, ParaStyle, StyleRun};
pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value};
/// Temporal interpolation on one side of a keyframe.
+35
View File
@@ -179,6 +179,31 @@ pub enum Composer {
fn single_line() -> Composer {
Composer::SingleLine
}
/// How the corners of a text stroke are drawn (Character panel Line Join).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LineJoin {
/// Sharp corners (AE's default for new text).
#[default]
Miter,
Round,
Bevel,
}
impl LineJoin {
pub fn parse(s: &str) -> Option<LineJoin> {
Some(match s.to_ascii_lowercase().as_str() {
"miter" => LineJoin::Miter,
"round" => LineJoin::Round,
"bevel" => LineJoin::Bevel,
_ => return None,
})
}
}
fn round_join() -> LineJoin {
LineJoin::Round
}
fn yes() -> bool {
true
}
@@ -402,6 +427,9 @@ pub struct TextDoc {
pub apply_fill: bool,
pub apply_stroke: bool,
pub stroke_over_fill: bool,
/// Documents saved before line joins existed drew round joins.
#[serde(default = "round_join")]
pub line_join: LineJoin,
pub tracking: f64,
/// None = auto (120% of size).
pub leading: Option<f64>,
@@ -463,6 +491,7 @@ impl Default for TextDoc {
apply_fill: true,
apply_stroke: false,
stroke_over_fill: true,
line_join: LineJoin::Miter,
tracking: 0.0,
leading: None,
justify: Justify::Left,
@@ -909,6 +938,10 @@ impl TextDoc {
self.stroke_over_fill = v.as_bool().ok_or("strokeOverFill: expected a boolean")?;
return Ok(true);
}
"lineJoin" => {
self.line_join = v.as_str().and_then(LineJoin::parse).ok_or("lineJoin: expected miter|round|bevel")?;
return Ok(true);
}
"vertical" => {
self.vertical = v.as_bool().ok_or("vertical: expected a boolean")?;
return Ok(true);
@@ -1423,6 +1456,8 @@ mod tests {
assert!(d.is_uniform());
assert_eq!(d.style_at(2).size, 50.0);
assert_eq!(d.composer, Composer::SingleLine, "old box text keeps greedy breaking");
assert_eq!(d.line_join, LineJoin::Round, "old strokes keep their round corners");
assert_eq!(TextDoc::default().line_join, LineJoin::Miter);
assert!(d.ligatures);
assert_eq!(d.kerning, Kerning::Metrics);
// Round trip with runs.
+1
View File
@@ -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 }
+1
View File
@@ -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};
+3
View File
@@ -107,8 +107,11 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
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)
+517
View File
@@ -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<u32> {
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<u64> {
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<u64> {
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<Ds64> {
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<Marker> {
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<Marker> {
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<u32> = points.iter().map(|(id, _)| *id).collect();
let mut names: HashMap<u32, String> = HashMap::new();
let mut lengths: HashMap<u32, u32> = 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
/// (`<name>v</name>`).
fn xmp_value(entry: &str, name: &str) -> Option<String> {
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<f64> {
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<Marker> {
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("<rdf:li").skip(1) {
if let Some(r) = xmp_value(entry, "xmpDM:frameRate").as_deref().and_then(xmp_rate) {
rate = r;
}
let Some(start) = xmp_value(entry, "xmpDM:startTime").and_then(|v| v.trim().parse::<f64>().ok()) else { continue };
let length = xmp_value(entry, "xmpDM:duration").and_then(|v| v.trim().parse::<f64>().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<u8> {
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<u8> {
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<u8>]) -> Vec<u8> {
let body: Vec<u8> = 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<u8> {
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<u8> {
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<u8>]) -> Vec<u8> {
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", &ltxt), 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#"<x:xmpmeta><rdf:RDF><rdf:Description><xmpDM:Tracks><rdf:Bag><rdf:li rdf:parseType="Resource"><xmpDM:trackName>Markers</xmpDM:trackName><xmpDM:trackType>Cue</xmpDM:trackType><xmpDM:frameRate>f44100</xmpDM:frameRate><xmpDM:markers><rdf:Seq><rdf:li rdf:parseType="Resource"><xmpDM:startTime>44100</xmpDM:startTime><xmpDM:duration>0</xmpDM:duration><xmpDM:name>One &amp; Two</xmpDM:name></rdf:li><rdf:li rdf:parseType="Resource"><xmpDM:startTime>132300</xmpDM:startTime><xmpDM:duration>22050</xmpDM:duration><xmpDM:name>Three</xmpDM:name></rdf:li></rdf:Seq></xmpDM:markers></rdf:li></rdf:Bag></xmpDM:Tracks></rdf:Description></rdf:RDF></x:xmpmeta>"#;
#[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#"<rdf:Seq><rdf:li xmpDM:startTime="24000" xmpDM:duration="0" xmpDM:name="Attr &#233;"/></rdf:Seq>"#;
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#"<rdf:li xmpDM:startTime="44100" xmpDM:name="Same"/><rdf:li xmpDM:startTime="88200" xmpDM:name="Only XMP"/>"#;
let file = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Same")]), chunk(b"_PMX", xmp.as_bytes())]);
let names: Vec<String> = 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<u8> {
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<u8> {
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<u8>]) -> Vec<u8> {
let body: Vec<u8> = 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::<Vec<_>>();
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());
}
}
+9 -1
View File
@@ -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,
+6 -1
View File
@@ -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<i64>,
/// 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<Marker>,
}
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
@@ -150,6 +154,7 @@ impl Default for Footage {
page: 0,
alphabetical: false,
start_timecode: None,
markers: vec![],
}
}
}
+86 -14
View File
@@ -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();
+121 -10
View File
@@ -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<u8> {
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<u8> {
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<u8> {
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<u8> {
/// [`Image::to_rgba8_over`] with the colour dithered ([`dither_u8`]).
pub fn to_rgba8_over_dithered(&self, bg: [f32; 3]) -> Vec<u8> {
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<u8> {
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<u8> = 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<u8> = 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)
}
}
+5 -4
View File
@@ -184,8 +184,9 @@ impl<'a> Renderer<'a> {
pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<effectcraft_raster::AuxChannels>> {
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.
+82 -23
View File
@@ -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<f32> {
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]> {
+20 -2
View File
@@ -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());
+65 -1
View File
@@ -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<usize>) -> 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 {
+64 -10
View File
@@ -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])
+2 -2
View File
@@ -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])
}
}
+11 -2
View File
@@ -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<Vec<f32>> {
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<Buf> {
+2 -2
View File
@@ -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 {
+1
View File
@@ -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,
+141 -16
View File
@@ -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<PropertyOverride>,
pub(crate) media_overrides: Vec<MediaOverride>,
}
/// 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<PropertyOverride>, Vec<MediaOverride>)> {
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<PropertyOverride> = 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<ItemId> {
/// 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<Collapsed> {
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<Arc<Buf>> {
self.content_buf_timed(ctx, layer, None).map(|(b, _)| b)
+191
View File
@@ -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<std::sync::Arc<crate::Image>> {
None
}
fn model(&self, _: ItemId, f: &effectcraft_project::Footage) -> Option<std::sync::Arc<effectcraft_model::Model>> {
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");
}
+37 -1
View File
@@ -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<f32> {
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))];
+61 -1
View File
@@ -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
+10 -1
View File
@@ -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))
};
+12 -1
View File
@@ -510,6 +510,7 @@ pub struct PlacedGlyph {
pub fill: [f32; 4],
pub stroke: [f32; 4],
pub stroke_width: f64,
pub join: effectcraft_path::Join,
/// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows
/// on every side, in character space (0 when not bold).
pub bold: f64,
@@ -838,6 +839,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
fill,
stroke,
stroke_width: (base_stroke_w + x.stroke_width).max(0.0),
join: path_join(doc.line_join),
bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 },
apply_fill: st.apply_fill,
char_index: g.char_index,
@@ -853,6 +855,15 @@ pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> {
text_geom(ctx, layer).map(|t| t.glyphs.iter().map(|g| (g.path(), g.xf)).collect()).unwrap_or_default()
}
fn path_join(j: effectcraft_keyframe::LineJoin) -> effectcraft_path::Join {
use effectcraft_keyframe::LineJoin;
match j {
LineJoin::Miter => effectcraft_path::Join::Miter,
LineJoin::Round => effectcraft_path::Join::Round,
LineJoin::Bevel => effectcraft_path::Join::Bevel,
}
}
/// One paint pass of a glyph: fill (false) or stroke (true).
type Pass = (usize, bool);
@@ -919,7 +930,7 @@ fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect:
}
outer
} else if stroke {
let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
let st = StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() };
effectcraft_path::stroke_coverage(paths, &st, &mm, w, h)
} else {
grown_coverage(paths, &mm, w, h, bold)
+2
View File
@@ -598,6 +598,7 @@ fn touching_glyphs_join_without_a_seam() {
fill,
stroke: [0.0; 4],
stroke_width: 0.0,
join: effectcraft_path::Join::Miter,
bold: 0.0,
apply_fill: true,
char_index: 0,
@@ -668,6 +669,7 @@ fn faux_bold_under_a_non_uniform_scale() {
fill: [1.0; 4],
stroke: [0.0; 4],
stroke_width: 0.0,
join: effectcraft_path::Join::Miter,
bold: 2.0,
apply_fill: true,
char_index: 0,
+5 -5
View File
@@ -572,7 +572,7 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude
groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top), g.xf.surface_colors(c)));
}
if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 {
let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() };
if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) {
let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op];
groups.push((vec![region], c, g.m, usize::from(stroke_on_top), g.xf.surface_colors(c)));
@@ -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;
+40 -4
View File
@@ -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, &params);
@@ -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, &params);
@@ -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, &params);
@@ -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, &params);
+17 -9
View File
@@ -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<Image> = 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
}
+23 -17
View File
@@ -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 }
}
+79 -32
View File
@@ -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<Item<'a>> {
pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option<Item<'a>> {
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<Item<'a>> {
pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec<Item<'a>> {
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<Item<'a>> {
fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option<Item<'a>> {
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<f32> {
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<f32> {
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<f32> {
/// 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<f32> {
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<Buf>) -> Arc<Buf> {
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<Item<'a>> {
fn collapsed_items<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, order: usize, out: (u32, u32)) -> Vec<Item<'a>> {
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)
})
+154 -3
View File
@@ -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<Item = (i64, i64)>) -> 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]
+65
View File
@@ -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));
+3 -27
View File
@@ -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
+2 -3
View File
@@ -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 });
+44
View File
@@ -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]
+10 -1
View File
@@ -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]]
}
+17 -3
View File
@@ -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).
+95 -16
View File
@@ -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<FaceId> = 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<FaceId> = 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);
}
+5 -3
View File
@@ -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::<egui::Id>(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;
+2
View File
@@ -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", "კომპრესორი"),
+136
View File
@@ -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]

Some files were not shown because too many files have changed in this diff Show More