mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 07:38:24 +00:00
Merge remote-tracking branch 'origin/main' into fix/2261-pad-budget
# Conflicts: # crates/effects/src/lib.rs
This commit is contained in:
commit
2e9542dada
114 files changed
+4436
-427
No files matched your search
@@ -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
@@ -1623,6 +1623,7 @@ name = "effectcraft-media"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
"effectcraft-project",
|
||||
|
||||
@@ -84,9 +84,10 @@ a few places where it matters to us:
|
||||
without a plugin.
|
||||
- **A project file you can read.** Projects are versioned JSON (`.ecproj`), so they diff cleanly
|
||||
in version control.
|
||||
- **Everything is scriptable.** Every menu item, timeline drag and property edit goes through one
|
||||
command registry, so the same actions are reachable from a command line, a JSON control
|
||||
channel and an MCP server for agents.
|
||||
- **Everything is scriptable.** Menu actions, timeline edits and property changes are exposed as
|
||||
commands. Engine commands are available through the CLI, JSON control channel and MCP;
|
||||
UI-only commands use the frontend dispatcher and can be invoked through the control channel
|
||||
or an attached MCP bridge.
|
||||
- **No FFmpeg.** Video and audio decoding and encoding are pure Rust: FilmCraft's codecs and
|
||||
EffectCraft's own VP9, AV1, HEVC and Opus encoders.
|
||||
|
||||
@@ -173,8 +174,10 @@ cannot express; File ▸ Import ▸ Lottie… opens one as a new composition.
|
||||
|
||||
## Built for agents
|
||||
|
||||
Everything you can do from a menu is a command with an id, and agents can reach every one of
|
||||
them:
|
||||
Engine commands are available through the CLI, JSON control channel and MCP. UI-only commands
|
||||
need the running app's frontend dispatcher: complete menu access requires the app's control
|
||||
channel or an attached MCP bridge. Headless MCP sessions expose supported engine commands,
|
||||
but not UI-only commands or bridge-only `ui_*` tools.
|
||||
|
||||
- **MCP server:** `effectcraft-cli mcp` speaks the Model Context Protocol over stdio, headless
|
||||
or bridged to the running app (`--bridge 9877`). Tools cover commands, the project and property
|
||||
|
||||
@@ -35,7 +35,7 @@ fn import_footage(qa: &mut Qa) -> (Value, Value) {
|
||||
// Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸
|
||||
// Photoshop Layers) from a small comp.
|
||||
let seq = write_sequence(qa);
|
||||
let imp = qa.exec("file.import", json!({"paths": [seq]}));
|
||||
let imp = qa.exec("file.import", json!({"paths": [seq], "sequence": true}));
|
||||
let seq_item = imp["items"][0].clone();
|
||||
let project = qa.tool("get_project", json!({}));
|
||||
let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone();
|
||||
|
||||
@@ -52,7 +52,7 @@ fn shake_offset(f: u32) -> (i64, i64) {
|
||||
|
||||
/// Import a sequence at 12 fps and make a comp of it; returns (comp, layer).
|
||||
fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) {
|
||||
let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone();
|
||||
let item = qa.exec("file.import", json!({"paths": [first], "sequence": true}))["items"][0].clone();
|
||||
qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12}));
|
||||
let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
|
||||
let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -312,15 +312,16 @@ impl Buf {
|
||||
self.offset[0] += l as f64;
|
||||
self.offset[1] += t as f64;
|
||||
}
|
||||
/// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`].
|
||||
/// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`] and as
|
||||
/// clamped by [`Image::effective_pad`].
|
||||
pub fn pad(&mut self, pad: u32) {
|
||||
let pad = pad_within_budget(self.img.width, self.img.height, pad);
|
||||
if pad == 0 {
|
||||
let eff = self.img.effective_pad(pad_within_budget(self.img.width, self.img.height, pad));
|
||||
if eff == 0 {
|
||||
return;
|
||||
}
|
||||
self.img = self.img.padded(pad);
|
||||
self.offset[0] += pad as f64;
|
||||
self.offset[1] += pad as f64;
|
||||
self.img = self.img.padded(eff);
|
||||
self.offset[0] += eff as f64;
|
||||
self.offset[1] += eff as f64;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1384,6 +1385,20 @@ mod tests {
|
||||
assert_eq!(out.img.data, img.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_offset_follows_the_clamped_pad() {
|
||||
// Image::padded clamps a hostile pad to keep each side within MAX_PADDED_SIDE; the offset
|
||||
// must move by the pad actually applied, or the content drifts from its layer position.
|
||||
let mut img = Image::new(29_990, 2);
|
||||
img.set(0, 0, [1.0, 0.0, 0.0, 1.0]);
|
||||
let mut buf = Buf { img, offset: [3.0, 4.0], scale: 1.0 };
|
||||
buf.pad(u32::MAX);
|
||||
// Width binds: (MAX_PADDED_SIDE - 29 990) / 2 = 5.
|
||||
assert_eq!(buf.offset, [8.0, 9.0]);
|
||||
let (x, y) = buf.to_px([-3.0, -4.0]);
|
||||
assert_eq!(buf.img.get(x as i64, y as i64), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn dump_registry() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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<_>>()))
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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])"),
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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: &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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
@@ -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
@@ -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() } };
|
||||
|
||||
@@ -207,6 +207,60 @@ function ease() { return __interp(__ease, arguments); }
|
||||
function easeIn() { return __interp(__easeIn, arguments); }
|
||||
function easeOut() { return __interp(__easeOut, arguments); }
|
||||
|
||||
// Cubic-Bezier easing (the CSS cubic-bezier() shape): [x1, y1, x2, y2], with x1 / x2 in 0..1
|
||||
// and y1 / y2 free (overshoot included). Solves x(u) = x by Newton–Raphson, then by bisection
|
||||
// when Newton leaves 0..1 or the derivative vanishes, and returns y(u). A curve that is not four
|
||||
// finite numbers is linear, never an error.
|
||||
function __cubicBezier(curve) {
|
||||
var x1, y1, x2, y2;
|
||||
if (Array.isArray(curve) && curve.length === 4) {
|
||||
x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3];
|
||||
}
|
||||
if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' ||
|
||||
!isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) {
|
||||
return __lin;
|
||||
}
|
||||
x1 = Math.max(0, Math.min(1, x1));
|
||||
x2 = Math.max(0, Math.min(1, x2));
|
||||
function bx(u) { var v = 1 - u; return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u; }
|
||||
function dbx(u) { var v = 1 - u; return 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2); }
|
||||
function by(u) { var v = 1 - u; return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u; }
|
||||
function dby(u) { var v = 1 - u; return 3 * v * v * y1 + 6 * v * u * (y2 - y1) + 3 * u * u * (1 - y2); }
|
||||
return function (x) {
|
||||
if (x <= 0) return 0;
|
||||
if (x >= 1) return 1;
|
||||
var u = x;
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var f = bx(u) - x;
|
||||
var d = dbx(u);
|
||||
if (Math.abs(d) < 1e-12) break;
|
||||
// Stop when the *value* is accurate: near the endpoints dy/dx is steep, so a residual in x
|
||||
// alone can still mean a visible error in y (f * dy/dx estimates it).
|
||||
if (Math.abs(f * dby(u) / d) < 1e-9) return by(u);
|
||||
var n = u - f / d;
|
||||
if (!(n >= 0 && n <= 1)) break;
|
||||
u = n;
|
||||
}
|
||||
var lo = 0, hi = 1;
|
||||
for (var j = 0; j < 60; j++) {
|
||||
var mid = (lo + hi) / 2;
|
||||
if (bx(mid) < x) lo = mid; else hi = mid;
|
||||
}
|
||||
return by((lo + hi) / 2);
|
||||
};
|
||||
}
|
||||
|
||||
// bezier(t, tMin, tMax, value1, value2, curve), or bezier(t, value1, value2, curve): the curve is
|
||||
// the sixth / fourth argument. Read it by position, not from the end: when it is missing or
|
||||
// unusable, the interpolation falls back to linear without consuming the last value.
|
||||
function bezier() {
|
||||
var a = arguments, n = a.length;
|
||||
if (n >= 6) return __interp(__cubicBezier(a[5]), [a[0], a[1], a[2], a[3], a[4]]);
|
||||
if (n === 5) return __interp(__lin, [a[0], a[1], a[2], a[3], a[4]]);
|
||||
if (n === 4) return __interp(__cubicBezier(a[3]), [a[0], a[1], a[2]]);
|
||||
return __interp(__lin, [a[0], a[1], a[2]]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Random numbers and noise.
|
||||
|
||||
|
||||
+124
-2
@@ -314,8 +314,78 @@ fn interpolation_with_arrays() {
|
||||
assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]);
|
||||
}
|
||||
|
||||
// ---- object model -----------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn cubic_bezier_curves() {
|
||||
let f = fx();
|
||||
// ease(t, …) is cubic-bezier(1/3, 0, 2/3, 1): x(u) = u and y(u) = 3u² - 2u³.
|
||||
assert_close!(f.num("bezier(0.25, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), f.num("ease(0.25, 0, 1, 0, 100)", 0.0));
|
||||
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), 50.0);
|
||||
// Symmetric ease-in-out: slower at both ends, faster in the middle.
|
||||
let early = f.num("bezier(0.25, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0);
|
||||
let late = f.num("bezier(0.75, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0);
|
||||
assert!(early < 25.0 && late > 75.0, "ease-in-out: {early}, {late}");
|
||||
// Control points on the diagonal are linear.
|
||||
assert_close!(f.num("bezier(0.3, 0, 1, 0, 100, [0, 0, 1, 1])", 0.0), 30.0);
|
||||
// t is clamped to tMin..tMax; tMin == tMax behaves like the other helpers.
|
||||
assert_close!(f.num("bezier(-1, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 0.0);
|
||||
assert_close!(f.num("bezier(2, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0);
|
||||
assert_close!(f.num("bezier(5, 2, 2, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubic_bezier_short_form_arrays_and_fallbacks() {
|
||||
let f = fx();
|
||||
// Short form: no tMin / tMax.
|
||||
assert_close!(f.num("bezier(0.25, 10, 20, [0, 0, 1, 1])", 0.0), 12.5);
|
||||
assert_eq!(f.pos("bezier(time, 0, 1, [0, 0], [100, 50], [0.42, 0, 0.58, 1])", 1.0), [100.0, 50.0, 0.0]);
|
||||
// y outside 0..1 overshoots, as CSS allows.
|
||||
let over = f.num("bezier(0.5, 0, 1, 0, 100, [0.5, 2, 0.5, 2])", 0.0);
|
||||
assert!(over > 100.0, "overshoot: {over}");
|
||||
// A curve that is not four finite numbers falls back to linear instead of throwing.
|
||||
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0);
|
||||
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0);
|
||||
// A missing or unusable curve must not consume the last value: read the curve by position.
|
||||
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100)", 0.0), 50.0);
|
||||
assert_close!(f.num("bezier(0.5, 0, 100)", 0.0), 50.0);
|
||||
assert_close!(f.num("bezier(0.5, 0, 100, [1, 0])", 0.0), 50.0);
|
||||
let v = f.ev(f.shape, "contents/#1/contents/fill/color", "bezier(0.5, 0, 1, [0, 0, 0, 1], [1, 1, 1, 1])", 0.0).unwrap();
|
||||
assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0]));
|
||||
// Exactly four values are a curve; extra ones mean the curve is malformed, so linear.
|
||||
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 0])", 0.0), 50.0);
|
||||
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 2, 3])", 0.0), 50.0);
|
||||
// x control points outside 0..1 are clamped, as CSS requires.
|
||||
let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0);
|
||||
assert!(v.is_finite(), "clamped curve: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubic_bezier_is_accurate_near_the_endpoints() {
|
||||
/// The value the curve reaches at `x`, by bisection on its monotone x(u).
|
||||
fn reference(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 {
|
||||
let bx = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * x1 + 3.0 * (1.0 - u) * u * u * x2 + u * u * u;
|
||||
let by = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * y1 + 3.0 * (1.0 - u) * u * u * y2 + u * u * u;
|
||||
let (mut lo, mut hi) = (0.0f64, 1.0f64);
|
||||
for _ in 0..100 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if bx(mid) < x {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
by(0.5 * (lo + hi))
|
||||
}
|
||||
// Steep curves: a residual in x is not a residual in y near the endpoints (dy/dx is in the
|
||||
// thousands), so the solver stops on the estimated y error instead.
|
||||
let f = fx();
|
||||
for (c, x) in [([0.0, 1000.0, 1.0, 1.0], 1e-6), ([0.0, 100.0, 1.0, 0.0], 1e-9), ([0.8, -500.0, 1.0, 1.0], 0.999999)] {
|
||||
let got = f.num(&format!("bezier({x}, 0, 1, [{}, {}, {}, {}])", c[0], c[1], c[2], c[3]), 0.0);
|
||||
let want = reference(c[0], c[1], c[2], c[3], x);
|
||||
assert!((got - want).abs() < 1e-6, "curve {c:?} at {x}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- object model -----------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn comp_attributes() {
|
||||
let f = fx();
|
||||
@@ -908,6 +978,49 @@ fn source_rect_at_time() {
|
||||
assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_dimensions_follow_media_replacement() {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let footage = |p: &mut Project, name: &str, width, height| {
|
||||
p.add_item(
|
||||
name,
|
||||
Label::SeaFoam,
|
||||
None,
|
||||
ItemKind::Footage(effectcraft_project::Footage {
|
||||
path: name.into(),
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width,
|
||||
height,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
};
|
||||
let original = footage(&mut p, "original.png", 100, 80);
|
||||
let replacement = footage(&mut p, "replacement.png", 30, 40);
|
||||
let mut l = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: original }, (100, 80), None);
|
||||
l.props.prop_mut(ROT).unwrap().expr =
|
||||
Some(Expression { text: "thisLayer.width + thisLayer.height + thisLayer.source.width + sourceRectAtTime().width".into(), enabled: true });
|
||||
let lid = l.id;
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let c = p.comp(cid).unwrap();
|
||||
let l = c.layer(lid).unwrap();
|
||||
let media = effectcraft_render::MediaOverride::new(lid, replacement);
|
||||
let ctx = EvalCtx {
|
||||
project: &p,
|
||||
comp_id: cid,
|
||||
comp: c,
|
||||
time: Tick::ZERO,
|
||||
expr: Some(&Expressions),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[media],
|
||||
};
|
||||
assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0));
|
||||
}
|
||||
|
||||
// ---- colour -----------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
@@ -1115,7 +1228,16 @@ fn text_expression_selector_values() {
|
||||
let sel_of = |p: &Project| {
|
||||
let c = p.comp(cid).unwrap();
|
||||
let l = c.layer(lid).unwrap();
|
||||
let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None };
|
||||
let ctx = EvalCtx {
|
||||
project: p,
|
||||
comp_id: cid,
|
||||
comp: c,
|
||||
time: Tick::ZERO,
|
||||
expr: Some(&Expressions),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let doc = effectcraft_render::text::source_text(&ctx, l).unwrap();
|
||||
let lay = effectcraft_text::layout_doc(&doc);
|
||||
let anim = l.props.group("text/animators/#1").unwrap();
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,4 +12,5 @@ mod relink;
|
||||
mod tga_import;
|
||||
mod timeline_interop;
|
||||
mod vector_import;
|
||||
mod wav_markers;
|
||||
mod webm_metadata;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", <xt), labl(b"labl", 2, "named"), labl(b"note", 2, "ignored")]),
|
||||
]);
|
||||
let m = read(&file);
|
||||
assert_eq!((m[0].comment.as_str(), m[0].duration), ("from note", Tick::from_seconds_f64(1.0)));
|
||||
assert_eq!(m[1].comment, "named");
|
||||
}
|
||||
|
||||
const XMP_ELEMENTS: &str = r#"<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 & 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 é"/></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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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]> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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])
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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))];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -370,7 +370,16 @@ fn extruded_text_renders_as_a_mesh() {
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
set(l, "transform/rotationY", Value::Scalar(40.0));
|
||||
});
|
||||
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||||
let ctx = crate::EvalCtx {
|
||||
project: &p,
|
||||
comp_id: cid,
|
||||
comp: p.comp(cid).unwrap(),
|
||||
time: Tick::ZERO,
|
||||
expr: None,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let l = &ctx.comp.layers[0];
|
||||
let params = scene::extrusion_params(&ctx, l).unwrap();
|
||||
let meshes = scene::extruded_meshes(&ctx, l, ¶ms);
|
||||
@@ -409,7 +418,16 @@ fn shape_surface_items_colour_the_extruded_surfaces() {
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0));
|
||||
set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.0]));
|
||||
});
|
||||
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||||
let ctx = crate::EvalCtx {
|
||||
project: &p,
|
||||
comp_id: cid,
|
||||
comp: p.comp(cid).unwrap(),
|
||||
time: Tick::ZERO,
|
||||
expr: None,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let l = &ctx.comp.layers[0];
|
||||
let params = scene::extrusion_params(&ctx, l).unwrap();
|
||||
let meshes = scene::extruded_meshes(&ctx, l, ¶ms);
|
||||
@@ -445,7 +463,16 @@ fn text_animators_colour_the_extruded_surfaces() {
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0));
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
});
|
||||
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||||
let ctx = crate::EvalCtx {
|
||||
project: &p,
|
||||
comp_id: cid,
|
||||
comp: p.comp(cid).unwrap(),
|
||||
time: Tick::ZERO,
|
||||
expr: None,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let l = &ctx.comp.layers[0];
|
||||
let params = scene::extrusion_params(&ctx, l).unwrap();
|
||||
let meshes = scene::extruded_meshes(&ctx, l, ¶ms);
|
||||
@@ -643,7 +670,16 @@ fn extruded_strokes_render_as_bevelled_meshes() {
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(20.0));
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
});
|
||||
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||||
let ctx = crate::EvalCtx {
|
||||
project: &p,
|
||||
comp_id: cid,
|
||||
comp: p.comp(cid).unwrap(),
|
||||
time: Tick::ZERO,
|
||||
expr: None,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let l = &ctx.comp.layers[0];
|
||||
let params = scene::extrusion_params(&ctx, l).unwrap();
|
||||
let meshes = scene::extruded_meshes(&ctx, l, ¶ms);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]]
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", "კომპრესორი"),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -119,7 +119,8 @@ pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
|
||||
}
|
||||
}
|
||||
if e.command == "edit.clear" && app.ui.focused == PanelKind::Project {
|
||||
return app.session.is_enabled("project.delete");
|
||||
// An open text edit stands down: Clear must not delete the comp being typed into (#2227).
|
||||
return app.session.state.text_edit.is_none() && app.session.is_enabled("project.delete");
|
||||
}
|
||||
if e.command == "edit.clear" && app.ui.focused == PanelKind::RenderQueue {
|
||||
return !app.session.is_rendering() && !app.session.project.render_queue.is_empty();
|
||||
|
||||
@@ -151,7 +151,16 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI
|
||||
let s = &app.session;
|
||||
let (Some(cid), Some(comp)) = (s.active_comp_id(), s.active_comp()) else { return d };
|
||||
let Some(l) = comp.layer(layer) else { return d };
|
||||
let ectx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None };
|
||||
let ectx = effectcraft_engine::render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: s.time(),
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let (l2c, _) = crate::panels::viewer::l2c(&ectx, l);
|
||||
l2c.inverse().map_or(d, |inv| {
|
||||
let v = inv.apply_vec(vec2(d[0], d[1]));
|
||||
@@ -223,8 +232,13 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
return run_engine(app, ctx, "edit.pasteSvg", json!({"svg": svg}));
|
||||
}
|
||||
}
|
||||
// Clear follows panel focus, including the Edit menu and keyboard shortcut.
|
||||
// Clear follows panel focus, including the Edit menu and keyboard shortcut. An open text
|
||||
// edit stands down: the user is working in a composition, and the Project-panel redirect
|
||||
// would delete it without asking when nothing uses it (#2227).
|
||||
if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project {
|
||||
if app.session.state.text_edit.is_some() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Through `invoke`, so deleting items that compositions use still asks first (M3.16).
|
||||
return invoke(app, ctx, "project.delete", json!({}));
|
||||
}
|
||||
|
||||
@@ -166,7 +166,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let snap_project = app.session.project.clone();
|
||||
let snap_expr = app.session.expr.clone();
|
||||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref(), footage: None };
|
||||
let ectx = EvalCtx {
|
||||
project: &snap_project,
|
||||
comp_id: cid,
|
||||
comp: &comp,
|
||||
time: app.session.time(),
|
||||
expr: snap_expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
|
||||
p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim);
|
||||
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
|
||||
@@ -111,6 +111,7 @@ pub const COMPLETIONS: &[&str] = &[
|
||||
"ease",
|
||||
"easeIn",
|
||||
"easeOut",
|
||||
"bezier",
|
||||
"clamp",
|
||||
"random",
|
||||
"gaussRandom",
|
||||
|
||||
@@ -104,7 +104,16 @@ fn render_tex(
|
||||
match cached {
|
||||
Some(c) if c.key == key => Some(c),
|
||||
_ => {
|
||||
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None };
|
||||
let ectx = EvalCtx {
|
||||
project: &app.session.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time,
|
||||
expr: app.session.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
|
||||
r.expr = app.session.expr.as_deref();
|
||||
r.cache = Some(&app.session.layer_cache);
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let mut yy = y + 46.0;
|
||||
|
||||
// Shortcut.
|
||||
label(yy, "Shortcut");
|
||||
label(yy, tr("Shortcut"));
|
||||
let names: Vec<String> = PreviewShortcut::ALL.iter().map(|s| s.label().to_string()).collect();
|
||||
if let Some(i) = dropdown_row(
|
||||
app,
|
||||
@@ -91,14 +91,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let row = if i < 4 { i / 2 } else { i - 2 };
|
||||
let cx = x0 + col as f32 * 110.0 - 2.0;
|
||||
let cy = yy + row as f32 * 22.0;
|
||||
if check_row(app, ui, &p, pos2(cx, cy), key, text, on) {
|
||||
if check_row(app, ui, &p, pos2(cx, cy), key, tr(text), on) {
|
||||
changes.push(json!({"shortcut": current.id(), key: !on}));
|
||||
}
|
||||
}
|
||||
yy += 4.0 * 22.0 + 6.0;
|
||||
|
||||
// Range (and the pre/post-roll of Play Around Current Time).
|
||||
label(yy, "Range");
|
||||
label(yy, tr("Range"));
|
||||
let ranges: Vec<String> = PreviewRange::ALL.iter().map(|r| r.label().to_string()).collect();
|
||||
let range_ids = ["workArea", "workAreaExtended", "entireDuration", "aroundCurrentTime"];
|
||||
if let Some(i) = dropdown_row(
|
||||
@@ -116,7 +116,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if o.range == PreviewRange::AroundCurrentTime {
|
||||
for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() {
|
||||
let lx = xv + k as f32 * 100.0;
|
||||
p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim);
|
||||
p.text(pos2(lx, yy), Align2::LEFT_CENTER, tr(text), Tokens::ui(11.5), t.text_dim);
|
||||
let (r, nv, _) = widgets::hot_number_at(
|
||||
ui,
|
||||
pos2(lx + 52.0, yy - 9.0),
|
||||
@@ -134,7 +134,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
|
||||
// Play From.
|
||||
label(yy, "Play From");
|
||||
label(yy, tr("Play From"));
|
||||
let froms: Vec<String> = PlayFrom::ALL.iter().map(|f| f.label().to_string()).collect();
|
||||
if let Some(i) = dropdown_row(
|
||||
app,
|
||||
@@ -151,7 +151,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
|
||||
// Frame Rate, Skip, Resolution.
|
||||
let comp_fps = app.session.active_comp().map(|c| c.frame_rate.as_f64());
|
||||
label(yy, "Frame Rate");
|
||||
label(yy, tr("Frame Rate"));
|
||||
const RATES: [f64; 12] = [8.0, 10.0, 12.0, 12.5, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0];
|
||||
let auto = format!("({:.2}) Auto", comp_fps.unwrap_or(0.0));
|
||||
let mut rates = vec![auto.clone()];
|
||||
@@ -180,7 +180,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32}));
|
||||
}
|
||||
yy += 24.0;
|
||||
label(yy, "Resolution");
|
||||
label(yy, tr("Resolution"));
|
||||
let ress: Vec<String> = PreviewResolution::ALL.iter().map(|r| r.label().to_string()).collect();
|
||||
let res_ids = ["auto", "full", "half", "third", "quarter", "custom"];
|
||||
if let Some(i) = dropdown_row(
|
||||
@@ -209,7 +209,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
yy += 24.0;
|
||||
if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", "Full Screen", o.full_screen) {
|
||||
if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", tr("Full Screen"), o.full_screen) {
|
||||
changes.push(json!({"shortcut": current.id(), "fullScreen": !o.full_screen}));
|
||||
}
|
||||
yy += 24.0;
|
||||
@@ -217,7 +217,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// What stopping does.
|
||||
p.text(pos2(x0, yy), Align2::LEFT_CENTER, tr_args("On ({}) stop:", &[&(current.label())]), Tokens::ui(11.5), t.text_dim);
|
||||
yy += 20.0;
|
||||
if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", "If caching, play cached frames", o.play_cached_frames) {
|
||||
if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", tr("If caching, play cached frames"), o.play_cached_frames) {
|
||||
changes.push(json!({"shortcut": current.id(), "playCachedFrames": !o.play_cached_frames}));
|
||||
}
|
||||
yy += 22.0;
|
||||
|
||||
@@ -70,7 +70,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let project = app.session.project.clone();
|
||||
let expr = app.session.expr.clone();
|
||||
let now = app.session.time();
|
||||
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None };
|
||||
let ectx = EvalCtx {
|
||||
project: &project,
|
||||
comp_id: cid,
|
||||
comp: &comp,
|
||||
time: now,
|
||||
expr: expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let x0 = rect.min.x + PAD;
|
||||
let w = rect.width() - 2.0 * PAD;
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect);
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user