Merge remote-tracking branch 'upstream/main' into rq-readback-3335

# Conflicts:
#	crates/render/src/lib.rs
This commit is contained in:
vangel76
2026-11-09 16:06:16 +01:00
105 changed files with 3208 additions and 987 deletions
+1 -1
View File
@@ -375,7 +375,7 @@ pub(crate) fn job_accel() -> Option<Arc<dyn Accelerator>> {
}
/// Render options of an analysis's input frames: 100 %; with an accelerator, the project's
/// renderer (Mercury GPU Acceleration: its GPU effects on the device; Software Only: the CPU).
/// renderer (GPU Acceleration: its GPU effects on the device; Software Only: the CPU).
pub(crate) fn analysis_opts(accel: Option<&dyn Accelerator>) -> RenderOpts {
RenderOpts { scale: 1.0, backend: if accel.is_some() { Backend::Auto } else { Backend::Cpu }, ..Default::default() }
}
+25 -4
View File
@@ -5,7 +5,7 @@
use effectcraft_color::Label;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{ItemKind, LayerSource, Solid};
use effectcraft_project::{ItemKind, LayerSource, MatteKind, Solid, TrackMatte};
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layers_p, str_p};
@@ -13,6 +13,8 @@ use crate::{EngineError, Result, Session, cmd};
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d";
/// Layer ▸ Create (`layer.create`): the `op` ([`OPS`]) on the given or selected layers; returns
/// the created layer ids and what was skipped.
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})")))?;
let (cid, lids) = layers_p(s, p)?;
@@ -99,8 +101,11 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
continue;
};
let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?;
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)
// In the source layer's pixels, as the footage is drawn (a placed page of an After
// Effects project fills the stored size, #3099).
let doc = crate::vector::footage_doc(&f, &bytes)
.unwrap_or_else(|| Err(format!("{}: not a vector file", f.path)))
.map(|d| crate::vector::fitted_to_footage(d, &f))
.map_err(EngineError::Other)?;
// Images become footage layers (PNG files written next to the source and
// imported), parented to the shape layer below it.
@@ -123,7 +128,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
// Without images: one shape layer. With images: the shapes between them
// in layers of their own, so the stack keeps the document's paint order.
let segments = if image_items.is_empty() { vec![doc.clone()] } else { crate::vector::split_at_images(&doc) };
let top = crate::vector::shapes_from_vector(proj, &comp, &src, segments.last().unwrap_or(&doc));
let mut top = crate::vector::shapes_from_vector(proj, &comp, &src, segments.last().unwrap_or(&doc));
// Top to bottom: the last segment, image n, segment n − 1, … image 1, segment 0.
let mut stack = vec![];
for k in (0..segments.len().saturating_sub(1)).rev() {
@@ -140,9 +145,25 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
sl.name = format!("{} {}", top.name, k + 1);
stack.push(sl);
}
// A placed page shows only its frame (the footage's raster ends there): every
// layer made from it takes a hidden frame layer as its track matte (#3099).
let matte = crate::vector::frame_matte(proj, &comp, &src, &f);
if let Some(m) = &matte {
let tm = Some(TrackMatte { layer: m.id, kind: MatteKind::Alpha });
top.track_matte = tm;
for l in &mut stack {
l.track_matte = tm;
}
}
let mut ids = vec![top.id];
ids.extend(stack.iter().filter(|l| matches!(l.source, LayerSource::Shape)).map(|l| l.id));
// Each directly above the source: the top layer first.
// The frame matte is made too (last: the selection stays on the top layer).
ids.extend(matte.as_ref().map(|m| m.id));
// Each directly above the source: the frame matte (when there is one) above
// all, then the top layer first.
if let Some(m) = matte {
insert_above(proj, cid, lid, m, true)?;
}
insert_above(proj, cid, lid, top, true)?;
for l in stack {
insert_above(proj, cid, lid, l, true)?;
+1 -1
View File
@@ -1352,7 +1352,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load Effect Plug-in...",
[],
None,
"{path (.wasm / .wat plug-in, API v1) | folder (loads every .wasm in it)}",
"{path (.wasm / .wat plug-in, WebAssembly ABI 1 or 2) | folder (loads every .wasm in it)}",
super::always,
plugins_load
),
+4 -4
View File
@@ -719,8 +719,8 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
Ok(serde_json::to_value(&s.project.settings).unwrap_or_default())
}
/// Video Rendering and Effects: report or set the renderer (Mercury GPU Acceleration when a GPU
/// adapter exists, else Mercury Software Only).
/// Video Rendering and Effects: report or set the renderer (GPU Acceleration when a GPU
/// adapter exists, else Software Rendering).
fn render_backend(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(b) = p.get("backend").or_else(|| p.get("renderer")) {
let gpu = match b {
@@ -748,14 +748,14 @@ pub fn backend_status(s: &Session) -> Value {
let gpu = s.project.settings.gpu_acceleration;
// Why it renders on the CPU (#262).
let why = if !gpu {
Some("the project's renderer is Mercury Software Only".to_string())
Some("the project's renderer is Software Rendering".to_string())
} else if adapter.is_none() {
Some(s.accel_note.clone().unwrap_or_else(|| "no GPU compositor is attached".into()))
} else {
None
};
json!({
"renderer": if gpu { "Mercury GPU Acceleration" } else { "Mercury Software Only" },
"renderer": if gpu { "GPU Acceleration" } else { "Software Rendering" },
"gpuAcceleration": gpu,
"adapter": adapter,
"active": if gpu && adapter.is_some() { "gpu" } else { "cpu" },
+3 -3
View File
@@ -1,4 +1,4 @@
//! Commands behind the Progress, Media Browser, Metadata and Lumetri Scopes panels.
//! Commands behind the Progress, Media Browser, Metadata and Video Scopes panels.
use effectcraft_project::ItemId;
use effectcraft_raster::Image;
@@ -159,7 +159,7 @@ fn project_comment(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
// ---------------------------------------------------------------- Lumetri Scopes
// ---------------------------------------------------------------- Video Scopes
/// The comp frame the scopes read (the viewer's image: over the comp background), at most
/// `max_side` pixels on the long side.
@@ -257,7 +257,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("project.setProjectComment", "Project Comment", [], None, "{comment}", always, project_comment),
query!(
"scopes.analyze",
"Lumetri Scopes",
"Video Scopes",
"{comp?, time?|frame?, scope?: waveformRgb|waveformLuma|waveformYc|vectorscopeYuv|vectorscopeHls|histogram|paradeRgb|paradeYuv, standard?: rec601|rec709|rec2020, float?, clamp?, size?}",
scopes_analyze
),
+7 -1
View File
@@ -176,9 +176,11 @@ fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u
Some((u32::try_from(found.index).ok()?, size))
}
/// The pixel size of vector footage `f` as its file is now (its page, framing and layer), or
/// `None` when the file cannot be read or shows nothing.
fn vector_size(s: &Session, f: &Footage) -> Option<(u32, u32)> {
let bytes = s.services.read_file(&f.path).ok()?;
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)?.ok()?;
let doc = crate::vector::footage_doc(f, &bytes)?.ok()?;
(!doc.root.children.is_empty()).then(|| doc.pixel_size())
}
@@ -229,6 +231,10 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
// The layer is gone from the file: keep the item as it was.
None => (w, h) = (f.width, f.height),
},
// A placed page of an After Effects project keeps the size After Effects stored: the
// layers' anchors and masks are measured against it, and the page is drawn to fill it
// (the probe would measure the page as a viewer frames it, #3099).
None if f.placed_page => (w, h) = (f.width, f.height),
// A later page of a PDF: the probe measures the first page.
None if f.page != 0 => (w, h) = (f.width, f.height),
None => {}
+1 -1
View File
@@ -238,7 +238,7 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp {
anim(&mut cam, "cameraOptions/focusDistance", keys(&[(0.0, Value::Scalar(1700.0)), (5.0, Value::Scalar(1900.0)), (10.0, Value::Scalar(1650.0))]));
// Key light: warm spot casting soft shadows. Fill: cool ambient.
let mut key = build::layer(p, &c, "Key Light", LayerSource::Light { kind: effectcraft_project::LightKind::Spot }, (1920, 1080), None);
let mut key = build::layer(p, &c, "Screen Color Key", LayerSource::Light { kind: effectcraft_project::LightKind::Spot }, (1920, 1080), None);
set(&mut key, "transform/position", Value::Vec3([500.0, -500.0, -700.0]));
set(&mut key, "transform/poi", Value::Vec3([960.0, 600.0, 300.0]));
set(&mut key, "lightOptions/intensity", Value::Scalar(150.0));
+1 -1
View File
@@ -259,7 +259,7 @@ pub fn tutorials() -> Vec<Tutorial> {
target: Some("menu.Layer"),
accepts: &["layer.newLight"],
when: Value::Null,
show: vec![("layer.newLight", json!({"kind": "Spot", "name": "Key Light"}))],
show: vec![("layer.newLight", json!({"kind": "Spot", "name": "Screen Color Key"}))],
}),
step(S {
title: "Add a camera",
+61 -3
View File
@@ -261,15 +261,73 @@ pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
}
/// `doc` (the document of footage `f`) in the footage's layer pixels, as the renderer draws it
/// ([`effectcraft_render::vector_fit`]): a placed page of an After Effects project is scaled to
/// fill the size stored for it; other footage is unchanged.
pub fn fitted_to_footage(mut doc: Doc, f: &effectcraft_project::Footage) -> Doc {
let (_, [sx, sy]) = effectcraft_render::vector_fit(f, [doc.width, doc.height], doc.pixel_size(), 1.0);
if f.placed_page {
doc.root.transform = Affine::scale_non_uniform(sx, sy) * doc.root.transform;
(doc.width, doc.height) = (doc.width * sx, doc.height * sy);
}
doc
}
/// Create Shapes from Vector Layer on a placed page of an After Effects project: a hidden shape
/// layer filling the frame the footage is drawn in (its `width` × `height` raster, in the layer
/// pixels of [`fitted_to_footage`]'s document), to be the Alpha track matte of every layer made
/// from the page, so bleed, art past the artboard and the page's background stay hidden as on
/// the footage (#3099). `None` for other footage, whose shapes are converted whole as before.
///
/// A track matte is drawn at the composition's resolution, so its edge is the footage's raster
/// edge on shape and image layers alike; a mask on an image layer would be drawn at the image's
/// own resolution and blur its edge when the image is scaled up.
pub fn frame_matte(proj: &mut Project, comp: &Comp, src: &Layer, f: &effectcraft_project::Footage) -> Option<Layer> {
if !f.placed_page || f.width == 0 || f.height == 0 {
return None;
}
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{0}" height="{1}"><rect width="{0}" height="{1}" fill="#ffffff"/></svg>"##,
f.width, f.height
);
let doc = effectcraft_svg::parse(svg.as_bytes()).ok()?;
let mut l = build::layer(proj, comp, &format!("{} Frame", src.name), LayerSource::Shape, (0, 0), None);
add_doc_shapes(proj, &mut l, &doc);
copy_transform(proj, src, &mut l);
like_source(&mut l, src);
// The layers it mattes carry the source's opacity already.
if let Some(o) = l.props.prop_mut("transform/opacity") {
o.value = Value::Scalar(100.0);
o.keys.clear();
o.expr = None;
}
l.name = comp.unique_layer_name(&format!("{} Frame", src.name));
l.switches.video = false;
Some(l)
}
/// The vector document of footage `f` (its file read as `bytes`; `None` for other formats): its
/// page, framed as [`effectcraft_project::Footage::placed_page`] says, restricted to its layer.
pub fn footage_doc(f: &effectcraft_project::Footage, bytes: &[u8]) -> Option<Result<Doc, String>> {
let framing = if f.placed_page { effectcraft_pdf::Framing::Placed } else { effectcraft_pdf::Framing::Viewer };
vector_doc(&f.path, bytes, f.layer.as_ref(), f.page, framing)
}
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page
/// `page` of a PDF, restricted to one layer when `layer` names it.
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32) -> Option<Result<Doc, String>> {
/// `page` of a PDF framed as `framing` says, restricted to one layer when `layer` names it.
pub fn vector_doc(
path: &str,
bytes: &[u8],
layer: Option<&effectcraft_project::SourceLayer>,
page: u32,
framing: effectcraft_pdf::Framing,
) -> Option<Result<Doc, String>> {
if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) {
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
}
effectcraft_pdf::sniff(bytes)?;
Some(
effectcraft_pdf::parse_page(bytes, page as usize)
effectcraft_pdf::parse_page_framed(bytes, page as usize, framing)
.map(|d| match layer {
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
None => d,
+1 -1
View File
@@ -1263,7 +1263,7 @@ Window
Essential Graphics | window.panel {"panel":"essentialGraphics"}
Info | window.panel {"panel":"info"} | Cmd+2
Learn | help.inAppTutorials
Lumetri Scopes | window.panel {"panel":"lumetriScopes"}
Video Scopes | window.panel {"panel":"lumetriScopes"}
Mask Interpolation | window.panel {"panel":"maskInterpolation"}
Media Browser | window.panel {"panel":"mediaBrowser"}
Metadata | window.panel {"panel":"metadata"}
+19
View File
@@ -23,6 +23,25 @@ fn platform_entries(mac: bool) -> Vec<(Vec<String>, MenuEntry)> {
out
}
/// Guard the generated display tree, not command IDs, format tokens or dynamic user names.
#[test]
fn builtin_menu_labels_do_not_reintroduce_vendor_feature_names() {
for mac in [true, false] {
let entries = platform_entries(mac);
for (path, entry) in &entries {
for label in path.iter().chain(std::iter::once(&entry.label)) {
let folded = label.to_lowercase();
assert!(!folded.starts_with("cc "), "{label}");
for mark in ["lumetri", "mercury", "mocha", "keylight"] {
assert!(!folded.contains(mark), "{label}");
}
}
}
let scopes = entries.iter().find(|(_, e)| e.command == "window.panel" && e.params["panel"] == "lumetriScopes").unwrap();
assert_eq!(scopes.1.label, "Video Scopes");
}
}
#[test]
fn top_level_order_matches_after_effects() {
let mac: Vec<String> =
+7 -4
View File
@@ -304,7 +304,8 @@ pub fn parse_catalog(bytes: &[u8]) -> std::result::Result<(Catalog, Vec<String>)
fn runs_here(r: &Release) -> bool {
// Without a pre-release suffix (`0.18.0-dev` is 0.18.0).
let app = env!("CARGO_PKG_VERSION").split(['-', '+']).next().unwrap_or_default();
r.api == crate::effects::plugin::WASM_API_VERSION && r.min_app.as_deref().is_none_or(|m| !newer(m, app))
(crate::effects::plugin::MIN_WASM_API_VERSION..=crate::effects::plugin::WASM_API_VERSION).contains(&r.api)
&& r.min_app.as_deref().is_none_or(|m| !newer(m, app))
}
/// The newest release this build can run (plug-in API and minimum app version).
@@ -789,12 +790,13 @@ fn pick(s: &Session, c: &str, p: &Value) -> Result<(String, Package, Release)> {
return Err(bad(
c,
format!(
"{} {} needs plug-in API {} and EffectCraft {} or later; this is EffectCraft {} with WebAssembly plug-in API {}",
"{} {} needs WebAssembly ABI {} and EffectCraft {} or later; this is EffectCraft {} with WebAssembly ABI {} to {}",
pkg.name,
rel.version,
rel.api,
rel.min_app.as_deref().unwrap_or("(any)"),
env!("CARGO_PKG_VERSION"),
crate::effects::plugin::MIN_WASM_API_VERSION,
crate::effects::plugin::WASM_API_VERSION
),
));
@@ -1125,8 +1127,9 @@ mod tests {
..Default::default()
};
assert_eq!(latest_compatible(&p).map(|r| r.version.as_str()), Some("1.2.0"));
assert!(runs_here(&rel("1.0.0", 1, None)), "API 1 modules still run on a build with a newer plug-in API");
assert!(!runs_here(&rel("1.0.0", 2, None)), "WebAssembly modules are API 1: an API 2 release can't load");
assert!(runs_here(&rel("1.0.0", 1, None)), "ABI 1 modules still run on a build with a newer ABI");
assert!(runs_here(&rel("1.0.0", 2, None)), "ABI 2 modules run on this build");
assert!(!runs_here(&rel("1.0.0", 3, None)), "a WebAssembly ABI this build doesn't implement can't load");
let j = package_json(&p, None, &Status::default());
assert_eq!(j["needsNewerApp"], true);
assert!(latest_compatible(&Package::default()).is_none());
+2 -2
View File
@@ -439,7 +439,7 @@ const RIGGED: &str = r#"{
],
"cameras": [{"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.6, "znear": 0.1}}],
"extensions": {"KHR_lights_punctual": {"lights": [
{"type": "spot", "name": "Key Light", "color": [1, 0.8, 0.6], "intensity": 1.5, "spot": {"innerConeAngle": 0.25, "outerConeAngle": 0.5}},
{"type": "spot", "name": "Screen Color Key", "color": [1, 0.8, 0.6], "intensity": 1.5, "spot": {"innerConeAngle": 0.25, "outerConeAngle": 0.5}},
{"type": "point", "name": "Fill Light", "intensity": 0.5, "range": 4}
]}}
}"#;
@@ -478,7 +478,7 @@ fn cameras_and_lights_from_model() {
let r = s.execute("light.fromModel", json!({})).unwrap();
let ls: Vec<Layer> = r["layers"].as_array().unwrap().iter().map(|v| layer_in(&s, cid(&s), v.as_u64().unwrap())).collect();
assert_eq!(ls.len(), 2);
let key = ls.iter().find(|l| l.name == "Key Light").unwrap();
let key = ls.iter().find(|l| l.name == "Screen Color Key").unwrap();
assert_eq!(key.source, LayerSource::Light { kind: LightKind::Spot });
assert!((key.props.prop("lightOptions/coneAngle").unwrap().value.as_f64() - 1.0f64.to_degrees()).abs() < 1e-6);
assert!((key.props.prop("lightOptions/coneFeather").unwrap().value.as_f64() - 50.0).abs() < 1e-6);
+29 -1
View File
@@ -1221,7 +1221,35 @@ fn render_backend_says_why_it_renders_on_the_cpu() {
assert_eq!(why(&mut s), json!("no GPU compositor is attached"));
s.accel_note = Some("headless: renders on the CPU unless started with --gpu".into());
assert_eq!(why(&mut s), json!("headless: renders on the CPU unless started with --gpu"));
assert_eq!(s.execute("render.backend", json!({"backend": "cpu"})).unwrap()["why"], json!("the project's renderer is Mercury Software Only"));
assert_eq!(s.execute("render.backend", json!({"backend": "cpu"})).unwrap()["why"], json!("the project's renderer is Software Rendering"));
}
/// Display labels are descriptive; machine-readable settings remain the same.
#[test]
fn renderer_labels_keep_their_stable_machine_values() {
let mut s = Session::default();
for (backend, label, gpu) in [("cpu", "Software Rendering", false), ("gpu", "GPU Acceleration", true)] {
let status = s.execute("render.backend", json!({"backend": backend})).unwrap();
assert_eq!(status["renderer"], json!(label));
assert_eq!(status["gpuAcceleration"], json!(gpu));
assert_eq!(status["active"], json!("cpu"), "the headless test has no GPU adapter");
}
}
/// The label `render.backend` reports is one it accepts back, so a caller can round-trip the
/// status instead of guessing the command's own values.
#[test]
fn renderer_status_labels_round_trip_through_the_command() {
let mut s = Session::default();
for (backend, label, gpu) in [("cpu", "Software Rendering", false), ("gpu", "GPU Acceleration", true)] {
let reported = s.execute("render.backend", json!({"backend": backend})).unwrap()["renderer"].clone();
assert_eq!(reported, json!(label));
let parsed = effectcraft_render::Backend::parse(reported.as_str().unwrap()).expect("the reported renderer parses back");
assert_eq!(parsed == effectcraft_render::Backend::Cpu, !gpu, "{reported}");
let again = s.execute("render.backend", json!({"backend": reported})).unwrap();
assert_eq!(again["renderer"], reported);
assert_eq!(again["gpuAcceleration"], json!(gpu));
}
}
/// Settings ▸ GPU Information names what the GPU compositor runs on, or why it is off, and the