mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 18:18:17 -06:00
Merge remote-tracking branch 'origin/main' into menu-bar-native-behaviour
# Conflicts: # crates/ui-egui/src/menus.rs
This commit is contained in:
@@ -503,6 +503,9 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
};
|
||||
let mut b = backend(args, true)?;
|
||||
let r = b.exec(&id, params)?;
|
||||
if let Some(e) = failed_render(&id, &r) {
|
||||
return Err(Failure::Error(e));
|
||||
}
|
||||
let saved = maybe_save(&mut b, args)?;
|
||||
emit(&with_saved(r, saved), json_out);
|
||||
}
|
||||
@@ -525,6 +528,9 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
};
|
||||
i += 1;
|
||||
let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?;
|
||||
if let Some(e) = failed_render(id, &r) {
|
||||
return Err(Failure::Error(format!("{id}: {e}")));
|
||||
}
|
||||
if !json_out {
|
||||
emit(&r, false);
|
||||
}
|
||||
@@ -647,6 +653,21 @@ fn positional<const N: usize>(args: &Args, usage: &str) -> Result<[String; N], F
|
||||
Ok(std::array::from_fn(|i| args.pos[i].clone()))
|
||||
}
|
||||
|
||||
/// `renderQueue.render` reports a failed item in its result, not as an error. Under `run`/`exec`
|
||||
/// that item fails the command (#1426), as it fails `render --queue`.
|
||||
fn failed_render(id: &str, r: &Value) -> Option<String> {
|
||||
if id != "renderQueue.render" {
|
||||
return None;
|
||||
}
|
||||
let failed: Vec<String> = r["items"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|i| Some(format!("{}: {}", i["compName"].as_str().unwrap_or("?"), i["status"].get("Failed")?.as_str().unwrap_or("failed"))))
|
||||
.collect();
|
||||
(!failed.is_empty()).then(|| failed.join("; "))
|
||||
}
|
||||
|
||||
fn with_saved(v: Value, saved: Option<String>) -> Value {
|
||||
match saved {
|
||||
None => v,
|
||||
@@ -670,11 +691,10 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
let mut s = session(args)?;
|
||||
if args.opt("--template").is_some() {
|
||||
template::prepare(&mut s, args)?;
|
||||
} else {
|
||||
match &args.project {
|
||||
Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?,
|
||||
None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?,
|
||||
};
|
||||
} else if let Some(p) = &args.project {
|
||||
s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?;
|
||||
} else if args.flag("--demo") || !args.flag("--empty") {
|
||||
s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?;
|
||||
}
|
||||
let err = |e: effectcraft_engine::EngineError| Failure::Error(e.to_string());
|
||||
if !args.flag("--queue") {
|
||||
|
||||
@@ -36,7 +36,20 @@ pub(super) fn validate(cmd: &str, args: &Args) -> Result<(), Failure> {
|
||||
{
|
||||
for input in ["--template", "--values"] {
|
||||
if let Some(path) = args.opt(input)
|
||||
&& std::fs::canonicalize(path).is_ok_and(|path| path == output)
|
||||
&& std::fs::canonicalize(path).is_ok_and(|path| {
|
||||
if path == output {
|
||||
return true;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// Distinct canonical paths can still name the same file through hard links.
|
||||
if let (Ok(input), Ok(output)) = (std::fs::metadata(&path), std::fs::metadata(&output)) {
|
||||
return input.dev() == output.dev() && input.ino() == output.ino();
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
{
|
||||
return Err(Failure::Error(format!("--out {out} would overwrite {input} {path}")));
|
||||
}
|
||||
|
||||
@@ -484,6 +484,32 @@ fn mcp_autosave_is_opt_in_and_rejects_invalid_combinations() {
|
||||
std::fs::remove_dir_all(config).unwrap();
|
||||
}
|
||||
|
||||
/// `run`/`exec renderQueue.render` (#1426): a failed item is a command error, exit 1 like
|
||||
/// `render --queue`, not a success with the failure buried in the JSON.
|
||||
#[test]
|
||||
fn a_failed_render_under_run_and_exec_exits_1() {
|
||||
let root = tmp("render-fails");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
// The output's folder is a file, so the encoder cannot open the output.
|
||||
let blocker = root.join("not-a-folder");
|
||||
std::fs::write(&blocker, "x").unwrap();
|
||||
let out = blocker.join("out.gif");
|
||||
let add = json!({"comp": "T", "format": "gif", "output": out.to_str().unwrap()}).to_string();
|
||||
let project = root.join("q.ecproj");
|
||||
let steps = ["comp.new", r#"{"name":"T","width":16,"height":16,"duration":0.1}"#, "layer.newSolid", "{}", "renderQueue.add", &add];
|
||||
ok_json(&[&["run", "--empty", "--save-as", project.to_str().unwrap()][..], &steps[..]].concat());
|
||||
|
||||
let (code, v) = run_json(&[&["run", "--empty"][..], &steps[..], &["renderQueue.render", r#"{"wait":true}"#][..]].concat());
|
||||
assert_eq!(code, 1, "{v}");
|
||||
let e = v["error"].as_str().unwrap_or_else(|| panic!("{v}"));
|
||||
assert!(e.starts_with("renderQueue.render: T: "), "{e}");
|
||||
|
||||
let (code, v) = run_json(&["exec", "renderQueue.render", "--project", project.to_str().unwrap()]);
|
||||
assert_eq!(code, 1, "{v}");
|
||||
assert!(v["error"].as_str().is_some_and(|e| e.starts_with("T: ")), "{v}");
|
||||
}
|
||||
|
||||
/// `render --bitrate` (#440) targets the bitrate in VP9 WebM.
|
||||
#[test]
|
||||
fn render_bitrate_applies_to_vp9_webm() {
|
||||
@@ -513,3 +539,57 @@ fn render_bitrate_applies_to_vp9_webm() {
|
||||
assert!(high > low, "100 kbps: {low} bytes, 8000 kbps: {high} bytes");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Render uses the same empty/project/demo selection as the other CLI commands.
|
||||
#[test]
|
||||
fn render_empty_does_not_load_the_demo() {
|
||||
let root = tmp("render-empty");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let project = root.join("control.ecproj");
|
||||
let template = root.join("control.ectemplate");
|
||||
let project_path = project.to_str().unwrap();
|
||||
let template_path = template.to_str().unwrap();
|
||||
let build = r#"
|
||||
var comp = app.project.items.addComp("Empty control", 64, 36, 1, 1, 30);
|
||||
var solid = comp.layers.addSolid([0, 1, 0], "Green", 64, 36, 1);
|
||||
solid.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Opacity");
|
||||
"#;
|
||||
ok_json(&["script", "--eval", build, "--save-as", project_path]);
|
||||
let params = json!({"comp": "Empty control", "path": template_path}).to_string();
|
||||
let exported = ok_json(&["exec", "essential.exportTemplate", ¶ms, "--project", project_path]);
|
||||
assert_eq!(exported["controls"], 1, "{exported}");
|
||||
let empty = ok_json(&["info", "--empty"]);
|
||||
assert!(empty["project"]["items"].as_array().unwrap().is_empty(), "{empty}");
|
||||
|
||||
let render = |options: &[&str], name: &str| {
|
||||
let output = root.join(name);
|
||||
assert!(!output.exists());
|
||||
let mut args = vec!["render", "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", output.to_str().unwrap()];
|
||||
args.extend_from_slice(options);
|
||||
let reply = ok_json(&args);
|
||||
assert_eq!(reply["rendered"].as_array().unwrap().len(), 1, "{reply}");
|
||||
assert_eq!(PathBuf::from(reply["rendered"][0]["output"].as_str().unwrap()), output);
|
||||
let bytes = std::fs::read(&output).unwrap();
|
||||
assert!(bytes.len() >= 10 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")), "{reply}");
|
||||
let dimensions = (u16::from_le_bytes([bytes[6], bytes[7]]), u16::from_le_bytes([bytes[8], bytes[9]]));
|
||||
(reply["rendered"][0]["comp"].clone(), dimensions)
|
||||
};
|
||||
// Defaults still render the demo, and an explicit demo wins over --empty.
|
||||
let demo = render(&[], "default.gif");
|
||||
assert_eq!(render(&["--demo", "--empty"], "demo.gif"), demo);
|
||||
// A supplied project wins over both flags, while template mode also accepts --empty.
|
||||
let project_result = render(&["--project", project_path, "--demo", "--empty"], "project.gif");
|
||||
assert_eq!(project_result, (json!("Empty control"), (16, 9)));
|
||||
assert_eq!(render(&["--template", template_path, "--empty"], "template.gif").1, (16, 9));
|
||||
|
||||
let output = root.join("empty.gif");
|
||||
assert!(!output.exists());
|
||||
let (code, reply) =
|
||||
run_json(&["render", "--empty", "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", output.to_str().unwrap()]);
|
||||
let output_exists = output.exists();
|
||||
assert!(!output_exists, "--empty must not render the demo into the requested output");
|
||||
assert_eq!(code, 1, "{reply}");
|
||||
assert_eq!(reply["error"], "command `renderQueue.add` is not available right now: select or open a composition", "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ fn template_output_alias_fixture(case: &str) -> PathBuf {
|
||||
dir
|
||||
}
|
||||
|
||||
fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) {
|
||||
fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) -> Value {
|
||||
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
let values_before = std::fs::read(dir.join("values.json")).unwrap();
|
||||
// Read the inputs before interpreting the subprocess status or error text.
|
||||
@@ -272,7 +272,8 @@ fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) {
|
||||
);
|
||||
assert!(template_preserved, "template rendering must preserve its template input when --out aliases that file");
|
||||
assert!(values_preserved, "template rendering must preserve its values input when --out aliases that file");
|
||||
assert!(!result.status.success(), "an output alias must fail before rendering overwrites an input");
|
||||
assert_eq!(result.status.code(), Some(1), "an output alias must fail before rendering overwrites an input");
|
||||
serde_json::from_slice(&result.stdout).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -652,3 +653,39 @@ fn template_missing_output_does_not_replace_extracted_media() {
|
||||
assert!(media_preserved, "missing --out must fail before overwriting previously extracted media");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn template_frame_output_cannot_alias_its_template_through_a_hard_link() {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let dir = template_output_alias_fixture("hard-linked-template");
|
||||
let input = dir.join("lt.ectemplate");
|
||||
let output = dir.join("hard-linked-template.png");
|
||||
std::fs::hard_link(&input, &output).unwrap();
|
||||
let input_metadata = std::fs::metadata(&input).unwrap();
|
||||
let output_metadata = std::fs::metadata(&output).unwrap();
|
||||
assert_eq!((input_metadata.dev(), input_metadata.ino()), (output_metadata.dev(), output_metadata.ino()), "actual hard-link file identity");
|
||||
assert_ne!(input.canonicalize().unwrap(), output.canonicalize().unwrap(), "the hard link has a distinct canonical path");
|
||||
let reply = assert_template_output_preserves_inputs(&dir, "hard-linked-template.png");
|
||||
assert_eq!(reply["error"], "--out hard-linked-template.png would overwrite --template lt.ectemplate", "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn template_frame_output_cannot_alias_its_values_through_a_hard_link() {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let dir = template_output_alias_fixture("hard-linked-values");
|
||||
let input = dir.join("values.json");
|
||||
let output = dir.join("hard-linked-values.png");
|
||||
std::fs::hard_link(&input, &output).unwrap();
|
||||
let input_metadata = std::fs::metadata(&input).unwrap();
|
||||
let output_metadata = std::fs::metadata(&output).unwrap();
|
||||
assert_eq!((input_metadata.dev(), input_metadata.ino()), (output_metadata.dev(), output_metadata.ino()), "actual hard-link file identity");
|
||||
assert_ne!(input.canonicalize().unwrap(), output.canonicalize().unwrap(), "the hard link has a distinct canonical path");
|
||||
let reply = assert_template_output_preserves_inputs(&dir, "hard-linked-values.png");
|
||||
assert_eq!(reply["error"], "--out hard-linked-values.png would overwrite --values values.json", "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -144,6 +144,10 @@ impl Services for WebServices {
|
||||
let dir = dir.trim_end_matches('/');
|
||||
list().into_iter().map(|(p, _)| p).filter(|p| p.rsplit_once('/').is_some_and(|(d, _)| d == dir)).collect()
|
||||
}
|
||||
/// The table's files (the default asks `std::fs`, which has no files on wasm32).
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
get(path).is_some()
|
||||
}
|
||||
fn is_dir(&self, _path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -441,6 +441,18 @@ try {
|
||||
const comps = await js(`effectcraft.inspect().then(i => i.activeComp)`);
|
||||
if (!comps) throw new Error("reopened project has no active comp");
|
||||
report.steps.reopen = { ...opened, activeComp: comps };
|
||||
|
||||
// Files the page holds (browser storage, no std::fs): a script opens one next to the project,
|
||||
// and the footage check finds the imported media instead of marking it missing.
|
||||
const hf = await js(`(async () => {
|
||||
const p = await effectcraft.addFile(new File(["from the page"], "script-data.txt"));
|
||||
await effectcraft.saveToBrowser("/host-files.ecproj");
|
||||
const run = await effectcraft.execute("script.run", {code: "var f = new File(" + JSON.stringify(p) + "); writeLn(f.open('r') ? f.read() : f.error);"});
|
||||
return {output: run.output, footage: await effectcraft.execute("footage.check", {wait: true})};
|
||||
})()`);
|
||||
report.steps.hostFiles = hf;
|
||||
check(hf.output === "from the page", `a script opens a file the page holds (${hf.output})`);
|
||||
check(hf.footage.checked > 0 && hf.footage.missing === 0, `stored footage isn't missing (${JSON.stringify(hf.footage)})`);
|
||||
report.steps.final = { screenshot: await shot("02-after") };
|
||||
|
||||
// Persistence: the session, imported media, settings and a project saved to browser storage
|
||||
|
||||
@@ -98,6 +98,9 @@ pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
|
||||
match id {
|
||||
"ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false,
|
||||
"ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false,
|
||||
// #1195: CC Particle World's motion trails (the averaged shutter samples) render on the
|
||||
// CPU kernel until the GPU particle pass supports them.
|
||||
"ec.sim.ccparticleworld" if ctx.env.shutter.filter(|(a, _, _)| *a > 0.0).is_some() => return false,
|
||||
_ => {}
|
||||
}
|
||||
let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true };
|
||||
@@ -199,6 +202,14 @@ mod tests {
|
||||
assert!(ok("ec.color.lumetri", &[]));
|
||||
assert!(!ok("ec.color.lumetri", &[("hslSecondary/correction/saturation", Value::Scalar(50.0))]));
|
||||
assert!(!ok("ec.noise.addgrain", &[("preset", Value::Enum(2))]));
|
||||
// #1195: CC Particle World's motion trails (shutter samples) render on the CPU.
|
||||
{
|
||||
let params = ctx_with("ec.sim.ccparticleworld", &[]);
|
||||
let env = crate::EffectEnv { frame_rate: 30.0, shutter: Some((180.0, -90.0, 16)), ..Default::default() };
|
||||
let ctx = crate::EffectCtx { params: ¶ms, time: 0.0, layer_size: [64.0, 64.0], seed: 0, adjustment: false, env };
|
||||
assert!(!super::gpu_supported("ec.sim.ccparticleworld", &ctx));
|
||||
}
|
||||
assert!(ok("ec.sim.ccparticleworld", &[]));
|
||||
assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))]));
|
||||
assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))]));
|
||||
assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))]));
|
||||
|
||||
@@ -326,20 +326,27 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 {
|
||||
v.abs().powf(p).copysign(v)
|
||||
}
|
||||
|
||||
/// Black & White: a pixel's gray is its smallest channel plus its chroma (largest − smallest
|
||||
/// channel) times the colour sliders' weight for its hue. A neutral pixel keeps its value, a pure
|
||||
/// hue becomes its slider's percentage (Reds 40 % turns pure red into 40 % gray), and the gray
|
||||
/// never moves further from the smallest channel than the pixel's own chroma times the weight.
|
||||
/// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted
|
||||
/// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright
|
||||
/// and black blocks (#1463), and 32-bpc overbrights divided by zero.
|
||||
fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")]
|
||||
.map(|v| v as f32 / 100.0);
|
||||
let tint_on = ctx.params.b("tint");
|
||||
let tc = ctx.params.color("tintColor");
|
||||
b.img.map_straight(|c| {
|
||||
let (h, s, _) = rgb_to_hsl(c[0], c[1], c[2]);
|
||||
let (h, _, _) = rgb_to_hsl(c[0], c[1], c[2]);
|
||||
// Interpolate the six hue weights around the colour wheel.
|
||||
let hp = h * 6.0;
|
||||
let i = hp.floor() as usize % 6;
|
||||
let f = hp - hp.floor();
|
||||
let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f;
|
||||
let base = luminance(c[0], c[1], c[2]);
|
||||
let gray = (base + (wt - 0.5) * s * 0.6).max(0.0);
|
||||
let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2]));
|
||||
let gray = (min + (max - min) * wt).max(0.0);
|
||||
if tint_on {
|
||||
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
|
||||
let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0));
|
||||
@@ -1220,6 +1227,66 @@ mod tests {
|
||||
assert!(go(0.0, 100.0, 64) >= 30);
|
||||
}
|
||||
|
||||
/// #1463: Black & White's sliders set the gray of each pure hue and blend between
|
||||
/// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright
|
||||
/// colour stays finite (it used to divide by zero).
|
||||
#[test]
|
||||
fn black_white_sliders_set_the_gray_of_each_hue() {
|
||||
let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]);
|
||||
let gray = |v: f32| [v, v, v, 1.0];
|
||||
// The defaults: Reds 40, Yellows 60, Greens 40, Cyans 60, Blues 20, Magentas 80 (%).
|
||||
let hues = [
|
||||
([1.0, 0.0, 0.0], 0.4),
|
||||
([1.0, 1.0, 0.0], 0.6),
|
||||
([0.0, 1.0, 0.0], 0.4),
|
||||
([0.0, 1.0, 1.0], 0.6),
|
||||
([0.0, 0.0, 1.0], 0.2),
|
||||
([1.0, 0.0, 1.0], 0.8),
|
||||
// Half as bright, half the gray; orange is halfway from Reds to Yellows.
|
||||
([0.5, 0.0, 0.0], 0.2),
|
||||
([1.0, 0.5, 0.0], 0.5),
|
||||
// Neutral pixels keep their value.
|
||||
([0.3, 0.3, 0.3], 0.3),
|
||||
([4.0, 4.0, 4.0], 4.0),
|
||||
// An overbright red: its smallest channel plus its chroma at Reds' 40 %.
|
||||
([1.5, 0.5, 0.5], 0.9),
|
||||
];
|
||||
for (c, want) in hues {
|
||||
let o = bw(&[], c);
|
||||
assert!(close(o, gray(want), 1e-5), "{c:?}: {o:?}, want {want}");
|
||||
}
|
||||
assert!(close(bw(&[("reds", num(120.0))], [1.0, 0.0, 0.0]), gray(1.2), 1e-5));
|
||||
assert!(close(bw(&[("reds", num(-200.0))], [1.0, 0.0, 0.0]), gray(0.0), 1e-5));
|
||||
assert!(close(bw(&[("blues", num(300.0))], [0.1, 0.1, 0.3]), gray(0.7), 1e-5));
|
||||
}
|
||||
|
||||
/// #1463: faint colour noise stays faint. The shadows of compressed footage carry a code or
|
||||
/// two of colour noise, which Black & White turned into bright and black blocks (the HSL
|
||||
/// saturation it scaled is near 1 for any tinted near-black pixel). At 8, 16 and 32 bpc the
|
||||
/// gray of a dark ramp with ±2 steps of colour noise stays within 2 steps of the clean ramp's.
|
||||
#[test]
|
||||
fn black_white_keeps_faint_colour_noise_faint() {
|
||||
// A step of 8 bpc, of 16 bpc (After Effects' 0..32768), and a small 32-bpc float one.
|
||||
for step in [1.0 / 255.0, 1.0 / 32768.0, 1e-3_f32] {
|
||||
let (w, h) = (64u32, 4u32);
|
||||
let (mut clean, mut noisy) = (Image::new(w, h), Image::new(w, h));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// A dark ramp up to a quarter gray, in whole steps...
|
||||
let v = (x as f32 / w as f32 * 0.25 / step).round() * step;
|
||||
// ...and up to ±2 steps of noise per channel, mixed differently in each pixel.
|
||||
let n = |k: u32| (((x * 7 + y * 13 + k * 2) % 5) as f32 - 2.0) * step;
|
||||
clean.set(x, y, [v, v, v, 1.0]);
|
||||
noisy.set(x, y, [(v + n(0)).max(0.0), (v + n(1)).max(0.0), (v + n(2)).max(0.0), 1.0]);
|
||||
}
|
||||
}
|
||||
let bw = |img| run_fx("ec.color.blackwhite", &[], img, 0.0, EffectEnv::default()).img;
|
||||
let (a, b) = (bw(clean), bw(noisy));
|
||||
let worst = a.data.iter().zip(&b.data).map(|(p, q)| (p[0] - q[0]).abs()).fold(0.0, f32::max);
|
||||
assert!(worst <= 2.0 * step + 1e-6, "step {step}: the noise moved the gray by {} steps", worst / step);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_clips_black_and_white_independently() {
|
||||
let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0];
|
||||
|
||||
@@ -238,9 +238,22 @@ impl SpritePlan {
|
||||
}
|
||||
|
||||
/// Rasterise (after [`SpritePlan::resolve`]) and meet the original layer `b`.
|
||||
pub fn finish(&self, mut b: Buf) -> Buf {
|
||||
pub fn finish(&self, b: Buf) -> Buf {
|
||||
let fx = splat(b.img.width, b.img.height, &self.sprites, self.acc);
|
||||
b.img = match self.post {
|
||||
self.finish_with(b, fx)
|
||||
}
|
||||
|
||||
/// [`finish`](Self::finish) with a prepared sprite image (the motion-blurred average of
|
||||
/// several frames, #1195).
|
||||
pub fn finish_with(&self, b: Buf, fx: Image) -> Buf {
|
||||
self.post.apply(b, fx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Post {
|
||||
/// Meet the sprite image `fx` with the original layer `b` (see [`SpritePlan::finish`]).
|
||||
fn apply(self, mut b: Buf, fx: Image) -> Buf {
|
||||
b.img = match self {
|
||||
Post::Combine(m) => combine(&b.img, &fx, m),
|
||||
Post::Replace => fx,
|
||||
Post::Lerp(t) => {
|
||||
@@ -1069,6 +1082,29 @@ fn pw_phys(ctx: &EffectCtx) -> Phys {
|
||||
}
|
||||
|
||||
fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
// #1195: with the comp's motion-blur shutter open, particles draw as motion trails: the
|
||||
// sprite fields of samples across the shutter are averaged, as Transform averages its
|
||||
// matrix samples. (The GPU compositor runs this case on the CPU: catalog::gpu_supported.)
|
||||
if let Some((angle, phase, n)) = ctx.env.shutter.filter(|(a, _, _)| *a > 0.0) {
|
||||
let n = (n.clamp(2, 64) as usize).max(2);
|
||||
let fd = 1.0 / ctx.fps().max(0.001);
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
let mut post: Option<Post> = None;
|
||||
for k in 0..n {
|
||||
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
|
||||
let sub = EffectCtx { params: ctx.params, time: t, layer_size: ctx.layer_size, seed: ctx.seed, adjustment: ctx.adjustment, env: ctx.env };
|
||||
if let Some(plan) = particle_world_plan(&sub, &b) {
|
||||
let fx = splat(b.img.width, b.img.height, &plan.sprites, plan.acc);
|
||||
acc.data.iter_mut().zip(&fx.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
post.get_or_insert(plan.post);
|
||||
}
|
||||
}
|
||||
acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= n as f32));
|
||||
return match post {
|
||||
Some(p) => p.apply(b, acc),
|
||||
None => b,
|
||||
};
|
||||
}
|
||||
match particle_world_plan(ctx, &b) {
|
||||
Some(plan) => plan.finish(b),
|
||||
None => b,
|
||||
@@ -1715,6 +1751,31 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
/// #1195: with the comp's shutter open, the particles smear across it: the average of the
|
||||
/// sampled sprite fields differs from the sharp frame, touches more pixels and keeps the
|
||||
/// alpha energy.
|
||||
#[test]
|
||||
fn particle_world_motion_blur_trails() {
|
||||
let v = pw_vals(21.0);
|
||||
let blank = Image::new(40, 40);
|
||||
let sharp = run("ec.sim.ccparticleworld", &v, blank.clone(), 2.0);
|
||||
let blurred = crate::run_fx(
|
||||
"ec.sim.ccparticleworld",
|
||||
&v,
|
||||
blank.clone(),
|
||||
2.0,
|
||||
EffectEnv { frame_rate: 10.0, shutter: Some((360.0, -180.0, 8)), ..Default::default() },
|
||||
)
|
||||
.img;
|
||||
assert!(sharp.data.iter().any(|p| p[3] > 0.0), "sharp frame has particles");
|
||||
assert_ne!(sharp.data, blurred.data, "the shutter smears the particles");
|
||||
let lit = |img: &Image| img.data.iter().filter(|p| p[3] > 0.005).count();
|
||||
let sum = |img: &Image| img.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
assert!(lit(&blurred) > lit(&sharp), "trails light more pixels: {} vs {}", lit(&blurred), lit(&sharp));
|
||||
let (sa, ba) = (sum(&sharp), sum(&blurred));
|
||||
assert!((ba - sa).abs() < sa * 0.6 + 1.0, "alpha energy roughly kept: {ba} vs {sa}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_world_seek_consistent() {
|
||||
let v = pw_vals(11.0);
|
||||
|
||||
@@ -245,7 +245,8 @@ fn has_footage(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
/// `File.execute()` from scripts (and agents): open a file with its default application.
|
||||
/// Needs Allow Scripts to Write Files and Access Network; with Warn User When Executing Files on,
|
||||
/// the frontend asks first (`confirmed: true` skips the question).
|
||||
/// the frontend asks first. `confirmed: true` is the frontend's answer to that question: a
|
||||
/// script can't give it for itself (#1246), so a script's request always asks.
|
||||
fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.executeFile", "missing `path`"))?;
|
||||
if !s.prefs.scripting.allow_scripts_write_files {
|
||||
@@ -257,7 +258,8 @@ fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(bad("file.executeFile", format!("no file {path}")));
|
||||
}
|
||||
if s.prefs.scripting.warn_executing_files && !b_p(p, "confirmed").unwrap_or(false) {
|
||||
let confirmed = b_p(p, "confirmed").unwrap_or(false) && !s.running_for_script();
|
||||
if s.prefs.scripting.warn_executing_files && !confirmed {
|
||||
s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) });
|
||||
return Ok(json!({"needsConfirmation": path}));
|
||||
}
|
||||
|
||||
@@ -417,6 +417,8 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str,
|
||||
let applies = |l: &Layer| match name {
|
||||
"audio" => l.props.sub("audio").is_some(),
|
||||
"lock" | "locked" => true,
|
||||
// Nulls have a Video switch too (it hides their outline in the viewer).
|
||||
"video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id),
|
||||
_ => l.source.is_av() && !mattes.contains(&l.id),
|
||||
};
|
||||
let mut alone = true;
|
||||
|
||||
@@ -115,7 +115,7 @@ fn hide_other_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Hide Other Video", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() {
|
||||
if !ids.contains(&l.id) && l.source.is_av() {
|
||||
if !ids.contains(&l.id) && (l.source.is_av() || matches!(l.source, LayerSource::Null)) {
|
||||
l.switches.video = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ fn edit_path_counted<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(
|
||||
let roto = super::paths::is_roto(g);
|
||||
let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?;
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) };
|
||||
// A path read from a file may have fewer tangents than vertices; the edits below index
|
||||
// all three arrays by vertex (#1250).
|
||||
sp.fit_tangents();
|
||||
let (r, op) = f(&mut sp)?;
|
||||
if roto {
|
||||
super::paths::roto_smooth(&mut sp);
|
||||
@@ -287,6 +290,7 @@ fn edit_vertices_counted(s: &mut Session, p: &Value, label: &str, removes: bool,
|
||||
let roto = super::paths::is_roto(g);
|
||||
let Some(pr) = g.get_mut("path") else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
sp.fit_tangents();
|
||||
f(&mut sp, idx);
|
||||
if roto {
|
||||
super::paths::roto_smooth(&mut sp);
|
||||
|
||||
@@ -235,7 +235,8 @@ mod tests {
|
||||
paths.sort();
|
||||
// Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed
|
||||
// separators; `Path` equality compares components, so both spellings are the same file.
|
||||
let expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"];
|
||||
let mut expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"];
|
||||
expected.sort();
|
||||
assert_eq!(paths.len(), expected.len(), "{paths:?}");
|
||||
for (got, want) in paths.iter().zip(expected) {
|
||||
assert_eq!(std::path::Path::new(got), std::path::Path::new(want), "{paths:?}");
|
||||
|
||||
@@ -371,6 +371,34 @@ fn execute_file_needs_the_gate_and_asks_first() {
|
||||
assert!(s.execute("file.executeFile", p).unwrap().get("opened").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_cannot_confirm_executing_a_file_for_itself() {
|
||||
// Warn User When Executing Files: only the user answers the question (#1246).
|
||||
let mut s = session();
|
||||
let f = tmp("script-run.txt");
|
||||
std::fs::write(&f, "x").unwrap();
|
||||
let path = f.to_string_lossy().to_string();
|
||||
let opened = |s: &mut Session| s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(_)));
|
||||
// Without the gate a script can't run files at all, confirmed or not.
|
||||
assert!(s.execute_for_script("file.executeFile", json!({"path": path, "confirmed": true})).is_err());
|
||||
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
|
||||
s.drain_events();
|
||||
// A script asserting `confirmed` still gets the question, directly or in a batch.
|
||||
let r = s.execute_for_script("file.executeFile", json!({"path": path, "confirmed": true})).unwrap();
|
||||
assert!(r.get("needsConfirmation").is_some(), "{r}");
|
||||
assert!(!opened(&mut s));
|
||||
let steps = json!({"steps": [{"command": "file.executeFile", "params": {"path": path, "confirmed": true}}]});
|
||||
let r = s.execute_for_script("engine.batch", steps).unwrap();
|
||||
assert!(r.to_string().contains("needsConfirmation"), "{r}");
|
||||
assert!(!opened(&mut s));
|
||||
// The user's answer from the frontend's question still opens it.
|
||||
assert!(s.execute("file.executeFile", json!({"path": path, "confirmed": true})).unwrap().get("opened").is_some());
|
||||
assert!(opened(&mut s));
|
||||
// With the warning off a script's request opens the file without a question, as before.
|
||||
s.execute("prefs.set", json!({"key": "scripting.warnExecutingFiles", "value": false})).unwrap();
|
||||
assert!(s.execute_for_script("file.executeFile", json!({"path": path})).unwrap().get("opened").is_some());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Edit ▸ History, recent lists
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -111,6 +111,38 @@ fn set_switch_others() {
|
||||
assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err());
|
||||
}
|
||||
|
||||
/// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles,
|
||||
/// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is
|
||||
/// hidden (parenting ignores the eye).
|
||||
#[test]
|
||||
fn null_video_switch() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [n], "switch": "video"})).unwrap();
|
||||
assert!(!layer(&s, n).switches.video);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(layer(&s, n).switches.video);
|
||||
// Alt-click on the solid's eye hides the null too; on the null's, the solid.
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
|
||||
assert!(layer(&s, a).switches.video && !layer(&s, n).switches.video);
|
||||
s.execute("layer.setSwitch", json!({"layers": [n], "switch": "video", "others": true})).unwrap();
|
||||
assert!(!layer(&s, a).switches.video && layer(&s, n).switches.video);
|
||||
s.execute("layer.showAllVideo", json!({})).unwrap();
|
||||
s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap();
|
||||
assert!(layer(&s, a).switches.video && !layer(&s, n).switches.video);
|
||||
// The hidden null still carries its child.
|
||||
s.execute("layer.setParent", json!({"layers": [a], "parent": n})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": n, "path": "transform/position", "value": [420, 180, 0]})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let c = s.project.comp(cid).unwrap().clone();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &c, Tick::ZERO);
|
||||
let child = c.layer(effectcraft_project::LayerId(a)).unwrap();
|
||||
let (m, _) = ctx.layer_to_comp(child);
|
||||
let centre = m.apply(effectcraft_geom::vec2(100.0, 50.0));
|
||||
assert!((centre.x - 420.0).abs() < 1e-6 && (centre.y - 180.0).abs() < 1e-6, "{centre:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_switches_menu() {
|
||||
let mut s = comp();
|
||||
|
||||
@@ -410,6 +410,77 @@ fn pen_tool_draws_edits_and_closes_a_mask() {
|
||||
assert_eq!(s.active_comp().unwrap().layers[0].masks().unwrap().children.len(), 1);
|
||||
}
|
||||
|
||||
/// Cut every mask path's tangent arrays in the saved project to one entry (static values and
|
||||
/// keyframes alike), then open the result: the shape of a hand-edited or foreign file (#1250).
|
||||
fn reopen_with_short_tangents(s: &mut Session) {
|
||||
fn cut(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.contains_key("vertices") {
|
||||
for k in ["in_tangents", "out_tangents"] {
|
||||
if let Some(Value::Array(a)) = o.get_mut(k) {
|
||||
a.truncate(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
o.values_mut().for_each(cut);
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(cut),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut j: Value = serde_json::from_str(&s.project.to_json()).unwrap();
|
||||
cut(&mut j);
|
||||
let p = effectcraft_project::Project::from_json(&j.to_string()).unwrap();
|
||||
s.replace_project(p, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_commands_fill_in_missing_tangents_of_a_loaded_path() {
|
||||
let (mut s, l) = setup();
|
||||
let m = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10], [90, 10], [90, 90]]})).unwrap()["mask"].as_u64().unwrap();
|
||||
reopen_with_short_tangents(&mut s);
|
||||
let path = |s: &Session| prop(s, l, "masks/#1/path").value.as_path().unwrap().clone();
|
||||
assert_eq!(path(&s).in_tangents.len(), 1, "the file really has short tangents");
|
||||
// Both used to index the tangent arrays by vertex and panic.
|
||||
s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 2, "in": [0, -20], "out": [0, 20]})).unwrap();
|
||||
s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [10, 90]})).unwrap();
|
||||
let p = path(&s);
|
||||
assert_eq!(p.vertices.len(), 4);
|
||||
assert_eq!((p.in_tangents.len(), p.out_tangents.len()), (4, 4));
|
||||
assert_eq!((p.in_tangents[2], p.out_tangents[2]), ([0.0, -20.0], [0.0, 20.0]));
|
||||
assert_eq!(p.in_tangents[1], [0.0, 0.0], "a missing tangent is a corner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_vertices_of_a_loaded_path_with_short_tangents_does_not_panic() {
|
||||
let (mut s, l) = setup();
|
||||
let m = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10], [90, 10], [90, 90]]})).unwrap()["mask"].as_u64().unwrap();
|
||||
reopen_with_short_tangents(&mut s);
|
||||
s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 2}]})).unwrap();
|
||||
s.execute("mask.deleteVertices", json!({})).unwrap();
|
||||
let p = prop(&s, l, "masks/#1/path").value.as_path().unwrap().clone();
|
||||
assert_eq!((p.vertices.len(), p.in_tangents.len(), p.out_tangents.len()), (2, 2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_commands_fill_in_missing_tangents_of_a_loaded_animated_path() {
|
||||
let (mut s, l) = setup();
|
||||
let m = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10], [90, 10], [90, 90]]})).unwrap()["mask"].as_u64().unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": l, "path": "masks/#1/path"})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 0, "point": [0, 0]})).unwrap();
|
||||
reopen_with_short_tangents(&mut s);
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 2, "in": [5, 5]})).unwrap();
|
||||
s.execute("mask.addVertex", json!({"layer": l, "mask": m, "index": 3, "point": [10, 90]})).unwrap();
|
||||
for k in &prop(&s, l, "masks/#1/path").keys {
|
||||
let p = k.value.as_path().unwrap();
|
||||
assert_eq!(p.vertices.len(), 4, "the other key gains the vertex too");
|
||||
assert!(p.in_tangents.len() == 4 && p.out_tangents.len() == 4, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_stopwatch_toggles_a_self_reference_expression() {
|
||||
let (mut s, l) = setup();
|
||||
|
||||
@@ -48,22 +48,43 @@ pub(crate) fn gpu_inflight(var: Option<&str>) -> usize {
|
||||
/// one window of rendered images is held in memory. Chunks hold no lock or slot, so a failing
|
||||
/// or panicking frame cannot leak one, and nested parallelism (or a single thread) cannot
|
||||
/// deadlock.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn map_frames<I: Send, T: Send>(
|
||||
ks: Vec<u64>,
|
||||
cap: Option<usize>,
|
||||
render: impl Fn(u64) -> I + Sync + Send,
|
||||
encode: impl Fn(u64, I) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
map_frames_while(ks, cap, || true, render, encode)
|
||||
}
|
||||
|
||||
/// [`map_frames`] whose `cap` holds only while `capped()`: once it turns false (the GPU stopped
|
||||
/// for good, so every frame falls back to the CPU), the remaining frames render in parallel
|
||||
/// like the uncapped CPU path instead of one chunk of `cap` at a time.
|
||||
pub(crate) fn map_frames_while<I: Send, T: Send>(
|
||||
ks: Vec<u64>,
|
||||
cap: Option<usize>,
|
||||
capped: impl Fn() -> bool,
|
||||
render: impl Fn(u64) -> I + Sync + Send,
|
||||
encode: impl Fn(u64, I) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
use rayon::prelude::*;
|
||||
let Some(n) = cap.map(|n| n.max(1)) else {
|
||||
let Some(n) = cap.map(|n| n.max(1)).filter(|_| capped()) else {
|
||||
return ks.into_par_iter().map(|k| encode(k, render(k))).collect();
|
||||
};
|
||||
let window = n.max(rayon::current_num_threads());
|
||||
let mut out = Vec::with_capacity(ks.len());
|
||||
for win in ks.chunks(window) {
|
||||
for (w, win) in ks.chunks(window).enumerate() {
|
||||
if !capped() {
|
||||
out.par_extend(ks[w * window..].par_iter().map(|&k| encode(k, render(k))));
|
||||
break;
|
||||
}
|
||||
let mut imgs = Vec::with_capacity(win.len());
|
||||
for chunk in win.chunks(n) {
|
||||
imgs.par_extend(chunk.par_iter().map(|&k| render(k)));
|
||||
let mut i = 0;
|
||||
while i < win.len() {
|
||||
let end = if capped() { (i + n).min(win.len()) } else { win.len() };
|
||||
imgs.par_extend(win[i..end].par_iter().map(|&k| render(k)));
|
||||
i = end;
|
||||
}
|
||||
out.par_extend(win.par_iter().copied().zip(imgs.into_par_iter()).map(|(k, img)| encode(k, img)));
|
||||
}
|
||||
@@ -187,7 +208,10 @@ impl<'a> Cx<'a> {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
map_frames(ks, self.gpu_cap(), |k| self.frame(comp, k), f)
|
||||
// The cap is for the GPU's memory: once it has stopped for good, frames render on the
|
||||
// CPU in parallel.
|
||||
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
|
||||
map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f)
|
||||
}
|
||||
|
||||
/// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every
|
||||
@@ -453,6 +477,36 @@ mod tests {
|
||||
(out, max.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_render_in_parallel_once_the_gpu_stops() {
|
||||
// Every frame after the GPU stopped falls back to the CPU: the cap no longer applies.
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
|
||||
let (live, now, max) = (AtomicBool::new(true), AtomicUsize::new(0), AtomicUsize::new(0));
|
||||
let out = pool.install(|| {
|
||||
map_frames_while(
|
||||
(0..24).collect(),
|
||||
Some(1),
|
||||
|| live.load(Ordering::SeqCst),
|
||||
|k| {
|
||||
if k == 1 {
|
||||
live.store(false, Ordering::SeqCst);
|
||||
}
|
||||
let c = now.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if k >= 2 {
|
||||
max.fetch_max(c, Ordering::SeqCst);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
now.fetch_sub(1, Ordering::SeqCst);
|
||||
k * 10
|
||||
},
|
||||
|_, v| v,
|
||||
)
|
||||
});
|
||||
assert_eq!(out, (0..24).map(|k| k * 10).collect::<Vec<_>>());
|
||||
assert!(max.load(Ordering::SeqCst) > 1, "frames after the GPU stopped ran one at a time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capped_frames_keep_their_order() {
|
||||
let want: Vec<u64> = (0..40).map(|k| k * 10).collect();
|
||||
|
||||
@@ -261,7 +261,10 @@ function noise(x) {
|
||||
function timeToFrames(t, fps, isDuration) {
|
||||
if (t === undefined) t = time + thisComp.displayStartTime;
|
||||
if (fps === undefined) fps = 1 / __S.fd;
|
||||
return Math.floor(t * fps + 1e-6);
|
||||
var frames = t * fps;
|
||||
// The 1e-6 keeps a whole number of frames that floating point overshoots (31/24 - 5/24) whole.
|
||||
if (isDuration && frames !== 0) return frames < 0 ? Math.floor(frames + 1e-6) : Math.ceil(frames - 1e-6);
|
||||
return Math.floor(frames + 1e-6);
|
||||
}
|
||||
|
||||
function framesToTime(frames, fps) {
|
||||
|
||||
@@ -1285,3 +1285,80 @@ fn layer_control_property_stands_for_its_layer() {
|
||||
assert_close!(f.num("effect('Map')(1).transform.scale[0]", 0.0), 100.0);
|
||||
assert_close!(f.num("effect('Map')(1).value.name == 'B' ? 1 : 0", 0.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_to_frames_rounds_durations_away_from_zero() {
|
||||
let f = fx();
|
||||
// Adobe's public expression reference distinguishes durations from absolute times.
|
||||
// Expected integer counts are literal values, independent of the implementation.
|
||||
for (expression, expected) in [
|
||||
("timeToFrames(-0.01, 24, true)", -1.0),
|
||||
("timeToFrames(-0.05, 24, true)", -2.0),
|
||||
("timeToFrames(0, 24, true)", 0.0),
|
||||
("timeToFrames(-0.125, 24, true)", -3.0),
|
||||
("timeToFrames(0.125, 24, true)", 3.0),
|
||||
("timeToFrames(0.01, 24, true)", 1.0),
|
||||
("timeToFrames(0.05, 24, true)", 2.0),
|
||||
] {
|
||||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_to_frames_rounds_absolute_times_toward_negative_infinity() {
|
||||
let f = fx();
|
||||
for (expression, expected) in [
|
||||
("timeToFrames(-0.01, 24, false)", -1.0),
|
||||
("timeToFrames(-0.05, 24, false)", -2.0),
|
||||
("timeToFrames(0, 24, false)", 0.0),
|
||||
("timeToFrames(-0.125, 24, false)", -3.0),
|
||||
("timeToFrames(0.125, 24, false)", 3.0),
|
||||
("timeToFrames(0.01, 24, false)", 0.0),
|
||||
("timeToFrames(0.05, 24, false)", 1.0),
|
||||
] {
|
||||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_to_frames_preserves_default_arguments() {
|
||||
let f = fx();
|
||||
// The fixture's composition is 30 fps with displayStartTime = 0.
|
||||
for (expression, evaluation_time, expected) in [
|
||||
("timeToFrames(0.01, 24)", 0.0, 0.0),
|
||||
("timeToFrames(-0.01, 24)", 0.0, -1.0),
|
||||
("timeToFrames(0.05)", 0.0, 1.0),
|
||||
("timeToFrames(-0.05)", 0.0, -2.0),
|
||||
("timeToFrames()", 0.0, 0.0),
|
||||
("timeToFrames()", 0.05, 1.0),
|
||||
("timeToFrames()", -0.05, -2.0),
|
||||
("timeToFrames(undefined, 24, true)", 0.05, 2.0),
|
||||
("timeToFrames(0.05, undefined, true)", 0.0, 2.0),
|
||||
] {
|
||||
assert_eq!(f.num(expression, evaluation_time), expected, "{expression} at {evaluation_time}s");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_to_frames_preserves_coercion_and_small_durations() {
|
||||
let f = fx();
|
||||
// Duration rounding must not erase tiny nonzero values; absolute times keep the existing tolerance.
|
||||
for (expression, expected) in [
|
||||
("timeToFrames('0.05', '24', false)", 1.0),
|
||||
("timeToFrames('0.05', '24', true)", 2.0),
|
||||
("timeToFrames('-0.05', '24', true)", -2.0),
|
||||
("timeToFrames(null, 24, true)", 0.0),
|
||||
("timeToFrames(0.05, null, true)", 0.0),
|
||||
("timeToFrames(0.05, 0, true)", 0.0),
|
||||
("timeToFrames(31 / 24 - 5 / 24, 24, true)", 26.0),
|
||||
("timeToFrames(5 / 24 - 31 / 24, 24, true)", -26.0),
|
||||
("timeToFrames(-1e-10, 24, false)", 0.0),
|
||||
("1 / timeToFrames(-0, 24, true) === Infinity ? 1 : 0", 1.0),
|
||||
("isNaN(timeToFrames(NaN, 24, true)) ? 1 : 0", 1.0),
|
||||
("timeToFrames(Infinity, 24, true) === Infinity ? 1 : 0", 1.0),
|
||||
("timeToFrames(-Infinity, 24, true) === -Infinity ? 1 : 0", 1.0),
|
||||
("var calls = 0; var t = {valueOf: function () { calls++; return 0.05; }}; timeToFrames(t, 24, true) + 100 * calls", 102.0),
|
||||
] {
|
||||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +317,10 @@ impl Accelerator for Gpu {
|
||||
self.ctx.name.clone()
|
||||
}
|
||||
|
||||
fn available(&self) -> bool {
|
||||
self.ctx.check_health().is_ok()
|
||||
}
|
||||
|
||||
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
|
||||
let img = self.render(r, comp, t);
|
||||
if img.is_none() {
|
||||
|
||||
@@ -154,8 +154,9 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let i = u32(max(floor(hp), 0.0)) % 6u;
|
||||
let f = hp - floor(hp);
|
||||
let wt = w[i] + (w[(i + 1u) % 6u] - w[i]) * f;
|
||||
let base = luminance(c);
|
||||
let gray = max(base + (wt - 0.5) * hsl.y * 0.6, 0.0);
|
||||
let mx = max(max(c.x, c.y), c.z);
|
||||
let mn = min(min(c.x, c.y), c.z);
|
||||
let gray = max(mn + (mx - mn) * wt, 0.0);
|
||||
var o = vec3<f32>(gray);
|
||||
if (P.u[0].y != 0u) {
|
||||
o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0));
|
||||
|
||||
+20
-9
@@ -49,18 +49,29 @@ pub(crate) fn hold_gpu_lock() {
|
||||
|
||||
/// The shared test device (the calling test holds [`hold_gpu_lock`]).
|
||||
pub(crate) fn gpu() -> Option<&'static Gpu> {
|
||||
static G: OnceLock<Option<Gpu>> = OnceLock::new();
|
||||
static G: OnceLock<Result<Gpu, String>> = OnceLock::new();
|
||||
hold_gpu_lock();
|
||||
G.get_or_init(|| {
|
||||
let g = Gpu::headless();
|
||||
if g.is_none() {
|
||||
eprintln!("effectcraft-gpu tests: no GPU adapter, skipping GPU comparisons");
|
||||
} else if let Some(g) = &g {
|
||||
eprintln!("effectcraft-gpu tests: adapter {}", g.ctx.name);
|
||||
let g = G.get_or_init(|| {
|
||||
let g = Gpu::try_headless();
|
||||
match &g {
|
||||
Ok(g) => eprintln!("effectcraft-gpu tests: adapter {}", g.ctx.name),
|
||||
Err(e) => eprintln!("effectcraft-gpu tests: no GPU ({e}), skipping GPU comparisons"),
|
||||
}
|
||||
g
|
||||
})
|
||||
.as_ref()
|
||||
});
|
||||
require_gpu("effectcraft-gpu tests", g.as_ref())
|
||||
}
|
||||
|
||||
/// `g`, or `None` (the GPU tests skip) when there is no GPU, unless `EFFECTCRAFT_REQUIRE_GPU=1`
|
||||
/// (as in `tests_init`): then a GPU that didn't start fails the test, with why.
|
||||
pub(crate) fn require_gpu<G>(tests: &str, g: Result<G, &String>) -> Option<G> {
|
||||
match g {
|
||||
Ok(g) => Some(g),
|
||||
Err(e) if std::env::var("EFFECTCRAFT_REQUIRE_GPU").is_ok_and(|v| v == "1") => {
|
||||
panic!("{tests}: EFFECTCRAFT_REQUIRE_GPU=1 but GPU initialization failed: {e}")
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The test device is on wgpu's OpenGL backend (Mesa's llvmpipe on the FreeBSD CI job).
|
||||
|
||||
@@ -17,11 +17,13 @@ use crate::Gpu;
|
||||
/// A device of the test's own (the test holds [`crate::tests::hold_gpu_lock`]).
|
||||
fn gpu() -> Option<Gpu> {
|
||||
crate::tests::hold_gpu_lock();
|
||||
let g = Gpu::headless();
|
||||
if g.is_none() {
|
||||
eprintln!("effectcraft-gpu adv3d tests: no GPU adapter, skipping");
|
||||
match Gpu::try_headless() {
|
||||
Ok(g) => Some(g),
|
||||
Err(e) => {
|
||||
eprintln!("effectcraft-gpu adv3d tests: no GPU ({e}), skipping");
|
||||
crate::tests::require_gpu("effectcraft-gpu adv3d tests", Err(&e))
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
fn add(p: &mut Project, cid: ItemId, src: LayerSource, edit: impl FnOnce(&mut effectcraft_project::Layer)) {
|
||||
|
||||
@@ -17,19 +17,19 @@ use crate::tests::{Pattern, Scene, diff, n, set, tolerance, v3};
|
||||
/// The device, held exclusively: deferred state is per device and a frame renders at a time
|
||||
/// (a frame worker renders one frame at a time too).
|
||||
pub(crate) fn deferred_gpu() -> Option<(&'static Gpu, std::sync::MutexGuard<'static, ()>)> {
|
||||
static G: OnceLock<Option<Gpu>> = OnceLock::new();
|
||||
static G: OnceLock<Result<Gpu, String>> = OnceLock::new();
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
// Also no other GPU test while it runs (see `hold_gpu_lock`).
|
||||
crate::tests::hold_gpu_lock();
|
||||
let g = G
|
||||
.get_or_init(|| {
|
||||
let g = pollster::block_on(Gpu::request_deferred()).map_err(|e| eprintln!("deferred gpu tests skipped: {e}")).ok();
|
||||
if let Some(g) = &g {
|
||||
eprintln!("deferred gpu tests: adapter {}", g.ctx.name);
|
||||
}
|
||||
g
|
||||
})
|
||||
.as_ref()?;
|
||||
let g = G.get_or_init(|| {
|
||||
let g = pollster::block_on(Gpu::request_deferred());
|
||||
match &g {
|
||||
Ok(g) => eprintln!("deferred gpu tests: adapter {}", g.ctx.name),
|
||||
Err(e) => eprintln!("deferred gpu tests: no GPU ({e}), skipping"),
|
||||
}
|
||||
g
|
||||
});
|
||||
let g = crate::tests::require_gpu("deferred gpu tests", g.as_ref())?;
|
||||
Some((g, LOCK.lock().unwrap_or_else(|e| e.into_inner())))
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ fn color_balance_vibrance_tritone_mixer() {
|
||||
fn black_white_colorama_selective_color() {
|
||||
effect_case("ec.color.blackwhite", &[]);
|
||||
effect_case("ec.color.blackwhite", &[("reds", n(120.0)), ("blues", n(-50.0)), ("cyans", n(200.0)), ("tint", on())]);
|
||||
effect_case("ec.color.blackwhite", &[("yellows", n(300.0)), ("greens", n(-200.0)), ("magentas", n(0.0))]);
|
||||
effect_case("ec.color.colorama", &[]);
|
||||
effect_case("ec.color.colorama", &[("inputPhase/mode", e(4)), ("inputPhase/phaseShift", n(75.0)), ("outputCycle/cycles", n(2.5)), ("blend", n(40.0))]);
|
||||
effect_case("ec.color.colorama", &[("inputPhase/mode", e(8)), ("outputCycle/cycles", n(1.3))]);
|
||||
|
||||
@@ -42,6 +42,16 @@ impl ShapePath {
|
||||
pub fn len(&self) -> usize {
|
||||
self.vertices.len()
|
||||
}
|
||||
/// Give both tangent arrays one entry per vertex: missing tangents become zero (a corner),
|
||||
/// extra ones are dropped. The three arrays are read independently from a project file, so a
|
||||
/// loaded path can disagree with itself (#1250). Returns whether anything changed.
|
||||
pub fn fit_tangents(&mut self) -> bool {
|
||||
let n = self.vertices.len();
|
||||
let changed = self.in_tangents.len() != n || self.out_tangents.len() != n;
|
||||
self.in_tangents.resize(n, [0.0; 2]);
|
||||
self.out_tangents.resize(n, [0.0; 2]);
|
||||
changed
|
||||
}
|
||||
/// Number of segments (vertex to next vertex, the closing one included).
|
||||
pub fn segment_count(&self) -> usize {
|
||||
let n = self.vertices.len();
|
||||
|
||||
@@ -308,7 +308,7 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve
|
||||
"op" => {
|
||||
o.put("a", "amount", &anim::num);
|
||||
o.raw("lj", json!(o.e("join") + 1));
|
||||
o.raw("ml", json!(o.f("miter")));
|
||||
o.put("ml", "miter", &anim::num);
|
||||
if (o.f("copies") - 1.0).abs() > 1e-9 || o.animated("copies") || o.f("copyOffset") != 0.0 {
|
||||
o.ex.warn(format!("{lbl}: Offset Paths copies are not part of Lottie (one copy exported)"));
|
||||
}
|
||||
@@ -543,9 +543,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
|
||||
if let Some(lj) = int(j, "lj") {
|
||||
set_enum(&mut g, "join", lj.saturating_sub(1).clamp(0, 2) as u32);
|
||||
}
|
||||
if let (Some(ml), Some(p)) = (j.get("ml").and_then(Json::as_f64), g.get_mut("miter")) {
|
||||
p.value = Value::Scalar(ml);
|
||||
}
|
||||
set(im, &mut g, "miter", j.get("ml"), &anim::to_scalar);
|
||||
}
|
||||
"pb" => set(im, &mut g, "amount", j.get("a"), &anim::to_scalar),
|
||||
"tw" => {
|
||||
|
||||
@@ -358,6 +358,55 @@ fn path_operators_roundtrip() {
|
||||
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_paths_miter_uses_animatable_lottie_value() {
|
||||
let (mut p, cid) = setup();
|
||||
let l = shape_layer(&mut p, cid, |ids| {
|
||||
let mut animated = build::shape_simple_op(ids, "offset").unwrap();
|
||||
animated.get_mut("miter").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(5.0)), Keyframe::new(s(1.0), Value::Scalar(9.0))];
|
||||
let mut constant = build::shape_simple_op(ids, "offset").unwrap();
|
||||
constant.get_mut("miter").unwrap().value = Value::Scalar(6.0);
|
||||
let mut stroke = build::shape_stroke(ids, [1.0, 1.0, 1.0, 1.0], 2.0);
|
||||
stroke.get_mut("miter").unwrap().value = Value::Scalar(7.0);
|
||||
vec![animated, constant, stroke]
|
||||
});
|
||||
push(&mut p, cid, l);
|
||||
|
||||
let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let shapes = &json["layers"][0]["shapes"];
|
||||
assert_eq!(shapes[0]["ty"], "op");
|
||||
assert_eq!(shapes[0]["ml"]["a"], json!(1));
|
||||
assert_eq!(shapes[0]["ml"]["k"][0]["s"], json!([5.0]));
|
||||
assert_eq!(shapes[0]["ml"]["k"][1]["s"], json!([9.0]));
|
||||
assert_eq!(shapes[1]["ml"], json!({"a": 0, "k": 6.0}));
|
||||
assert_eq!(shapes[2]["ty"], "st");
|
||||
assert_eq!(shapes[2]["ml"], json!(7.0));
|
||||
assert!(compare_comps(&p, cid, &q, nc) > 10);
|
||||
let restored = &q.comp(nc).unwrap().layers[0];
|
||||
assert_eq!(restored.props.group("contents/offset").unwrap().get("miter").unwrap().keys.len(), 2);
|
||||
|
||||
// Previously exported Offset Paths have a scalar ml; they remain importable.
|
||||
let mut old = json;
|
||||
old["layers"][0]["shapes"][1]["ml"] = json!(6.0);
|
||||
let mut legacy = Project::default();
|
||||
let bytes = serde_json::to_vec(&old).unwrap();
|
||||
let imported = import(&mut legacy, &bytes, "Legacy", "", &mut |_| None).unwrap();
|
||||
let constant = &legacy.comp(imported.comp).unwrap().layers[0].props;
|
||||
let second = constant
|
||||
.sub("contents")
|
||||
.unwrap()
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|n| match n {
|
||||
Node::Group(g) if g.match_id == "offset" => Some(g),
|
||||
_ => None,
|
||||
})
|
||||
.nth(1)
|
||||
.unwrap();
|
||||
assert_eq!(second.get("miter").unwrap().value, Value::Scalar(6.0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- layers
|
||||
|
||||
#[test]
|
||||
|
||||
+45
-1
@@ -848,7 +848,9 @@ impl T2<'_> {
|
||||
}
|
||||
11 => return,
|
||||
14 => {
|
||||
self.take_width(false);
|
||||
// A width precedes the operands when the count is odd (5 with seac, 1 alone);
|
||||
// the no-width counts (0 and 4) are even, like the stems' and rmoveto's.
|
||||
self.take_width(true);
|
||||
if self.stack.len() >= 4 {
|
||||
let n = self.stack.len();
|
||||
let s = &self.stack[n - 4..];
|
||||
@@ -1145,4 +1147,46 @@ mod tests {
|
||||
let cff = Cff::parse(&out).expect("the damaged Private DICT is skipped, the font parses");
|
||||
assert_eq!(cff.glyph_count(), 1);
|
||||
}
|
||||
|
||||
/// A four-operand `endchar` (`adx ady bchar achar`, no width) is a seac like the Type 1
|
||||
/// interpreter's: `endchar` must not eat `adx` as a width, and a five-operand one reads its
|
||||
/// width first.
|
||||
#[test]
|
||||
fn endchar_reads_its_width_by_operand_count() {
|
||||
let enc = |v: i32| -> Vec<u8> {
|
||||
if (-107..=107).contains(&v) {
|
||||
vec![(v + 139) as u8]
|
||||
} else {
|
||||
let mut o = vec![28];
|
||||
o.extend_from_slice(&(v as i16).to_be_bytes());
|
||||
o
|
||||
}
|
||||
};
|
||||
// The base glyph: a square drawn without a width (`500 500 -500 hlineto endchar`).
|
||||
let mut square = vec![];
|
||||
for v in [500, 500, -500] {
|
||||
square.extend(enc(v));
|
||||
}
|
||||
square.extend([6, 14]);
|
||||
// Four operands: `adx ady bchar achar endchar`, no width to take.
|
||||
let mut seac = vec![];
|
||||
for v in [10, 20, 65, 65] {
|
||||
seac.extend(enc(v));
|
||||
}
|
||||
seac.push(14);
|
||||
let font = write_test_cff(&[(".notdef", vec![14]), ("A", square.clone()), ("Aacute", seac)]);
|
||||
let cff = Cff::parse(&font).unwrap();
|
||||
let (p, _) = cff.outline(cff.gid_by_name("Aacute").unwrap()).unwrap();
|
||||
assert!(!p.elements().is_empty(), "the base glyph of the seac is part of the outline");
|
||||
// Five operands: the first is the width, the last four the seac.
|
||||
let mut wide = vec![];
|
||||
for v in [600, 0, 0, 65, 65] {
|
||||
wide.extend(enc(v));
|
||||
}
|
||||
wide.push(14);
|
||||
let font = write_test_cff(&[(".notdef", vec![14]), ("A", square), ("Awide", wide)]);
|
||||
let cff = Cff::parse(&font).unwrap();
|
||||
let (_, w) = cff.outline(cff.gid_by_name("Awide").unwrap()).unwrap();
|
||||
assert_eq!(w, 600.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,11 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec<u8>, usize, usize
|
||||
return Some((data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(w * h).collect(), w, h));
|
||||
}
|
||||
let bpc = if soft { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize } else { 1 };
|
||||
// Like `decode()`: only 1/2/4/8/16-bit depths are supported. Anything else (notably
|
||||
// `bpc >= 32`, where `1u32 << bpc` below would overflow) means "no usable mask".
|
||||
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
|
||||
return None;
|
||||
}
|
||||
let max = ((1u32 << bpc) - 1).max(1) as f64;
|
||||
let dec = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= 2);
|
||||
let v = unpack(&data, w, h, 1, bpc);
|
||||
|
||||
@@ -274,11 +274,32 @@ fn inline_image_with_absurd_dimensions_falls_back_to_ei_scan() {
|
||||
assert!(!doc.skipped.is_empty());
|
||||
// A length just under `usize::MAX` passes the row math; past byte 4096 of the content the
|
||||
// caller's `start + len` would then overflow.
|
||||
let content = format!("{}q 10 0 0 10 0 0 cm BI /W 2305843009213693440 /H 8 /CS /G /BPC 8 ID | ||||