mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 15:48:33 +00:00
Merge branch 'storytold:main' into fix/1615-cpu-inflight-cap
This commit is contained in:
127 files changed
+6928
-437
No files matched your search
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -117,6 +117,10 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar
|
||||
path into an editable Path 1 with the same geometry, as After Effects does; it used to
|
||||
apply to mask RotoBezier only, so a parametric shape path could not be converted at all
|
||||
and its vertices could not be edited or animated (#932).
|
||||
- 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking;
|
||||
strokes and undo history are preserved so restoring the timing allows the span edit again.
|
||||
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
|
||||
//! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice]
|
||||
//! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings)
|
||||
//! [--progress] report progress on stderr when it is not a terminal, up to four lines a second:
|
||||
//! `progress <frames done> <frames total> <seconds elapsed> <seconds left, or - when unknown>`
|
||||
//! effectcraft-cli render --template T.ectemplate [--values V.json] --out FILE
|
||||
//! effectcraft-cli render-frame --template T.ectemplate [--values V.json] [--time S] [--out F.png]
|
||||
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
|
||||
@@ -106,6 +108,8 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
|
||||
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
|
||||
a relative --out is relative to the working directory
|
||||
--progress: progress lines on stderr when it is not a terminal
|
||||
(progress <frames done> <total> <seconds elapsed> <seconds left|->)
|
||||
render/render-frame --template T.ectemplate [--values V.json] render one template instance
|
||||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] per-layer/effect render timings;
|
||||
--play N renders N consecutive frames with/without the layer cache;
|
||||
@@ -189,6 +193,7 @@ const FLAGS: &[&str] = &[
|
||||
"--small",
|
||||
"--adv3d",
|
||||
"--autosave",
|
||||
"--progress",
|
||||
];
|
||||
|
||||
struct Args {
|
||||
@@ -772,8 +777,21 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
s.execute("renderQueue.render", json!({"wait": false})).map_err(err)?;
|
||||
let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
|
||||
// `--progress`: one line per update for a program reading stderr (a terminal gets the live line).
|
||||
let lines = args.flag("--progress") && !tty;
|
||||
let (mut last_line, mut last_done) = (std::time::Instant::now(), u64::MAX);
|
||||
while s.is_rendering() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
if lines
|
||||
&& last_line.elapsed() >= std::time::Duration::from_millis(250)
|
||||
&& let Some(p) = s.render_progress()
|
||||
&& p.done != last_done
|
||||
{
|
||||
last_line = std::time::Instant::now();
|
||||
last_done = p.done;
|
||||
let left = p.remaining.map_or_else(|| "-".to_string(), |r| format!("{r:.1}"));
|
||||
note!("progress {} {} {:.1} {left}", p.done, p.total, p.item_elapsed);
|
||||
}
|
||||
if tty && let Some(p) = s.render_progress() {
|
||||
let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default();
|
||||
let _ = write!(
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::io::Read;
|
||||
|
||||
use effectcraft_engine::{
|
||||
EngineError, Session,
|
||||
project::{ItemId, LayerId},
|
||||
project::{ItemId, LayerId, essential::EgKind},
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -92,7 +92,7 @@ fn preflight(s: &Session, path: &str, values: &Map<String, Value>) -> Result<(),
|
||||
project.fix_next_id();
|
||||
let mut check = Session::new();
|
||||
check.project = project.into();
|
||||
Ok(instantiate(&mut check, source, values))
|
||||
Ok(instantiate(&mut check, source, values, false))
|
||||
})
|
||||
.map_err(|e| Failure::Error(e.to_string()))?
|
||||
}
|
||||
@@ -105,10 +105,27 @@ pub(super) fn prepare(s: &mut Session, args: &Args) -> Result<(), Failure> {
|
||||
}
|
||||
let imported = execute(s, "essential.importTemplate", json!({"path": path, "addToComp": false}))?;
|
||||
let source = imported.get("comp").and_then(Value::as_u64).ok_or_else(|| Failure::Error("template import did not return a composition".into()))?;
|
||||
instantiate(s, source, &values)
|
||||
instantiate(s, source, &values, true)
|
||||
}
|
||||
|
||||
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Result<(), Failure> {
|
||||
/// The file a Media Replacement control is given: a path, or `{path|file|media: path}`.
|
||||
fn media_path(value: &Value) -> Option<&str> {
|
||||
match value {
|
||||
Value::String(path) => Some(path.as_str()),
|
||||
Value::Object(m) => ["path", "file", "media"].iter().find_map(|k| m.get(*k)).and_then(Value::as_str),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the template's control of that name (looked up as `essential.set` does) is a Media Replacement.
|
||||
fn is_media_control(s: &Session, source: u64, name: &str) -> bool {
|
||||
let Some(eg) = s.project.comp(ItemId(source)).and_then(|c| c.essential.as_ref()) else { return false };
|
||||
let all = eg.flat();
|
||||
let control = all.iter().find(|c| c.name == name).or_else(|| all.iter().find(|c| c.name.eq_ignore_ascii_case(name)));
|
||||
control.is_some_and(|c| matches!(c.kind, EgKind::Media { .. }))
|
||||
}
|
||||
|
||||
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>, import_media: bool) -> Result<(), Failure> {
|
||||
// Duplicate and empty the source with commands: this preserves exact rational frame rate,
|
||||
// Tick duration, background, work area and renderer settings without round-tripping floats.
|
||||
let duplicate = execute(s, "project.duplicate", json!({"items": [source]}))?;
|
||||
@@ -145,6 +162,25 @@ fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Res
|
||||
// Let the wrapper carry the source composition's authored motion blur.
|
||||
execute(s, "layer.setSwitch", json!({"comp": parent, "layers": [instance], "switch": "motionBlur", "value": true}))?;
|
||||
for (control, value) in values {
|
||||
if is_media_control(s, source, control) {
|
||||
let path = media_path(value).ok_or_else(|| Failure::Error(format!("control `{control}` is a media control: give it a file path")))?;
|
||||
// The preflight session cannot import files; the real run reports a bad path.
|
||||
if !import_media {
|
||||
continue;
|
||||
}
|
||||
let imported = execute(s, "file.import", json!({"paths": [path]}))?;
|
||||
if let Some(err) = imported.get("errors").and_then(Value::as_array).and_then(|e| e.first()) {
|
||||
return Err(Failure::Error(format!("control `{control}`: cannot import {path}: {err}")));
|
||||
}
|
||||
let item = imported
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| Failure::Error(format!("control `{control}`: {path} did not import as footage")))?;
|
||||
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "item": item}))?;
|
||||
continue;
|
||||
}
|
||||
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "value": value}))?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -593,3 +593,77 @@ solid.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraph
|
||||
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);
|
||||
}
|
||||
|
||||
/// `WGPU_ADAPTER_NAME` that matches nothing is a clean `--gpu` error listing the adapters
|
||||
/// found (wgpu's own helper panics). Read once per process, hence a subprocess.
|
||||
#[test]
|
||||
fn gpu_adapter_name_mismatch_is_a_clean_error() {
|
||||
let out = bin()
|
||||
.args(["render-frame", "--gpu", "--max-side", "64", "--out", tmp("nomatch.png").to_str().unwrap(), "--json"])
|
||||
.env("WGPU_ADAPTER_NAME", "NoSuchAdapter-xyz")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert_eq!(out.status.code(), Some(1), "stdout: {stdout}\nstderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let v: Value = serde_json::from_str(stdout.trim()).unwrap();
|
||||
let e = v["error"].as_str().unwrap();
|
||||
assert!(e.contains("WGPU_ADAPTER_NAME=\"NoSuchAdapter-xyz\""), "{e}");
|
||||
assert!(e.contains("available: [") || e.contains("no adapters on the enabled backends"), "{e}");
|
||||
}
|
||||
|
||||
/// An exported-but-empty `WGPU_ADAPTER_NAME` behaves like unset: it never produces the
|
||||
/// adapter-name error (whether a GPU exists on this machine is not asserted).
|
||||
#[test]
|
||||
fn gpu_adapter_name_empty_is_unset() {
|
||||
let out = bin()
|
||||
.args(["render-frame", "--gpu", "--max-side", "64", "--out", tmp("emptyname.png").to_str().unwrap(), "--json"])
|
||||
.env("WGPU_ADAPTER_NAME", " ")
|
||||
.output()
|
||||
.unwrap();
|
||||
let all = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert!(!all.contains("WGPU_ADAPTER_NAME"), "{all}");
|
||||
}
|
||||
|
||||
/// Renders two seconds of the demo at quarter resolution (long enough for several progress
|
||||
/// updates) and returns stderr, which `output()` reads through a pipe: not a terminal.
|
||||
fn render_demo_stderr(name: &str, extra: &[&str]) -> String {
|
||||
let out = tmp(name);
|
||||
let mut args = vec!["render", "--demo", "--format", "gif", "--start", "0", "--end", "2", "--resolution", "quarter", "--out", out.to_str().unwrap()];
|
||||
args.extend_from_slice(extra);
|
||||
let o = bin().args(&args).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
|
||||
assert!(o.status.success(), "{args:?}: {stderr}");
|
||||
let _ = std::fs::remove_file(out);
|
||||
stderr
|
||||
}
|
||||
|
||||
/// `render --progress` with a piped stderr reports the render's frames as
|
||||
/// `progress <frames done> <frames total> <seconds elapsed> <seconds left|->` lines, one per
|
||||
/// change (#1778).
|
||||
#[test]
|
||||
fn render_progress_reports_frames_on_a_piped_stderr() {
|
||||
let stderr = render_demo_stderr("progress.gif", &["--progress"]);
|
||||
let mut last_done = None;
|
||||
let mut totals = vec![];
|
||||
for line in stderr.lines().filter(|l| l.starts_with("progress")) {
|
||||
let fields: Vec<&str> = line.split(' ').collect();
|
||||
assert_eq!(fields.len(), 5, "{line}");
|
||||
let done: u64 = fields[1].parse().unwrap_or_else(|e| panic!("{line}: frames done: {e}"));
|
||||
let total: u64 = fields[2].parse().unwrap_or_else(|e| panic!("{line}: frames total: {e}"));
|
||||
let elapsed: f64 = fields[3].parse().unwrap_or_else(|e| panic!("{line}: seconds elapsed: {e}"));
|
||||
assert!(fields[4] == "-" || fields[4].parse::<f64>().is_ok_and(|left| left >= 0.0), "{line}: seconds left");
|
||||
assert!(done <= total && total > 0 && elapsed >= 0.0, "{line}");
|
||||
assert!(last_done.is_none_or(|last| done > last), "one line per change, and frames only go up: {stderr}");
|
||||
last_done = Some(done);
|
||||
totals.push(total);
|
||||
}
|
||||
assert!(!totals.is_empty(), "no progress line on a piped stderr: {stderr}");
|
||||
assert!(totals.iter().all(|&t| t == totals[0]), "one render, one total: {stderr}");
|
||||
}
|
||||
|
||||
/// Without `--progress`, the same render writes no progress lines to a piped stderr (#1778).
|
||||
#[test]
|
||||
fn render_without_progress_writes_no_progress_lines() {
|
||||
let stderr = render_demo_stderr("no-progress.gif", &[]);
|
||||
assert!(!stderr.lines().any(|l| l.starts_with("progress")), "{stderr}");
|
||||
}
|
||||
@@ -689,3 +689,81 @@ fn template_frame_output_cannot_alias_its_values_through_a_hard_link() {
|
||||
assert_eq!(reply["error"], "--out hard-linked-values.png would overwrite --values values.json", "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A template whose `Clip` control is a Media Replacement showing `source.png` (blue), with
|
||||
/// `other.png` (red) beside it to replace it with, exported as `lt.ectemplate` in a fresh `dir`.
|
||||
fn media_template(case: &str) -> PathBuf {
|
||||
let temp_root = std::env::temp_dir().canonicalize().unwrap();
|
||||
let dir = temp_root.join(format!("ec-qa-template-{case}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
|
||||
ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap();
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 20, 40, 255])).save(dir.join("other.png")).unwrap();
|
||||
let imported = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]);
|
||||
let item = imported["result"]["items"][0].as_u64().unwrap();
|
||||
let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string();
|
||||
let added = ok(&dir, &["exec", "layer.addItem", ¶ms, "t.ecproj", "--save"]);
|
||||
let layer = added["result"]["layer"].as_u64().unwrap();
|
||||
let params = serde_json::json!({"comp": "Lower Third", "layer": layer, "name": "Clip"}).to_string();
|
||||
ok(&dir, &["exec", "essential.addMedia", ¶ms, "t.ecproj", "--save"]);
|
||||
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
|
||||
dir
|
||||
}
|
||||
|
||||
/// Renders the template's frame with `values` and checks that the control shows `other.png`.
|
||||
fn assert_renders_the_replacement(dir: &PathBuf, values: &str) {
|
||||
std::fs::write(dir.join("values.json"), values).unwrap();
|
||||
ok(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "out.png"]);
|
||||
let px = image::open(dir.join("out.png")).unwrap().to_rgba8().get_pixel(8, 8).0;
|
||||
assert!(
|
||||
(px[0] as i32 - 200).abs() <= 2 && (px[1] as i32 - 20).abs() <= 2 && (px[2] as i32 - 40).abs() <= 2,
|
||||
"{values}: replacement media rendered: {px:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_values_fill_a_media_replacement_control_with_a_file_path() {
|
||||
let dir = media_template("media-control");
|
||||
assert_renders_the_replacement(&dir, r#"{"Clip":"other.png"}"#);
|
||||
|
||||
// A control that is not given a path is rejected with a clear message.
|
||||
std::fs::write(dir.join("bad.json"), r#"{"Clip":5}"#).unwrap();
|
||||
let (code, reply) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "bad.json", "--time", "1", "--out", "bad.png"]);
|
||||
assert_eq!(code, 1, "{reply}");
|
||||
assert!(reply["error"].as_str().unwrap().contains("media control"), "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The object forms `{"path"|"file"|"media": …}` take a file path too, and the control is found
|
||||
/// by name the way `essential.set` finds it: exactly, then ignoring case (#1787).
|
||||
#[test]
|
||||
fn template_media_control_takes_a_path_object_and_a_name_in_any_case() {
|
||||
let dir = media_template("media-forms");
|
||||
for values in [
|
||||
r#"{"Clip":{"path":"other.png"}}"#,
|
||||
r#"{"Clip":{"file":"other.png"}}"#,
|
||||
r#"{"Clip":{"media":"other.png"}}"#,
|
||||
r#"{"clip":"other.png"}"#,
|
||||
r#"{"CLIP":{"path":"other.png"}}"#,
|
||||
] {
|
||||
let _ = std::fs::remove_file(dir.join("out.png"));
|
||||
assert_renders_the_replacement(&dir, values);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A media path that cannot be imported fails the render with an error naming the control and
|
||||
/// the path, and writes no frame (#1787).
|
||||
#[test]
|
||||
fn template_media_control_reports_a_path_that_cannot_be_imported() {
|
||||
let dir = media_template("media-missing");
|
||||
std::fs::write(dir.join("missing.json"), r#"{"Clip":"missing.png"}"#).unwrap();
|
||||
let (code, reply) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing.json", "--time", "1", "--out", "missing-out.png"]);
|
||||
assert_eq!(code, 1, "{reply}");
|
||||
let error = reply["error"].as_str().unwrap();
|
||||
assert!(error.contains("control `Clip`: cannot import missing.png"), "{reply}");
|
||||
assert!(!dir.join("missing-out.png").exists(), "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -57,6 +57,26 @@ function req(r) {
|
||||
return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
|
||||
}
|
||||
|
||||
// Run `op` on `store` in a readwrite transaction of its own; resolves once the transaction has
|
||||
// committed. A request's success doesn't mean the write is stored: the transaction can still
|
||||
// abort afterwards (quota at commit, the browser closing), rolling it back, and a write
|
||||
// reported as saved then is never retried (#1242). A failed request aborts the transaction
|
||||
// with its error; an explicit abort leaves no error, hence the AbortError.
|
||||
function write(db, store, op) {
|
||||
return new Promise((res, rej) => {
|
||||
let tx = null;
|
||||
try {
|
||||
tx = db.transaction(store, "readwrite");
|
||||
tx.oncomplete = () => res();
|
||||
tx.onabort = () => rej(tx.error ?? new DOMException("the storage transaction was aborted", "AbortError"));
|
||||
op(tx.objectStore(store));
|
||||
} catch (e) {
|
||||
try { tx?.abort(); } catch {}
|
||||
rej(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function idb() {
|
||||
if (!self.indexedDB) throw new Error("no IndexedDB");
|
||||
const open = indexedDB.open(DB, 1);
|
||||
@@ -70,10 +90,10 @@ async function idb() {
|
||||
return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified }));
|
||||
},
|
||||
async put(key, data, modified) {
|
||||
await req(tx("readwrite").put({ key, data: data.slice().buffer, modified }));
|
||||
await write(db, STORE, (s) => s.put({ key, data: data.slice().buffer, modified }));
|
||||
},
|
||||
async del(key) {
|
||||
await req(tx("readwrite").delete(key));
|
||||
await write(db, STORE, (s) => s.delete(key));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -315,7 +335,15 @@ function spawn(base) {
|
||||
/// path, bytes)` receives "reply" (json), "file" (path, bytes: a rendered file to download),
|
||||
/// "store" (path, bytes: a file to keep in browser storage) and "error" (json = message).
|
||||
export function workerRun(id, json, files, base, onMessage) {
|
||||
const w = idle.pop() ?? spawn(base);
|
||||
let w;
|
||||
try {
|
||||
w = idle.pop() ?? spawn(base);
|
||||
} catch (e) {
|
||||
// `new Worker` throws at once when the page's origin is opaque (a sandboxed iframe): the job
|
||||
// fails like one whose worker didn't start.
|
||||
setTimeout(() => onMessage("error", String((e && e.message) || e), null, null));
|
||||
return;
|
||||
}
|
||||
running.set(id, w);
|
||||
w.ready.then(() => {
|
||||
w.worker.onmessage = (e) => {
|
||||
@@ -380,7 +408,17 @@ const frameWorkers = [];
|
||||
/// GPU effects on it.
|
||||
export function frameWorkerStart(base, onMessage, gpu) {
|
||||
const index = frameWorkers.length;
|
||||
const worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` });
|
||||
let worker;
|
||||
try {
|
||||
worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` });
|
||||
} catch (e) {
|
||||
// The constructor throws at once when the page's origin is opaque (a sandboxed iframe). The
|
||||
// slot stays, and the failure is reported like a worker that died, after the caller has
|
||||
// recorded the index: frames then render on the page's thread (as with `?noworkers`).
|
||||
frameWorkers.push(null);
|
||||
setTimeout(() => onMessage(index, undefined, undefined, String((e && e.message) || e || "frame worker failed")));
|
||||
return index;
|
||||
}
|
||||
worker.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
|
||||
@@ -550,7 +588,7 @@ async function openFolder(handle, remember) {
|
||||
if (remember) {
|
||||
try {
|
||||
const db = await handlesDb();
|
||||
await req(db.transaction("folders", "readwrite").objectStore("folders").put({ name, handle }));
|
||||
await write(db, "folders", (s) => s.put({ name, handle }));
|
||||
} catch (e) { console.info("media browser: cannot remember the folder", e); }
|
||||
}
|
||||
return { name, entries };
|
||||
@@ -602,7 +640,7 @@ export async function browseForget(name) {
|
||||
folders.delete(name);
|
||||
try {
|
||||
const db = await handlesDb();
|
||||
await req(db.transaction("folders", "readwrite").objectStore("folders").delete(name));
|
||||
await write(db, "folders", (s) => s.delete(name));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// The IndexedDB storage backend of the web app (js/host.js) under Node with a fake IndexedDB, no
|
||||
// browser or Wasm build needed:
|
||||
//
|
||||
// node apps/effectcraft-web/tests/storage.mjs
|
||||
//
|
||||
// A write is acknowledged only once its transaction has committed, not when its request
|
||||
// succeeded: a transaction that aborts afterwards rolls the write back, and the write must be
|
||||
// reported as failed so the flush keeps it for a retry (#1242). Exits non-zero on failure.
|
||||
import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
globalThis.self = globalThis;
|
||||
|
||||
// A small IndexedDB: requests succeed (or fail) one task after they're made, and a readwrite
|
||||
// transaction commits once its requests are done, unless it was aborted (which rolls back its
|
||||
// writes). `fault` makes the next request do something else.
|
||||
let fault = null; // "abortAfterSuccess" | "requestError" | "throw"
|
||||
const databases = new Map();
|
||||
|
||||
class Request {
|
||||
constructor(tx) {
|
||||
this.transaction = tx;
|
||||
this.result = undefined;
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
class Store {
|
||||
constructor(tx, rows) {
|
||||
this.tx = tx;
|
||||
this.rows = rows;
|
||||
}
|
||||
request(apply) {
|
||||
const tx = this.tx;
|
||||
if (tx.finished) throw new DOMException("the transaction has finished", "TransactionInactiveError");
|
||||
const f = fault;
|
||||
fault = null;
|
||||
if (f === "throw") throw new DOMException("the value can't be cloned", "DataCloneError");
|
||||
const r = new Request(tx);
|
||||
tx.pending++;
|
||||
setTimeout(() => {
|
||||
tx.pending--;
|
||||
if (tx.finished) return;
|
||||
if (f === "requestError") {
|
||||
r.error = new DOMException("the key already exists", "ConstraintError");
|
||||
const e = { target: r, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||||
r.onerror?.(e);
|
||||
tx.onerror?.(e);
|
||||
if (!e.defaultPrevented) tx.fail(r.error);
|
||||
return;
|
||||
}
|
||||
r.result = apply(tx.staged);
|
||||
r.onsuccess?.({ target: r });
|
||||
if (f === "abortAfterSuccess") tx.abort();
|
||||
tx.maybeCommit();
|
||||
}, 0);
|
||||
return r;
|
||||
}
|
||||
put(v) {
|
||||
return this.request((rows) => { rows.set(v[this.keyPath], structuredClone(v)); return v[this.keyPath]; });
|
||||
}
|
||||
delete(k) {
|
||||
return this.request((rows) => { rows.delete(k); });
|
||||
}
|
||||
get(k) {
|
||||
return this.request((rows) => rows.get(k));
|
||||
}
|
||||
getAll() {
|
||||
return this.request((rows) => [...rows.values()]);
|
||||
}
|
||||
}
|
||||
|
||||
class Transaction {
|
||||
constructor(db, name) {
|
||||
this.db = db;
|
||||
this.name = name;
|
||||
this.staged = new Map(db.stores.get(name));
|
||||
this.pending = 0;
|
||||
this.finished = false;
|
||||
this.error = null;
|
||||
setTimeout(() => this.maybeCommit(), 0);
|
||||
}
|
||||
objectStore(name) {
|
||||
const s = new Store(this, this.staged);
|
||||
s.keyPath = this.db.keyPaths.get(name);
|
||||
return s;
|
||||
}
|
||||
maybeCommit() {
|
||||
if (this.finished || this.pending > 0) return;
|
||||
this.finished = true;
|
||||
this.db.stores.set(this.name, this.staged);
|
||||
setTimeout(() => this.oncomplete?.(), 0);
|
||||
}
|
||||
fail(error) {
|
||||
this.error = error;
|
||||
this.abort();
|
||||
}
|
||||
abort() {
|
||||
if (this.finished) throw new DOMException("the transaction has finished", "InvalidStateError");
|
||||
this.finished = true;
|
||||
setTimeout(() => this.onabort?.(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
class Database {
|
||||
constructor() {
|
||||
this.stores = new Map();
|
||||
this.keyPaths = new Map();
|
||||
}
|
||||
createObjectStore(name, { keyPath }) {
|
||||
this.stores.set(name, new Map());
|
||||
this.keyPaths.set(name, keyPath);
|
||||
}
|
||||
transaction(name) {
|
||||
if (!this.stores.has(name)) throw new DOMException(`no store ${name}`, "NotFoundError");
|
||||
return new Transaction(this, name);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.indexedDB = {
|
||||
open(name) {
|
||||
const r = new Request(null);
|
||||
setTimeout(() => {
|
||||
let db = databases.get(name);
|
||||
if (!db) {
|
||||
db = new Database();
|
||||
databases.set(name, db);
|
||||
r.result = db;
|
||||
r.onupgradeneeded?.();
|
||||
}
|
||||
r.result = db;
|
||||
r.onsuccess?.({ target: r });
|
||||
}, 0);
|
||||
return r;
|
||||
},
|
||||
};
|
||||
|
||||
const host = await import(pathToFileURL(join(here, "../js/host.js")));
|
||||
assert.equal(await host.storeOpen("indexeddb"), "indexeddb");
|
||||
const stored = (key) => databases.get("effectcraft").stores.get("entries").get(key);
|
||||
const bytes = (s) => new TextEncoder().encode(s);
|
||||
const text = (row) => row && new TextDecoder().decode(new Uint8Array(row.data));
|
||||
|
||||
// A healthy write resolves once committed, and the store has it then.
|
||||
await host.storePut("project.json", bytes("v1"), 1);
|
||||
assert.equal(text(stored("project.json")), "v1", "a resolved put is committed");
|
||||
|
||||
// The put's request succeeds, then the transaction aborts: the put fails, the old value stays.
|
||||
fault = "abortAfterSuccess";
|
||||
await assert.rejects(host.storePut("project.json", bytes("v2"), 2), { name: "AbortError" }, "an aborted put is reported");
|
||||
assert.equal(text(stored("project.json")), "v1", "the aborted put was rolled back");
|
||||
|
||||
// The same for a delete: it fails and the entry stays.
|
||||
fault = "abortAfterSuccess";
|
||||
await assert.rejects(host.storeDelete("project.json"), { name: "AbortError" }, "an aborted delete is reported");
|
||||
assert.equal(text(stored("project.json")), "v1", "the aborted delete was rolled back");
|
||||
|
||||
// A failed request aborts its transaction with the request's error.
|
||||
fault = "requestError";
|
||||
await assert.rejects(host.storePut("project.json", bytes("v3"), 3), { name: "ConstraintError" });
|
||||
assert.equal(text(stored("project.json")), "v1");
|
||||
|
||||
// A request that throws at once (a value that can't be stored) fails the write.
|
||||
fault = "throw";
|
||||
await assert.rejects(host.storePut("project.json", bytes("v4"), 4), { name: "DataCloneError" });
|
||||
assert.equal(text(stored("project.json")), "v1");
|
||||
|
||||
// The retry after a failure commits the latest value, and a later delete removes it.
|
||||
await host.storePut("project.json", bytes("v5"), 5);
|
||||
assert.equal(text(stored("project.json")), "v5");
|
||||
await host.storeDelete("project.json");
|
||||
assert.equal(stored("project.json"), undefined, "a resolved delete is committed");
|
||||
const all = await host.storeLoadAll();
|
||||
assert.deepEqual(all, [], "nothing else was written");
|
||||
|
||||
await tick();
|
||||
console.log("storage: ok");
|
||||
@@ -7,6 +7,8 @@
|
||||
// (#218), and not when it is unchanged.
|
||||
// - A worker whose start fails fails its job and is terminated, instead of the job waiting for
|
||||
// "ready" forever (#216): host.js on the worker's error, worker.js re-raising the failed start.
|
||||
// - A `new Worker` that throws (a sandboxed iframe's opaque origin, #1586) fails the job, and a
|
||||
// frame worker is reported dead, instead of the exception escaping into startup.
|
||||
// Exits non-zero on failure.
|
||||
import assert from "node:assert/strict";
|
||||
import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
@@ -67,6 +69,35 @@ assert.equal(failed.kind, "error");
|
||||
assert.ok(workers[1].terminated, "the failed worker is terminated");
|
||||
assert.equal(host.workerStats().running, 0);
|
||||
|
||||
// A `new Worker` that throws (a sandboxed iframe's opaque origin): the job fails, and a frame
|
||||
// worker is reported dead, instead of the exception escaping into startup.
|
||||
failStart = false;
|
||||
const RealWorker = globalThis.Worker;
|
||||
globalThis.Worker = class {
|
||||
constructor() {
|
||||
throw new Error("SecurityError: cannot be accessed from origin 'null'");
|
||||
}
|
||||
};
|
||||
// (Take the idle workers out of play first, so that a new one has to be built.)
|
||||
for (let n = host.workerStats().idle, id = 100; n > 0; n--, id++) {
|
||||
host.workerRun(id, "{}", [], "http://localhost/", () => {});
|
||||
host.workerCancel(id);
|
||||
}
|
||||
assert.equal(host.workerStats().idle, 0);
|
||||
const threw = await run(6, []);
|
||||
assert.equal(threw.kind, "error");
|
||||
assert.match(threw.json, /SecurityError/);
|
||||
assert.equal(host.workerStats().running, 0);
|
||||
let frameCall = null;
|
||||
const frameDone = new Promise((resolve) => {
|
||||
frameCall = host.frameWorkerStart("http://localhost/", (index, json, bytes, error) => resolve({ index, error }), false);
|
||||
});
|
||||
const dead = await frameDone;
|
||||
assert.equal(dead.index, frameCall, "the dead worker keeps its index");
|
||||
assert.match(dead.error, /SecurityError/);
|
||||
host.frameWorkerPost(frameCall, { type: "frame" }); // no worker behind it: ignored
|
||||
globalThis.Worker = RealWorker;
|
||||
|
||||
// #216, worker side: a rejected start is re-raised where the page's `onerror` sees it.
|
||||
const dir = mkdtempSync(join(tmpdir(), "ec-worker-"));
|
||||
copyFileSync(join(here, "../web/worker.js"), join(dir, "worker.js"));
|
||||
|
||||
@@ -71,7 +71,13 @@
|
||||
...ec.info(),
|
||||
crossOriginIsolated: self.crossOriginIsolated,
|
||||
webgpu: !!navigator.gpu,
|
||||
serviceWorker: !!(navigator.serviceWorker && navigator.serviceWorker.controller),
|
||||
serviceWorker: (() => {
|
||||
try {
|
||||
return !!(navigator.serviceWorker && navigator.serviceWorker.controller);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
load: window.effectcraftLoad,
|
||||
}),
|
||||
// Browser storage (OPFS / IndexedDB): save without downloading, list, remove, wait for writes.
|
||||
@@ -83,8 +89,14 @@
|
||||
workerFrameCheck: (params) => ec.workerFrameCheck(params ?? {}).then(JSON.parse),
|
||||
};
|
||||
// Offline start and install (PWA): the service worker caches the app's files.
|
||||
if ("serviceWorker" in navigator && !new URLSearchParams(location.search).has("nosw")) {
|
||||
navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e));
|
||||
if (!new URLSearchParams(location.search).has("nosw")) {
|
||||
try {
|
||||
if ("serviceWorker" in navigator && navigator.serviceWorker) {
|
||||
navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("service worker:", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Compile once: the page and its render workers instantiate the same module.
|
||||
|
||||
@@ -23,8 +23,8 @@ mod control_server;
|
||||
mod launch_guard;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
// Built everywhere so it stays checked; only the macOS app uses it.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
// Built everywhere so it stays checked; the macOS and Linux apps use it.
|
||||
#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
|
||||
mod sheet_dialogs;
|
||||
mod window;
|
||||
|
||||
@@ -122,6 +122,22 @@ fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands' file dialogs open without blocking the frame where the host's backend would:
|
||||
/// on macOS as sheets instead of nested modal loops (#511), on Linux through the desktop
|
||||
/// portal from a worker thread — a blocking portal dialog leaves the main thread waiting on its
|
||||
/// answer without pumping events, so the window stops answering the compositor and the desktop
|
||||
/// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps
|
||||
/// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends.
|
||||
fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
let ctx = ctx.clone();
|
||||
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
let _ = (app, ctx);
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
|
||||
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
|
||||
@@ -301,11 +317,11 @@ fn main() -> eframe::Result {
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
// The commands' file dialogs don't block the frame where the platform's backend
|
||||
// would (#511, #1275); see [`install_file_dialogs`].
|
||||
install_file_dialogs(&mut app, &cc.egui_ctx);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// The commands' file dialogs open as sheets instead of nested modal loops (#511).
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
|
||||
app.hooks.app_action = Some(Box::new(native_menu::app_action));
|
||||
app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
|
||||
}
|
||||
@@ -597,4 +613,17 @@ mod tests {
|
||||
assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`"));
|
||||
}
|
||||
}
|
||||
|
||||
/// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the
|
||||
/// compositor while it is open, so the commands' file dialogs go through the non-blocking
|
||||
/// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages.
|
||||
#[test]
|
||||
fn commands_file_dialogs_do_not_block_the_frame() {
|
||||
let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
super::install_file_dialogs(&mut app, &egui::Context::default());
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
//! File dialogs that don't block the frame (macOS), for `Hooks::file_dialog_async`.
|
||||
//! File dialogs that don't block the frame (macOS and Linux), for `Hooks::file_dialog_async`.
|
||||
//!
|
||||
//! A blocking `rfd::FileDialog` runs a nested modal event loop inside eframe's frame, that is
|
||||
//! inside winit's event handler. On macOS a file dragged over the window while that loop ran
|
||||
//! re-entered winit's handler, which panics across the Objective-C boundary and aborts the app
|
||||
//! (#511). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window and returns at once;
|
||||
//! the answer arrives when the sheet closes, outside the frame, and the app runs the command then.
|
||||
//! (#511). On Linux the blocking dialog is worse than slow: rfd uses the XDG desktop portal, so
|
||||
//! the main thread sits waiting on the portal's answer and never pumps winit's events — the
|
||||
//! window stops answering the compositor and the desktop offers to force-quit the app while the
|
||||
//! Open dialog is up (#1275). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window
|
||||
//! (macOS) or through the portal from a worker thread (Linux) and returns at once; the answer
|
||||
//! arrives when the dialog closes, outside the frame, and the app runs the command then.
|
||||
|
||||
use effectcraft_ui_egui::{DialogDone, FileDialog};
|
||||
use rfd::{AsyncFileDialog, FileHandle};
|
||||
|
||||
@@ -66,7 +66,7 @@ sync_github_name = true
|
||||
display_name = "Jason Brashear"
|
||||
|
||||
[people.nanox333]
|
||||
display_name = "nanox333"
|
||||
display_name = "nnaannoo"
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
@@ -87,3 +87,11 @@ sync_github_name = false
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
@@ -301,7 +301,8 @@ impl OffsetCurve {
|
||||
}
|
||||
let n = ext.len();
|
||||
// Tangents: the mean of the neighbouring slopes, flat at local extrema and next to flat
|
||||
// segments so the curve never overshoots its points.
|
||||
// segments, and at most three times either slope (Fritsch-Carlson) so the curve never
|
||||
// overshoots its points, even beside a short steep segment.
|
||||
let slope = |k: usize| (ext[k + 1].1 - ext[k].1) / (ext[k + 1].0 - ext[k].0).max(1e-6);
|
||||
let m: Vec<f32> = (0..n)
|
||||
.map(|k| {
|
||||
@@ -309,7 +310,12 @@ impl OffsetCurve {
|
||||
return 0.0;
|
||||
}
|
||||
let (a, b) = (slope(k - 1), slope(k));
|
||||
if a * b <= 0.0 { 0.0 } else { (a + b) * 0.5 }
|
||||
if a * b <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
let lim = 3.0 * a.abs().min(b.abs());
|
||||
((a + b) * 0.5).clamp(-lim, lim)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let eval = |x: f32| -> f32 {
|
||||
@@ -989,6 +995,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_saturation_curve_stays_between_its_points() {
|
||||
// #1656: a point dragged to the bottom right next to another one. The steep short
|
||||
// segment used to pull the long one beside it far outside the box (|offset| > 0.5).
|
||||
// The third pair sits across the hue wrap (0.98 and 0.005 are neighbours).
|
||||
let cases = [("0.3,0.4 0.31,0 0.7,0.6", true), ("0.1,0.3 0.3,0.5 0.31,1 0.7,0.5", false), ("0.5,0.6 0.98,0.4 0.005,0", true)];
|
||||
for (pts, periodic) in cases {
|
||||
let c = OffsetCurve::parse(pts, periodic).unwrap();
|
||||
for i in 0..=1000 {
|
||||
let x = i as f32 / 1000.0;
|
||||
let v = c.at(x);
|
||||
assert!((-0.5..=0.5).contains(&v), "{pts} at {x}: {v}");
|
||||
}
|
||||
for p in parse_points(pts) {
|
||||
assert!((c.at(p.0) - (p.1 - 0.5)).abs() < 0.02, "{pts}: passes through {p:?}");
|
||||
}
|
||||
if periodic {
|
||||
assert!((c.at(0.0) - c.at(1.0)).abs() < 1e-4, "{pts}: no seam at the wrap");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_px(a: [f32; 4], b: [f32; 4], eps: f32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps)
|
||||
}
|
||||
|
||||
@@ -164,7 +164,10 @@ fn wave_warp(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let kind = ctx.params.e("waveType");
|
||||
let pin = ctx.params.e("pinning");
|
||||
let seed = ctx.seed ^ 0x3a7e;
|
||||
let (dx, dy) = (dir.sin(), -dir.cos());
|
||||
// Cardinal directions must not acquire a tiny perpendicular slope: at a square-wave
|
||||
// boundary cos(90°)'s rounding residue can flip a whole column on CPU but not GPU.
|
||||
let axis = |v: f64| if v.abs() < 4.0 * f64::EPSILON { 0.0 } else { v };
|
||||
let (dx, dy) = (axis(dir.sin()), -axis(dir.cos()));
|
||||
if !ctx.adjustment {
|
||||
b.pad(h.abs().ceil() as u32 + 1);
|
||||
}
|
||||
@@ -556,8 +559,21 @@ mod tests {
|
||||
let (a, b) = (o.img.get(pad, y + pad), im.get(0, y));
|
||||
assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "row {y}: {a:?} vs {b:?}");
|
||||
}
|
||||
let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im);
|
||||
let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im.clone());
|
||||
assert!(free.img.data.iter().zip(&o.img.data).any(|(a, b)| differs(*a, *b)));
|
||||
// #1181: direction 90° → vertical displacement. Top Edge (4) holds the first row,
|
||||
// Bottom Edge (6) the last, and the rest of the layer still moves.
|
||||
for (pin, edge_row) in [(Value::Enum(4), 0i64), (Value::Enum(6), 29i64)] {
|
||||
let set = [("direction", num(90.0)), ("height", num(5.0)), ("pinning", pin)];
|
||||
let o = run("ec.distort.wavewarp", &set, im.clone());
|
||||
let pad = ((o.img.width - im.width) / 2) as i64;
|
||||
for x in 0..40 {
|
||||
let (a, b) = (o.img.get(x + pad, edge_row + pad), im.get(x, edge_row));
|
||||
assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "pin {edge_row} col {x}: {a:?} vs {b:?}");
|
||||
}
|
||||
let mid = 15;
|
||||
assert!((0..40).any(|x| differs(o.img.get(x + pad, mid + pad), im.get(x, mid))), "pin {edge_row}: the middle of the layer must still distort");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -363,7 +363,9 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay);
|
||||
let d = qx * nx + qy * ny;
|
||||
let ad = d.abs();
|
||||
if ad > half + soft_w {
|
||||
// The wire is the segment A to B, not an endless line.
|
||||
let along = qx * tx + qy * ty;
|
||||
if !(0.0..=len).contains(&along) || ad > half + soft_w {
|
||||
continue;
|
||||
}
|
||||
// Positions just outside the wire on both sides.
|
||||
@@ -580,6 +582,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_removal_stays_between_a_and_b() {
|
||||
let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]);
|
||||
for y in 0..20 {
|
||||
img.set(10, y, [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
let out = run_fx(
|
||||
"ec.key.ccsimplewireremoval",
|
||||
&[("pointA", Value::Vec2([10.5, 5.0])), ("pointB", Value::Vec2([10.5, 15.0])), ("removalStyle", Value::Enum(0)), ("thickness", num(3.0))],
|
||||
img.clone(),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
assert!((out.img.get(10, 10)[0] - 0.5).abs() < 0.05, "inside the segment");
|
||||
assert_eq!(out.img.get(10, 2), img.get(10, 2), "before A");
|
||||
assert_eq!(out.img.get(10, 18), img.get(10, 18), "after B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_removal_frame_offset_uses_neighbour_frame() {
|
||||
use crate::{EffectHost, LayerPixels};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Keyframe clipboard, selection, nudging, direct key/ease edits (Graph Editor handles) and key
|
||||
//! inspection for the Keyframe Velocity / Keyframe Interpolation dialogs.
|
||||
|
||||
use effectcraft_keyframe::{Ease, Interp, Keyframe, key_at, retime_roving, side_ease};
|
||||
use effectcraft_project::{LayerId, Property, Uid};
|
||||
use effectcraft_keyframe::{Ease, Interp, Keyframe, ShapePath, Value as KValue, key_at, retime_roving, side_ease};
|
||||
use effectcraft_project::{ItemId, LayerId, Property, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -61,6 +61,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if clip.is_empty() {
|
||||
return Err(bad("keys.paste", "no keyframes have been copied"));
|
||||
}
|
||||
// A copied Mask Path / shape Path key pasted onto Position becomes a motion path (#1425).
|
||||
if let [c] = clip.as_slice()
|
||||
&& let Some(KValue::Path(path)) = c.keys.first().map(|k| &k.value)
|
||||
&& let Some(target) = path_target(s, p)?
|
||||
{
|
||||
return paste_path(s, p, target, path);
|
||||
}
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = time_p(s, p, Some(comp));
|
||||
@@ -134,6 +141,86 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The spatial property (Position, Anchor Point…) a copied path pastes onto as a motion path:
|
||||
/// the `prop` / `path` given, else a selected one of the target layers. `None` when there's none.
|
||||
pub(super) fn path_target(s: &Session, p: &Value) -> Result<Option<(ItemId, LayerId, Uid)>> {
|
||||
let (cid, targets) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let motion = |pr: &Property| pr.spatial && !pr.static_only && matches!(pr.value, KValue::Vec2(_) | KValue::Vec3(_));
|
||||
let explicit = targets.first().and_then(|lid| {
|
||||
let l = comp.layer(*lid)?;
|
||||
let pr = match p.get("prop").and_then(Value::as_u64) {
|
||||
Some(u) => l.props.find(u),
|
||||
None => l.props.prop(str_p(p, "path")?),
|
||||
}?;
|
||||
Some((*lid, pr))
|
||||
});
|
||||
if p.get("prop").is_some() || p.get("path").is_some() {
|
||||
return Ok(explicit.filter(|(_, pr)| motion(pr)).map(|(lid, pr)| (cid, lid, pr.uid)));
|
||||
}
|
||||
let selected = s
|
||||
.state
|
||||
.selected_props
|
||||
.iter()
|
||||
.filter(|(lid, _)| targets.contains(lid))
|
||||
.find(|(lid, uid)| comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(motion));
|
||||
Ok(selected.map(|(lid, uid)| (cid, *lid, *uid)))
|
||||
}
|
||||
|
||||
/// Paste a mask or shape path onto a spatial property as After Effects does: a keyframe per
|
||||
/// vertex with the path's tangents, from the CTI over 2 s (a closed path ends back on its first
|
||||
/// vertex), the inner keys roving so the layer moves at one speed. Like After Effects, the
|
||||
/// vertices are pasted as they are (layer-space numbers, not converted to the target's space).
|
||||
pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, LayerId, Uid), path: &ShapePath) -> Result<Value> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = time_p(s, p, Some(comp));
|
||||
let target = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let mut order: Vec<usize> = (0..path.vertices.len()).collect();
|
||||
if path.closed && !order.is_empty() {
|
||||
order.push(0);
|
||||
}
|
||||
if order.is_empty() {
|
||||
return Err(bad("keys.paste", "the copied path has no points"));
|
||||
}
|
||||
let span = Tick::from_seconds_f64(2.0).0 / i64::try_from(order.len().saturating_sub(1).max(1)).unwrap_or(i64::MAX);
|
||||
let at = |xy: Option<&[f64; 2]>| {
|
||||
let [x, y] = xy.copied().unwrap_or_default();
|
||||
[x, y, 0.0]
|
||||
};
|
||||
let last = order.len() - 1;
|
||||
let keys: Vec<(Tick, KValue, [f64; 3], [f64; 3], bool)> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &v)| {
|
||||
let [x, y, z] = at(path.vertices.get(v));
|
||||
let value = match target.props.find(uid).map(|pr| &pr.value) {
|
||||
Some(KValue::Vec2(_)) => KValue::Vec2([x, y]),
|
||||
_ => KValue::Vec3([x, y, z]),
|
||||
};
|
||||
let time = target.layer_time(t + Tick(span.saturating_mul(i64::try_from(i).unwrap_or(i64::MAX))));
|
||||
(time, value, at(path.in_tangents.get(v)), at(path.out_tangents.get(v)), i != 0 && i != last)
|
||||
})
|
||||
.collect();
|
||||
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?;
|
||||
let mut sel = vec![];
|
||||
for (time, value, spatial_in, spatial_out, roving) in keys {
|
||||
let k = Keyframe { spatial_in, spatial_out, spatial_auto: false, roving, ..Keyframe::new(time, value) };
|
||||
match pr.keys.binary_search_by(|x| x.time.cmp(&time)) {
|
||||
Ok(i) => pr.keys[i] = k,
|
||||
Err(i) => pr.keys.insert(i, k),
|
||||
}
|
||||
sel.push(time);
|
||||
}
|
||||
// Re-timing keeps the keys' order, so the pasted keys are found again by index.
|
||||
let pasted: Vec<usize> = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect();
|
||||
retime_roving(&mut pr.keys, pr.spatial);
|
||||
st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Select every keyframe of the selected properties, or of the given / selected layers.
|
||||
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, mut layers) = layers_p(s, p)?;
|
||||
|
||||
@@ -10,7 +10,7 @@ use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project,
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, selected_leaf_props, str_p};
|
||||
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, resolve_effect_path, selected_leaf_props, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
/// Preset file format version tag (`*.ecpreset`, JSON).
|
||||
@@ -147,7 +147,8 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
for (path, src) in &props {
|
||||
match l.props.prop_mut(path) {
|
||||
let resolved = resolve_effect_path(l, path);
|
||||
match l.props.prop_mut(&resolved) {
|
||||
Some(pr) if std::mem::discriminant(&pr.value) == std::mem::discriminant(&src.value) => {
|
||||
pr.value = src.value.clone();
|
||||
pr.keys = src
|
||||
|
||||
@@ -115,6 +115,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !items.is_empty() {
|
||||
return super::prop_groups::duplicate_contents(s, &items);
|
||||
}
|
||||
// Text animators or their selectors selected → duplicate them inside their text layers.
|
||||
let items = super::prop_groups::selected_animator_items(s);
|
||||
if !items.is_empty() {
|
||||
return super::prop_groups::duplicate_contents(s, &items);
|
||||
}
|
||||
}
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let new = s.edit("Duplicate", None, |proj, st| {
|
||||
@@ -328,7 +333,8 @@ fn paste_properties(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let lt = l.layer_time(t);
|
||||
for (path, v) in &values {
|
||||
if let Some(pr) = l.props.prop_mut(path)
|
||||
let path = super::resolve_effect_path(l, path);
|
||||
if let Some(pr) = l.props.prop_mut(&path)
|
||||
&& let Some(v) = pr.value.coerce_json(&v.to_json())
|
||||
{
|
||||
pr.set_value_at(lt, v);
|
||||
@@ -458,7 +464,8 @@ fn paste_links(s: &mut Session, clip: LinkClip) -> Result<Value> {
|
||||
for lid in &ids {
|
||||
let l = super::layer_mut(proj, cid, *lid)?;
|
||||
for (path, e) in &pairs {
|
||||
if let Some(pr) = l.props.prop_mut(path) {
|
||||
let path = super::resolve_effect_path(l, path);
|
||||
if let Some(pr) = l.props.prop_mut(&path) {
|
||||
pr.expr = Some(e.clone());
|
||||
n += 1;
|
||||
}
|
||||
@@ -496,6 +503,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(clip) = s.state.link_clipboard.clone() {
|
||||
return paste_links(s, clip);
|
||||
}
|
||||
// A copied Mask Path / shape Path pasted onto Position becomes a motion path (#1425).
|
||||
if let [(_, effectcraft_keyframe::Value::Path(path))] = s.state.value_clipboard.as_slice()
|
||||
&& let Some(target) = super::anim::path_target(s, p)?
|
||||
{
|
||||
let path = path.clone();
|
||||
return super::anim::paste_path(s, p, target, &path);
|
||||
}
|
||||
if !s.state.value_clipboard.is_empty() {
|
||||
return paste_properties(s, p);
|
||||
}
|
||||
|
||||
@@ -404,6 +404,13 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
||||
}
|
||||
}
|
||||
paths = rest;
|
||||
// Composition imports already made their sources, so they bypass add_footage's
|
||||
// probed-footage refresh. Refresh them before any background probe job starts (#1070).
|
||||
for id in &items {
|
||||
if let Some(ItemKind::Footage(f)) = s.project.item(ItemId(*id)).map(|it| &it.kind) {
|
||||
s.forget_decoded(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Numbered stills as image sequences (the Import dialog's "<format> Sequence" and Force
|
||||
// Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage.
|
||||
|
||||
@@ -140,9 +140,10 @@ fn srgb_to_linear(c: f32) -> f32 {
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
/// A unique EXR layer name (no dots: they separate the layer from the channel).
|
||||
/// A unique EXR layer name (no dots: they separate the layer from the channel). Printable ASCII only:
|
||||
/// the `exr` crate panics on characters it cannot store in a channel name (#1702).
|
||||
fn exr_name(name: &str, used: &mut Vec<String>) -> String {
|
||||
let base: String = name.chars().map(|c| if c == '.' || c.is_control() { '_' } else { c }).collect();
|
||||
let base: String = name.chars().map(|c| if c == '.' || !(c == ' ' || c.is_ascii_graphic()) { '_' } else { c }).collect();
|
||||
let base = if base.trim().is_empty() { "Layer".to_string() } else { base };
|
||||
let mut n = base.clone();
|
||||
let mut k = 2;
|
||||
|
||||
@@ -442,6 +442,24 @@ pub fn match_path_of(g: &effectcraft_project::PropGroup, uid: effectcraft_projec
|
||||
None
|
||||
}
|
||||
|
||||
/// Turn `effects/<match name>[#n]/<property>` (as [`match_path_of`] writes it) into a uid path on
|
||||
/// `l`. Path parsing splits on `.`, which would cut a match name like `ec.control.slider` apart.
|
||||
/// Any other path, or an effect that is not on the layer, comes back unchanged.
|
||||
pub(crate) fn resolve_effect_path(l: &Layer, path: &str) -> String {
|
||||
let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() };
|
||||
let (match_id, nth) = match seg.rsplit_once('#') {
|
||||
Some((m, n)) if !m.is_empty() => match n.parse::<usize>() {
|
||||
Ok(n) if n >= 1 => (m, n),
|
||||
_ => (seg, 1),
|
||||
},
|
||||
_ => (seg, 1),
|
||||
};
|
||||
match l.effects().and_then(|fx| fx.groups().filter(|g| g.match_id == match_id).nth(nth - 1)) {
|
||||
Some(g) => format!("effects/@{}/{tail}", g.uid),
|
||||
None => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected properties (leaves) of the active comp as (layer, uid); selected groups expand to
|
||||
/// their properties.
|
||||
pub(crate) fn selected_leaf_props(s: &Session) -> Vec<(LayerId, effectcraft_project::Uid)> {
|
||||
|
||||
@@ -108,13 +108,89 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"rotoBezier": on}))
|
||||
}
|
||||
|
||||
/// The parametric shape paths that Convert To Bezier Path replaces with an editable path.
|
||||
const PARAMETRIC: [&str; 3] = ["rect", "ellipse", "star"];
|
||||
|
||||
/// The path a parametric shape group draws, in the group's own space, read at `lt`. This is the
|
||||
/// geometry the renderer builds from the same values (`render::shapes::collect`), so the converted
|
||||
/// path is the shape that was on screen rather than an approximation of it; the defaults match
|
||||
/// that code for a property a group doesn't carry (a polygon has no inner radius or roundness).
|
||||
fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option<ShapePath> {
|
||||
let f = |id: &str, d: f64| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_f64());
|
||||
let v2 = |id: &str, d: [f64; 2]| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_vec2());
|
||||
let path = match g.match_id.as_str() {
|
||||
"rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)),
|
||||
"ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])),
|
||||
"star" => effectcraft_path::polystar(
|
||||
g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0,
|
||||
f("points", 5.0),
|
||||
v2("position", [0.0; 2]),
|
||||
f("rotation", 0.0),
|
||||
f("innerRadius", 50.0),
|
||||
f("outerRadius", 100.0),
|
||||
f("innerRoundness", 0.0),
|
||||
f("outerRoundness", 0.0),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
// A closed parametric shape is one subpath; anything else would not be this shape.
|
||||
effectcraft_path::from_kurbo(&path).into_iter().next()
|
||||
}
|
||||
|
||||
fn convert_to_bezier(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_mask_groups(s, p, "Convert To Bezier Path", |g| {
|
||||
if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind {
|
||||
*roto_bezier = false;
|
||||
// On a shape layer this replaces a Rectangle, Ellipse or Polystar path with an editable
|
||||
// Path 1 of the same geometry, as After Effects does (#932). The parametric properties are
|
||||
// read at the current time and the path that comes out is static: a size or roundness
|
||||
// animation cannot survive the conversion, in After Effects either.
|
||||
let (cid, items) = selected_shape_items(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let shapes: Vec<(LayerId, Uid)> = items
|
||||
.into_iter()
|
||||
.filter(|(l, u)| {
|
||||
comp.layer(*l).and_then(|l| l.props.sub("contents")).and_then(|c| c.find_group(*u)).is_some_and(|g| PARAMETRIC.contains(&g.match_id.as_str()))
|
||||
})
|
||||
.collect();
|
||||
if shapes.is_empty() {
|
||||
return edit_mask_groups(s, p, "Convert To Bezier Path", |g| {
|
||||
if let GroupKind::Mask { roto_bezier, .. } = &mut g.kind {
|
||||
*roto_bezier = false;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let t = s.time();
|
||||
let n = shapes.len();
|
||||
let made = s.edit("Convert To Bezier Path", None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let mut made = Vec::new();
|
||||
for (lid, uid) in &shapes {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?;
|
||||
let Some(old) = contents.find_group(*uid) else { continue };
|
||||
let Some(path) = parametric_path(old, lt) else { continue };
|
||||
// Path Direction is the shape's own, not a default: reversing is a separate command.
|
||||
let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum());
|
||||
let enabled = old.enabled;
|
||||
let mut g = build::shape_path(&mut Ids(&mut next), path);
|
||||
if let Some(pr) = g.prop_mut("direction") {
|
||||
pr.value = KV::Enum(direction);
|
||||
}
|
||||
g.enabled = enabled;
|
||||
let new_uid = g.uid;
|
||||
let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?;
|
||||
let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue };
|
||||
parent.children[at] = Node::Group(g);
|
||||
made.push((*lid, new_uid));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
proj.next_id = next;
|
||||
// The new paths are what is selected, so the vertices can be edited straight away.
|
||||
if !made.is_empty() {
|
||||
st.selected_props = made.clone();
|
||||
}
|
||||
Ok(made)
|
||||
})?;
|
||||
Ok(json!({"paths": made.len(), "of": n}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- mask options
|
||||
|
||||
@@ -44,7 +44,8 @@ fn with_prop<T>(
|
||||
) -> Result<T> {
|
||||
let t = s.time_of(cid);
|
||||
let linear = s.prefs.general.default_spatial_linear;
|
||||
s.edit(label, merge, |proj, _| {
|
||||
s.edit(label, merge, |proj, state| {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?;
|
||||
@@ -59,10 +60,61 @@ fn with_prop<T>(
|
||||
k.spatial_out = [0.0; 3];
|
||||
}
|
||||
}
|
||||
if pr.match_id == "type" {
|
||||
let removed = sync_polystar_type(l, uid, lt, &mut next)?;
|
||||
if !removed.is_empty() {
|
||||
state.selected_props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop));
|
||||
state.selected_keys.retain(|key| key.layer != lid || !removed.contains(&key.prop));
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
Ok(r)
|
||||
})
|
||||
}
|
||||
|
||||
/// A static Polygon has no inner controls; switching to Star creates them in schema order
|
||||
/// with AE's switch defaults (#1685). Animated/expression-driven types need the controls at
|
||||
/// every time, including when the current value is Polygon.
|
||||
fn sync_polystar_type(layer: &mut Layer, uid: Uid, time: Tick, next: &mut Uid) -> Result<Vec<Uid>> {
|
||||
if !matches!(layer.source, effectcraft_project::LayerSource::Shape) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let Some(group) =
|
||||
layer.props.parent_of_mut(uid).filter(|g| g.match_id == "star" && matches!(g.kind, GroupKind::Indexed) && g.get("type").is_some_and(|p| p.uid == uid))
|
||||
else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let Some(kind) = group.get("type") else { return Ok(vec![]) };
|
||||
let needs_inner = kind.is_animated() || kind.has_expression() || kind.value_at(time) == effectcraft_keyframe::Value::Enum(0);
|
||||
if !needs_inner {
|
||||
let mut removed = vec![];
|
||||
group.children.retain(|node| {
|
||||
if let effectcraft_project::Node::Prop(p) = node
|
||||
&& matches!(p.match_id.as_str(), "innerRadius" | "innerRoundness")
|
||||
{
|
||||
removed.push(p.uid);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
return Ok(removed);
|
||||
}
|
||||
for (inner, name, outer, value, ui) in [
|
||||
("innerRadius", "Inner Radius", "outerRadius", 50.0, effectcraft_project::ParamUi::Pixels),
|
||||
("innerRoundness", "Inner Roundness", "outerRoundness", 0.0, effectcraft_project::ParamUi::Percent),
|
||||
] {
|
||||
if group.get(inner).is_none() {
|
||||
let uid = *next;
|
||||
*next = next.checked_add(1).ok_or_else(|| bad("prop", "property ids exhausted"))?;
|
||||
let prop = Property::new(uid, inner, name, effectcraft_keyframe::Value::Scalar(value)).with_ui(ui);
|
||||
let index =
|
||||
group.children.iter().position(|n| matches!(n, effectcraft_project::Node::Prop(p) if p.match_id == outer)).unwrap_or(group.children.len());
|
||||
group.children.insert(index, effectcraft_project::Node::Prop(prop));
|
||||
}
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// Explicit command key times default to layer time; readers and the CTI use comp time.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum KeyTimeBase {
|
||||
@@ -241,7 +293,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
|
||||
}
|
||||
|
||||
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
|
||||
/// `offset` (a scrub) the same change from each layer's own value. With `offset` and
|
||||
/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims`
|
||||
/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers,
|
||||
/// which keep the rest of their own value (#1434). With `offset` and
|
||||
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
|
||||
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
|
||||
/// one that a continuing scrub keeps folding into).
|
||||
@@ -261,6 +315,23 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect());
|
||||
let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len())));
|
||||
let scalar = p.get("value").is_some_and(Value::is_number);
|
||||
// Typed into some components only: the others stay each layer's own.
|
||||
let dims: Option<Vec<usize>> = match (&delta, p.get("dims").and_then(Value::as_array), &new) {
|
||||
(None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()),
|
||||
_ => None,
|
||||
};
|
||||
let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| {
|
||||
if let (Some(dims), Some(n)) = (&dims, &new)
|
||||
&& let Some(mut c) = current_components(s, cid, olid, ouid)
|
||||
{
|
||||
for &d in dims {
|
||||
if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
q["value"] = json!(c);
|
||||
}
|
||||
};
|
||||
// `p` aimed at (layer, prop) with its value moved by `k` × the change.
|
||||
let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| {
|
||||
if let Some(d) = &delta
|
||||
@@ -291,6 +362,7 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut q = twin_params(p, olid, ouid);
|
||||
shifted(s, &mut q, olid, ouid, k);
|
||||
typed(s, &mut q, olid, ouid);
|
||||
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
|
||||
let _ = set(s, &q);
|
||||
}
|
||||
@@ -967,6 +1039,7 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let sel = s.state.selected_keys.clone();
|
||||
s.edit("Delete Keyframes", None, |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut removed = 0usize;
|
||||
for k in &sel {
|
||||
if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop))
|
||||
&& let Some(i) = key_at(&pr.keys, k.time)
|
||||
@@ -975,10 +1048,13 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
pr.value = pr.keys[0].value.clone();
|
||||
}
|
||||
pr.keys.remove(i);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
st.selected_keys.clear();
|
||||
Ok(json!(sel.len()))
|
||||
// The keys actually removed: a selected key that is gone (or listed twice) isn't counted,
|
||||
// so a caller can tell that a delete changed nothing.
|
||||
Ok(json!(removed))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1229,7 +1305,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Property Value",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}",
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}",
|
||||
has_layers,
|
||||
set_selected
|
||||
),
|
||||
|
||||
@@ -100,6 +100,22 @@ pub(crate) fn selected_contents(s: &Session) -> Vec<(LayerId, Uid)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Text animators and their selectors selected on the active comp's text layers, top to bottom.
|
||||
/// A selector inside a selected animator isn't listed, since it is copied with it.
|
||||
pub(crate) fn selected_animator_items(s: &Session) -> Vec<(LayerId, Uid)> {
|
||||
let mut out = vec![];
|
||||
let Some(c) = s.active_comp().filter(|_| !s.state.selected_props.is_empty()) else { return out };
|
||||
for l in c.layers.iter().filter(|l| matches!(l.source, LayerSource::Text)) {
|
||||
if let Some(animators) = l.props.group("text/animators") {
|
||||
let selected = |u: Uid| s.state.selected_props.contains(&(l.id, u));
|
||||
let mut uids = vec![];
|
||||
collect_selected(animators, &selected, &mut uids, 0);
|
||||
out.extend(uids.into_iter().map(|u| (l.id, u)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_selected(g: &PropGroup, selected: &dyn Fn(Uid) -> bool, out: &mut Vec<Uid>, depth: usize) {
|
||||
if depth > super::shape_stroke::MAX_DEPTH {
|
||||
return;
|
||||
@@ -127,8 +143,9 @@ pub(crate) fn cut_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste with shape items on the clipboard: copies go into each selected shape layer's
|
||||
/// Contents, above its topmost selected item (in that item's group), else on top. Names stay
|
||||
/// unique in their group ("Rectangle 2"…), and the copies are selected.
|
||||
/// Contents, above its topmost selected item (in that item's group), else on top (paints and path
|
||||
/// operations alone: at the bottom). Names stay unique in their group ("Rectangle 2"…), and the
|
||||
/// copies are selected.
|
||||
pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "edit.paste";
|
||||
let clip = s.state.contents_clipboard.clone();
|
||||
@@ -140,6 +157,9 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(c, "select a shape layer to paste shape items into"));
|
||||
}
|
||||
let sel = selected_contents(s);
|
||||
let acts_on_above = clip.iter().all(|g| !matches!(g.match_id.as_str(), "group" | "rect" | "ellipse" | "star" | "path"));
|
||||
// Merge Paths clears the draws above it, so at the bottom it would erase the paints (#1681).
|
||||
let has_merge = clip.iter().any(|g| g.match_id == "merge");
|
||||
let label = match clip.as_slice() {
|
||||
[g] => format!("Paste {}", g.name),
|
||||
_ => "Paste".to_string(),
|
||||
@@ -154,11 +174,19 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let parent = l.props.parent_of(*u)?;
|
||||
Some((parent.uid, parent.children.iter().position(|n| n.uid() == *u)?))
|
||||
});
|
||||
let unselected = spot.is_none();
|
||||
let (parent, at) = match spot {
|
||||
Some((pu, i)) => (l.props.find_group_mut(pu), i),
|
||||
None => (l.props.sub_mut("contents"), 0),
|
||||
};
|
||||
let parent = parent.ok_or_else(|| bad(c, "the layer has no contents"))?;
|
||||
// Trim Paths, Wiggle Paths, fills… act on the items above them (as Add places them),
|
||||
// so with nothing selected they go to the bottom: on top they'd affect nothing (#1681).
|
||||
let at = match unselected {
|
||||
true if has_merge => merge_spot(parent),
|
||||
true if acts_on_above => parent.children.len(),
|
||||
_ => at,
|
||||
};
|
||||
for (k, g) in clip.iter().enumerate() {
|
||||
let mut g = g.clone();
|
||||
g.reassign_uids(&mut next);
|
||||
@@ -174,8 +202,21 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"contents": pasted.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Edit ▸ Duplicate with shape items selected: each is copied in place, above the original and
|
||||
/// named as After Effects does ("Rectangle 1" → "Rectangle 2"), and the copies are selected.
|
||||
/// Where Merge Paths goes in `contents` when nothing is selected: before the first paint, so the
|
||||
/// paints still draw the merged path (the renderer drops the draws above a Merge Paths), else after
|
||||
/// the last path item.
|
||||
fn merge_spot(contents: &PropGroup) -> usize {
|
||||
let is = |n: &Node, ids: &[&str]| matches!(n, Node::Group(g) if ids.contains(&g.match_id.as_str()));
|
||||
let paints = ["fill", "stroke", "gfill", "gstroke"];
|
||||
match contents.children.iter().position(|n| is(n, &paints)) {
|
||||
Some(i) => i,
|
||||
None => contents.children.iter().rposition(|n| is(n, &["rect", "ellipse", "star", "path"])).map_or(0, |i| i + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit ▸ Duplicate with shape items, text animators or selectors selected: each is copied in
|
||||
/// place, above the original and named as After Effects does ("Rectangle 1" → "Rectangle 2"),
|
||||
/// and the copies are selected.
|
||||
pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<Value> {
|
||||
let c = "edit.duplicate";
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
@@ -183,7 +224,7 @@ pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> R
|
||||
let mut next = proj.next_id;
|
||||
let mut out = vec![];
|
||||
for (lid, uid) in items {
|
||||
let parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the shape item is gone"))?;
|
||||
let parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the group is gone"))?;
|
||||
let Some((i, Node::Group(g))) = parent.children.iter().enumerate().find(|(_, n)| n.uid() == *uid) else { continue };
|
||||
let mut g = g.clone();
|
||||
g.reassign_uids(&mut next);
|
||||
|
||||
+22
-10
@@ -345,6 +345,11 @@ fn one_view() -> u8 {
|
||||
1
|
||||
}
|
||||
|
||||
/// `t` as a current time of `comp`: a frame within its duration.
|
||||
fn comp_frame_at(comp: &Comp, t: Tick) -> Tick {
|
||||
comp.frame_rate.snap_nearest(t.min(comp.duration - comp.frame_duration()).max(Tick::ZERO))
|
||||
}
|
||||
|
||||
/// What Copy with Property Links / Copy Expression Only put on the clipboard.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LinkClip {
|
||||
@@ -864,11 +869,7 @@ impl Session {
|
||||
|
||||
pub fn set_time(&mut self, t: Tick) {
|
||||
if let Some(c) = self.state.active_comp {
|
||||
let comp = self.project.comp(c);
|
||||
let t = match comp {
|
||||
Some(comp) => comp.frame_rate.snap_nearest(t.clamp(Tick::ZERO, comp.duration - comp.frame_duration())),
|
||||
None => t,
|
||||
};
|
||||
let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t));
|
||||
self.state.times.insert(c, t);
|
||||
if self.prefs.general.sync_time_related_items {
|
||||
self.sync_related_times(c, t);
|
||||
@@ -987,15 +988,24 @@ impl Session {
|
||||
self.events.push(Event::OpenComp(id));
|
||||
}
|
||||
|
||||
/// The open compositions, as a project file records them ([`effectcraft_project::SavedView`]).
|
||||
/// The open compositions and their current times, as a project file records them
|
||||
/// ([`effectcraft_project::SavedView`]).
|
||||
pub fn saved_view(&self) -> effectcraft_project::SavedView {
|
||||
effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() }
|
||||
let comp_times = self.state.times.iter().filter(|(id, _)| self.project.comp(**id).is_some()).map(|(id, t)| (*id, *t)).collect();
|
||||
effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone(), comp_times }
|
||||
}
|
||||
|
||||
/// Reopen the compositions a project file was saved with (#748). Ids that no longer name a
|
||||
/// comp are skipped; with none left the project stays on the comp [`Session::replace_project`]
|
||||
/// opened (its first), as files from before the view was saved do.
|
||||
/// Reopen the compositions a project file was saved with (#748), each at its saved current
|
||||
/// time (#1714). Ids that no longer name a comp are skipped; with none left the project stays
|
||||
/// on the comp [`Session::replace_project`] opened (its first), as files from before the view
|
||||
/// was saved do.
|
||||
pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) {
|
||||
for (id, t) in &view.comp_times {
|
||||
if let Some(comp) = self.project.comp(*id) {
|
||||
let t = comp_frame_at(comp, *t);
|
||||
self.state.times.insert(*id, t);
|
||||
}
|
||||
}
|
||||
let mut open: Vec<ItemId> = Vec::new();
|
||||
for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) {
|
||||
if !open.contains(&id) {
|
||||
@@ -1232,6 +1242,8 @@ mod tests_codec_options;
|
||||
#[cfg(test)]
|
||||
mod tests_color_view;
|
||||
#[cfg(test)]
|
||||
mod tests_delete_keys;
|
||||
#[cfg(test)]
|
||||
mod tests_disk_cache;
|
||||
#[cfg(test)]
|
||||
mod tests_effects;
|
||||
|
||||
@@ -428,6 +428,8 @@ pub struct Prefs {
|
||||
pub recent_presets: Vec<String>,
|
||||
/// Character panel: recently used font families, newest first.
|
||||
pub recent_fonts: Vec<String>,
|
||||
/// Effects & Presets: the starred effects' ids, in the order starred.
|
||||
pub effects_favorites: Vec<String>,
|
||||
/// Preview panel: every shortcut's options.
|
||||
pub preview: crate::preview::PreviewSettings,
|
||||
/// View ▸ Simulate Output ▸ My Custom RGB.
|
||||
@@ -468,6 +470,7 @@ impl Default for Prefs {
|
||||
recent_footage: vec![],
|
||||
recent_presets: vec![],
|
||||
recent_fonts: vec![],
|
||||
effects_favorites: vec![],
|
||||
preview: crate::preview::PreviewSettings::default(),
|
||||
custom_rgb: crate::viewer::CustomRgb::default(),
|
||||
comp_presets: vec![],
|
||||
@@ -597,6 +600,9 @@ impl Prefs {
|
||||
pub fn normalize(&mut self) {
|
||||
self.version = PREFS_VERSION;
|
||||
self.comp_presets.retain(CompPreset::is_valid);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.effects_favorites.retain(|id| !id.is_empty() && seen.insert(id.clone()));
|
||||
self.effects_favorites.truncate(MAX_EFFECTS_FAVORITES);
|
||||
if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) {
|
||||
self.startup.window_graphics = Startup::default().window_graphics;
|
||||
}
|
||||
@@ -715,6 +721,7 @@ impl Prefs {
|
||||
recent_footage: std::mem::take(&mut self.recent_footage),
|
||||
recent_presets: std::mem::take(&mut self.recent_presets),
|
||||
recent_fonts: std::mem::take(&mut self.recent_fonts),
|
||||
effects_favorites: std::mem::take(&mut self.effects_favorites),
|
||||
preview: std::mem::take(&mut self.preview),
|
||||
comp_presets: std::mem::take(&mut self.comp_presets),
|
||||
..d
|
||||
@@ -916,6 +923,23 @@ impl Prefs {
|
||||
push_mru(&mut self.recent_fonts, family, 30);
|
||||
}
|
||||
|
||||
/// Is effect `id` starred in Effects & Presets?
|
||||
pub fn is_effect_favorite(&self, id: &str) -> bool {
|
||||
self.effects_favorites.iter().any(|f| f == id)
|
||||
}
|
||||
|
||||
/// Star effect `id` in Effects & Presets, or un-star it; true when it is starred now.
|
||||
pub fn toggle_effect_favorite(&mut self, id: &str) -> bool {
|
||||
if self.is_effect_favorite(id) {
|
||||
self.effects_favorites.retain(|f| f != id);
|
||||
false
|
||||
} else {
|
||||
self.effects_favorites.push(id.to_string());
|
||||
self.effects_favorites.truncate(MAX_EFFECTS_FAVORITES);
|
||||
self.is_effect_favorite(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The recent fonts the Character panel shows (at most Number of Recent Fonts to Display).
|
||||
pub fn recent_fonts_shown(&self) -> &[String] {
|
||||
let n = (self.type_.recent_fonts as usize).min(self.recent_fonts.len());
|
||||
@@ -933,6 +957,9 @@ impl Prefs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most effects Effects & Presets keeps starred (a hand-edited settings file can hold more).
|
||||
const MAX_EFFECTS_FAVORITES: usize = 1000;
|
||||
|
||||
fn push_mru(list: &mut Vec<String>, v: &str, max: usize) {
|
||||
list.retain(|x| x != v);
|
||||
list.insert(0, v.to_string());
|
||||
|
||||
@@ -686,3 +686,50 @@ fn reopening_a_project_restores_its_active_comp() {
|
||||
o.insert("openComps".into(), open);
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer times are saved as exact ticks and read back unchanged, and each comp reopens at the
|
||||
/// current time it was saved at, not at 0, where a layer starting later shows nothing (#1714).
|
||||
#[test]
|
||||
fn reopening_a_project_restores_layer_times_and_current_time() {
|
||||
use effectcraft_project::ItemId;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
let dir = std::env::temp_dir().join(format!("ec-test-1714-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("times.ecproj").to_string_lossy().to_string();
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 108, "height": 192, "duration": 60})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "crown"})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap();
|
||||
comp.frame_rate = FrameRate::FPS_59_94;
|
||||
// The layer from the issue: 34f, 315f and 3033f at 59.94 fps.
|
||||
let l = comp.layers.first_mut().unwrap();
|
||||
(l.start_time, l.in_point, l.out_point) = (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800));
|
||||
s.execute("time.set", json!({"time": 6.0})).unwrap();
|
||||
let t = s.time();
|
||||
// A second comp keeps its own time.
|
||||
let b = ItemId(s.execute("comp.new", json!({"name": "B"})).unwrap()["comp"].as_u64().unwrap());
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
let tb = s.time();
|
||||
s.execute("comp.open", json!({"comp": "C"})).unwrap();
|
||||
s.execute("file.saveAs", json!({"path": path})).unwrap();
|
||||
|
||||
let mut s2 = Session::default();
|
||||
s2.execute("file.open", json!({"path": path})).unwrap();
|
||||
let l = s2.project.comp(cid).unwrap().layers.first().unwrap();
|
||||
assert_eq!((l.start_time, l.in_point, l.out_point), (Tick(144_086_342_400), Tick(1_334_917_584_000), Tick(12_853_349_308_800)));
|
||||
assert_eq!((s2.time(), s2.state.times.get(&b).copied()), (t, Some(tb)));
|
||||
assert!(l.in_point <= s2.time() && s2.time() < l.out_point, "the layer shows at the reopened time");
|
||||
assert!(!s2.is_dirty());
|
||||
|
||||
// Hand-edited times past the end or before the start land on the last or first frame.
|
||||
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
file["compTimes"] = json!({cid.0.to_string(): i64::MAX, b.0.to_string(): -5});
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let mut s3 = Session::default();
|
||||
s3.execute("file.open", json!({"path": path})).unwrap();
|
||||
let c = s3.project.comp(cid).unwrap();
|
||||
assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration()));
|
||||
assert_eq!(s3.time(), c.frame_rate.snap_nearest(c.duration - c.frame_duration()));
|
||||
assert_eq!(s3.state.times.get(&b).copied(), Some(Tick::ZERO));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! `keys.delete` reports the keys it removed, not the keys it was asked to remove.
|
||||
|
||||
use effectcraft_project::LayerId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{KeyRef, Session};
|
||||
|
||||
fn setup() -> (Session, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute_checked("comp.new", json!({"width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let layer = s.execute_checked("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for t in 0..3 {
|
||||
s.execute_checked("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap();
|
||||
}
|
||||
let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
(s, layer, prop)
|
||||
}
|
||||
|
||||
fn keys_of(s: &Session, layer: u64, prop: u64) -> Vec<KeyRef> {
|
||||
let keys = &s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys;
|
||||
keys.iter().map(|k| KeyRef { layer: LayerId(layer), prop, time: k.time }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_keys_counts_only_the_keys_it_removed() {
|
||||
let (mut s, layer, prop) = setup();
|
||||
let keys = keys_of(&s, layer, prop);
|
||||
assert_eq!(keys.len(), 3);
|
||||
// Two real keys, one of them listed twice, and a key of a property that does not exist.
|
||||
let missing = KeyRef { layer: LayerId(layer), prop: u64::MAX, time: keys[1].time };
|
||||
s.state.selected_keys = vec![keys[0], keys[2], keys[0], missing];
|
||||
let removed = s.execute_checked("keys.delete", json!({})).unwrap();
|
||||
assert_eq!(removed, json!(2));
|
||||
assert_eq!(keys_of(&s, layer, prop), vec![keys[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_keys_reports_zero_when_no_selected_key_exists() {
|
||||
let (mut s, layer, prop) = setup();
|
||||
let keys = keys_of(&s, layer, prop);
|
||||
s.state.selected_keys = vec![KeyRef { layer: LayerId(layer), prop: u64::MAX, time: keys[0].time }];
|
||||
let removed = s.execute_checked("keys.delete", json!({})).unwrap();
|
||||
assert_eq!(removed, json!(0));
|
||||
assert_eq!(keys_of(&s, layer, prop), keys);
|
||||
}
|
||||
@@ -741,3 +741,56 @@ fn effect_colour_labels_set_clear_undo_and_round_trip() {
|
||||
let l = old.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().clone();
|
||||
assert_eq!(l.effects().unwrap().groups().next().unwrap().label, None);
|
||||
}
|
||||
|
||||
/// #1746: a preset saved from an effect property (`effects/ec.control.slider/slider`, `#2` for the second
|
||||
/// instance) applies back to the same layer instead of landing in `skipped`.
|
||||
#[test]
|
||||
fn preset_round_trips_effect_properties_by_match_name() {
|
||||
let (mut s, a, _) = comp_with_two_solids();
|
||||
apply(&mut s, a, "Slider Control");
|
||||
apply(&mut s, a, "Slider Control");
|
||||
let slider = |s: &Session, n: usize| match layer(s, a).effects().unwrap().groups().nth(n).unwrap().get("slider").unwrap().value {
|
||||
KV::Scalar(v) => v,
|
||||
ref other => panic!("not a scalar: {other:?}"),
|
||||
};
|
||||
for (n, value) in [(1, 30.0), (2, 70.0)] {
|
||||
s.execute("prop.set", json!({"layer": a, "path": format!("effects/#{n}/slider"), "value": value})).unwrap();
|
||||
}
|
||||
for n in [1, 2] {
|
||||
let path = std::env::temp_dir().join(format!("ec-1746-{}-{n}.ecpreset", std::process::id())).to_string_lossy().to_string();
|
||||
s.execute("prop.select", json!({"layer": a, "path": format!("effects/#{n}/slider")})).unwrap();
|
||||
s.execute("anim.savePreset", json!({"path": path})).unwrap();
|
||||
let saved = std::fs::read_to_string(&path).unwrap();
|
||||
let want = if n == 1 { "effects/ec.control.slider/slider" } else { "effects/ec.control.slider#2/slider" };
|
||||
assert!(saved.contains(want), "{saved}");
|
||||
s.execute("prop.set", json!({"layer": a, "path": format!("effects/#{n}/slider"), "value": 0.0})).unwrap();
|
||||
let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [a]})).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(r["applied"], 1, "{r}");
|
||||
assert_eq!(r["skipped"], json!([]), "{r}");
|
||||
assert_eq!(slider(&s, n - 1), if n == 1 { 30.0 } else { 70.0 });
|
||||
}
|
||||
}
|
||||
|
||||
/// #1746: Edit ▸ Copy of an effect property writes the same `effects/ec.blur.gaussian/blurriness` path,
|
||||
/// and Paste (the value, or Copy Expression Only) sets that property of the other layer's effect.
|
||||
#[test]
|
||||
fn copied_effect_property_pastes_onto_the_same_effect() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
apply(&mut s, a, "Gaussian Blur");
|
||||
apply(&mut s, b, "Gaussian Blur");
|
||||
s.execute("prop.set", json!({"layer": a, "path": "effects/#1/blurriness", "value": 12.5})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "effects/#1/blurriness"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
let r = s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(r["values"], 1, "{r}");
|
||||
assert_eq!(layer(&s, b).props.prop("effects/#1/blurriness").unwrap().value, KV::Scalar(12.5));
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "effects/#1/blurriness", "expression": "time * 10"})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "effects/#1/blurriness"})).unwrap();
|
||||
s.execute("edit.copyExpressionOnly", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
let r = s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(r["expressions"], 1, "{r}");
|
||||
assert_eq!(layer(&s, b).props.prop("effects/#1/blurriness").unwrap().expr.as_ref().unwrap().text, "time * 10");
|
||||
}
|
||||
@@ -77,6 +77,19 @@ fn proexr_writes_layer_prefixed_channels_and_the_composite() {
|
||||
assert!(at("R", 16, 16) < 0.5, "multiplied by 50% blue (linear)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proexr_names_a_non_ascii_layer_with_underscores_instead_of_panicking() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Frame", "width": 8, "height": 8, "frameRate": 24, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "你好", "color": "#ff0000"})).unwrap();
|
||||
let dir = out_dir("frame-exr-non-ascii");
|
||||
let path = dir.join("frame.exr").to_string_lossy().to_string();
|
||||
let r = s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
|
||||
let names: Vec<&str> = r["channels"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"__.R") && names.contains(&"__.A"), "{names:?}");
|
||||
assert!(std::path::Path::new(&path).exists());
|
||||
}
|
||||
|
||||
/// Save a 32×32 comp holding one opaque solid of `color` (an sRGB-encoded triple) as ProEXR and
|
||||
/// return the centre pixel of the composite and of the solid's own layer: `[R, G, B]` each.
|
||||
fn proexr_centre(color: [f64; 3], bit_depth: u64) -> ([f32; 3], [f32; 3]) {
|
||||
|
||||
@@ -1445,6 +1445,60 @@ fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch()
|
||||
assert_eq!(vals, vec![10.0, 0.0], "reversed");
|
||||
}
|
||||
|
||||
/// #1425: a mask path (its value, or a keyframe of it) pasted onto Position becomes a motion
|
||||
/// path, as in After Effects: a key per vertex with the path's tangents, over 2 s from the CTI,
|
||||
/// the inner keys roving; a closed path returns to its start. AE pastes the layer-space numbers
|
||||
/// as they are, so the source layer's and the target parent's transforms don't move them.
|
||||
#[test]
|
||||
fn a_copied_path_pastes_onto_position_as_a_motion_path() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
// A moved and scaled source: converting to comp space would give 2v + (200, 100).
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [400, 200]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [200, 200]})).unwrap();
|
||||
let rect = json!([[0, 0], [40, 0], [40, 40], [0, 40]]);
|
||||
s.execute("mask.new", json!({"layer": a, "vertices": rect.clone(), "outTangents": [[10, 0]], "closed": true})).unwrap();
|
||||
// B's parent would shift a converted Position by (0, −50).
|
||||
let parent = solid(&mut s, "#0000ff");
|
||||
s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [100, 100]})).unwrap();
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
s.execute("layer.setParent", json!({"layers": [b], "parent": parent})).unwrap();
|
||||
let motion = |s: &Session, id: u64| {
|
||||
let l = layer(s, id);
|
||||
let pr = l.props.prop("transform/position").unwrap().clone();
|
||||
pr.keys.iter().map(|k| (l.comp_time(k.time).seconds(), k.value.as_vec3(), k.spatial_out, k.roving)).collect::<Vec<_>>()
|
||||
};
|
||||
let check = |keys: Vec<(f64, [f64; 3], [f64; 3], bool)>, t0: f64| {
|
||||
let want = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0], [0.0, 0.0]];
|
||||
assert_eq!(keys.len(), 5, "a key per vertex and the closing one: {keys:?}");
|
||||
for (i, ((t, v, _, roving), w)) in keys.iter().zip(want).enumerate() {
|
||||
assert!((v[0] - w[0]).abs() < 1e-6 && (v[1] - w[1]).abs() < 1e-6, "key {i}: {v:?}");
|
||||
assert_eq!(*roving, i != 0 && i != 4, "key {i} roving");
|
||||
assert!(*t > t0 - 1e-6 && *t < t0 + 2.0 + 1e-6, "key {i} at {t}");
|
||||
}
|
||||
assert!((keys[0].0 - t0).abs() < 1e-6 && (keys[4].0 - t0 - 2.0).abs() < 1e-6, "2 s from the CTI");
|
||||
assert!(keys.windows(2).all(|w| w[0].0 < w[1].0));
|
||||
assert_eq!(keys[0].2[..2], [10.0, 0.0], "the path's tangent");
|
||||
};
|
||||
// A Mask Path keyframe, pasted at 1 s.
|
||||
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "masks/#1/path"})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "masks/#1/path"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": b, "path": "transform/position"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
check(motion(&s, b), 1.0);
|
||||
// The same path as an unanimated mask, pasted at 5 s onto an unparented layer.
|
||||
s.execute("mask.new", json!({"layer": a, "vertices": rect, "outTangents": [[10, 0]], "closed": true})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "masks/#2/path"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let c = solid(&mut s, "#ffffff");
|
||||
s.execute("time.set", json!({"time": 5.0})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": c, "path": "transform/position"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
check(motion(&s, c), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j_and_k_stop_at_keys_markers_and_the_work_area() {
|
||||
let mut s = comp();
|
||||
@@ -1541,6 +1595,40 @@ fn shape_contents_copy_cut_and_paste_between_shape_layers() {
|
||||
assert!(s.execute("edit.paste", json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Pasting Trim Paths into another shape layer with nothing selected puts it below the shapes,
|
||||
/// where it acts on them (it acts on the items above it); on top it would trim nothing (#1681, #1629).
|
||||
#[test]
|
||||
fn pasted_path_operator_goes_below_the_shapes_it_acts_on() {
|
||||
let mut s = comp();
|
||||
let a = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let trim = s.execute("layer.addShapeItem", json!({"layer": a, "kind": "trim"})).unwrap()["uid"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newShape", json!({"kind": "star", "name": "B"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "prop": trim})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(contents_names(&s, b), ["Polystar 1", "Trim Paths 1"]);
|
||||
}
|
||||
|
||||
/// Pasting Merge Paths onto a painted layer with nothing selected keeps the paint: the renderer
|
||||
/// drops the draws above a Merge Paths, so it goes between the path and the paints (#1681).
|
||||
#[test]
|
||||
fn pasted_merge_paths_keeps_the_targets_paint() {
|
||||
let mut s = comp();
|
||||
let a = s.execute("layer.newShape", json!({"kind": "none", "name": "A"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let merge = s.execute("layer.addShapeItem", json!({"layer": a, "kind": "merge"})).unwrap()["uid"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newShape", json!({"kind": "none", "name": "B"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.addShapeItem", json!({"layer": b, "kind": "rect"})).unwrap();
|
||||
s.execute("layer.addShapeItem", json!({"layer": b, "kind": "fill"})).unwrap();
|
||||
let before = opaque_centroid(&s).2;
|
||||
s.execute("prop.select", json!({"layer": a, "prop": merge})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(contents_names(&s, b), ["Rectangle Path 1", "Merge Paths 1", "Fill 1"]);
|
||||
assert_eq!(opaque_centroid(&s).2, before);
|
||||
}
|
||||
|
||||
/// Edit ▸ Duplicate with shape items selected duplicates them in their layer, above the
|
||||
/// original and named "Rectangle 2", not the layer (#227).
|
||||
#[test]
|
||||
|
||||
@@ -212,6 +212,34 @@ fn prefs_commands_get_set_reset_and_persist() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Effects & Presets' starred effects live in the settings (#1692): they come back in the next
|
||||
/// session, un-starring sticks too, Reset All keeps them, and a hand-edited list loses its
|
||||
/// repeats and empty ids on load.
|
||||
#[test]
|
||||
fn effects_favorites_persist_across_sessions() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = Session { config: Some(store.clone()), ..Default::default() };
|
||||
assert!(s.prefs.toggle_effect_favorite("ADBE Gaussian Blur 2"));
|
||||
assert!(s.prefs.toggle_effect_favorite("ADBE Glo2"));
|
||||
s.save_prefs();
|
||||
let reopen = |store: &Arc<MemoryConfig>| {
|
||||
let mut s = Session { config: Some(store.clone()), ..Default::default() };
|
||||
s.load_settings();
|
||||
s
|
||||
};
|
||||
let mut s2 = reopen(&store);
|
||||
assert_eq!(s2.prefs.effects_favorites, ["ADBE Gaussian Blur 2", "ADBE Glo2"]);
|
||||
assert!(!s2.prefs.toggle_effect_favorite("ADBE Glo2"), "un-starred");
|
||||
s2.save_prefs();
|
||||
let mut s3 = reopen(&store);
|
||||
assert_eq!(s3.prefs.effects_favorites, ["ADBE Gaussian Blur 2"]);
|
||||
s3.execute("prefs.reset", json!({})).unwrap();
|
||||
assert!(s3.prefs.is_effect_favorite("ADBE Gaussian Blur 2"), "Reset All keeps the stars");
|
||||
let edited = store.read(PREFS_FILE).unwrap().replace("\"effectsFavorites\": [", "\"effectsFavorites\": [\"\", \"ADBE Glo2\", \"ADBE Glo2\", ");
|
||||
store.write(PREFS_FILE, &edited).unwrap();
|
||||
assert_eq!(reopen(&store).prefs.effects_favorites, ["ADBE Glo2", "ADBE Gaussian Blur 2"]);
|
||||
}
|
||||
|
||||
/// Composition Settings presets the user saves live in the settings: they persist, a save with
|
||||
/// an existing name replaces it, they survive Reset All, and invalid ones (from a hand-edited
|
||||
/// settings file) are dropped on load.
|
||||
|
||||
@@ -476,3 +476,110 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
|
||||
assert!(!s.is_enabled(id), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The first path item in a shape layer's Contents, at any depth: (uid, match_id). A new shape
|
||||
/// layer nests it in a group ("Rectangle 1" ▸ "Rectangle Path 1"), as After Effects does.
|
||||
fn first_shape(s: &Session, l: u64) -> (u64, String) {
|
||||
fn walk(g: &effectcraft_project::PropGroup) -> Option<(u64, String)> {
|
||||
if ["rect", "ellipse", "star", "path"].contains(&g.match_id.as_str()) {
|
||||
return Some((g.uid, g.match_id.clone()));
|
||||
}
|
||||
g.groups().find_map(walk)
|
||||
}
|
||||
walk(layer(s, l).props.sub("contents").unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn shape_path_of(s: &Session, l: u64, uid: u64) -> effectcraft_keyframe::ShapePath {
|
||||
let ly = layer(s, l);
|
||||
let g = ly.props.sub("contents").unwrap().find_group(uid).unwrap();
|
||||
let _ = &g;
|
||||
g.get("path").unwrap().value.as_path().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Convert To Bezier Path on a shape layer's parametric path gives an editable Path 1 with the
|
||||
/// same geometry, as After Effects does (#932). The command only turned RotoBezier off for masks,
|
||||
/// so a Rectangle, Ellipse or Polystar path could not be converted at all.
|
||||
#[test]
|
||||
fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() {
|
||||
let convert = |args: serde_json::Value| {
|
||||
let mut s = comp();
|
||||
let l = s.execute("layer.newShape", args).unwrap()["layer"].as_u64().unwrap();
|
||||
let (uid, was) = first_shape(&s, l);
|
||||
assert_ne!(was, "path", "it starts out parametric");
|
||||
s.state.selected_props = vec![(LayerId(l), uid)];
|
||||
let out = s.execute("path.convertToBezier", json!({})).unwrap();
|
||||
assert_eq!(out["paths"].as_u64(), Some(1));
|
||||
let (new_uid, now) = first_shape(&s, l);
|
||||
assert_eq!(now, "path", "it is a bezier path now");
|
||||
// The new path is selected, so its vertices can be edited straight away.
|
||||
assert_eq!(s.state.selected_props, vec![(LayerId(l), new_uid)]);
|
||||
let path = shape_path_of(&s, l, new_uid);
|
||||
assert!(path.closed, "a closed shape stays closed");
|
||||
(s, l, was, path)
|
||||
};
|
||||
let radius = |v: [f64; 2]| v[0].hypot(v[1]);
|
||||
|
||||
// A 120 x 80 rectangle is its four corners, joined by straight edges.
|
||||
let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]}));
|
||||
assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]);
|
||||
assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}");
|
||||
// Undo brings the parametric shape back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(first_shape(&s, l).1, was, "undo restores the parametric shape");
|
||||
|
||||
// An ellipse is four cubic arcs: a vertex on each axis, with tangents of 0.5523 x the radius
|
||||
// (the usual circle approximation). Straight lines here would be a diamond.
|
||||
let (_, _, _, ell) = convert(json!({"kind": "ellipse", "size": [120, 80]}));
|
||||
assert_eq!(ell.vertices, vec![[0.0, -40.0], [60.0, 0.0], [0.0, 40.0], [-60.0, 0.0]]);
|
||||
let kappa = 4.0 / 3.0 * (std::f64::consts::SQRT_2 - 1.0);
|
||||
for (i, want) in [60.0 * kappa, 40.0 * kappa, 60.0 * kappa, 40.0 * kappa].into_iter().enumerate() {
|
||||
let t = ell.in_tangents[i];
|
||||
assert!((radius(t) - want).abs() < 1e-6, "vertex {i}: tangent {t:?} should be {want}");
|
||||
}
|
||||
|
||||
// A five-point star alternates the outer and inner radius, so ten vertices.
|
||||
let (_, _, _, star) = convert(json!({"kind": "star", "size": [120, 120]}));
|
||||
assert_eq!(star.vertices.len(), 10);
|
||||
for (i, v) in star.vertices.iter().enumerate() {
|
||||
let want = if i % 2 == 0 { 60.0 } else { 30.0 };
|
||||
assert!((radius(*v) - want).abs() < 1e-6, "vertex {i} at {v:?} should be {want} from the centre");
|
||||
}
|
||||
|
||||
// A polygon has one vertex per point, all at the outer radius.
|
||||
let (_, _, _, poly) = convert(json!({"kind": "polygon", "size": [120, 120]}));
|
||||
assert!(poly.vertices.len() >= 3);
|
||||
assert!(poly.vertices.iter().all(|v| (radius(*v) - 60.0).abs() < 1e-6), "{:?}", poly.vertices);
|
||||
assert_ne!(poly.vertices.len(), star.vertices.len(), "a polygon is not a star");
|
||||
}
|
||||
|
||||
/// The shape's own values are read, not defaults: Roundness rounds the corners and Position moves
|
||||
/// the path, both at the time the conversion is made.
|
||||
#[test]
|
||||
fn convert_to_bezier_reads_the_shape_as_it_is_set() {
|
||||
let mut s = comp();
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let (uid, _) = first_shape(&s, l);
|
||||
// Properties are addressed by their own uid.
|
||||
let pr_uid = |s: &Session, id: &str| {
|
||||
let ly = layer(s, l);
|
||||
ly.props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid
|
||||
};
|
||||
let (round, pos) = (pr_uid(&s, "roundness"), pr_uid(&s, "position"));
|
||||
s.execute("prop.set", json!({"layer": l, "path": format!("@{round}"), "value": 20.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": format!("@{pos}"), "value": [10.0, 5.0]})).unwrap();
|
||||
|
||||
s.state.selected_props = vec![(LayerId(l), uid)];
|
||||
s.execute("path.convertToBezier", json!({})).unwrap();
|
||||
let (new_uid, now) = first_shape(&s, l);
|
||||
assert_eq!(now, "path");
|
||||
let path = shape_path_of(&s, l, new_uid);
|
||||
|
||||
// Rounded corners need more than four vertices, and curves between them.
|
||||
assert!(path.vertices.len() > 4, "roundness rounds the corners: {} vertices", path.vertices.len());
|
||||
assert!(path.in_tangents.iter().chain(&path.out_tangents).any(|t| *t != [0.0, 0.0]), "with curves");
|
||||
// Position moves the path: the bounds are centred on it, not on the origin.
|
||||
let xs: Vec<f64> = path.vertices.iter().map(|v| v[0]).collect();
|
||||
let ys: Vec<f64> = path.vertices.iter().map(|v| v[1]).collect();
|
||||
let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0;
|
||||
assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! undo/redo and serde round-trips).
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Node, Project, PropGroup};
|
||||
use effectcraft_project::{LayerId, Node, Project, PropGroup};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
@@ -244,3 +244,27 @@ fn menus_have_text_animation_entries() {
|
||||
assert!(s.is_enabled("layer.enablePerChar3D"));
|
||||
assert!(!s.is_enabled("layer.addTextSelector"), "needs an animator");
|
||||
}
|
||||
|
||||
/// Duplicate with a Range Selector selected under a text animator copies the selector, not the
|
||||
/// whole text layer (#1665); a selected animator is copied the same way.
|
||||
#[test]
|
||||
fn duplicate_copies_selected_text_selector_not_the_layer() {
|
||||
let (mut s, t) = session();
|
||||
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["tracking"]})).unwrap();
|
||||
let anims = |s: &Session| s.active_comp().unwrap().layer(LayerId(t)).unwrap().props.group("text/animators").unwrap().clone();
|
||||
let selectors = |s: &Session| -> Vec<u64> { anims(s).groups().next().unwrap().sub("selectors").unwrap().groups().map(|g| g.uid).collect() };
|
||||
let sel = selectors(&s)[0];
|
||||
|
||||
s.execute("prop.select", json!({"layer": t, "prop": sel, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.duplicate", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "no second text layer");
|
||||
assert_eq!(selectors(&s).len(), 2, "the selector is copied");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(selectors(&s), vec![sel]);
|
||||
|
||||
let anim = anims(&s).groups().next().unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": t, "prop": anim, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.duplicate", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
assert_eq!(anims(&s).groups().count(), 2, "the animator is copied");
|
||||
}
|
||||
@@ -317,6 +317,66 @@ fn fonts_filter_variable_families() {
|
||||
assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable));
|
||||
}
|
||||
|
||||
/// A variable font of `name` and `fvar` only: one `wght` axis 100..900 (default 300, style
|
||||
/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name
|
||||
/// `<family without spaces>-<style without spaces>`.
|
||||
fn instanced_variable_font(family: &str, instances: &[(&str, f32)]) -> Vec<u8> {
|
||||
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
|
||||
let squash = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).collect::<String>();
|
||||
let mut strings: Vec<(u16, String)> = vec![(1, family.into()), (2, "Light".into())];
|
||||
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, instances.len() as u16, 10]);
|
||||
fvar.extend(b"wght".iter().copied().chain(u16s(&[100, 0, 300, 0, 900, 0, 0, 256])));
|
||||
for (i, (style, w)) in instances.iter().enumerate() {
|
||||
let id = 256 + 2 * i as u16;
|
||||
strings.push((id, (*style).into()));
|
||||
strings.push((id + 1, format!("{}-{}", squash(family), squash(style))));
|
||||
fvar.extend(u16s(&[id, 0]));
|
||||
fvar.extend(((w * 65536.0) as i32).to_be_bytes());
|
||||
fvar.extend(u16s(&[id + 1]));
|
||||
}
|
||||
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
|
||||
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
|
||||
let mut off = 0;
|
||||
for (id, st) in &enc {
|
||||
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
|
||||
off += st.len() as u16;
|
||||
}
|
||||
enc.iter().for_each(|(_, st)| name.extend(st));
|
||||
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
|
||||
let mut body: Vec<u8> = vec![];
|
||||
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
|
||||
out.extend(tag);
|
||||
out.extend([0u8; 4]);
|
||||
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
body.extend(data);
|
||||
}
|
||||
out.extend(body);
|
||||
out
|
||||
}
|
||||
|
||||
/// #1563: a variable font's named instances are styles of its family in `text.fonts`, and a
|
||||
/// text layer set in one (by style, or by the instance's PostScript name as After Effects scripts
|
||||
/// do) is laid out at the instance's axis values; an axis set on the text still wins.
|
||||
#[test]
|
||||
fn variable_font_named_instances_are_styles() {
|
||||
const FAMILY: &str = "EffectCraft Named Instance Test";
|
||||
assert_eq!(effectcraft_text::fonts::add_font_data(instanced_variable_font(FAMILY, &[("Light", 300.0), ("Regular", 400.0), ("Black", 900.0)])).len(), 1);
|
||||
let (mut s, t) = session("Hi");
|
||||
let fonts = s.execute("text.fonts", json!({"query": FAMILY})).unwrap();
|
||||
assert_eq!(fonts["families"][0]["styles"], json!(["Light", "Regular", "Black"]), "{fonts}");
|
||||
let wght = |s: &Session| -> Vec<Vec<(String, f32)>> { effectcraft_text::layout_doc(&doc(s, t)).glyphs.iter().map(|g| g.variations.clone()).collect() };
|
||||
let black = vec![("wght".to_string(), 900.0)];
|
||||
s.execute("layer.setText", json!({"layer": t, "font": FAMILY, "style": "Black"})).unwrap();
|
||||
let by_style = wght(&s);
|
||||
assert!(!by_style.is_empty() && by_style.iter().all(|v| v == &black), "{by_style:?}");
|
||||
s.execute("layer.setText", json!({"layer": t, "font": "EffectCraftNamedInstanceTest-Black", "style": "Regular"})).unwrap();
|
||||
let by_ps = wght(&s);
|
||||
assert!(!by_ps.is_empty() && by_ps.iter().all(|v| v == &black), "{by_ps:?}");
|
||||
s.execute("layer.setText", json!({"layer": t, "variations": {"wght": 650}})).unwrap();
|
||||
assert!(wght(&s).iter().all(|v| v == &[("wght".to_string(), 650.0)]), "{:?}", wght(&s));
|
||||
}
|
||||
|
||||
/// `layer.setText`'s schema lists every OpenType key as optional, so checked execution (control
|
||||
/// channel, MCP) accepts them and requires none (#520).
|
||||
#[test]
|
||||
|
||||
@@ -1343,3 +1343,45 @@ fn relative_trim_moves_every_selected_layer_by_the_same_delta() {
|
||||
s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap();
|
||||
assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a));
|
||||
}
|
||||
|
||||
/// #1434: with several 3D layers selected, typing a value into one component (Z Position) sets
|
||||
/// that component on each of them; their other components stay their own. Before, every
|
||||
/// selected layer took the edited layer's whole value and they piled up on top of it.
|
||||
#[test]
|
||||
fn a_typed_component_applies_only_that_component_to_the_selected_layers() {
|
||||
let (mut s, a) = setup();
|
||||
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (l, xy) in [(a, [10.0, 20.0]), (b, [110.0, 120.0]), (c, [210.0, 220.0])] {
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 5.0]})).unwrap();
|
||||
}
|
||||
s.execute("layer.select", json!({"layers": [a, b, c]})).unwrap();
|
||||
let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value.components();
|
||||
let uid = prop(&s, a, "transform/position").uid;
|
||||
// The panels send the edited layer's value with the typed component, and which one it was.
|
||||
let undo = s.history.undo.len();
|
||||
s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, -300.0], "selected": true, "offset": false, "dims": [2]})).unwrap();
|
||||
assert_eq!(pos(&s, a), vec![10.0, 20.0, -300.0]);
|
||||
assert_eq!(pos(&s, b), vec![110.0, 120.0, -300.0], "B keeps its X and Y");
|
||||
assert_eq!(pos(&s, c), vec![210.0, 220.0, -300.0], "C keeps its X and Y");
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(pos(&s, b), vec![110.0, 120.0, 5.0]);
|
||||
|
||||
// Linked Scale: both edited components go to every layer, Z stays each layer's own.
|
||||
s.execute("prop.set", json!({"layer": c, "path": "transform/scale", "value": [50.0, 50.0, 30.0]})).unwrap();
|
||||
let scale_uid = prop(&s, a, "transform/scale").uid;
|
||||
s.execute("prop.set", json!({"layer": a, "prop": scale_uid, "value": [200.0, 200.0, 100.0], "selected": true, "dims": [0, 1]})).unwrap();
|
||||
assert_eq!(prop(&s, c, "transform/scale").value.components(), vec![200.0, 200.0, 30.0]);
|
||||
|
||||
// Position with Separate Dimensions on one of the other layers.
|
||||
s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, 40.0], "selected": true, "dims": [2]})).unwrap();
|
||||
let axis = |m: &str| prop(&s, b, &format!("transform/{m}")).value.as_f64();
|
||||
assert_eq!((axis("positionX"), axis("positionY"), axis("positionZ")), (110.0, 120.0, 40.0));
|
||||
|
||||
// Without `dims` (agents setting a whole value) every selected layer takes the value as is.
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap();
|
||||
assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]);
|
||||
}
|
||||
@@ -637,3 +637,63 @@ fn each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file() {
|
||||
s.execute_checked("view.res.auto", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
|
||||
}
|
||||
|
||||
/// #1685: changing Type updates the property tree in the same undo step, including when
|
||||
/// addressed by uid (as in the UI) rather than by path (scripts/control channel).
|
||||
#[test]
|
||||
fn polystar_type_switch_updates_inner_controls_and_undoes() {
|
||||
let mut s = comp();
|
||||
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
|
||||
let polygon = group(&s);
|
||||
let type_uid = polygon.get("type").unwrap().uid;
|
||||
assert!(polygon.get("innerRadius").is_none());
|
||||
s.execute("prop.set", json!({"layer": lid, "prop": type_uid, "value": 0})).unwrap();
|
||||
let star = group(&s);
|
||||
assert_eq!(star.get("innerRadius").unwrap().value, KV::Scalar(50.0));
|
||||
assert_eq!(star.get("innerRoundness").unwrap().value, KV::Scalar(0.0));
|
||||
assert_eq!(star.get("innerRadius").unwrap().ui, effectcraft_project::ParamUi::Pixels);
|
||||
assert_eq!(star.get("innerRoundness").unwrap().ui, effectcraft_project::ParamUi::Percent);
|
||||
let names: Vec<_> = star.children.iter().filter_map(|n| n.as_prop()).map(|p| p.match_id.as_str()).collect();
|
||||
assert_eq!(names, ["direction", "type", "points", "position", "rotation", "innerRadius", "outerRadius", "innerRoundness", "outerRoundness"]);
|
||||
assert_eq!(star.get("outerRadius"), polygon.get("outerRadius"));
|
||||
assert!(s.undo());
|
||||
assert_eq!(group(&s), polygon);
|
||||
assert!(s.redo());
|
||||
assert_eq!(group(&s), star);
|
||||
// Reapplying the same Type must not reset custom inner values or allocate new ids.
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/innerRadius", "value": 37})).unwrap();
|
||||
let before = group(&s);
|
||||
let next = s.project.next_id;
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/type", "value": 0})).unwrap();
|
||||
assert_eq!(group(&s), before);
|
||||
assert_eq!(s.project.next_id, next);
|
||||
let inner_uid = before.get("innerRadius").unwrap().uid;
|
||||
s.state.selected_props = vec![(LayerId(lid), inner_uid)];
|
||||
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: inner_uid, time: Tick::ZERO }];
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/type", "value": 1})).unwrap();
|
||||
assert_eq!(group(&s).get("type").unwrap().value, KV::Enum(1));
|
||||
assert!(group(&s).get("innerRadius").is_none());
|
||||
assert!(group(&s).get("innerRoundness").is_none());
|
||||
assert!(s.state.selected_props.is_empty());
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
assert!(s.undo());
|
||||
assert_eq!(group(&s), before, "undo restores the original inner values and ids");
|
||||
assert!(s.redo());
|
||||
assert!(group(&s).get("innerRadius").is_none());
|
||||
s.execute("prop.set", json!({"layer": lid, "prop": type_uid, "value": 0})).unwrap();
|
||||
assert_eq!(group(&s).get("innerRadius").unwrap().value, KV::Scalar(50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polystar_animated_type_keeps_the_inner_controls_needed_by_other_frames() {
|
||||
let mut s = comp();
|
||||
let lid = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/innerRadius", "value": 27})).unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": lid, "path": "contents/group/contents/star/type", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "contents/group/contents/star/type", "time": 1, "value": 1})).unwrap();
|
||||
let star = layer(&s, lid).props.group("contents/group/contents/star").unwrap().clone();
|
||||
assert_eq!(star.get("type").unwrap().value_at(Tick::ZERO), KV::Enum(0));
|
||||
assert_eq!(star.get("type").unwrap().value_at(Tick::from_seconds_f64(1.0)), KV::Enum(1));
|
||||
assert_eq!(star.get("innerRadius").unwrap().value, KV::Scalar(27.0));
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Footage whose frame times are rounded to whole milliseconds (#1442), as phones and messaging
|
||||
//! apps write MP4s: a 30 fps clip's frames last 33 or 34 ms, a 60 fps clip's 16 or 17 ms. It
|
||||
//! imports at its true rate, a comp made from it (New Comp from Selection takes the footage's
|
||||
//! rate) renders every source frame once, and the H.264 output has that rate and frame count.
|
||||
//!
|
||||
//! The fixtures are written here with FilmCraft's own encoder and muxer: 128×32 frames showing
|
||||
//! their index in binary (eight bars, white for a set bit).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_media::MediaPool;
|
||||
use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_isobmff::{AvcConfig, Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
|
||||
const W: u32 = 128;
|
||||
const H: u32 = 32;
|
||||
const BARS: u32 = 8;
|
||||
|
||||
fn out_dir(name: &str) -> PathBuf {
|
||||
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).expect("mkdir");
|
||||
d
|
||||
}
|
||||
|
||||
/// Durations of `n` frames at `rate` with their times rounded to whole milliseconds.
|
||||
fn ms_rounded(rate: FrameRate, n: i64) -> Vec<u32> {
|
||||
let at = |k: i64| (2 * k * 1000 * rate.den + rate.num) / (2 * rate.num);
|
||||
(0..n).map(|k| u32::try_from(at(k + 1) - at(k)).expect("duration")).collect()
|
||||
}
|
||||
|
||||
/// An H.264 MP4 (video track timescale 1000) with one frame per entry of `durations` (ms);
|
||||
/// frame k shows k in binary.
|
||||
fn write_movie(path: &Path, durations: &[u32]) {
|
||||
let mut cfg = filmcraft_h264enc::EncoderConfig::new(W, H, 30, 1);
|
||||
cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed;
|
||||
cfg.aud = false;
|
||||
cfg.bframes = 0;
|
||||
cfg.keyint = 30;
|
||||
cfg.scenecut = false;
|
||||
let mut enc = filmcraft_h264enc::Encoder::new(cfg).expect("encoder");
|
||||
let (u, v) = (vec![128u8; (W * H / 4) as usize], vec![128u8; (W * H / 4) as usize]);
|
||||
let mut packets = Vec::new();
|
||||
for k in 0..durations.len() {
|
||||
let y: Vec<u8> = (0..W * H).map(|i| if (k >> (i % W * BARS / W)) & 1 == 1 { 235 } else { 16 }).collect();
|
||||
let frame = filmcraft_h264enc::YuvFrame { y: &y, u: &u, v: &v, y_stride: W as usize, uv_stride: W as usize / 2 };
|
||||
packets.extend(enc.try_encode(&frame, k as i64).expect("encode"));
|
||||
}
|
||||
packets.extend(enc.flush());
|
||||
assert_eq!(packets.len(), durations.len(), "one access unit per frame");
|
||||
let avcc = AvcConfig::parse(&enc.avcc()).expect("avcC");
|
||||
let file = std::fs::File::create(path).expect("create");
|
||||
let mut mux = Mp4Writer::new(file, WriterOptions::new(Brand::Mp4)).expect("mux");
|
||||
let t = mux.add_track(TrackConfig::new(SampleEntry::avc(avcc, W as u16, H as u16), 1000)).expect("track");
|
||||
for (p, &duration) in packets.iter().zip(durations) {
|
||||
mux.write_sample(t, WriteSample { data: &p.data, duration, composition_offset: 0, is_sync: p.keyframe }).expect("sample");
|
||||
}
|
||||
mux.finish().expect("finish");
|
||||
}
|
||||
|
||||
/// The frame index a decoded frame shows.
|
||||
fn shown(img: &Image) -> u32 {
|
||||
(0..BARS).filter(|b| img.data[img.idx(b * W / BARS + W / BARS / 2, H / 2)][1] > 0.5).map(|b| 1 << b).sum()
|
||||
}
|
||||
|
||||
fn frame_count(f: &Footage) -> i64 {
|
||||
f.frame_rate.frame_at(f.duration - Tick(1)) + 1
|
||||
}
|
||||
|
||||
/// Every frame of a movie in order, as indices.
|
||||
fn frames_of(f: &Footage) -> Vec<u32> {
|
||||
let pool = MediaPool::new();
|
||||
(0..frame_count(f)).map(|k| shown(&pool.frame_at(f, f.frame_rate.tick_of(k)).expect("decode"))).collect()
|
||||
}
|
||||
|
||||
/// Import `path`, make a comp from it as New Comp from Selection does (the footage's size, rate
|
||||
/// and duration), render it to H.264 and return the output's footage.
|
||||
fn comp_from_footage(path: &Path, out: &Path) -> Footage {
|
||||
let footage = effectcraft_media::probe(path).expect("probe");
|
||||
let mut p = Project::default();
|
||||
let fid = p.add_item("clip.mp4", Label::Aqua, None, ItemKind::Footage(footage.clone()));
|
||||
let rate = p.item(fid).and_then(|it| it.frame_rate()).expect("a movie has a rate");
|
||||
let mut comp = Comp::new(W, H, rate, footage.duration);
|
||||
comp.layers.push(build::layer(&mut p, &comp, "clip.mp4", LayerSource::Footage { item: fid }, (W, H), None));
|
||||
let cid = p.add_item("clip", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let pool = MediaPool::new();
|
||||
let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) };
|
||||
let out_path = out.to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: &p,
|
||||
footage: &pool,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &settings,
|
||||
output: &om,
|
||||
path: &out_path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
let r = export(&job, &mut |_| true).expect("export");
|
||||
assert_eq!(r.frames as i64, frame_count(&footage), "the render has the footage's frames");
|
||||
effectcraft_media::probe(out).expect("probe the render")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn millisecond_rounded_footage_keeps_its_rate_through_comp_and_render() {
|
||||
let d = out_dir("ms_rounded");
|
||||
for (rate, frames) in [(FrameRate::FPS_30, 120), (FrameRate::FPS_60, 240)] {
|
||||
let path = d.join(format!("ms-{}.mp4", rate.num));
|
||||
write_movie(&path, &ms_rounded(rate, frames));
|
||||
let f = effectcraft_media::probe(&path).expect("probe");
|
||||
assert_eq!((f.frame_rate, frame_count(&f)), (rate, frames), "{rate:?} footage");
|
||||
let bytes: Arc<[u8]> = std::fs::read(&path).expect("read").into();
|
||||
let from_bytes = effectcraft_media::probe_bytes(&path.to_string_lossy(), bytes).expect("probe bytes");
|
||||
assert_eq!(from_bytes.frame_rate, rate, "{rate:?} footage read from memory");
|
||||
let all: Vec<u32> = (0..frames as u32).collect();
|
||||
assert_eq!(frames_of(&f), all, "{rate:?} footage: each source frame once");
|
||||
|
||||
let out = comp_from_footage(&path, &d.join(format!("render-{}.mp4", rate.num)));
|
||||
assert_eq!((out.frame_rate, frame_count(&out)), (rate, frames), "{rate:?} render");
|
||||
assert_eq!(out.duration, Tick::from_seconds_f64(4.0), "{rate:?} render duration");
|
||||
assert_eq!(frames_of(&out), all, "{rate:?} render: no frame skipped or repeated");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ntsc_and_variable_rate_footage_is_not_pulled_to_a_round_rate() {
|
||||
let d = out_dir("ms_rounded_controls");
|
||||
for rate in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] {
|
||||
let path = d.join(format!("ms-{}.mp4", rate.num));
|
||||
write_movie(&path, &ms_rounded(rate, 60));
|
||||
assert_eq!(effectcraft_media::probe(&path).expect("probe").frame_rate, rate);
|
||||
}
|
||||
// Frames of 40, 45 and 50 ms: the reader's median (45 ms, 22.2 fps) stands.
|
||||
let path = d.join("variable.mp4");
|
||||
write_movie(&path, &[40, 45, 50].repeat(20));
|
||||
let f = effectcraft_media::probe(&path).expect("probe");
|
||||
assert!(!FrameRate::COMMON.contains(&f.frame_rate) && (f.frame_rate.as_f64() - 1000.0 / 45.0).abs() < 0.01, "{:?}", f.frame_rate);
|
||||
}
|
||||
+43
-9
@@ -37,8 +37,12 @@ pub enum Req {
|
||||
LayerLookup { comp: u64, key: Key },
|
||||
/// `[name, index, inPoint, outPoint, startTime, width, height, parentId|null, hasVideo, is3D, source, enabled]`.
|
||||
LayerInfo { comp: u64, layer: u64 },
|
||||
/// `[path, isGroup, name, matchName, numChildren]` or null.
|
||||
/// `[path, isGroup, name, matchName, numChildren, propertyIndex]` or null.
|
||||
Child { comp: u64, layer: u64, group: String, key: Key },
|
||||
/// `propertyGroup(up)` of the property or group at `path`: the group `up` levels above it as
|
||||
/// `[path, isGroup, name, matchName, numChildren, propertyIndex]`, `"layer"` when that is the
|
||||
/// layer itself, or null above the layer.
|
||||
Ancestor { comp: u64, layer: u64, path: String, up: i64 },
|
||||
/// `[name, uid, kind, dims, keyTimes, keyValues, hasExpression, propertyIndex, matchName]`.
|
||||
PropInfo { comp: u64, layer: u64, path: String },
|
||||
/// Value at comp time `t`; `pre` = keyframes only (no expression).
|
||||
@@ -256,6 +260,18 @@ fn alias(key: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A child node as expression data (see [`Req::Child`]); `index` is 1-based among its siblings.
|
||||
fn node_resp(path: String, n: &Node, index: usize) -> Resp {
|
||||
Resp::List(vec![
|
||||
Resp::Str(path),
|
||||
Resp::Bool(n.as_group().is_some()),
|
||||
Resp::Str(n.name().into()),
|
||||
Resp::Str(n.match_id().into()),
|
||||
Resp::Num(n.as_group().map_or(0, |g| g.children.len()) as f64),
|
||||
Resp::Num(index as f64),
|
||||
])
|
||||
}
|
||||
|
||||
fn find_child<'g>(g: &'g PropGroup, key: &Key) -> Option<&'g Node> {
|
||||
match key {
|
||||
Key::Index(i) => usize::try_from(*i).ok().filter(|i| *i >= 1).and_then(|i| g.children.get(i - 1)),
|
||||
@@ -353,15 +369,33 @@ impl<'a> Resolver<'a> {
|
||||
let l = self.layer(*comp, *layer)?;
|
||||
let g = if group.is_empty() { &l.props } else { l.props.group(group)? };
|
||||
let n = find_child(g, key)?;
|
||||
let index = g.children.iter().position(|c| c.uid() == n.uid()).unwrap_or(0) + 1;
|
||||
let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) };
|
||||
let count = n.as_group().map_or(0, |g| g.children.len());
|
||||
Resp::List(vec![
|
||||
Resp::Str(path),
|
||||
Resp::Bool(n.as_group().is_some()),
|
||||
Resp::Str(n.name().into()),
|
||||
Resp::Str(n.match_id().into()),
|
||||
Resp::Num(count as f64),
|
||||
])
|
||||
node_resp(path, n, index)
|
||||
}
|
||||
Req::Ancestor { comp, layer, path, up } => {
|
||||
let l = self.layer(*comp, *layer)?;
|
||||
// The node's full `@uid/@uid/…` path from the layer: its ancestors are its prefixes.
|
||||
let uid = match self.prop(*comp, *layer, path) {
|
||||
Some((_, p)) => p.uid,
|
||||
None => l.props.node(path)?.uid(),
|
||||
};
|
||||
let full = l.props.path_of(uid)?;
|
||||
let segs: Vec<&str> = full.split('/').collect();
|
||||
let up = usize::try_from(*up).ok().filter(|u| *u >= 1 && *u <= segs.len())?;
|
||||
if up == segs.len() {
|
||||
return Some(Resp::Str("layer".into()));
|
||||
}
|
||||
let keep = segs.len() - up;
|
||||
let group_path = segs.get(..keep)?.join("/");
|
||||
let parent = match keep {
|
||||
1 => &l.props,
|
||||
_ => l.props.group(&segs.get(..keep - 1)?.join("/"))?,
|
||||
};
|
||||
let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::<u64>().ok()?;
|
||||
let index = parent.children.iter().position(|c| c.uid() == uid)?;
|
||||
let n = parent.children.get(index)?;
|
||||
node_resp(group_path, n, index + 1)
|
||||
}
|
||||
Req::PropInfo { comp, layer, path } => {
|
||||
let (l, p) = self.prop(*comp, *layer, path)?;
|
||||
|
||||
@@ -18,7 +18,7 @@ var selectorValue = [100, 100, 100];
|
||||
|
||||
// Request ops (must match runtime.rs).
|
||||
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10;
|
||||
var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14, __SOURCE = 15;
|
||||
var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14, __SOURCE = 15, __ANCESTOR = 16;
|
||||
|
||||
function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
|
||||
time = t;
|
||||
@@ -427,6 +427,18 @@ function __child(c, l, group, key) {
|
||||
return info[1] ? __mkGroup(c, l, info) : __mkProp(c, l, info[0]);
|
||||
}
|
||||
|
||||
// `propertyGroup(countUp)` of the property or group at `path`: the group `countUp` levels above
|
||||
// it (default 1), the layer at the top (#1619).
|
||||
function __ancestor(c, l, path, countUp) {
|
||||
var n = countUp === undefined ? 1 : __v(countUp);
|
||||
if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more');
|
||||
n = Math.floor(n);
|
||||
var info = __h(__ANCESTOR, c, l, path, n);
|
||||
if (info === 'layer') return new Layer(c, l);
|
||||
if (info === null) __err('propertyGroup(' + n + ') goes above the layer');
|
||||
return __mkGroup(c, l, info);
|
||||
}
|
||||
|
||||
// Comp ------------------------------------------------------------------------------------------
|
||||
|
||||
function Comp(c) { this.__c = c; this.__i = null; }
|
||||
@@ -645,6 +657,8 @@ var __GroupProto = {
|
||||
get name() { return this.__info[2]; },
|
||||
get matchName() { return this.__info[3]; },
|
||||
get numProperties() { return this.__info[4]; },
|
||||
get propertyIndex() { return this.__info[5]; },
|
||||
propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); },
|
||||
get active() { return true; },
|
||||
toString: function () { return '[object PropertyGroup]'; },
|
||||
};
|
||||
@@ -730,6 +744,7 @@ Prop.prototype = {
|
||||
get numKeys() { return this.__info[4].length; },
|
||||
get propertyIndex() { return this.__info[7]; },
|
||||
get matchName() { return this.__info[8]; },
|
||||
propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); },
|
||||
__wrap: function (v) {
|
||||
var kind = this.__info[2];
|
||||
if (kind === 'layer') return v === null ? null : new Layer(this.__c, v);
|
||||
|
||||
@@ -186,6 +186,7 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
||||
},
|
||||
14 => Req::Project,
|
||||
15 => Req::SourceInfo { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)? },
|
||||
16 => Req::Ancestor { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, path: string(args, 3, ctx)?, up: num(args, 4, ctx)? as i64 },
|
||||
_ => return Err(JsNativeError::typ().with_message("unknown host request").into()),
|
||||
};
|
||||
let found = FRAMES.with(|f| {
|
||||
|
||||
@@ -1362,3 +1362,5 @@ fn time_to_frames_preserves_coercion_and_small_durations() {
|
||||
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
|
||||
}
|
||||
}
|
||||
|
||||
mod property_group;
|
||||
@@ -78,6 +78,8 @@ const SEEDS: &[&str] = &[
|
||||
"mask('Mask 1').maskOpacity",
|
||||
"mask('Mask 1').maskPath.points()[0]",
|
||||
"content('Rectangle 1').content('Rectangle Path 1').size[0]",
|
||||
"thisProperty.propertyGroup(1).propertyIndex * 10 + thisProperty.propertyGroup(1).numProperties",
|
||||
"effect(1)(1).propertyGroup(1).propertyGroup(2).name",
|
||||
"marker.numKeys > 0 ? marker.key(1).time : 0",
|
||||
"marker.nearestKey(time).comment",
|
||||
"thisComp.marker.key(1).time",
|
||||
@@ -130,7 +132,7 @@ const TOKENS: &[&str] = &[
|
||||
"catch", "finally", "throw ", "class ", "this", "null", "undefined", "NaN", "Infinity", "true", "-0", "0x", "1e", "1n",
|
||||
"/a+/g", "time", "value", "index", "thisComp", "thisLayer", "thisProperty", "layer(", "comp(", "effect(", "mask(",
|
||||
"content(", "key(", "marker", "valueAtTime(", "wiggle(", "loopOut(", "sourceRectAtTime(", "toComp(", "text.sourceText",
|
||||
".style", ".setText(", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments",
|
||||
".style", ".setText(", ".propertyGroup(", ".propertyIndex", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments",
|
||||
];
|
||||
|
||||
/// Numbers swapped in for numeric literals.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! `propertyGroup(countUp)` on properties and property groups, and a group's `propertyIndex`
|
||||
//! (#1619): name-independent references up the property tree, as in After Effects.
|
||||
|
||||
use super::*;
|
||||
|
||||
const SCALE_1: &str = "contents/#1/transform/scale";
|
||||
const SCALE_2: &str = "contents/#2/transform/scale";
|
||||
|
||||
/// The test comp with a second shape group "Rectangle 2" on layer "Shape", and "Rectangle 1"'s
|
||||
/// Transform ▸ Scale keyed 0 → 100 over 0–1 s.
|
||||
fn staggered() -> Fx {
|
||||
let mut f = fx();
|
||||
let g = group_push(&mut f.p, |ids| {
|
||||
let rect = build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0);
|
||||
build::shape_group(ids, "Rectangle 2", vec![rect])
|
||||
});
|
||||
let shape = f.layer_mut(f.shape);
|
||||
shape.props.sub_mut("contents").unwrap().children.push(g.into());
|
||||
shape.props.prop_mut(SCALE_1).unwrap().keys =
|
||||
vec![Keyframe::new(Tick::ZERO, Value::Vec2([0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec2([100.0, 100.0]))];
|
||||
f
|
||||
}
|
||||
|
||||
impl Fx {
|
||||
fn shape_num(&self, path: &str, text: &str) -> f64 {
|
||||
match self.ev(self.shape, path, &format!("var r = ({text}); [r, r]"), 0.0) {
|
||||
Ok(v) => v.as_vec2()[0],
|
||||
Err(e) => panic!("{text}: {e}"),
|
||||
}
|
||||
}
|
||||
fn shape_err(&self, path: &str, text: &str) -> String {
|
||||
match self.ev(self.shape, path, text, 0.0) {
|
||||
Ok(v) => panic!("{text}: expected an error, got {v:?}"),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_group_stagger_from_the_adobe_community_answer() {
|
||||
let f = staggered();
|
||||
// thisProperty = Scale, propertyGroup(1) = Transform, propertyGroup(2) = the shape group.
|
||||
let text = "offset = .1;\nid = thisProperty.propertyGroup(2).propertyIndex;\ncontent(\"Rectangle 1\").transform.scale.valueAtTime(time-id*offset)";
|
||||
let v = f.ev(f.shape, SCALE_2, text, 0.5).unwrap().as_vec2();
|
||||
// "Rectangle 2" is the second group in Contents: Rectangle 1's scale 0.2 s earlier.
|
||||
assert_close!(v[0], 30.0);
|
||||
assert_close!(v[1], 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_walks_up_from_the_evaluated_property() {
|
||||
let f = staggered();
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).name == 'Transform' ? 1 : 0"), 1.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup().name == 'Transform' ? 1 : 0"), 1.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).name == 'Rectangle 2' ? 1 : 0"), 1.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(3).name == 'Contents' ? 1 : 0"), 1.0);
|
||||
assert_close!(f.shape_num(SCALE_1, "thisProperty.propertyGroup(2).propertyIndex"), 1.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).propertyIndex"), 2.0);
|
||||
// A shape group holds Blend Mode, Contents and Transform, in After Effects' order.
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyIndex"), 3.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).numProperties"), 3.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(3).numProperties"), 2.0);
|
||||
// Groups walk on: Transform → group → Contents.
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyGroup(2).name == 'Contents' ? 1 : 0"), 1.0);
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyGroup(1).propertyIndex"), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_reaches_the_layer() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("thisProperty.propertyGroup(1).name == 'Transform' ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0);
|
||||
assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0);
|
||||
let s = staggered();
|
||||
assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_of_an_effect_parameter_is_the_effect() {
|
||||
let f = fx();
|
||||
let path = "effects/#1/slider";
|
||||
let ev = |text: &str| f.ev(f.a, path, text, 0.0).unwrap().as_f64();
|
||||
assert_close!(ev("thisProperty.propertyGroup(1).name == 'Slider Control' ? 1 : 0"), 1.0);
|
||||
assert_close!(ev("thisProperty.propertyGroup(2).name == 'Effects' ? 1 : 0"), 1.0);
|
||||
// The name-independent rig: read the effect this parameter belongs to by its own name.
|
||||
assert_close!(ev("effect(thisProperty.propertyGroup(1).name)('Slider') + 1"), 43.0);
|
||||
assert_close!(ev("thisProperty.propertyGroup(1).propertyIndex"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_on_other_layers_properties_and_groups() {
|
||||
let f = staggered();
|
||||
// From layer B: another layer's property and group, reached by name.
|
||||
let ev = |text: &str| f.ev(f.b, ROT, text, 0.0).unwrap().as_f64();
|
||||
assert_close!(ev("thisComp.layer('A').effect(1)('Slider').propertyGroup(1).name == 'Slider Control' ? 1 : 0"), 1.0);
|
||||
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 2').propertyIndex"), 2.0);
|
||||
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 2').transform.scale.propertyGroup(2).propertyIndex"), 2.0);
|
||||
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 1').content('Fill 1').propertyGroup(2).name == 'Rectangle 1' ? 1 : 0"), 1.0);
|
||||
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 1').propertyGroup(2).name == 'Shape' ? 1 : 0"), 1.0);
|
||||
assert_close!(ev("thisComp.layer('A').effect('Slider Control').propertyIndex"), 1.0);
|
||||
assert_close!(ev("thisComp.layer('A').mask('Mask 1').propertyGroup(1).numProperties"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_out_of_range_is_an_error_not_a_crash() {
|
||||
let f = staggered();
|
||||
for text in [
|
||||
"thisProperty.propertyGroup(0)",
|
||||
"thisProperty.propertyGroup(-1)",
|
||||
"thisProperty.propertyGroup(5)",
|
||||
"thisProperty.propertyGroup(1e300)",
|
||||
"thisProperty.propertyGroup(NaN)",
|
||||
"thisProperty.propertyGroup('x')",
|
||||
"thisProperty.propertyGroup(1).propertyGroup(4)",
|
||||
] {
|
||||
let e = f.shape_err(SCALE_2, text);
|
||||
assert!(e.contains("propertyGroup"), "{text}: {e}");
|
||||
}
|
||||
let e = f.err("thisProperty.propertyGroup(3)");
|
||||
assert!(e.contains("propertyGroup"), "{e}");
|
||||
// A fractional count rounds down, as an index does.
|
||||
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2.7).propertyIndex"), 2.0);
|
||||
}
|
||||
@@ -320,7 +320,12 @@ fn pack(s: &Scene) -> [Vec<u8>; 7] {
|
||||
let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]);
|
||||
globals.extend([e.rotation, lit as u32 as f32, 0.0, 0.0]);
|
||||
let mats = s.materials.iter().flat_map(|m| {
|
||||
let flags = (m.double_sided as u32) | (m.unlit as u32) << 1 | (m.accepts_lights as u32) << 2 | (m.receives_shadows as u32) << 3 | (m.alpha_mode << 8);
|
||||
let flags = (m.double_sided as u32)
|
||||
| (m.unlit as u32) << 1
|
||||
| (m.accepts_lights as u32) << 2
|
||||
| (m.receives_shadows as u32) << 3
|
||||
| (m.shadows_only as u32) << 4
|
||||
| (m.alpha_mode << 8);
|
||||
[
|
||||
m.base[0],
|
||||
m.base[1],
|
||||
|
||||
+151
-8
@@ -140,6 +140,74 @@ pub struct TransferStats {
|
||||
pub readback_bytes: u64,
|
||||
}
|
||||
|
||||
/// `WGPU_ADAPTER_NAME` trimmed; empty or whitespace-only counts as unset (an empty
|
||||
/// substring would otherwise match the first adapter and skip the power preference).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn wanted_adapter_name(var: Option<&str>) -> Option<&str> {
|
||||
var.map(str::trim).filter(|w| !w.is_empty())
|
||||
}
|
||||
|
||||
/// Index of the first adapter whose name contains `want`, compared case-insensitively.
|
||||
/// Only the name is matched, as in wgpu (the backend is chosen with `WGPU_BACKEND`).
|
||||
/// `Err` lists each distinct adapter once as `name (backend)`, so a typo or an
|
||||
/// OS/driver-specific name is easy to spot.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn match_adapter_name(want: &str, adapters: &[(String, String)]) -> Result<usize, String> {
|
||||
if adapters.is_empty() {
|
||||
return Err(format!("WGPU_ADAPTER_NAME={want:?}: no adapters on the enabled backends"));
|
||||
}
|
||||
let w = want.to_lowercase();
|
||||
adapters.iter().position(|(n, _)| n.to_lowercase().contains(&w)).ok_or_else(|| {
|
||||
let mut list: Vec<String> = Vec::new();
|
||||
for l in adapters.iter().map(|(n, b)| format!("{n} ({b})")) {
|
||||
if !list.contains(&l) {
|
||||
list.push(l);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"no adapter name matches WGPU_ADAPTER_NAME={want:?} (matched against the name only; \
|
||||
select a backend with WGPU_BACKEND); available: [{}]",
|
||||
list.join(", ")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The adapter named by `WGPU_ADAPTER_NAME` (case-insensitive substring, like wgpu's
|
||||
/// `initialize_adapter_from_env`, which panics when none matches). `None` when unset or empty.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn adapter_by_name(instance: &wgpu::Instance) -> Option<Result<wgpu::Adapter, String>> {
|
||||
let var = std::env::var("WGPU_ADAPTER_NAME").ok();
|
||||
let want = wanted_adapter_name(var.as_deref())?;
|
||||
let mut all = instance.enumerate_adapters(wgpu::Backends::all()).await;
|
||||
let adapters: Vec<(String, String)> = all
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let i = a.get_info();
|
||||
(i.name, format!("{:?}", i.backend))
|
||||
})
|
||||
.collect();
|
||||
Some(match_adapter_name(want, &adapters).map(|i| all.swap_remove(i)))
|
||||
}
|
||||
/// `WGPU_POWER_PREF` (`low` / `high` / `none`), default HighPerformance. wgpu ignores an
|
||||
/// unknown value silently; warn so a typo is noticed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn power_pref_from_env() -> wgpu::PowerPreference {
|
||||
wgpu::PowerPreference::from_env().unwrap_or_else(|| {
|
||||
if let Ok(v) = std::env::var("WGPU_POWER_PREF") {
|
||||
log::warn!("gpu: ignoring WGPU_POWER_PREF={v:?} (expected low, high or none)");
|
||||
}
|
||||
wgpu::PowerPreference::HighPerformance
|
||||
})
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn power_pref_from_env() -> wgpu::PowerPreference {
|
||||
wgpu::PowerPreference::HighPerformance
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn adapter_by_name(_: &wgpu::Instance) -> Option<Result<wgpu::Adapter, String>> {
|
||||
None
|
||||
}
|
||||
|
||||
impl Transfers {
|
||||
fn count(&self, up: bool, bytes: usize) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
@@ -774,19 +842,28 @@ impl GpuContext {
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// Match eframe's backend override so CLI and headless acceptance tests
|
||||
// exercise the backend requested for the native viewer.
|
||||
desc.backends = wgpu::Backends::from_env().unwrap_or(desc.backends);
|
||||
// Honour the same wgpu env vars as the viewer (WGPU_BACKEND, WGPU_DX12_COMPILER,
|
||||
// WGPU_VALIDATION, WGPU_DEBUG, WGPU_POWER_PREF), plus WGPU_ADAPTER_NAME (CLI/headless
|
||||
// only for now; the viewer ignores it). Not full parity: the viewer also changes
|
||||
// instance flags and has a GL fallback.
|
||||
// Note: with_env() also resets memory_budget_thresholds to their default (unused here).
|
||||
desc = desc.with_env();
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
desc.backends = wgpu::Backends::BROWSER_WEBGPU;
|
||||
}
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no adapter: {e}"))?;
|
||||
let (adapter, by_name) = match adapter_by_name(&instance).await {
|
||||
Some(a) => (a?, true),
|
||||
None => (
|
||||
instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions { power_preference: power_pref_from_env(), ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no adapter: {e}"))?,
|
||||
false,
|
||||
),
|
||||
};
|
||||
let limits = adapter.limits();
|
||||
let required_limits = wgpu::Limits {
|
||||
max_texture_dimension_2d: limits.max_texture_dimension_2d.min(16384),
|
||||
@@ -796,7 +873,10 @@ impl GpuContext {
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no device: {e}"))?;
|
||||
.map_err(|e| match by_name {
|
||||
true => format!("no device on {} (picked by WGPU_ADAPTER_NAME): {e}", adapter.get_info().name),
|
||||
false => format!("no device: {e}"),
|
||||
})?;
|
||||
let context = GpuContext::new(&adapter, device, queue)?;
|
||||
// This path owns the device; install handlers only after construction succeeds.
|
||||
crate::readback::install(&context.device, &context.readbacks);
|
||||
@@ -1392,3 +1472,66 @@ impl<'g> Enc<'g> {
|
||||
pub(crate) fn mip_levels(w: u32, h: u32) -> u32 {
|
||||
u32::BITS - w.max(h).max(1).leading_zeros()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod adapter_name_tests {
|
||||
use super::{match_adapter_name, wanted_adapter_name};
|
||||
|
||||
fn adapters() -> Vec<(String, String)> {
|
||||
[("NVIDIA GeForce GTX 1070", "Vulkan"), ("Intel(R) UHD Graphics 630", "Dx12"), ("llvmpipe (LLVM 19.1.7, 256 bits)", "Gl")]
|
||||
.map(|(n, b)| (n.into(), b.into()))
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_case_insensitive_substring() {
|
||||
assert_eq!(match_adapter_name("intel", &adapters()), Ok(1));
|
||||
assert_eq!(match_adapter_name("GTX 1070", &adapters()), Ok(0));
|
||||
assert_eq!(match_adapter_name("LLVMPIPE", &adapters()), Ok(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_match_wins() {
|
||||
assert_eq!(match_adapter_name("i", &adapters()), Ok(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_blank_name_is_unset_and_value_is_trimmed() {
|
||||
assert_eq!(wanted_adapter_name(None), None);
|
||||
assert_eq!(wanted_adapter_name(Some("")), None);
|
||||
assert_eq!(wanted_adapter_name(Some(" ")), None);
|
||||
assert_eq!(wanted_adapter_name(Some(" intel ")), Some("intel"));
|
||||
assert_eq!(match_adapter_name(wanted_adapter_name(Some(" intel ")).unwrap(), &adapters()), Ok(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_lists_name_and_backend() {
|
||||
let e = match_adapter_name("NoSuch", &adapters()).unwrap_err();
|
||||
assert!(e.contains("\"NoSuch\""), "{e}");
|
||||
assert!(e.contains("Intel(R) UHD Graphics 630 (Dx12)"), "{e}");
|
||||
assert!(e.contains("NVIDIA GeForce GTX 1070 (Vulkan)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_is_not_part_of_the_match_and_message_says_so() {
|
||||
let e = match_adapter_name("dx12", &adapters()).unwrap_err();
|
||||
assert!(e.contains("name only"), "{e}");
|
||||
assert!(e.contains("WGPU_BACKEND"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_lists_identical_name_and_backend_once() {
|
||||
let mut a = adapters();
|
||||
a.insert(2, ("Intel(R) UHD Graphics 630".into(), "Dx12".into()));
|
||||
a.push(("Intel(R) UHD Graphics 630".into(), "Vulkan".into()));
|
||||
let e = match_adapter_name("NoSuch", &a).unwrap_err();
|
||||
assert_eq!(e.matches("Intel(R) UHD Graphics 630 (Dx12)").count(), 1, "{e}");
|
||||
assert_eq!(e.matches("Intel(R) UHD Graphics 630 (Vulkan)").count(), 1, "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_adapters_has_its_own_message() {
|
||||
let e = match_adapter_name("x", &[]).unwrap_err();
|
||||
assert!(e.contains("no adapters on the enabled backends"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,10 @@ fn wave_warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let dir = ctx.params.f("direction").to_radians();
|
||||
let speed = ctx.params.f("speed");
|
||||
let phase = ctx.params.f("phase").to_radians() + ctx.time * speed * TAU;
|
||||
let (dx, dy) = (dir.sin(), -dir.cos());
|
||||
// Cardinal directions must not acquire a tiny perpendicular slope: at a square-wave
|
||||
// boundary cos(90°)'s rounding residue can flip a whole column on CPU but not GPU.
|
||||
let axis = |v: f64| if v.abs() < 4.0 * f64::EPSILON { 0.0 } else { v };
|
||||
let (dx, dy) = (axis(dir.sin()), -axis(dir.cos()));
|
||||
if !ctx.adjustment {
|
||||
b.pad(e, h.abs().ceil() as u32 + 1)?;
|
||||
}
|
||||
|
||||
@@ -493,7 +493,7 @@ fn wire_removal(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = ctx.params.e("removalStyle");
|
||||
p.f[0] = [a.0 as f32, a.1 as f32, -ty as f32, tx as f32];
|
||||
p.f[1] = [half as f32, (half * slope as f64) as f32, mirror, 0.0];
|
||||
p.f[1] = [half as f32, (half * slope as f64) as f32, mirror, len as f32];
|
||||
run(e, "fp2_wire", &p, b, None, None)
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,53 @@ struct FOut {
|
||||
@location(1) depth: u32,
|
||||
};
|
||||
|
||||
// shade::to_light: unit direction to the light (xyz) and its distance (w).
|
||||
fn to_light(kind: u32, lpos: vec3<f32>, ldir: vec3<f32>, pos: vec3<f32>) -> vec4<f32> {
|
||||
if (kind == 0u) {
|
||||
return vec4<f32>(-ldir, 1e30);
|
||||
}
|
||||
let d = lpos - pos;
|
||||
let len = sqrt(dot(d, d));
|
||||
if (len < 1e-6) {
|
||||
return vec4<f32>(0.0, 0.0, -1.0, 0.0);
|
||||
}
|
||||
return vec4<f32>(d / len, len);
|
||||
}
|
||||
|
||||
// shade::shadow_only: the strongest shadow any light casts on the fragment.
|
||||
fn shadow_only_amount(unlit: bool, normal: vec3<f32>, front: bool, pos: vec3<f32>) -> f32 {
|
||||
var amount = 0.0;
|
||||
if (unlit) {
|
||||
return amount;
|
||||
}
|
||||
var n = norm3(normal);
|
||||
if (!front) {
|
||||
n = -n;
|
||||
}
|
||||
let nl_count = u32(g.fwd.w);
|
||||
for (var li = 0u; li < nl_count; li = li + 1u) {
|
||||
let lb = li * LIGHT_STRIDE;
|
||||
let l0 = lights[lb];
|
||||
let l1 = lights[lb + 1u];
|
||||
let l2 = lights[lb + 2u];
|
||||
let l3 = lights[lb + 3u];
|
||||
let l4 = lights[lb + 4u];
|
||||
let kind = u32(l0.w);
|
||||
let tl = to_light(kind, l0.xyz, l1.xyz, pos);
|
||||
let to_l = tl.xyz;
|
||||
let dist = tl.w;
|
||||
if (attenuation(kind, u32(l3.x), l3.y, l3.z, l2.w, l1.w, l1.xyz, to_l, dist) <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
let n_l = dot(n, to_l);
|
||||
if (n_l <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n_l));
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
let mb = i.material * MAT_STRIDE;
|
||||
@@ -302,6 +349,7 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
let unlit = (flags & 2u) != 0u;
|
||||
let accepts_lights = (flags & 4u) != 0u;
|
||||
let receives_shadows = (flags & 8u) != 0u;
|
||||
let shadows_only = (flags & 16u) != 0u;
|
||||
let alpha_mode = (flags >> 8u) & 3u;
|
||||
if (!front && !double_sided) {
|
||||
discard;
|
||||
@@ -330,6 +378,10 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
}
|
||||
var out: FOut;
|
||||
out.depth = bitcast<u32>(dot(g.view_z.xyz, i.pos) + g.view_z.w);
|
||||
if (shadows_only) {
|
||||
out.color = vec4<f32>(0.0, 0.0, 0.0, alpha * shadow_only_amount(unlit || !accepts_lights, i.normal, front, i.pos));
|
||||
return out;
|
||||
}
|
||||
if (unlit || !accepts_lights || g.env2.y < 0.5) {
|
||||
let c = rgb + emissive;
|
||||
out.color = vec4<f32>(c * alpha, alpha);
|
||||
@@ -381,22 +433,9 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
let l3 = lights[lb + 3u];
|
||||
let l4 = lights[lb + 4u];
|
||||
let kind = u32(l0.w);
|
||||
var to_l: vec3<f32>;
|
||||
var dist: f32;
|
||||
if (kind == 0u) {
|
||||
to_l = -l1.xyz;
|
||||
dist = 1e30;
|
||||
} else {
|
||||
let d = l0.xyz - i.pos;
|
||||
let len = sqrt(dot(d, d));
|
||||
if (len < 1e-6) {
|
||||
to_l = vec3<f32>(0.0, 0.0, -1.0);
|
||||
dist = 0.0;
|
||||
} else {
|
||||
to_l = d / len;
|
||||
dist = len;
|
||||
}
|
||||
}
|
||||
let tl = to_light(kind, l0.xyz, l1.xyz, i.pos);
|
||||
let to_l = tl.xyz;
|
||||
let dist = tl.w;
|
||||
let k = attenuation(kind, u32(l3.x), l3.y, l3.z, l2.w, l1.w, l1.xyz, to_l, dist);
|
||||
if (k <= 0.0) {
|
||||
continue;
|
||||
|
||||
@@ -828,7 +828,7 @@ fn fp2_io_final(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Simple Wire Removal (keying2::wire_removal)
|
||||
// u[0].x = style; f[0] = (ax, ay, nx, ny); f[1] = (hw thickness, soft width, mirror).
|
||||
// u[0].x = style; f[0] = (ax, ay, nx, ny); f[1] = (hw thickness, soft width, mirror, segment length).
|
||||
|
||||
fn fp2_wire_pos(base: vec2<f32>, nrm: vec2<f32>, s: f32) -> vec4<f32> {
|
||||
return sample_bilinear_clamped(src, base.x + nrm.x * s, base.y + nrm.y * s);
|
||||
@@ -851,7 +851,9 @@ fn fp2_wire(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let q = vec2<f32>(fx - a.x, fy - a.y);
|
||||
let d = q.x * nrm.x + q.y * nrm.y;
|
||||
let ad = abs(d);
|
||||
if (ad > hw + soft_w) {
|
||||
// The wire is the segment A to B: the tangent is the normal turned back by 90 degrees.
|
||||
let along = q.x * nrm.y - q.y * nrm.x;
|
||||
if (along < 0.0 || along > P.f[1].w || ad > hw + soft_w) {
|
||||
textureStore(out, p, px);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,6 +155,18 @@ fn gpu_matches_cpu_unlit_and_full_frames() {
|
||||
assert!((diff as f64) < 0.02 * cpu.data.len() as f64, "{diff} pixels differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_matches_cpu_shadow_catcher() {
|
||||
// #1691: the floor plane as a shadow catcher ("Accepts Shadows: Only").
|
||||
let Some(g) = gpu() else { return };
|
||||
let (mut p, cid) = scene_project();
|
||||
let floor = p.comp_mut(cid).unwrap().layers.last_mut().unwrap();
|
||||
set(floor, "materialOptions/acceptsShadows", Value::Enum(2));
|
||||
let s = scene(&p, cid);
|
||||
assert!(s.materials.iter().any(|m| m.shadows_only) && !s.shadows.is_empty());
|
||||
compare(&s, &g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_raster_perf_smoke() {
|
||||
let Some(g) = gpu() else { return };
|
||||
|
||||
@@ -28,6 +28,11 @@ fn twirl_bulge_ripple_wave_warp() {
|
||||
effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]);
|
||||
effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]);
|
||||
effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]);
|
||||
// #1181: the single-edge pinning paths, with the default vertical displacement direction.
|
||||
effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(25.0)), ("pinning", e(4))]);
|
||||
effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(19.0)), ("pinning", e(6)), ("waveType", e(1))]);
|
||||
effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(4)), ("speed", n(2.0))]);
|
||||
effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(6)), ("speed", n(2.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -79,6 +84,9 @@ fn shape_case(id: &str, vals: &[(&str, Value)]) {
|
||||
fn edge_pinning_on_a_shape_layer() {
|
||||
shape_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]);
|
||||
shape_case("ec.distort.wavewarp", &[("pinning", e(1)), ("height", n(6.0)), ("width", n(25.0))]);
|
||||
// #1181: single-edge pinning over the layer rect, not just All Edges.
|
||||
shape_case("ec.distort.wavewarp", &[("pinning", e(4)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]);
|
||||
shape_case("ec.distort.wavewarp", &[("pinning", e(6)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]);
|
||||
shape_case(
|
||||
"ec.distort.bulge",
|
||||
&[("pinAllEdges", on()), ("height", n(2.0)), ("hradius", n(40.0)), ("vradius", n(30.0)), ("center", Value::Vec2([28.5, 20.5]))],
|
||||
|
||||
@@ -213,3 +213,80 @@ fn a_new_project_importing_an_overwritten_file_shows_the_new_pixels() {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
assert!(near(pixel(&s, cid, 32, 24), BLUE), "stale pixels from the earlier project: {:?}", pixel(&s, cid, 32, 24));
|
||||
}
|
||||
|
||||
fn layered_comp(s: &mut Session, path: &str, import_as: &str) -> ItemId {
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap();
|
||||
ItemId(r["comps"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// Composition imports create their sources before probing ordinary footage (#1070).
|
||||
#[test]
|
||||
fn fresh_layered_composition_import_refreshes_its_sources() {
|
||||
for import_as in ["composition", "compositionLayerSizes"] {
|
||||
let path = tmp(&format!("fresh-{import_as}.psd"));
|
||||
let (red, blue) = (flat(RED, false), flat(BLUE, false));
|
||||
assert_eq!(red.len(), blue.len(), "the overwritten file has the same byte size");
|
||||
std::fs::write(&path, red).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let old_comp = layered_comp(&mut s, &path, import_as);
|
||||
assert!(near(pixel(&s, old_comp, 32, 24), RED));
|
||||
s.execute_checked("file.newProject", json!({})).unwrap();
|
||||
save(&path, &blue);
|
||||
s.drain_events();
|
||||
let comp = layered_comp(&mut s, &path, import_as);
|
||||
assert_eq!(comp, old_comp, "new projects reuse their item ids");
|
||||
assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview");
|
||||
assert!(near(pixel(&s, comp, 32, 24), BLUE), "layered import showed {:?}", pixel(&s, comp, 32, 24));
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_reimport_refreshes_existing_layers_and_keeps_unrelated_decoded_files() {
|
||||
let path = tmp("layered-reimport.psd");
|
||||
let other = tmp("layered-unchanged.psd");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
std::fs::write(&other, flat(GREEN, false)).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let comp = layered_comp(&mut s, &path, "compositionLayerSizes");
|
||||
let r = s.execute_checked("file.import", json!({"paths": [&other]})).unwrap();
|
||||
let other_item = ItemId(r["items"][0].as_u64().unwrap());
|
||||
let other_footage = footage(&s, other_item);
|
||||
let cached = s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap();
|
||||
assert!(near(pixel(&s, comp, 32, 24), RED));
|
||||
let before = s.project.clone();
|
||||
let undo = s.history.undo.len();
|
||||
save(&path, &flat(BLUE, false));
|
||||
s.drain_events();
|
||||
layered_comp(&mut s, &path, "compositionLayerSizes");
|
||||
assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview");
|
||||
assert!(near(pixel(&s, comp, 32, 24), BLUE), "existing layers see the refreshed source");
|
||||
assert!(std::sync::Arc::ptr_eq(&cached, &s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap()));
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "composition import stays one undo step");
|
||||
s.undo();
|
||||
assert!(std::sync::Arc::ptr_eq(&s.project, &before));
|
||||
assert!(near(pixel(&s, comp, 32, 24), BLUE), "undo does not undo an external file write");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_file(other).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_import_refreshes_layered_sources_before_the_probe_job_finishes() {
|
||||
let path = tmp("layered-background.psd");
|
||||
let other = tmp("layered-background.png");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
image::RgbaImage::from_pixel(64, 48, image::Rgba([0, 255, 0, 255])).save(&other).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let comp = layered_comp(&mut s, &path, "compositionLayerSizes");
|
||||
assert!(near(pixel(&s, comp, 32, 24), RED));
|
||||
save(&path, &flat(BLUE, false));
|
||||
s.drain_events();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [&path, &other], "importAs": "compositionLayerSizes", "background": true})).unwrap();
|
||||
assert_eq!(r["started"], true, "ordinary footage is probed in a background job");
|
||||
assert!(near(pixel(&s, comp, 32, 24), BLUE), "composition sources are refreshed before the job is applied");
|
||||
s.wait_jobs();
|
||||
assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview");
|
||||
assert!(near(pixel(&s, s.active_comp_id().unwrap(), 32, 24), BLUE));
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_file(other).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Targa import through the same host and commands used by the desktop and CLI.
|
||||
|
||||
use effectcraft_engine::render::RenderOpts;
|
||||
use effectcraft_project::{FootageKind, ItemId, ItemKind};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tga_stills_and_numbered_sequences_import_and_render() {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-tga-import-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// A generated 4×4, 24-bit, top-origin TGA. No binary fixtures are retained.
|
||||
let write = |name: &str, bgr: [u8; 3]| {
|
||||
let mut bytes = vec![0; 18];
|
||||
bytes[2] = 2;
|
||||
bytes[12] = 4;
|
||||
bytes[14] = 4;
|
||||
bytes[16] = 24;
|
||||
bytes[17] = 0x20;
|
||||
bytes.extend_from_slice(&bgr.repeat(16));
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
path.to_string_lossy().to_string()
|
||||
};
|
||||
let still = write("still.TGA", [0, 0, 255]);
|
||||
let first = write("shot_0001.tga", [0, 0, 255]);
|
||||
write("shot_0002.tga", [255, 0, 0]);
|
||||
for (path, sequence) in [(still, false), (first, true)] {
|
||||
let mut session = effectcraft_host::session();
|
||||
let imported = session.execute_checked("file.import", json!({"paths": [path], "sequence": sequence})).unwrap();
|
||||
assert_eq!(imported["errors"], json!([]), "{imported}");
|
||||
assert_eq!(imported["items"].as_array().unwrap().len(), 1);
|
||||
let item = ItemId(imported["items"][0].as_u64().unwrap());
|
||||
let ItemKind::Footage(footage) = &session.project.item(item).unwrap().kind else { panic!("not footage") };
|
||||
assert_eq!(footage.kind, if sequence { FootageKind::Sequence } else { FootageKind::Still });
|
||||
assert_eq!(footage.sequence.len(), if sequence { 2 } else { 0 });
|
||||
session.execute_checked("comp.new", json!({"name": "TGA", "width": 4, "height": 4, "frameRate": 30, "duration": 1})).unwrap();
|
||||
session.execute_checked("layer.addItem", json!({"item": item.0})).unwrap();
|
||||
let comp = session.active_comp_id().unwrap();
|
||||
let image = session.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
assert_eq!(image.get(2, 2), [1.0, 0.0, 0.0, 1.0]);
|
||||
if sequence {
|
||||
let image = session.render(comp, FrameRate::FPS_30.tick_of(1), RenderOpts::default());
|
||||
assert_eq!(image.get(2, 2), [0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -19,8 +19,10 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
|
||||
// A movie is read in place (its index), not loaded whole.
|
||||
if let Some(src) = crate::stream::probe_source(path) {
|
||||
let mut f = footage_from_info(&path.to_string_lossy(), src?.info());
|
||||
let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h));
|
||||
let header = crate::stream::movie_header(path);
|
||||
let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn);
|
||||
turn_footage(&mut f, turn);
|
||||
correct_rate(&mut f, header.as_deref().and_then(video_rate));
|
||||
return Ok(f);
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
|
||||
@@ -95,12 +97,24 @@ 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);
|
||||
let src = filmcraft_codecs::open_bytes(&name, bytes)?;
|
||||
let mut f = footage_from_info(path, src.info());
|
||||
turn_footage(&mut f, turn);
|
||||
correct_rate(&mut f, rate);
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's
|
||||
/// own estimate misreads it.
|
||||
fn correct_rate(f: &mut Footage, rate: Option<FrameRate>) {
|
||||
if f.kind == FootageKind::Video
|
||||
&& let Some(rate) = rate
|
||||
{
|
||||
f.frame_rate = rate;
|
||||
}
|
||||
}
|
||||
|
||||
/// The size as displayed: a movie shot with the camera on its side trades width and height.
|
||||
fn turn_footage(f: &mut Footage, turn: Orientation) {
|
||||
if swaps_sides(turn) {
|
||||
@@ -254,3 +268,86 @@ pub(crate) fn video_turn(bytes: &[u8]) -> Orientation {
|
||||
(false, _, false) => Orientation::Rotate270,
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame rate of an MP4/MOV movie's video track when the reader's own estimate, the median
|
||||
/// frame duration, misreads it. Phones and messaging apps store frame times rounded to whole
|
||||
/// milliseconds: a 30 fps clip's frames last 33 or 34 ms and a 60 fps clip's 16 or 17 ms, whose
|
||||
/// median reads as 30.303 or 58.824 fps. `None` for other containers and for movies the median
|
||||
/// reads right.
|
||||
fn video_rate(bytes: &[u8]) -> Option<FrameRate> {
|
||||
let file = filmcraft_isobmff::open(bytes).ok()?;
|
||||
let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?;
|
||||
averaged_rate(t.timescale, t.samples.iter().take(240).map(|s| s.duration))
|
||||
}
|
||||
|
||||
/// The standard rate ([`FrameRate::COMMON`]) within 0.5 % of what the frame durations near their
|
||||
/// median average to (`durations` in `1 / timescale` s); `None` when the median duration is a
|
||||
/// standard rate already, or when the average is no standard rate (variable or unusual rates keep
|
||||
/// the reader's estimate).
|
||||
fn averaged_rate(timescale: u32, durations: impl Iterator<Item = u32>) -> Option<FrameRate> {
|
||||
let ts = i128::from(timescale);
|
||||
let mut durs: Vec<u64> = durations.filter(|&d| d > 0).map(u64::from).collect();
|
||||
durs.sort_unstable();
|
||||
let median = *durs.get(durs.len() / 2)?;
|
||||
if FrameRate::COMMON.iter().any(|r| ts * i128::from(r.den) == i128::from(median) * i128::from(r.num)) {
|
||||
return None;
|
||||
}
|
||||
// Durations within a quarter of the median (widened, so extreme durations can't overflow).
|
||||
let (n, sum) = durs.iter().filter(|&&d| d.abs_diff(median).saturating_mul(4) <= median).fold((0u64, 0u64), |(n, s), &d| (n + 1, s.saturating_add(d)));
|
||||
if sum == 0 {
|
||||
return None;
|
||||
}
|
||||
let fps = timescale as f64 * n as f64 / sum as f64;
|
||||
FrameRate::COMMON
|
||||
.into_iter()
|
||||
.map(|r| (r, (fps / r.as_f64() - 1.0).abs()))
|
||||
.filter(|&(_, off)| off <= 0.005)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(r, _)| r)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale.
|
||||
fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec<u32> {
|
||||
let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num);
|
||||
(0..n).map(|k| (at(k + 1) - at(k)) as u32).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn millisecond_rounded_frame_times_average_to_their_rate() {
|
||||
assert_eq!(rounded(FrameRate::FPS_30, 1000, 6), [33, 34, 33, 33, 34, 33]);
|
||||
assert_eq!(averaged_rate(1000, rounded(FrameRate::FPS_30, 1000, 120).into_iter()), Some(FrameRate::FPS_30));
|
||||
assert_eq!(averaged_rate(1000, rounded(FrameRate::FPS_60, 1000, 240).into_iter()), Some(FrameRate::FPS_60));
|
||||
// NTSC rates stay NTSC, not the round rate next to them.
|
||||
for r in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] {
|
||||
assert_eq!(averaged_rate(1000, rounded(r, 1000, 240).into_iter()), Some(r), "{r:?}");
|
||||
}
|
||||
// A short clip: its few frames average to the rate as well.
|
||||
assert_eq!(averaged_rate(1000, [33, 34, 33].into_iter()), Some(FrameRate::FPS_30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_and_variable_rates_keep_the_readers_estimate() {
|
||||
// A median that is a standard rate already reads right.
|
||||
assert_eq!(averaged_rate(30000, rounded(FrameRate::FPS_29_97, 30000, 240).into_iter()), None);
|
||||
assert_eq!(averaged_rate(90000, [3000; 10].into_iter()), None);
|
||||
assert_eq!(averaged_rate(600, [20, 20, 19, 21, 20].into_iter()), None);
|
||||
// Durations averaging to no standard rate (22.2 fps).
|
||||
assert_eq!(averaged_rate(1000, [40, 45, 50].repeat(20).into_iter()), None);
|
||||
// A truly 30.3 fps clip is not pulled to 30 (1 % off).
|
||||
assert_eq!(averaged_rate(1000, [33; 30].into_iter()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_durations_have_no_rate() {
|
||||
assert_eq!(averaged_rate(1000, std::iter::empty()), None);
|
||||
assert_eq!(averaged_rate(1000, [0, 0, 0].into_iter()), None);
|
||||
assert_eq!(averaged_rate(0, [33, 34, 33].into_iter()), None);
|
||||
assert_eq!(averaged_rate(u32::MAX, [u32::MAX, u32::MAX - 1, 1, 0].into_iter()), None);
|
||||
assert_eq!(averaged_rate(u32::MAX, [u32::MAX; 300].into_iter()), None);
|
||||
assert_eq!(video_rate(b"not a movie"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Tiny synthetic Targa files exercise import without external fixtures or codecs.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_media::{MediaPool, is_importable, probe_bytes};
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind};
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
/// A classic 2×2 TGA without a footer. True-colour pixels are stored BGR(A); RLE
|
||||
/// packets repeat each row's colour twice. `pixels` is always in top-down RGBA order.
|
||||
fn tga(pixels: [[u8; 4]; 4], alpha: bool, rle: bool, top: bool) -> Vec<u8> {
|
||||
let mut bytes = vec![0; 18];
|
||||
bytes[2] = if rle { 10 } else { 2 };
|
||||
bytes[12] = 2;
|
||||
bytes[14] = 2;
|
||||
bytes[16] = if alpha { 32 } else { 24 };
|
||||
bytes[17] = if top { 0x20 } else { 0 } | if alpha { 8 } else { 0 };
|
||||
let rows = if top { [[0, 1], [2, 3]] } else { [[2, 3], [0, 1]] };
|
||||
for row in rows {
|
||||
if rle {
|
||||
assert_eq!(pixels[row[0]], pixels[row[1]]);
|
||||
bytes.push(0x81); // A two-pixel run.
|
||||
}
|
||||
for &i in row.iter().take(if rle { 1 } else { 2 }) {
|
||||
let [r, g, b, a] = pixels[i];
|
||||
bytes.extend_from_slice(&[b, g, r]);
|
||||
if alpha {
|
||||
bytes.push(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tga_raw_and_rle_preserve_colours_orientation_and_alpha() {
|
||||
for alpha in [false, true] {
|
||||
for rle in [false, true] {
|
||||
for top in [false, true] {
|
||||
let pixels = if rle {
|
||||
[[255, 0, 0, 128], [255, 0, 0, 128], [0, 0, 255, 255], [0, 0, 255, 255]]
|
||||
} else {
|
||||
[[255, 0, 0, 128], [0, 255, 0, 255], [0, 0, 255, 64], [255, 255, 255, 0]]
|
||||
};
|
||||
let path = if top { "/frame.TGA" } else { "/frame.tga" };
|
||||
assert!(is_importable(Path::new(path)));
|
||||
let bytes: Arc<[u8]> = tga(pixels, alpha, rle, top).into();
|
||||
let footage = probe_bytes(path, bytes.clone()).unwrap();
|
||||
assert_eq!(footage.kind, FootageKind::Still);
|
||||
assert_eq!((footage.width, footage.height), (2, 2));
|
||||
assert_eq!(footage.codec, "TGA");
|
||||
assert_eq!(footage.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore });
|
||||
assert!(footage.has_video && !footage.has_audio);
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes(path, bytes);
|
||||
let image = pool.frame_at(&footage, Tick::ZERO).unwrap();
|
||||
for (actual, [r, g, b, a]) in image.data.iter().zip(pixels) {
|
||||
let a = if alpha { f32::from(a) / 255.0 } else { 1.0 };
|
||||
let expected = [f32::from(r) / 255.0 * a, f32::from(g) / 255.0 * a, f32::from(b) / 255.0 * a, a];
|
||||
assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{actual:?} != {expected:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tga_sequence_decodes_each_frame_from_memory() {
|
||||
let pool = MediaPool::new();
|
||||
pool.set_read_ahead(false);
|
||||
let paths = ["/shot_0001.tga", "/shot_0002.tga"];
|
||||
let colours = [[255, 0, 0, 255], [0, 0, 255, 255]];
|
||||
let mut footage = None;
|
||||
for (path, colour) in paths.iter().zip(colours) {
|
||||
let bytes: Arc<[u8]> = tga([colour; 4], false, true, true).into();
|
||||
footage.get_or_insert_with(|| probe_bytes(path, bytes.clone()).unwrap());
|
||||
pool.add_bytes(path, bytes);
|
||||
}
|
||||
let mut footage = Footage { kind: FootageKind::Sequence, sequence: paths.iter().map(|s| s.to_string()).collect(), ..footage.unwrap() };
|
||||
footage.sync_sequence_duration();
|
||||
for (n, expected) in [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]].into_iter().enumerate() {
|
||||
let image = pool.frame_at(&footage, footage.frame_rate.tick_of(n as i64)).unwrap();
|
||||
assert_eq!(image.get(0, 0), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_tga_returns_contextual_errors() {
|
||||
for bytes in [vec![], vec![0; 17], vec![0; 18]] {
|
||||
let error = probe_bytes("/broken.tga", bytes.into()).unwrap_err().to_string();
|
||||
assert!(error.contains("/broken.tga"), "{error}");
|
||||
}
|
||||
let mut bytes = tga([[255, 0, 0, 255]; 4], true, false, true);
|
||||
bytes.truncate(19); // Valid metadata, truncated pixel data.
|
||||
let bytes: Arc<[u8]> = bytes.into();
|
||||
let footage = probe_bytes("/truncated.tga", bytes.clone()).unwrap();
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes(&footage.path, bytes);
|
||||
let error = pool.frame_at(&footage, Tick::ZERO).unwrap_err().to_string();
|
||||
assert!(error.contains("/truncated.tga"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recognised_signature_takes_precedence_over_the_tga_extension() {
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::new_rgb8(3, 2).write_to(&mut bytes, image::ImageFormat::Png).unwrap();
|
||||
let bytes: Arc<[u8]> = bytes.into_inner().into();
|
||||
let footage = probe_bytes("/actually-png.tga", bytes.clone()).unwrap();
|
||||
assert_eq!(footage.codec, "PNG");
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes(&footage.path, bytes);
|
||||
let image = pool.frame_at(&footage, Tick::ZERO).unwrap();
|
||||
assert_eq!((image.width, image.height), (3, 2));
|
||||
}
|
||||
+91
-11
@@ -32,6 +32,57 @@ pub const BINARY_MAGIC: &[u8] = b"Kaydara FBX Binary \0\x1a\0";
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Largest decoded array accepted (bytes).
|
||||
const MAX_ARRAY_BYTES: usize = 1 << 30;
|
||||
/// Most memory one import may keep: parsed arrays as stored (8 bytes per element, whatever the
|
||||
/// wire type) plus the meshes built from them (#1234).
|
||||
const MAX_IMPORT_BYTES: usize = 1 << 31;
|
||||
|
||||
/// What is left of an import's memory budget.
|
||||
struct Budget(std::cell::Cell<usize>);
|
||||
|
||||
impl Budget {
|
||||
fn new(bytes: usize) -> Budget {
|
||||
Budget(std::cell::Cell::new(bytes))
|
||||
}
|
||||
/// Take `bytes` (None: a size that overflowed) from the budget, or fail the import before
|
||||
/// anything that large is allocated.
|
||||
fn take(&self, bytes: Option<usize>, what: &str) -> Result<()> {
|
||||
let left = self.0.get();
|
||||
match bytes.filter(|&b| b <= left) {
|
||||
Some(b) => {
|
||||
self.0.set(left - b);
|
||||
Ok(())
|
||||
}
|
||||
None => Err(perr(format!("FBX: {what} would take the import past its {} MiB memory limit", MAX_IMPORT_BYTES >> 20))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes a mesh keeps.
|
||||
fn mesh_bytes(m: &Mesh) -> Option<usize> {
|
||||
m.primitives.iter().try_fold(0usize, |acc, p| {
|
||||
let n = p.positions.len().checked_mul(12)?.checked_add(p.normals.len().checked_mul(12)?)?.checked_add(p.uvs.len().checked_mul(8)?)?;
|
||||
let n = n.checked_add(p.tangents.len().checked_mul(16)?)?.checked_add(p.joints.len().checked_mul(8)?)?.checked_add(p.weights.len().checked_mul(16)?)?;
|
||||
acc.checked_add(n)?.checked_add(p.indices.len().checked_mul(4)?)
|
||||
})
|
||||
}
|
||||
|
||||
/// Bytes the values under `nodes` keep. Used for ASCII files, which hold array elements as
|
||||
/// one `Prop` each and are parsed before they can be counted (the text itself bounds them).
|
||||
fn value_bytes(nodes: &[FbxNode]) -> Option<usize> {
|
||||
nodes.iter().try_fold(0usize, |acc, n| {
|
||||
let own = n.props.iter().try_fold(0usize, |a, p| {
|
||||
let extra = match p {
|
||||
Prop::Ints(v) => v.len().checked_mul(8)?,
|
||||
Prop::Floats(v) => v.len().checked_mul(8)?,
|
||||
Prop::Str(v) => v.len(),
|
||||
Prop::Bytes(v) => v.len(),
|
||||
Prop::Int(_) | Prop::Float(_) => 0,
|
||||
};
|
||||
a.checked_add(std::mem::size_of::<Prop>())?.checked_add(extra)
|
||||
})?;
|
||||
acc.checked_add(own)?.checked_add(value_bytes(&n.children)?)
|
||||
})
|
||||
}
|
||||
|
||||
/// One property value of an FBX node.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -126,7 +177,17 @@ impl FbxNode {
|
||||
/// Read an FBX file (binary or ASCII) into its top-level nodes and the file version
|
||||
/// (e.g. 7400).
|
||||
pub fn read(bytes: &[u8]) -> Result<(u32, Vec<FbxNode>)> {
|
||||
if bytes.starts_with(BINARY_MAGIC) { read_binary(bytes) } else { read_ascii(&String::from_utf8_lossy(bytes)) }
|
||||
read_within(bytes, &Budget::new(MAX_IMPORT_BYTES))
|
||||
}
|
||||
|
||||
fn read_within(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec<FbxNode>)> {
|
||||
if bytes.starts_with(BINARY_MAGIC) {
|
||||
read_binary(bytes, budget)
|
||||
} else {
|
||||
let (version, nodes) = read_ascii(&String::from_utf8_lossy(bytes))?;
|
||||
budget.take(value_bytes(&nodes), "its values")?;
|
||||
Ok((version, nodes))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -136,6 +197,7 @@ struct Bin<'a> {
|
||||
d: &'a [u8],
|
||||
/// 64-bit record offsets (version 7500 and later).
|
||||
wide: bool,
|
||||
budget: &'a Budget,
|
||||
}
|
||||
|
||||
impl Bin<'_> {
|
||||
@@ -207,7 +269,7 @@ impl Bin<'_> {
|
||||
}
|
||||
|
||||
fn prop(&self, pos: &mut usize, pend: usize) -> Result<Prop> {
|
||||
let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide };
|
||||
let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide, budget: self.budget };
|
||||
let t = sub.arr::<1>(pos)?[0];
|
||||
Ok(match t {
|
||||
b'Y' => Prop::Int(i16::from_le_bytes(sub.arr(pos)?) as i64),
|
||||
@@ -234,6 +296,9 @@ impl Bin<'_> {
|
||||
_ => 1,
|
||||
};
|
||||
let want = len.checked_mul(size).filter(|&n| n <= MAX_ARRAY_BYTES).ok_or_else(|| perr("FBX: array too large"))?;
|
||||
// Elements are kept as f64/i64 (booleans and 32-bit values widen), so the budget
|
||||
// is charged for what is kept, not for the wire bytes.
|
||||
sub.budget.take(len.checked_mul(8), "an array")?;
|
||||
let data = sub.take(pos, clen)?;
|
||||
let raw = match enc {
|
||||
0 => data.to_vec(),
|
||||
@@ -263,10 +328,10 @@ fn object_name(b: &[u8]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_binary(bytes: &[u8]) -> Result<(u32, Vec<FbxNode>)> {
|
||||
fn read_binary(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec<FbxNode>)> {
|
||||
let mut pos = BINARY_MAGIC.len();
|
||||
let version = Bin { d: bytes, wide: false }.u32(&mut pos)?;
|
||||
let bin = Bin { d: bytes, wide: version >= 7500 };
|
||||
let version = Bin { d: bytes, wide: false, budget }.u32(&mut pos)?;
|
||||
let bin = Bin { d: bytes, wide: version >= 7500, budget };
|
||||
let mut nodes = vec![];
|
||||
while pos < bytes.len() {
|
||||
match bin.record(&mut pos, 0) {
|
||||
@@ -728,7 +793,13 @@ fn short_name(full: &str) -> &str {
|
||||
|
||||
/// Parse an FBX file into a model. `resolve(uri)` reads texture files next to it.
|
||||
pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Model> {
|
||||
let (version, doc) = read(bytes)?;
|
||||
parse_within(bytes, resolve, MAX_IMPORT_BYTES)
|
||||
}
|
||||
|
||||
/// [`parse`] keeping at most `limit` bytes of arrays and meshes.
|
||||
fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>, limit: usize) -> Result<Model> {
|
||||
let budget = Budget::new(limit);
|
||||
let (version, doc) = read_within(bytes, &budget)?;
|
||||
if version != 0 && version < 7000 {
|
||||
return Err(ModelError::Unsupported(format!("FBX {}.{} (only FBX 7.x files are read)", version / 1000, version % 1000 / 100)));
|
||||
}
|
||||
@@ -835,19 +906,28 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
|
||||
Some(materials.len() - 1)
|
||||
};
|
||||
|
||||
// One mesh per Model with Mesh geometry (material slots are per Model).
|
||||
// One mesh per Model with Mesh geometry (material slots are per Model). Models instancing the
|
||||
// same geometry with the same materials share one mesh rather than each building a copy.
|
||||
let mut meshes: Vec<Mesh> = vec![];
|
||||
let mut mesh_of: HashMap<(i64, Vec<usize>), usize> = HashMap::new();
|
||||
for &id in &model_ids {
|
||||
let Some(&ni) = node_of.get(&id) else { continue };
|
||||
let Some(gid) = links.iter().find(|l| l.1 == id && kind(l.0) == Some("Geometry")).map(|l| l.0) else { continue };
|
||||
let Some(geom) = by_id.get(&gid) else { continue };
|
||||
let mats: Vec<usize> =
|
||||
links.iter().filter(|l| l.1 == id && kind(l.0) == Some("Material")).filter_map(|l| material_index(l.0, &mut materials, &mut textures)).collect();
|
||||
let normal_mapped: Vec<bool> = materials.iter().map(|m| m.normal_tex.is_some()).collect();
|
||||
let name = nodes.get(ni).map(|n| n.name.clone()).unwrap_or_default();
|
||||
let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue };
|
||||
meshes.push(mesh);
|
||||
let mesh_index = meshes.len() - 1;
|
||||
let mesh_index = match mesh_of.get(&(gid, mats.clone())) {
|
||||
Some(&i) => i,
|
||||
None => {
|
||||
let normal_mapped: Vec<bool> = materials.iter().map(|m| m.normal_tex.is_some()).collect();
|
||||
let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue };
|
||||
budget.take(mesh_bytes(&mesh), &format!("mesh `{name}`"))?;
|
||||
meshes.push(mesh);
|
||||
mesh_of.insert((gid, mats), meshes.len() - 1);
|
||||
meshes.len() - 1
|
||||
}
|
||||
};
|
||||
let geo = by_id.get(&id).and_then(|o| geometric(&Props70::of(o)));
|
||||
match geo {
|
||||
Some(matrix) => {
|
||||
|
||||
@@ -325,3 +325,87 @@ fn hostile_fbx_files_fail_cleanly() {
|
||||
assert!(matches!(parse(v6.as_bytes(), &|_| None), Err(ModelError::Unsupported(_))));
|
||||
assert!(parse(b"Objects: {\n Model: 1, \"Model::Empty\", \"Null\" {\n }\n}\n", &|_| None).is_err());
|
||||
}
|
||||
|
||||
/// A version-7400 binary FBX holding one top-level node `A` with uncompressed arrays of type
|
||||
/// `t` (`b'b'`, `b'i'`, `b'f'`, `b'd'`, `b'l'`) and `len` elements each.
|
||||
fn binary_arrays(arrays: &[(u8, usize)]) -> Vec<u8> {
|
||||
let mut props = vec![];
|
||||
for &(t, len) in arrays {
|
||||
let size = match t {
|
||||
b'd' | b'l' => 8,
|
||||
b'f' | b'i' => 4,
|
||||
_ => 1,
|
||||
};
|
||||
props.push(t);
|
||||
props.extend((len as u32).to_le_bytes());
|
||||
props.extend(0u32.to_le_bytes());
|
||||
props.extend(((len * size) as u32).to_le_bytes());
|
||||
props.extend(std::iter::repeat_n(1u8, len * size));
|
||||
}
|
||||
let mut out = BINARY_MAGIC.to_vec();
|
||||
out.extend(7400u32.to_le_bytes());
|
||||
let end = out.len() + 13 + 1 + props.len();
|
||||
out.extend((end as u32).to_le_bytes());
|
||||
out.extend((arrays.len() as u32).to_le_bytes());
|
||||
out.extend((props.len() as u32).to_le_bytes());
|
||||
out.push(1);
|
||||
out.push(b'A');
|
||||
out.extend(props);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fbx_arrays_are_charged_for_the_memory_they_keep() {
|
||||
// A boolean array is one byte per element on the wire but eight once kept as `Prop::Ints`:
|
||||
// the budget counts the eight (#1234).
|
||||
let n = 10_000;
|
||||
let bin = binary_arrays(&[(b'b', n)]);
|
||||
assert!(read_within(&bin, &Budget::new(n * 8)).is_ok());
|
||||
let e = read_within(&bin, &Budget::new(n * 8 - 1)).unwrap_err();
|
||||
assert!(e.to_string().contains("memory limit"), "{e}");
|
||||
// 32-bit floats widen to f64 the same way.
|
||||
assert!(read_within(&binary_arrays(&[(b'f', n)]), &Budget::new(n * 4)).is_err());
|
||||
// Several arrays each within the budget but not together.
|
||||
let bin = binary_arrays(&[(b'i', n), (b'd', n), (b'b', n)]);
|
||||
assert!(read_within(&bin, &Budget::new(n * 24)).is_ok());
|
||||
assert!(read_within(&bin, &Budget::new(n * 20)).is_err());
|
||||
// ASCII values count too.
|
||||
let (_, doc) = read(ASCII.as_bytes()).unwrap();
|
||||
let values = value_bytes(&doc).unwrap();
|
||||
assert!(values > 45 * std::mem::size_of::<Prop>(), "{values}");
|
||||
assert!(parse_within(ASCII.as_bytes(), &|_| None, values - 1).unwrap_err().to_string().contains("memory limit"));
|
||||
}
|
||||
|
||||
/// [`ASCII`] with two more Models instancing its geometry: `Copy` with the same two materials,
|
||||
/// `Red only` with just the first.
|
||||
fn instanced_scene() -> String {
|
||||
ASCII
|
||||
.replace(
|
||||
"\tMaterial: 400,",
|
||||
"\tModel: 301, \"Model::Copy\", \"Mesh\" {\n\t}\n\tModel: 302, \"Model::Red only\", \"Mesh\" {\n\t}\n\tMaterial: 400,",
|
||||
)
|
||||
.replace(
|
||||
"\tC: \"OO\",500,300\n",
|
||||
"\tC: \"OO\",500,300\n\tC: \"OO\",301,0\n\tC: \"OO\",100,301\n\tC: \"OO\",400,301\n\tC: \"OO\",500,301\n\tC: \"OO\",302,0\n\tC: \"OO\",100,302\n\tC: \"OO\",400,302\n",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fbx_models_instancing_one_geometry_share_its_mesh() {
|
||||
let m = parse(instanced_scene().as_bytes(), &|_| None).unwrap();
|
||||
let mesh_of = |name: &str| {
|
||||
let n = m.nodes.iter().position(|n| n.name == name).unwrap();
|
||||
// The mesh sits on the node, or on its geometric-offset child.
|
||||
m.nodes[n].mesh.or_else(|| m.nodes[n].children.iter().find_map(|&c| m.nodes[c].mesh)).unwrap()
|
||||
};
|
||||
assert_eq!(mesh_of("Shape"), mesh_of("Copy"), "same geometry and materials: one mesh");
|
||||
assert_ne!(mesh_of("Shape"), mesh_of("Red only"), "different materials: its own mesh");
|
||||
assert_eq!(m.meshes.len(), 2);
|
||||
// A budget with room for one copy of the geometry's mesh, but not three, still imports.
|
||||
let one = parse(ASCII.as_bytes(), &|_| None).unwrap();
|
||||
let mesh = mesh_bytes(&one.meshes[0]).unwrap();
|
||||
let (_, doc) = read(instanced_scene().as_bytes()).unwrap();
|
||||
let values = value_bytes(&doc).unwrap();
|
||||
assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + 2 * mesh).is_ok());
|
||||
assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + mesh).is_err());
|
||||
}
|
||||
+65
-22
@@ -39,13 +39,15 @@ pub fn split_glb(b: &[u8]) -> Result<(&[u8], Option<&[u8]>)> {
|
||||
while o + 8 <= total {
|
||||
let len = u32_at(o).unwrap_or(0) as usize;
|
||||
let ty = u32_at(o + 4).unwrap_or(0);
|
||||
let data = b.get(o + 8..o + 8 + len).ok_or_else(|| perr("GLB chunk runs past the end of the file"))?;
|
||||
let past_end = || perr("GLB chunk runs past the end of the file");
|
||||
let end = (o + 8).checked_add(len).ok_or_else(past_end)?;
|
||||
let data = b.get(o + 8..end).ok_or_else(past_end)?;
|
||||
match ty {
|
||||
CHUNK_JSON if json.is_none() => json = Some(data),
|
||||
CHUNK_BIN if bin.is_none() => bin = Some(data),
|
||||
_ => {}
|
||||
}
|
||||
o += 8 + len;
|
||||
o = end;
|
||||
}
|
||||
Ok((json.ok_or_else(|| perr("GLB without a JSON chunk"))?, bin))
|
||||
}
|
||||
@@ -115,8 +117,25 @@ struct Doc<'a> {
|
||||
fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] {
|
||||
v.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[])
|
||||
}
|
||||
/// A non-negative integer field; one too large for `usize` counts as absent (#1235).
|
||||
fn idx(v: &Value, k: &str) -> Option<usize> {
|
||||
v.get(k).and_then(Value::as_u64).map(|x| x as usize)
|
||||
v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok())
|
||||
}
|
||||
/// Node indices in an array field (out-of-range ones are dropped).
|
||||
fn indices(v: &Value, k: &str) -> Vec<usize> {
|
||||
arr(v, k).iter().filter_map(Value::as_u64).filter_map(|c| usize::try_from(c).ok()).collect()
|
||||
}
|
||||
|
||||
/// The most components an accessor without a buffer view (all zeros) may have: nothing in the
|
||||
/// file backs its count, so it is capped instead of checked against a buffer (#1235).
|
||||
const MAX_ZERO_COMPONENTS: usize = 1 << 24;
|
||||
|
||||
/// Where an accessor's elements lie in its buffer: `count` elements of `elem` bytes, `stride`
|
||||
/// apart from `base`, all checked to be inside `buf`.
|
||||
struct Span<'b> {
|
||||
buf: &'b [u8],
|
||||
base: usize,
|
||||
stride: usize,
|
||||
}
|
||||
fn num(v: &Value, k: &str, d: f64) -> f64 {
|
||||
v.get(k).and_then(Value::as_f64).unwrap_or(d)
|
||||
@@ -132,6 +151,39 @@ fn nums<const N: usize>(v: &Value, k: &str, d: [f64; N]) -> [f64; N] {
|
||||
}
|
||||
|
||||
impl Doc<'_> {
|
||||
/// The accessor's element count and total components (`count * n`), checked; and its span in
|
||||
/// the buffer, validated before anything is allocated from the count. `None` for an
|
||||
/// accessor without a buffer view, whose components are all zero.
|
||||
fn layout(&self, acc: &Value, n: usize, csize: usize) -> Result<(usize, usize, Option<Span<'_>>)> {
|
||||
let count = match acc.get("count") {
|
||||
None => 0,
|
||||
Some(c) => c.as_u64().and_then(|c| usize::try_from(c).ok()).ok_or_else(|| perr("accessor count is not a valid size"))?,
|
||||
};
|
||||
let total = count.checked_mul(n).ok_or_else(|| perr("accessor count is too large"))?;
|
||||
let Some(bv) = idx(acc, "bufferView") else {
|
||||
if total > MAX_ZERO_COMPONENTS {
|
||||
return Err(perr(format!("accessor without a bufferView has {total} components (at most {MAX_ZERO_COMPONENTS})")));
|
||||
}
|
||||
return Ok((count, total, None));
|
||||
};
|
||||
let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?;
|
||||
let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?;
|
||||
let past = || perr("accessor reads past its buffer");
|
||||
let base = idx(view, "byteOffset").unwrap_or(0).checked_add(idx(acc, "byteOffset").unwrap_or(0)).ok_or_else(past)?;
|
||||
let elem = csize * n;
|
||||
let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem);
|
||||
if stride < elem {
|
||||
return Err(perr(format!("bufferView byteStride {stride} is smaller than its {elem}-byte elements")));
|
||||
}
|
||||
if count > 0 {
|
||||
let end = (count - 1).checked_mul(stride).and_then(|o| o.checked_add(base)).and_then(|o| o.checked_add(elem)).ok_or_else(past)?;
|
||||
if end > buf.len() {
|
||||
return Err(past());
|
||||
}
|
||||
}
|
||||
Ok((count, total, Some(Span { buf, base, stride })))
|
||||
}
|
||||
|
||||
/// An accessor as `f32` rows of `n` components (normalised integers mapped to 0..1 / -1..1).
|
||||
fn floats(&self, a: usize) -> Result<(Vec<f32>, usize)> {
|
||||
let acc = arr(self.j, "accessors").get(a).ok_or_else(|| perr(format!("no accessor {a}")))?;
|
||||
@@ -145,7 +197,6 @@ impl Doc<'_> {
|
||||
"MAT4" => 16,
|
||||
t => return Err(perr(format!("accessor type `{t}`"))),
|
||||
};
|
||||
let count = idx(acc, "count").unwrap_or(0);
|
||||
let ct = idx(acc, "componentType").unwrap_or(5126);
|
||||
let norm = acc.get("normalized").and_then(Value::as_bool).unwrap_or(false);
|
||||
let csize = match ct {
|
||||
@@ -154,17 +205,13 @@ impl Doc<'_> {
|
||||
5125 | 5126 => 4,
|
||||
_ => return Err(perr(format!("componentType {ct}"))),
|
||||
};
|
||||
let mut out = vec![0.0f32; count * n];
|
||||
let Some(bv) = idx(acc, "bufferView") else {
|
||||
let (count, total, span) = self.layout(acc, n, csize)?;
|
||||
let mut out = vec![0.0f32; total];
|
||||
let Some(Span { buf, base, stride }) = span else {
|
||||
// No buffer view: all zeros (sparse substitution is not supported).
|
||||
return Ok((out, n));
|
||||
};
|
||||
let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?;
|
||||
let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?;
|
||||
let base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0);
|
||||
let elem = csize * n;
|
||||
// Matrices of 1/2-byte components pad columns to 4 bytes; ignored (rare).
|
||||
let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem);
|
||||
for i in 0..count {
|
||||
for c in 0..n {
|
||||
let o = base + i * stride + c * csize;
|
||||
@@ -207,18 +254,14 @@ impl Doc<'_> {
|
||||
"VEC2" => 2,
|
||||
_ => 1,
|
||||
};
|
||||
let count = idx(acc, "count").unwrap_or(0);
|
||||
let csize = match ct {
|
||||
5120 | 5121 => 1,
|
||||
5122 | 5123 => 2,
|
||||
_ => 4,
|
||||
};
|
||||
let Some(bv) = idx(acc, "bufferView") else { return Ok(vec![0; count * n]) };
|
||||
let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?;
|
||||
let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?;
|
||||
let base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0);
|
||||
let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(csize * n);
|
||||
let mut out = Vec::with_capacity(count * n);
|
||||
let (count, total, span) = self.layout(acc, n, csize)?;
|
||||
let Some(Span { buf, base, stride }) = span else { return Ok(vec![0; total]) };
|
||||
let mut out = Vec::with_capacity(total);
|
||||
for i in 0..count {
|
||||
for c in 0..n {
|
||||
let o = base + i * stride + c * csize;
|
||||
@@ -268,7 +311,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
|
||||
arr(&j, "bufferViews").get(bv).and_then(|v| {
|
||||
let b = d.buffers.get(idx(v, "buffer").unwrap_or(0))?;
|
||||
let o = idx(v, "byteOffset").unwrap_or(0);
|
||||
b.get(o..o + idx(v, "byteLength").unwrap_or(0)).map(<[u8]>::to_vec)
|
||||
b.get(o..o.checked_add(idx(v, "byteLength").unwrap_or(0))?).map(<[u8]>::to_vec)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -366,13 +409,13 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
|
||||
skin: idx(n, "skin"),
|
||||
camera: idx(n, "camera"),
|
||||
light: n.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).and_then(|e| idx(e, "light")),
|
||||
children: arr(n, "children").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(),
|
||||
children: indices(n, "children"),
|
||||
});
|
||||
}
|
||||
|
||||
let scene = idx(&j, "scene").unwrap_or(0);
|
||||
let roots: Vec<usize> = match arr(&j, "scenes").get(scene) {
|
||||
Some(s) => arr(s, "nodes").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(),
|
||||
Some(s) => indices(s, "nodes"),
|
||||
None => {
|
||||
// No scene: every node that is nobody's child.
|
||||
let mut child = vec![false; nodes.len()];
|
||||
@@ -389,7 +432,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
|
||||
|
||||
let mut skins = vec![];
|
||||
for s in arr(&j, "skins") {
|
||||
let joints: Vec<usize> = arr(s, "joints").iter().filter_map(Value::as_u64).map(|c| c as usize).collect();
|
||||
let joints: Vec<usize> = indices(s, "joints");
|
||||
let inverse_bind = match idx(s, "inverseBindMatrices") {
|
||||
Some(a) => {
|
||||
let (f, _) = d.floats(a)?;
|
||||
|
||||
@@ -492,3 +492,75 @@ fn stl_hostile_input_is_an_error() {
|
||||
let m = stl::parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0)])).unwrap();
|
||||
assert_eq!(m.meshes[0].primitives[0].normals[0], [1.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
/// A one-triangle glTF whose POSITION is accessor 0, over a buffer of `bytes` zero bytes.
|
||||
fn gltf_doc(accessors: &str, views: &str, bytes: usize) -> String {
|
||||
let b64 = "A".repeat(bytes / 3 * 4) + ["", "AA==", "AAA="][bytes % 3];
|
||||
format!(
|
||||
r#"{{"asset": {{"version": "2.0"}}, "scenes": [{{"nodes": [0]}}], "nodes": [{{"mesh": 0}}],
|
||||
"meshes": [{{"primitives": [{{"attributes": {{"POSITION": 0}}}}]}}],
|
||||
"buffers": [{{"byteLength": {bytes}, "uri": "data:application/octet-stream;base64,{b64}"}}],
|
||||
"bufferViews": [{views}], "accessors": [{accessors}]}}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gltf_accessor_counts_are_checked_against_the_buffer_before_allocating() {
|
||||
// Untrusted counts, offsets and strides must fail the import, not allocate from the count or
|
||||
// overflow (#1235).
|
||||
let view = r#"{"buffer": 0, "byteLength": 36}"#;
|
||||
let cases = [
|
||||
// A count far beyond the 36-byte buffer.
|
||||
(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 4000000000}"#, view, "past its buffer"),
|
||||
// count * components overflows.
|
||||
(r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 9223372036854775807}"#, view, "too large"),
|
||||
// Offsets that overflow when added.
|
||||
(
|
||||
r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "byteOffset": 16}"#,
|
||||
r#"{"buffer": 0, "byteOffset": 18446744073709551610}"#,
|
||||
"past its buffer",
|
||||
),
|
||||
// A stride that overflows with the count.
|
||||
(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3000000000}"#, r#"{"buffer": 0, "byteStride": 252}"#, "past its buffer"),
|
||||
// A stride smaller than an element would re-read the same bytes for a huge count.
|
||||
(r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 30}"#, r#"{"buffer": 0, "byteStride": 1}"#, "smaller than"),
|
||||
// No buffer view: nothing backs the count, so it is capped.
|
||||
(r#"{"componentType": 5126, "type": "VEC3", "count": 1000000000}"#, view, "without a bufferView"),
|
||||
];
|
||||
let t0 = std::time::Instant::now();
|
||||
for (acc, v, want) in cases {
|
||||
let doc = gltf_doc(acc, v, 36);
|
||||
let e = crate::gltf::parse(doc.as_bytes(), &|_| None).err().unwrap_or_else(|| panic!("{acc} imported"));
|
||||
assert!(e.to_string().contains(want), "{acc}: {e}");
|
||||
}
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gltf_integer_accessors_are_checked_too() {
|
||||
let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteLength": 36}"#, 36)
|
||||
.replace(r#""attributes": {"POSITION": 0}"#, r#""attributes": {"POSITION": 0}, "indices": 1"#);
|
||||
for idx in [
|
||||
r#"{"bufferView": 0, "componentType": 5125, "type": "SCALAR", "count": 4000000000}"#,
|
||||
r#"{"componentType": 5125, "type": "SCALAR", "count": 4000000000}"#,
|
||||
r#"{"bufferView": 0, "componentType": 5125, "type": "VEC4", "count": 9223372036854775807}"#,
|
||||
] {
|
||||
let doc = doc.replace(r#""count": 3}]"#, &format!(r#""count": 3}}, {idx}]"#));
|
||||
assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err(), "{idx}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gltf_strided_and_buffer_less_accessors_still_import() {
|
||||
// Interleaved positions (stride 24) ending exactly at the buffer's end, and a small all-zero
|
||||
// accessor without a view.
|
||||
let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 60);
|
||||
let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap();
|
||||
assert_eq!(m.meshes[0].primitives[0].positions.len(), 3);
|
||||
let doc = gltf_doc(r#"{"componentType": 5126, "type": "VEC3", "count": 6}"#, r#"{"buffer": 0}"#, 3);
|
||||
let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap();
|
||||
assert_eq!(m.meshes[0].primitives[0].positions.len(), 6);
|
||||
// One byte short of the third element is an error.
|
||||
let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 59);
|
||||
assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err());
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape, Stroke};
|
||||
|
||||
/// The most elements a path being built holds: an operator that would add more stops the
|
||||
/// PostScript program, or the PDF content stream.
|
||||
pub(crate) const MAX_PATH: usize = 1 << 20;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Plain,
|
||||
|
||||
@@ -12,7 +12,7 @@ use effectcraft_svg::{
|
||||
};
|
||||
use kurbo::{Point, Shape as _};
|
||||
|
||||
use crate::build::Builder;
|
||||
use crate::build::{Builder, MAX_PATH};
|
||||
use crate::color::{Cs, Func, color_space, text_string};
|
||||
use crate::font::Font;
|
||||
use crate::object::{Dict, File, Lexer, Obj, decode_stream};
|
||||
@@ -281,7 +281,12 @@ impl<'a> Interp<'a> {
|
||||
self.ext_gstate(&g, res, depth);
|
||||
}
|
||||
}
|
||||
// Path construction.
|
||||
// Path construction. Each operator adds at most five elements (`re`), so when fewer
|
||||
// than five more fit within `MAX_PATH`, the operator stops the stream.
|
||||
"m" | "l" | "c" | "v" | "y" | "h" | "re" if self.path.elements().len() > MAX_PATH - 5 => {
|
||||
self.b.skip("content (path limit)");
|
||||
return;
|
||||
}
|
||||
"m" => {
|
||||
let p = Point::new(n(0), n(1));
|
||||
self.path.move_to(p);
|
||||
|
||||
+123
-51
@@ -14,7 +14,7 @@ use std::rc::Rc;
|
||||
use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke};
|
||||
use kurbo::{PathEl, Point};
|
||||
|
||||
use crate::build::Builder;
|
||||
use crate::build::{Builder, MAX_PATH};
|
||||
use crate::font::Font;
|
||||
use crate::object::{ascii85, is_white, parse_num};
|
||||
|
||||
@@ -441,7 +441,14 @@ impl Interp {
|
||||
break;
|
||||
}
|
||||
}
|
||||
other => self.push(other.clone()),
|
||||
// Other items count against the budget as names do.
|
||||
other => {
|
||||
r = self.spend(1);
|
||||
if r.is_err() {
|
||||
break;
|
||||
}
|
||||
self.push(other.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
self.calls -= 1;
|
||||
@@ -456,11 +463,7 @@ impl Interp {
|
||||
}
|
||||
|
||||
fn exec_name(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> {
|
||||
if self.budget == 0 {
|
||||
self.b.skip("PostScript (operation limit)");
|
||||
return Err(Flow::Quit);
|
||||
}
|
||||
self.budget -= 1;
|
||||
self.spend(1)?;
|
||||
if let Some(v) = self.lookup(n) {
|
||||
return match v {
|
||||
V::Proc(_) => self.exec_obj(v, src),
|
||||
@@ -473,6 +476,21 @@ impl Interp {
|
||||
self.builtin(n, src)
|
||||
}
|
||||
|
||||
/// Count `n` operations against the budget, and stop the program when fewer are left. An
|
||||
/// operator that makes, reads or copies path elements counts each one as an operation.
|
||||
fn spend(&mut self, n: usize) -> Result<(), Flow> {
|
||||
match self.budget.checked_sub(n) {
|
||||
Some(left) => {
|
||||
self.budget = left;
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
self.b.skip("PostScript (operation limit)");
|
||||
Err(Flow::Quit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn device_scale(&self) -> f64 {
|
||||
self.gs.ctm.determinant().abs().sqrt()
|
||||
}
|
||||
@@ -483,13 +501,34 @@ impl Interp {
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
fn move_to(&mut self, p: Point) {
|
||||
/// Stop the program when a path of `len` elements would be longer than [`MAX_PATH`].
|
||||
fn path_fits(&mut self, len: usize) -> Result<(), Flow> {
|
||||
if len <= MAX_PATH {
|
||||
return Ok(());
|
||||
}
|
||||
self.b.skip("PostScript (path limit)");
|
||||
Err(Flow::Quit)
|
||||
}
|
||||
|
||||
/// [`Self::path_fits`] for the current path with `n` more elements.
|
||||
fn room(&mut self, n: usize) -> Result<(), Flow> {
|
||||
self.path_fits(self.gs.path.elements().len().saturating_add(n))
|
||||
}
|
||||
|
||||
fn move_to(&mut self, p: Point) -> Result<(), Flow> {
|
||||
// A moveto right after a moveto replaces it (PostScript Language Reference §8.2, `moveto`).
|
||||
if matches!(self.gs.path.elements().last(), Some(PathEl::MoveTo(_))) {
|
||||
self.gs.path.pop();
|
||||
}
|
||||
self.room(1)?;
|
||||
let q = self.gs.ctm * p;
|
||||
self.gs.path.move_to(q);
|
||||
self.gs.cur = Some(q);
|
||||
self.gs.start = Some(q);
|
||||
Ok(())
|
||||
}
|
||||
fn line_to(&mut self, p: Point) {
|
||||
fn line_to(&mut self, p: Point) -> Result<(), Flow> {
|
||||
self.room(1 + usize::from(self.gs.cur.is_none()))?;
|
||||
let q = self.gs.ctm * p;
|
||||
if self.gs.cur.is_none() {
|
||||
self.gs.path.move_to(q);
|
||||
@@ -497,20 +536,23 @@ impl Interp {
|
||||
}
|
||||
self.gs.path.line_to(q);
|
||||
self.gs.cur = Some(q);
|
||||
Ok(())
|
||||
}
|
||||
fn curve_to(&mut self, a: Point, b: Point, c: Point) {
|
||||
fn curve_to(&mut self, a: Point, b: Point, c: Point) -> Result<(), Flow> {
|
||||
self.room(1 + usize::from(self.gs.cur.is_none()))?;
|
||||
let m = self.gs.ctm;
|
||||
if self.gs.cur.is_none() {
|
||||
self.gs.path.move_to(m * a);
|
||||
}
|
||||
self.gs.path.curve_to(m * a, m * b, m * c);
|
||||
self.gs.cur = Some(m * c);
|
||||
Ok(())
|
||||
}
|
||||
fn current_user(&self) -> Point {
|
||||
self.gs.ctm.inverse() * self.gs.cur.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn arc(&mut self, neg: bool) {
|
||||
fn arc(&mut self, neg: bool) -> Result<(), Flow> {
|
||||
let a2 = self.popn();
|
||||
let a1 = self.popn();
|
||||
let r = self.popn();
|
||||
@@ -540,16 +582,23 @@ impl Interp {
|
||||
let (a1, sweep) = ((a1 % 360.0).to_radians(), sweep.to_radians());
|
||||
let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin());
|
||||
if self.gs.cur.is_some() {
|
||||
self.line_to(start);
|
||||
self.line_to(start)?;
|
||||
} else {
|
||||
self.move_to(start);
|
||||
self.move_to(start)?;
|
||||
}
|
||||
let arc = kurbo::Arc::new(c, (r, r), a1, sweep, 0.0);
|
||||
let m = self.gs.ctm;
|
||||
arc.to_cubic_beziers(0.1, |p1, p2, p3| {
|
||||
// kurbo divides an arc into more curves the larger the radius (about 3.7 × 10^49 for a
|
||||
// quarter turn at a radius of 10^300, more than a usize can count), so each one counts
|
||||
// against the budget and the path limit.
|
||||
let mut curves = arc.append_iter(0.1);
|
||||
while let Some(PathEl::CurveTo(p1, p2, p3)) = curves.next() {
|
||||
self.room(1)?;
|
||||
self.spend(1)?;
|
||||
self.gs.path.curve_to(m * p1, m * p2, m * p3);
|
||||
self.gs.cur = Some(m * p3);
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn paint(&mut self, fill: Option<FillRule>, stroke: bool) {
|
||||
@@ -569,11 +618,16 @@ impl Interp {
|
||||
self.b.fill_stroke(path, Affine::IDENTITY, fill.map(|r| (Paint::Color(self.gs.color), 1.0, r)), st);
|
||||
}
|
||||
|
||||
fn rect_path(&mut self) -> Vec<BezPath> {
|
||||
fn rect_path(&mut self) -> Result<Vec<BezPath>, Flow> {
|
||||
// x y w h, or an array / string of numbers.
|
||||
let top = self.pop();
|
||||
let nums: Vec<f64> = match &top {
|
||||
V::Array(_) | V::Proc(_) => top.items().unwrap_or_default().iter().filter_map(V::num).collect(),
|
||||
V::Array(_) | V::Proc(_) => {
|
||||
let items = top.items().unwrap_or_default();
|
||||
// An array's rectangles count against the budget, five path elements each.
|
||||
self.spend(items.len() / 4 * 5)?;
|
||||
items.iter().filter_map(V::num).collect()
|
||||
}
|
||||
V::Num(h) => {
|
||||
let w = self.popn();
|
||||
let y = self.popn();
|
||||
@@ -582,8 +636,10 @@ impl Interp {
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
self.path_fits(nums.len() / 4 * 5)?;
|
||||
let m = self.gs.ctm;
|
||||
nums.as_chunks::<4>()
|
||||
Ok(nums
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|r| {
|
||||
@@ -595,7 +651,7 @@ impl Interp {
|
||||
p.close_path();
|
||||
p
|
||||
})
|
||||
.collect()
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn grestore(&mut self) {
|
||||
@@ -799,10 +855,12 @@ impl Interp {
|
||||
let c = matches!(self.pop(), V::Bool(true));
|
||||
self.exec_obj(if c { t } else { f }, src)?;
|
||||
}
|
||||
// Each pass of a loop counts against the budget.
|
||||
"repeat" => {
|
||||
let p = self.pop();
|
||||
let k = self.popn().clamp(0.0, 1e6) as usize;
|
||||
for _ in 0..k {
|
||||
self.spend(1)?;
|
||||
match self.exec_obj(p.clone(), src) {
|
||||
Err(Flow::Exit) => break,
|
||||
r => r?,
|
||||
@@ -816,6 +874,7 @@ impl Interp {
|
||||
let mut i = self.popn();
|
||||
let mut guard = 0;
|
||||
while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 {
|
||||
self.spend(1)?;
|
||||
self.push(V::Num(i));
|
||||
match self.exec_obj(p.clone(), src) {
|
||||
Err(Flow::Exit) => break,
|
||||
@@ -828,6 +887,7 @@ impl Interp {
|
||||
"loop" => {
|
||||
let p = self.pop();
|
||||
for _ in 0..1_000_000 {
|
||||
self.spend(1)?;
|
||||
match self.exec_obj(p.clone(), src) {
|
||||
Err(Flow::Exit) => break,
|
||||
r => r?,
|
||||
@@ -840,6 +900,7 @@ impl Interp {
|
||||
V::Dict(d) => {
|
||||
let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
for (k, v) in items {
|
||||
self.spend(1)?;
|
||||
self.push(V::Lit(k.into()));
|
||||
self.push(v);
|
||||
match self.exec_obj(p.clone(), src) {
|
||||
@@ -851,6 +912,7 @@ impl Interp {
|
||||
V::Str(s) => {
|
||||
let bytes = s.borrow().clone();
|
||||
for c in bytes {
|
||||
self.spend(1)?;
|
||||
self.push(V::Num(c as f64));
|
||||
match self.exec_obj(p.clone(), src) {
|
||||
Err(Flow::Exit) => break,
|
||||
@@ -860,6 +922,7 @@ impl Interp {
|
||||
}
|
||||
a => {
|
||||
for v in a.items().unwrap_or_default() {
|
||||
self.spend(1)?;
|
||||
self.push(v);
|
||||
match self.exec_obj(p.clone(), src) {
|
||||
Err(Flow::Exit) => break,
|
||||
@@ -1117,6 +1180,7 @@ impl Interp {
|
||||
"languagelevel" => self.push(V::Num(3.0)),
|
||||
"version" | "product" => self.push(V::Str(Rc::new(RefCell::new(b"3010".to_vec())))),
|
||||
"save" => {
|
||||
self.spend(self.gs.path.elements().len())?;
|
||||
self.saved.push(self.gs.clone());
|
||||
self.gs.depth = self.b.depth();
|
||||
self.push(V::Null);
|
||||
@@ -1146,6 +1210,7 @@ impl Interp {
|
||||
}
|
||||
// Graphics state.
|
||||
"gsave" => {
|
||||
self.spend(self.gs.path.elements().len())?;
|
||||
let mut g = self.gs.clone();
|
||||
g.depth = self.b.depth();
|
||||
self.saved.push(g);
|
||||
@@ -1306,21 +1371,21 @@ impl Interp {
|
||||
}
|
||||
"moveto" => {
|
||||
let p = self.user_point();
|
||||
self.move_to(p);
|
||||
self.move_to(p)?;
|
||||
}
|
||||
"rmoveto" => {
|
||||
let d = self.user_point();
|
||||
let c = self.current_user();
|
||||
self.move_to(Point::new(c.x + d.x, c.y + d.y));
|
||||
self.move_to(Point::new(c.x + d.x, c.y + d.y))?;
|
||||
}
|
||||
"lineto" => {
|
||||
let p = self.user_point();
|
||||
self.line_to(p);
|
||||
self.line_to(p)?;
|
||||
}
|
||||
"rlineto" => {
|
||||
let d = self.user_point();
|
||||
let c = self.current_user();
|
||||
self.line_to(Point::new(c.x + d.x, c.y + d.y));
|
||||
self.line_to(Point::new(c.x + d.x, c.y + d.y))?;
|
||||
}
|
||||
"curveto" | "rcurveto" => {
|
||||
let p3 = self.user_point();
|
||||
@@ -1329,25 +1394,27 @@ impl Interp {
|
||||
if n == "rcurveto" {
|
||||
let c = self.current_user();
|
||||
let o = |p: Point| Point::new(c.x + p.x, c.y + p.y);
|
||||
self.curve_to(o(p1), o(p2), o(p3));
|
||||
self.curve_to(o(p1), o(p2), o(p3))?;
|
||||
} else {
|
||||
self.curve_to(p1, p2, p3);
|
||||
self.curve_to(p1, p2, p3)?;
|
||||
}
|
||||
}
|
||||
"closepath" => {
|
||||
if self.gs.cur.is_some() {
|
||||
self.room(1)?;
|
||||
self.gs.path.close_path();
|
||||
self.gs.cur = self.gs.start;
|
||||
}
|
||||
}
|
||||
"arc" => self.arc(false),
|
||||
"arcn" => self.arc(true),
|
||||
"arc" => self.arc(false)?,
|
||||
"arcn" => self.arc(true)?,
|
||||
"currentpoint" => {
|
||||
let c = self.current_user();
|
||||
self.push(V::Num(c.x));
|
||||
self.push(V::Num(c.y));
|
||||
}
|
||||
"pathbbox" => {
|
||||
self.spend(self.gs.path.elements().len())?;
|
||||
let r = kurbo::Shape::bounding_box(&self.gs.path);
|
||||
let inv = self.gs.ctm.inverse();
|
||||
let (a, b) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1));
|
||||
@@ -1366,13 +1433,14 @@ impl Interp {
|
||||
"stroke" => self.paint(None, true),
|
||||
"clip" | "eoclip" => {
|
||||
let rule = if n == "clip" { FillRule::NonZero } else { FillRule::EvenOdd };
|
||||
self.spend(self.gs.path.elements().len())?;
|
||||
let p = self.gs.path.clone();
|
||||
if !p.elements().iter().all(|e| matches!(e, PathEl::MoveTo(_))) {
|
||||
self.b.clip(p, rule);
|
||||
}
|
||||
}
|
||||
"rectfill" | "rectstroke" | "rectclip" => {
|
||||
let paths = self.rect_path();
|
||||
let paths = self.rect_path()?;
|
||||
if n == "rectstroke" && matches!(self.st.last(), Some(V::Array(_))) {
|
||||
self.pop();
|
||||
}
|
||||
@@ -1607,9 +1675,9 @@ impl Interp {
|
||||
/// A string's glyph outlines (device space) from the current point, with an extra
|
||||
/// displacement `extra(index, code)` (user space) after each glyph; moves the current
|
||||
/// point.
|
||||
fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Option<(BezPath, String)> {
|
||||
let d = self.gs.font.clone()?;
|
||||
let (font, m) = self.font_of(&d)?;
|
||||
fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result<Option<(BezPath, String)>, Flow> {
|
||||
let Some(d) = self.gs.font.clone() else { return Ok(None) };
|
||||
let Some((font, m)) = self.font_of(&d) else { return Ok(None) };
|
||||
let mut p = self.current_user();
|
||||
let mut path = BezPath::new();
|
||||
let mut text = String::new();
|
||||
@@ -1624,8 +1692,8 @@ impl Interp {
|
||||
let (ex, ey) = extra(i, code);
|
||||
p += adv + kurbo::Vec2::new(ex, ey);
|
||||
}
|
||||
self.move_to(p);
|
||||
Some((path, text))
|
||||
self.move_to(p)?;
|
||||
Ok(Some((path, text)))
|
||||
}
|
||||
|
||||
fn fill_text(&mut self, path: BezPath, text: &str) {
|
||||
@@ -1641,11 +1709,12 @@ impl Interp {
|
||||
}
|
||||
}
|
||||
|
||||
fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) {
|
||||
match self.text_path(s, extra) {
|
||||
fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result<(), Flow> {
|
||||
match self.text_path(s, extra)? {
|
||||
Some((path, text)) => self.fill_text(path, &text),
|
||||
None => self.b.skip("text (no font)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Text and font operators (PostScript Language Reference §5.1, §8.2).
|
||||
@@ -1703,20 +1772,20 @@ impl Interp {
|
||||
"StandardEncoding" | "ISOLatin1Encoding" => self.push(encoding_array(n)),
|
||||
"show" => {
|
||||
let s = self.pop_string();
|
||||
self.show_with(&s, |_, _| (0.0, 0.0));
|
||||
self.show_with(&s, |_, _| (0.0, 0.0))?;
|
||||
}
|
||||
"ashow" => {
|
||||
let s = self.pop_string();
|
||||
let ay = self.popn();
|
||||
let ax = self.popn();
|
||||
self.show_with(&s, |_, _| (ax, ay));
|
||||
self.show_with(&s, |_, _| (ax, ay))?;
|
||||
}
|
||||
"widthshow" => {
|
||||
let s = self.pop_string();
|
||||
let ch = self.popn() as u32;
|
||||
let cy = self.popn();
|
||||
let cx = self.popn();
|
||||
self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) });
|
||||
self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) })?;
|
||||
}
|
||||
"awidthshow" => {
|
||||
let s = self.pop_string();
|
||||
@@ -1725,18 +1794,18 @@ impl Interp {
|
||||
let ch = self.popn() as u32;
|
||||
let cy = self.popn();
|
||||
let cx = self.popn();
|
||||
self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) });
|
||||
self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) })?;
|
||||
}
|
||||
"kshow" | "cshow" => {
|
||||
// The procedure between characters is not run.
|
||||
let s = self.pop_string();
|
||||
self.pop();
|
||||
self.show_with(&s, |_, _| (0.0, 0.0));
|
||||
self.show_with(&s, |_, _| (0.0, 0.0))?;
|
||||
}
|
||||
"xshow" | "yshow" | "xyshow" => {
|
||||
let disp: Vec<f64> = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect();
|
||||
let s = self.pop_string();
|
||||
self.explicit_show(&s, &disp, n);
|
||||
self.explicit_show(&s, &disp, n)?;
|
||||
}
|
||||
"glyphshow" => {
|
||||
let name = self.pop().key();
|
||||
@@ -1747,7 +1816,7 @@ impl Interp {
|
||||
let tmp = transformed(&d, Affine::IDENTITY);
|
||||
tmp.borrow_mut().insert("Encoding".into(), array(e));
|
||||
self.gs.font = Some(tmp);
|
||||
self.show_with(b"A", |_, _| (0.0, 0.0));
|
||||
self.show_with(b"A", |_, _| (0.0, 0.0))?;
|
||||
self.gs.font = Some(d);
|
||||
}
|
||||
"charpath" => {
|
||||
@@ -1755,14 +1824,17 @@ impl Interp {
|
||||
let s = self.pop_string();
|
||||
let saved = std::mem::take(&mut self.gs.path);
|
||||
let start = self.gs.start;
|
||||
match self.text_path(&s, |_, _| (0.0, 0.0)) {
|
||||
match self.text_path(&s, |_, _| (0.0, 0.0))? {
|
||||
Some((glyphs, _)) => {
|
||||
let mut p = saved;
|
||||
p.extend(glyphs);
|
||||
// The outlines join the path: each element counts against the budget and
|
||||
// the path limit.
|
||||
self.gs.path = saved;
|
||||
self.room(glyphs.elements().len() + 1)?;
|
||||
self.spend(glyphs.elements().len())?;
|
||||
self.gs.path.extend(glyphs);
|
||||
if let Some(c) = self.gs.cur {
|
||||
p.move_to(c);
|
||||
self.gs.path.move_to(c);
|
||||
}
|
||||
self.gs.path = p;
|
||||
self.gs.start = start;
|
||||
}
|
||||
None => self.gs.path = saved,
|
||||
@@ -1812,7 +1884,7 @@ impl Interp {
|
||||
|
||||
/// `xshow` / `yshow` / `xyshow`: glyph displacements from a number array instead of the
|
||||
/// advances.
|
||||
fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) {
|
||||
fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) -> Result<(), Flow> {
|
||||
let step = if n == "xyshow" { 2 } else { 1 };
|
||||
let d: Vec<(f64, f64)> = (0..s.len())
|
||||
.map(|k| {
|
||||
@@ -1827,13 +1899,13 @@ impl Interp {
|
||||
.collect();
|
||||
let Some(fd) = self.gs.font.clone() else {
|
||||
self.b.skip("text (no font)");
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
let Some((font, m)) = self.font_of(&fd) else { return };
|
||||
let Some((font, m)) = self.font_of(&fd) else { return Ok(()) };
|
||||
self.show_with(s, move |k, c| {
|
||||
let adv = m * Point::new(font.width(c), 0.0) - m * Point::ZERO;
|
||||
let (dx, dy) = d.get(k).copied().unwrap_or((0.0, 0.0));
|
||||
(dx - adv.x, dy - adv.y)
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -396,6 +396,100 @@ fn arcs_with_huge_angles_end_at_their_second_angle() {
|
||||
assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50));
|
||||
}
|
||||
|
||||
/// Parses `body` after a square through [`eps_soon`]: the square stays, and the program stops at
|
||||
/// the operation limit.
|
||||
fn runs_out_of_budget(body: &str) {
|
||||
let doc = eps_soon(&format!("0 0 10 10 rectfill {body}"));
|
||||
assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped);
|
||||
assert_eq!(doc.skipped, ["PostScript (operation limit)"], "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_made_in_a_loop_run_out_of_the_budget() {
|
||||
// Each curve, rectangle element and glyph outline element an operator makes counts against the
|
||||
// operation budget: `arc` at a radius of 2.2 × 10^18 makes about 6,800 curves a call,
|
||||
// `rectclip` with an array of 2,000 rectangles makes 10,000 elements, and `charpath` makes the
|
||||
// outlines of 1,000 H's.
|
||||
for body in [
|
||||
"{ newpath 0 0 2200000000000000000 0 1440 arc } loop",
|
||||
"/a 8000 array def 0 1 7999 { a exch 0 put } for { gsave a rectclip grestore } loop",
|
||||
"/Helvetica findfont 10 scalefont setfont /s 1000 string def 0 1 999 { s exch 72 put } for \
|
||||
{ newpath 0 0 moveto s false charpath } loop",
|
||||
] {
|
||||
runs_out_of_budget(body);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_read_or_copied_in_a_loop_run_out_of_the_budget() {
|
||||
// `pathbbox` reads each element of the current path, and `gsave`, `save` and `clip` copy it:
|
||||
// each element counts against the operation budget. The path has 100,001 elements.
|
||||
let path = "newpath 0 0 moveto 0 1 99999 { pop 1 0 rlineto } for";
|
||||
for body in ["{ pathbbox pop pop pop pop } loop", "{ gsave grestore } loop", "{ save restore } loop", "{ gsave clip grestore } loop"] {
|
||||
runs_out_of_budget(&format!("{path} {body}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_loops_run_out_of_the_budget() {
|
||||
// Each pass of `loop`, `repeat`, `for` and `forall` counts against the operation budget, and
|
||||
// so does each item of a procedure that is not a name. `clear` empties the operand stack that
|
||||
// the passes fill.
|
||||
let literals = format!("{{ {} clear }} loop", "0 ".repeat(100_000));
|
||||
for body in [
|
||||
"{ {} loop } loop",
|
||||
"{ 1000000 {} repeat } loop",
|
||||
"{ 0 1 999999 {} for clear } loop",
|
||||
"/s 1000000 string def { s {} forall clear } loop",
|
||||
"/a 1000000 array def { a {} forall clear } loop",
|
||||
"/d 1 dict def 0 1 99999 { d exch 0 put } for { d {} forall clear } loop",
|
||||
literals.as_str(),
|
||||
] {
|
||||
runs_out_of_budget(body);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_stop_at_the_path_limit() {
|
||||
// A path holds at most 1,048,576 elements. An operator that would add more stops the program:
|
||||
// the square drawn before it stays, and the square after it is left out.
|
||||
for body in [
|
||||
"newpath 0 0 moveto 1100 { 1000 { 1 0 rlineto } repeat } repeat",
|
||||
"newpath 0 0 moveto 0 0 0 0 0 0 1100 { 1000 { 6 copy rcurveto } repeat } repeat",
|
||||
"newpath 0 0 moveto 1100 { 1000 { closepath } repeat } repeat",
|
||||
// 1,024 turns at a radius of 2 × 10^17: about 1,171,000 curves.
|
||||
"newpath 0 0 200000000000000000 0 368640 arc",
|
||||
// At a radius of 10^300, kurbo divides the arc into more curves than a usize can count.
|
||||
"newpath 0 0 10 300 exp 0 90 arc",
|
||||
// The outlines of 100,000 H's, 13 elements each.
|
||||
"/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \
|
||||
0 0 moveto s false charpath",
|
||||
// 210,000 rectangles of five elements each.
|
||||
"/a 840000 array def 0 1 839999 { a exch 0 put } for a rectfill",
|
||||
] {
|
||||
let doc = eps_soon(&format!("0 0 10 10 rectfill {body} 20 20 10 10 rectfill"));
|
||||
assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped);
|
||||
assert_eq!(doc.skipped, ["PostScript (path limit)"], "{body}");
|
||||
}
|
||||
// A `moveto` replaces a `moveto` just before it (PostScript Language Reference §8.2, `moveto`),
|
||||
// and so does the current point that `show` sets, so 550,000 pieces of text placed with
|
||||
// `moveto` and `show` leave a path of one element.
|
||||
let doc = eps_soon("/Helvetica findfont 10 scalefont setfont 550 { 1000 { 0 0 moveto ( ) show } repeat } repeat 0 0 10 10 rectfill");
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
assert_eq!(doc.flatten().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_paths_stop_at_the_path_limit() {
|
||||
// A content stream's current path holds at most 1,048,576 elements: `re` (five elements each)
|
||||
// and `h` stop the stream at the limit, and the square painted before it stays.
|
||||
for (op, n) in [("0 0 1 1 re ", 210_000), ("h ", 1_100_000)] {
|
||||
let doc = parse(&page_pdf(&format!("0 0 10 10 re f 0 0 m {} f 20 20 10 10 re f", op.repeat(n)), "", vec![])).unwrap();
|
||||
assert_eq!(doc.flatten().len(), 1, "{op}: {:?}", doc.skipped);
|
||||
assert_eq!(doc.skipped, ["content (path limit)"], "{op}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_an_empty_path_is_a_no_op() {
|
||||
// "s", "b" and "b*" closed the path without a current point (kurbo asserts on that).
|
||||
|
||||
@@ -49,8 +49,9 @@ pub fn saved_by(text: &str) -> Option<String> {
|
||||
serde_json::from_str::<Meta>(text).ok()?.saved_by
|
||||
}
|
||||
|
||||
/// The compositions that were open when a project file was saved (`activeComp` and `openComps`
|
||||
/// next to `savedBy`), so it reopens where it was left (#748). Editor state rather than project
|
||||
/// The compositions that were open when a project file was saved and their current times
|
||||
/// (`activeComp`, `openComps` and `compTimes` next to `savedBy`), so it reopens where it was left
|
||||
/// (#748, #1714). Editor state rather than project
|
||||
/// content: opening a comp doesn't make the project unsaved, and it isn't part of
|
||||
/// [`Project`]. Older files have none; older versions ignore it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
@@ -62,6 +63,10 @@ pub struct SavedView {
|
||||
/// The open comps (Timeline tabs), in tab order.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub open_comps: Vec<ItemId>,
|
||||
/// Each comp's current time, so a project reopens on the frame it was saved at rather than
|
||||
/// at 0, where its layers may not have started yet (#1714).
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub comp_times: BTreeMap<ItemId, Tick>,
|
||||
}
|
||||
|
||||
/// The view a project file was saved with ([`SavedView`]); empty for older files. Values that
|
||||
@@ -73,6 +78,8 @@ pub fn saved_view(text: &str) -> SavedView {
|
||||
active_comp: Option<serde_json::Value>,
|
||||
#[serde(rename = "openComps", default)]
|
||||
open_comps: Option<serde_json::Value>,
|
||||
#[serde(rename = "compTimes", default)]
|
||||
comp_times: Option<serde_json::Value>,
|
||||
}
|
||||
let Ok(m) = serde_json::from_str::<Meta>(text) else { return SavedView::default() };
|
||||
SavedView {
|
||||
@@ -82,6 +89,11 @@ pub fn saved_view(text: &str) -> SavedView {
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map_or_else(Vec::new, |a| a.iter().filter_map(serde_json::Value::as_u64).map(ItemId).collect()),
|
||||
comp_times: m
|
||||
.comp_times
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.map_or_else(BTreeMap::new, |o| o.iter().filter_map(|(k, v)| Some((ItemId(k.parse().ok()?), Tick(v.as_i64()?)))).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,14 +173,20 @@ fn project_files_name_their_version_and_always_read_back() {
|
||||
#[test]
|
||||
fn project_files_remember_the_open_comps() {
|
||||
let (p, cid) = project_with_layer();
|
||||
let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid] };
|
||||
let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid], comp_times: [(cid, effectcraft_time::Tick(4_237_833_600))].into() };
|
||||
let text = p.to_file_json_with_view(&view).unwrap();
|
||||
assert_eq!(crate::saved_view(&text), view);
|
||||
assert_eq!(Project::from_json(&text).unwrap(), p, "the view is not part of the project");
|
||||
// Files from before #748 have no view; a garbled one is ignored, not an error.
|
||||
assert_eq!(crate::saved_view(&p.to_file_json().unwrap()), crate::SavedView::default());
|
||||
let odd = text.replacen(&format!("\"activeComp\": {}", cid.0), "\"activeComp\": \"Main\"", 1);
|
||||
assert_eq!(crate::saved_view(&odd), crate::SavedView { active_comp: None, open_comps: vec![cid] });
|
||||
assert_eq!(crate::saved_view(&odd), crate::SavedView { active_comp: None, ..view.clone() });
|
||||
// Entries that aren't an id and a whole tick count are skipped; the rest are kept.
|
||||
let times = text.replacen("\"compTimes\": {", "\"compTimes\": {\"x\": 1, \"7\": \"a\", \"8\": 1.5, \"9\": -4,", 1);
|
||||
let expect = [(cid, effectcraft_time::Tick(4_237_833_600)), (crate::ItemId(9), effectcraft_time::Tick(-4))].into();
|
||||
assert_eq!(crate::saved_view(×).comp_times, expect);
|
||||
let not_a_map = text.replacen("\"compTimes\": {", "\"compTimes\": [], \"unused\": {", 1);
|
||||
assert_eq!(crate::saved_view(¬_a_map), crate::SavedView { comp_times: Default::default(), ..view });
|
||||
}
|
||||
|
||||
/// The Project panel's "used N times": layers in every comp that use the item directly, a precomp
|
||||
|
||||
@@ -538,7 +538,9 @@ pub struct RenderOpts {
|
||||
/// Output scale relative to comp pixels (1 = Full, 0.5 = Half…).
|
||||
pub scale: f64,
|
||||
pub motion_blur: bool,
|
||||
/// Render guide layers (viewer only).
|
||||
/// Render guide layers (viewer only). Only the top-level comp's own guide layers: nested
|
||||
/// comps (precomps, collapsed precomps, comps read by effects) never draw theirs, as in
|
||||
/// After Effects (see [`Renderer::nested_opts`]).
|
||||
pub guides: bool,
|
||||
/// Draft quality: fewer motion-blur samples, bilinear only.
|
||||
pub draft: bool,
|
||||
@@ -742,9 +744,16 @@ impl<'a> Renderer<'a> {
|
||||
sub
|
||||
}
|
||||
|
||||
/// The render options one level down (precomps, collapsed precomps, comps read by effects):
|
||||
/// guide layers render only in their own comp, never when it is nested.
|
||||
fn nested_opts(&self) -> RenderOpts {
|
||||
RenderOpts { guides: false, ..self.opts }
|
||||
}
|
||||
|
||||
/// A renderer for nested work (precomps, layers read by effects): one level deeper.
|
||||
fn nested(&self) -> Renderer<'a> {
|
||||
Renderer {
|
||||
opts: self.nested_opts(),
|
||||
depth: self.depth + 1,
|
||||
outer: None,
|
||||
opacity_mul: 1.0,
|
||||
@@ -835,7 +844,10 @@ impl<'a> Renderer<'a> {
|
||||
if let Some(slot) = slot {
|
||||
*slot = Some(link);
|
||||
}
|
||||
Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, nctx))
|
||||
Some((
|
||||
Renderer { opts: self.nested_opts(), depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self },
|
||||
nctx,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn opacity_mul(&self) -> f32 {
|
||||
@@ -2402,8 +2414,12 @@ mod tests_footage_size;
|
||||
#[cfg(test)]
|
||||
mod tests_frame_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_guides;
|
||||
#[cfg(test)]
|
||||
mod tests_mask_blur;
|
||||
#[cfg(test)]
|
||||
mod tests_mask_expansion;
|
||||
#[cfg(test)]
|
||||
mod tests_nested;
|
||||
#[cfg(test)]
|
||||
mod tests_paint;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use effectcraft_effects::Buf;
|
||||
use effectcraft_geom::{Mat3, vec2};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_path::{FillRule, StrokeStyle};
|
||||
use effectcraft_path::{FillRule, Join};
|
||||
use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur};
|
||||
use effectcraft_raster::{Mask, box_blur, gaussian_blur};
|
||||
use rayon::prelude::*;
|
||||
@@ -72,21 +72,15 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup,
|
||||
let variable = !sp.feather.is_empty();
|
||||
let mut cov = if variable {
|
||||
effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear)
|
||||
} else if exp.abs() > 0.01 {
|
||||
// Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right
|
||||
// when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a
|
||||
// ring around an uncovered middle (growing) or islands (shrinking).
|
||||
let grown = effectcraft_path::offset::offset_path(&path, exp, Join::Round, 4.0);
|
||||
effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &m, w, h, FillRule::NonZero)
|
||||
} else {
|
||||
effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero)
|
||||
};
|
||||
if exp.abs() > 0.01 && !variable {
|
||||
let ring = effectcraft_path::stroke_coverage(
|
||||
std::slice::from_ref(&path),
|
||||
&StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() },
|
||||
&m,
|
||||
w,
|
||||
h,
|
||||
);
|
||||
cov.data.par_iter_mut().zip(ring.data.par_iter()).for_each(|(c, r)| {
|
||||
*c = if exp > 0.0 { (*c + r - *c * r).min(1.0) } else { (*c * (1.0 - r)).max(0.0) };
|
||||
});
|
||||
}
|
||||
let feather = ctx.v2(layer, g, "feather", [0.0; 2]);
|
||||
if feather[0] > 0.0 || feather[1] > 0.0 {
|
||||
let img = cov.to_image();
|
||||
|
||||
@@ -57,6 +57,10 @@ struct Draw {
|
||||
blend: BlendMode,
|
||||
/// Front / Bevel / Side / Back colours of the paint's extrusion (Advanced 3D).
|
||||
surfaces: SurfaceColors,
|
||||
/// Paint item of the contents group this draw was collected in (as opposed to one bubbled up
|
||||
/// from a nested group). Merge Paths drops only these paints above it (#1174); a group's own
|
||||
/// fill or stroke keeps rendering inside the group.
|
||||
local: bool,
|
||||
}
|
||||
|
||||
/// Straight RGB colours of an extrusion's surfaces, by [`effectcraft_model::extrude::Surface`]
|
||||
@@ -234,6 +238,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
opacity: opacity(g),
|
||||
blend: blend_mode(ctx, layer, g),
|
||||
surfaces: [None; 4],
|
||||
local: true,
|
||||
});
|
||||
}
|
||||
"stroke" => {
|
||||
@@ -247,6 +252,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
opacity: opacity(g),
|
||||
blend: blend_mode(ctx, layer, g),
|
||||
surfaces: [None; 4],
|
||||
local: true,
|
||||
});
|
||||
}
|
||||
"gfill" | "gstroke" => {
|
||||
@@ -270,6 +276,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
opacity: opacity(g),
|
||||
blend: blend_mode(ctx, layer, g),
|
||||
surfaces: [None; 4],
|
||||
local: true,
|
||||
});
|
||||
}
|
||||
"trim" => {
|
||||
@@ -328,8 +335,9 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
arena.push(ops::merge(&input, mode));
|
||||
live = vec![arena.len() - 1];
|
||||
// As in After Effects, fills and strokes above a Merge Paths in its group are not
|
||||
// rendered: the merged path is painted by the paint items below it.
|
||||
draws.clear();
|
||||
// rendered: the merged path is painted by the paint items below it. Paint inside
|
||||
// groups above the merge still renders — it belongs to those groups (#1174).
|
||||
draws.retain(|d| !d.local);
|
||||
}
|
||||
"group" => {
|
||||
let Some(inner) = g.sub("contents") else { continue };
|
||||
@@ -341,6 +349,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
for mut d in cd {
|
||||
d.xf = m * d.xf;
|
||||
d.opacity *= op;
|
||||
d.local = false;
|
||||
draws.push(d);
|
||||
}
|
||||
live.extend(cl);
|
||||
|
||||
@@ -76,6 +76,24 @@ fn merge_add_feeds_fill_below_and_drops_paint_above() {
|
||||
assert!((coverage(&img) - 80.0 * 40.0).abs() < 20.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
/// #1174: Adding Merge Paths to a shape layer's Contents, after the group that holds the
|
||||
/// shape and its paint, erased the render. The merge drops paints above it *in its group* —
|
||||
/// not paint that belongs to a group above it.
|
||||
#[test]
|
||||
fn merge_paths_below_a_group_keeps_the_group_paint() {
|
||||
for mode in [0u32, 1] {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
let rect = build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0);
|
||||
let fill = build::shape_fill(ids, RED);
|
||||
vec![build::shape_group(ids, "Group 1", vec![rect, fill]), op(ids, "merge", &[("mode", Value::Enum(mode))])]
|
||||
});
|
||||
let img = frame(&p, cid);
|
||||
let c = img.get(100, 50);
|
||||
assert!(c[0] > 0.99 && c[1] < 0.01 && c[3] > 0.99, "mode {mode}: group rectangle must still render, got {c:?}");
|
||||
assert!((coverage(&img) - 40.0 * 40.0).abs() < 20.0, "mode {mode}: {}", coverage(&img));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_subtract_and_intersect() {
|
||||
let sub = |mode: u32| {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Guide layers: drawn in their own comp's viewer (`RenderOpts::guides`), never when that comp is
|
||||
//! nested in another (precomp, collapsed precomp, 2D or 3D), and never in final renders.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{NoFootage, RenderOpts, Renderer};
|
||||
|
||||
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], size: (u32, u32), three_d: bool) -> Layer {
|
||||
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: size.0, height: size.1, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, size, None);
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
|
||||
l.switches.three_d = three_d;
|
||||
l
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Nest {
|
||||
/// A plain precomp layer (the nested comp rendered on its own, then composited).
|
||||
Precomp,
|
||||
/// Collapse Transformations on a 2D precomp layer.
|
||||
Collapsed,
|
||||
/// A collapsed 3D precomp in a Classic 3D comp.
|
||||
Collapsed3d,
|
||||
/// A collapsed 3D precomp in an Advanced 3D comp.
|
||||
CollapsedAdvanced,
|
||||
}
|
||||
|
||||
/// "Inner": a red 100×100 background with a green 40×40 guide layer in the middle; "Outer": the
|
||||
/// same size, holding only Inner nested as `nest`.
|
||||
fn scene(nest: Nest) -> (Project, ItemId, ItemId) {
|
||||
let three_d = matches!(nest, Nest::Collapsed3d | Nest::CollapsedAdvanced);
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = BitDepth::Bpc32;
|
||||
let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let mut guide = solid(&mut p, &inner, [0.0, 1.0, 0.0], (40, 40), three_d);
|
||||
guide.switches.guide = true;
|
||||
let bg = solid(&mut p, &inner, [1.0, 0.0, 0.0], (100, 100), three_d);
|
||||
let mut inner = inner;
|
||||
inner.layers = vec![guide, bg];
|
||||
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
|
||||
let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
if matches!(nest, Nest::CollapsedAdvanced) {
|
||||
outer.renderer = effectcraft_project::Renderer::Advanced3D;
|
||||
}
|
||||
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
|
||||
pre.switches.collapse = !matches!(nest, Nest::Precomp);
|
||||
pre.switches.three_d = three_d;
|
||||
outer.layers = vec![pre];
|
||||
let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into()));
|
||||
(p, iid, oid)
|
||||
}
|
||||
|
||||
/// The centre pixel of `comp` rendered with or without guide layers.
|
||||
fn centre(p: &Project, comp: ItemId, guides: bool) -> [f32; 4] {
|
||||
Renderer::new(p, &NoFootage, RenderOpts { guides, ..Default::default() }).comp_frame(comp, Tick::ZERO).get(50, 50)
|
||||
}
|
||||
|
||||
fn is_green(c: [f32; 4]) -> bool {
|
||||
c[1] > 0.9 && c[0] < 0.1
|
||||
}
|
||||
|
||||
fn is_red(c: [f32; 4]) -> bool {
|
||||
c[0] > 0.9 && c[1] < 0.1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_layer_renders_in_its_own_comp_with_guides_on() {
|
||||
let (p, inner, _) = scene(Nest::Precomp);
|
||||
let c = centre(&p, inner, true);
|
||||
assert!(is_green(c), "guide layer drawn in its own comp's viewer: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_renders_leave_guide_layers_out_by_default() {
|
||||
assert!(!RenderOpts::default().guides);
|
||||
let (p, inner, _) = scene(Nest::Precomp);
|
||||
let c = centre(&p, inner, false);
|
||||
assert!(is_red(c), "guide layer left out of final renders: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_layers_of_a_nested_comp_never_render_in_the_parent() {
|
||||
for nest in [Nest::Precomp, Nest::Collapsed, Nest::Collapsed3d, Nest::CollapsedAdvanced] {
|
||||
let (p, inner, outer) = scene(nest);
|
||||
// The nested comp's own viewer still shows them…
|
||||
let own = centre(&p, inner, true);
|
||||
assert!(is_green(own), "{nest:?}: guide layer in its own comp: {own:?}");
|
||||
// …but not the parent's viewer (guides on) nor its final render.
|
||||
for guides in [true, false] {
|
||||
let c = centre(&p, outer, guides);
|
||||
assert!(is_red(c), "{nest:?}, guides {guides}: nested guide layer drawn in the parent: {c:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guide_precomp_layer_of_the_top_comp_follows_the_guides_option() {
|
||||
// The precomp layer itself is a guide layer in the top comp: shown in the viewer, not in
|
||||
// final renders.
|
||||
let (mut p, _, outer) = scene(Nest::Precomp);
|
||||
p.comp_mut(outer).unwrap().layers[0].switches.guide = true;
|
||||
assert!(is_red(centre(&p, outer, true)));
|
||||
assert!(centre(&p, outer, false)[3] < 0.01, "nothing drawn without guides");
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Mask Expansion grows or shrinks the mask outline by its distance, however large: past the
|
||||
//! shape's curvature radius it must still cover everything within the distance of the path,
|
||||
//! never leave a ring around an uncovered middle.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{ShapePath, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Node, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{NoFootage, RenderOpts, Renderer};
|
||||
|
||||
const W: u32 = 400;
|
||||
const H: u32 = 300;
|
||||
|
||||
/// A white 400×300 solid with one mask of `shape` in `mode` expanded by `expansion` pixels.
|
||||
fn project(shape: ShapePath, mode: MaskMode, expansion: f64) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: W, height: H, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (W, H), None);
|
||||
let mut next = p.next_id;
|
||||
let mut m = build::mask(&mut Ids(&mut next), "Mask 1", shape, mode, [255, 0, 0]);
|
||||
p.next_id = next;
|
||||
m.get_mut("expansion").unwrap().value = Value::Scalar(expansion);
|
||||
l.props.sub_mut("masks").unwrap().children.push(Node::Group(m));
|
||||
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// Alpha at each of `points` for a Subtract mask on a 40 px circle at the centre (the hole).
|
||||
fn hole(expansion: f64, points: &[(i64, i64)]) -> Vec<f32> {
|
||||
let (p, cid) = project(ShapePath::ellipse([200.0, 150.0], 40.0, 40.0), MaskMode::Subtract, expansion);
|
||||
let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
points.iter().map(|&(x, y)| img.get(x, y)[3]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_expansion_cuts_the_circle() {
|
||||
let a = hole(0.0, &[(200, 150), (215, 150), (225, 150)]);
|
||||
assert!(a[0] < 0.01 && a[1] < 0.01, "inside the 20 px radius is cut: {a:?}");
|
||||
assert!(a[2] > 0.99, "outside it is kept: {a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expansion_past_the_radius_grows_a_solid_hole() {
|
||||
// +60 on a 20 px radius: a solid hole of radius 80. The fill + stroke blend left a ring
|
||||
// from 40 to 80 px with the layer showing at 21–39 px.
|
||||
let a = hole(60.0, &[(200, 150), (230, 150), (200, 120), (275, 150), (285, 150)]);
|
||||
assert!(a[..4].iter().all(|&v| v < 0.01), "everything within 80 px is cut: {a:?}");
|
||||
assert!(a[4] > 0.99, "beyond 80 px is kept: {a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_expansion_cuts_the_whole_frame() {
|
||||
// +600 reaches every pixel; the blend left only the 20 px circle cut.
|
||||
let a = hole(600.0, &[(200, 150), (230, 150), (0, 0), (399, 299), (399, 0)]);
|
||||
assert!(a.iter().all(|&v| v < 0.01), "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_expansion_shrinks_then_removes_the_hole() {
|
||||
let a = hole(-15.0, &[(200, 150), (210, 150)]);
|
||||
assert!(a[0] < 0.01, "a 5 px hole remains: {a:?}");
|
||||
assert!(a[1] > 0.99, "shrunk away from 10 px out: {a:?}");
|
||||
let a = hole(-25.0, &[(200, 150), (190, 150)]);
|
||||
assert!(a.iter().all(|&v| v > 0.99), "shrunk past its radius, nothing is cut: {a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_rect_add_mask_has_round_corners() {
|
||||
// A 100 px square mask (Add) grown by 30: the sides move out 30 px, the corners round off.
|
||||
let (p, cid) = project(ShapePath::rect([200.0, 150.0], 100.0, 100.0), MaskMode::Add, 30.0);
|
||||
let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
assert!(img.get(275, 150)[3] > 0.99, "25 px past the right side is inside");
|
||||
assert!(img.get(285, 150)[3] < 0.01, "35 px past it is outside");
|
||||
// 25 px diagonally out from the corner (250,100) is within 30 px: inside.
|
||||
assert!(img.get(267, 82)[3] > 0.99);
|
||||
// The square's bounding corner (280,70) is 42 px from the corner: a miter would cover it.
|
||||
assert!(img.get(277, 73)[3] < 0.01);
|
||||
}
|
||||
+18
-10
@@ -147,15 +147,6 @@ fn unit_of(based: BasedOn, g: &CharGlyph, lay: &TextLayout) -> (usize, usize) {
|
||||
}
|
||||
}
|
||||
|
||||
fn unit_count(based: BasedOn, lay: &TextLayout) -> usize {
|
||||
match based {
|
||||
BasedOn::Characters => lay.chars,
|
||||
BasedOn::CharactersExcludingSpaces => lay.chars_no_space,
|
||||
BasedOn::Words => lay.words,
|
||||
BasedOn::Lines => lay.lines,
|
||||
}
|
||||
}
|
||||
|
||||
fn layer_seed(layer: &Layer, seed: f64) -> u64 {
|
||||
(seed as i64 as u64) ^ layer.id.0.wrapping_mul(0x9E37_79B9)
|
||||
}
|
||||
@@ -179,6 +170,23 @@ fn range_params(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, n: usize) -> Rang
|
||||
}
|
||||
}
|
||||
|
||||
/// A Range Selector as the viewer's bars see it (#1011): Based On, whether Units is Index, and
|
||||
/// Start / End / Offset as the fractions the renderer uses.
|
||||
pub fn range_bars(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, lay: &TextLayout) -> (BasedOn, bool, Range) {
|
||||
let adv = sel.sub("advanced");
|
||||
let based = BasedOn::from_index(adv.map(|a| ctx.e(layer, a, "basedOn")).unwrap_or(0));
|
||||
let index = adv.is_some_and(|a| ctx.e(layer, a, "units") == 1);
|
||||
(based, index, range_params(ctx, layer, sel, lay.units(based)))
|
||||
}
|
||||
|
||||
/// The Start / End value that puts a Range Selector bar at fraction `f` of the `n` units, with
|
||||
/// Offset `offset` (a fraction): the bar's place less the Offset, within 0..100%, or in whole
|
||||
/// units with Units = Index.
|
||||
pub fn range_value_of(f: f64, offset: f64, units_index: bool, n: usize) -> f64 {
|
||||
let v = (f - offset).clamp(0.0, 1.0);
|
||||
if units_index { (v * n as f64).round() } else { v * 100.0 }
|
||||
}
|
||||
|
||||
/// Selection of every glyph by an animator's selectors, per dimension.
|
||||
pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> {
|
||||
let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default();
|
||||
@@ -199,7 +207,7 @@ pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &
|
||||
};
|
||||
let mode = Mode::from_index(mode);
|
||||
let based = BasedOn::from_index(based);
|
||||
let n = unit_count(based, lay);
|
||||
let n = lay.units(based);
|
||||
let values: Box<dyn Fn(usize, usize, [f64; 3]) -> [f64; 3]> = match sel.match_id.as_str() {
|
||||
"rangeSelector" => {
|
||||
let r = range_params(ctx, layer, sel, n);
|
||||
|
||||
@@ -550,3 +550,17 @@ fn touching_glyphs_join_without_a_seam() {
|
||||
let multiply = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, false)], BlendMode::Multiply).get(10, 10);
|
||||
assert!(multiply[3] < 0.9, "{multiply:?}");
|
||||
}
|
||||
|
||||
/// #1011: what dragging a Range Selector bar to fraction `f` of the text writes.
|
||||
#[test]
|
||||
fn range_bar_drag_values() {
|
||||
// Percentage: the bar's place less the Offset, within 0..100.
|
||||
assert!((range_value_of(0.5, 0.0, false, 7) - 50.0).abs() < 1e-9);
|
||||
assert!((range_value_of(0.5, 0.25, false, 7) - 25.0).abs() < 1e-9, "Offset 25%: the bar at 50% is Start 25%");
|
||||
assert_eq!(range_value_of(0.1, 0.25, false, 7), 0.0, "never below 0");
|
||||
assert_eq!(range_value_of(1.0, -0.5, false, 7), 100.0, "nor above 100");
|
||||
// Index: whole units, the nearest boundary (3.4 of 7 characters → 3, 3.6 → 4).
|
||||
assert_eq!(range_value_of(3.4 / 7.0, 0.0, true, 7), 3.0);
|
||||
assert_eq!(range_value_of(3.6 / 7.0, 0.0, true, 7), 4.0);
|
||||
assert_eq!(range_value_of(3.6 / 7.0, 2.0 / 7.0, true, 7), 2.0, "less a 2-character Offset");
|
||||
}
|
||||
@@ -62,6 +62,8 @@ pub struct Material {
|
||||
pub unlit: bool,
|
||||
pub accepts_lights: bool,
|
||||
pub receives_shadows: bool,
|
||||
/// "Accepts Shadows: Only": draws just the shadows it receives, as black × their amount.
|
||||
pub shadows_only: bool,
|
||||
pub casts_shadows: bool,
|
||||
/// Drawn (false for "Casts Shadows: Only").
|
||||
pub visible: bool,
|
||||
@@ -92,6 +94,7 @@ impl Default for Material {
|
||||
unlit: false,
|
||||
accepts_lights: true,
|
||||
receives_shadows: true,
|
||||
shadows_only: false,
|
||||
casts_shadows: false,
|
||||
visible: true,
|
||||
diffuse_k: 1.0,
|
||||
@@ -104,7 +107,7 @@ impl Default for Material {
|
||||
impl Material {
|
||||
/// Drawn in the transparent (sorted, blended) pass.
|
||||
pub fn transparent(&self) -> bool {
|
||||
self.alpha_mode == 2 || self.opacity < 0.999
|
||||
self.alpha_mode == 2 || self.opacity < 0.999 || self.shadows_only
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +363,7 @@ fn card_material(ctx: &EvalCtx, layer: &Layer) -> Material {
|
||||
double_sided: true,
|
||||
accepts_lights: c.accepts_lights,
|
||||
receives_shadows: c.accepts_shadows != 0,
|
||||
shadows_only: c.accepts_shadows == 2,
|
||||
casts_shadows: c.casts_shadows != 0,
|
||||
visible: c.casts_shadows != 2,
|
||||
diffuse_k: c.diffuse,
|
||||
@@ -375,9 +379,11 @@ fn model_options(ctx: &EvalCtx, layer: &Layer, lin: bool) -> (Material, Option<m
|
||||
let e = |m: &str, d: u32| g.and_then(|g| g.get(m)).map_or(d, |p| ctx.value(layer, p).as_enum());
|
||||
let b = |m: &str| g.and_then(|g| g.get(m)).is_none_or(|p| ctx.value(layer, p).as_bool());
|
||||
let casts = e("castsShadows", 0);
|
||||
let accepts = e("acceptsShadows", 1);
|
||||
let opts = Material {
|
||||
accepts_lights: b("acceptsLights"),
|
||||
receives_shadows: e("acceptsShadows", 1) != 0,
|
||||
receives_shadows: accepts != 0,
|
||||
shadows_only: accepts == 2,
|
||||
casts_shadows: casts != 0,
|
||||
visible: casts != 2,
|
||||
..Material::default()
|
||||
@@ -879,7 +885,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item:
|
||||
|| l.is_light()
|
||||
|| matches!(l.source, LayerSource::Camera)
|
||||
|| (any_solo && !l.switches.solo)
|
||||
|| (!r.opts.guides && l.switches.guide)
|
||||
|| (!sub.opts.guides && l.switches.guide)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -262,6 +262,16 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 {
|
||||
1.0 - l.shadow_darkness * (1.0 - f)
|
||||
}
|
||||
|
||||
/// Unit direction from `p` to light `l`, and the distance (infinite for a parallel light).
|
||||
fn to_light(l: &Light, p: [f32; 3]) -> ([f32; 3], f32) {
|
||||
if l.kind == 0 {
|
||||
return (scale(l.dir, -1.0), f32::INFINITY);
|
||||
}
|
||||
let d = sub(l.pos, p);
|
||||
let len = dot(d, d).sqrt();
|
||||
if len < 1e-6 { ([0.0, 0.0, -1.0], 0.0) } else { (scale(d, 1.0 / len), len) }
|
||||
}
|
||||
|
||||
/// Distance attenuation × spot cone of a light at `p` (AE falloff modes).
|
||||
pub fn attenuation(l: &Light, p: [f32; 3], to_l: [f32; 3], dist: f32) -> f32 {
|
||||
let mut k = match l.falloff {
|
||||
@@ -341,6 +351,9 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
|
||||
if let Some(t) = tex(m.tex_emissive) {
|
||||
emissive = [emissive[0] * t[0], emissive[1] * t[1], emissive[2] * t[2]];
|
||||
}
|
||||
if m.shadows_only {
|
||||
return Some(shadow_only(s, m, f, alpha));
|
||||
}
|
||||
if m.unlit || !m.accepts_lights || (s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]) {
|
||||
let c = add(rgb, emissive);
|
||||
return Some([c[0] * alpha, c[1] * alpha, c[2] * alpha, alpha]);
|
||||
@@ -370,13 +383,7 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
|
||||
let v = if s.ortho { scale(s.cam_fwd, -1.0) } else { norm(sub(s.eye, f.pos)) };
|
||||
let mut out = [0.0f32; 3];
|
||||
for l in &s.lights {
|
||||
let (to_l, dist) = if l.kind == 0 {
|
||||
(scale(l.dir, -1.0), f32::INFINITY)
|
||||
} else {
|
||||
let d = sub(l.pos, f.pos);
|
||||
let len = dot(d, d).sqrt();
|
||||
if len < 1e-6 { ([0.0, 0.0, -1.0], 0.0) } else { (scale(d, 1.0 / len), len) }
|
||||
};
|
||||
let (to_l, dist) = to_light(l, f.pos);
|
||||
let k = attenuation(l, f.pos, to_l, dist);
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
@@ -408,3 +415,25 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
|
||||
let c = add(out, emissive);
|
||||
Some([c[0] * alpha, c[1] * alpha, c[2] * alpha, alpha])
|
||||
}
|
||||
|
||||
/// "Accepts Shadows: Only" (classic `compose::shade_texel`): black at the strongest shadow any
|
||||
/// light casts on the fragment, nothing where it is unshadowed or takes no lights.
|
||||
fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] {
|
||||
if m.unlit || !m.accepts_lights {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let n = if f.front { norm(f.normal) } else { scale(norm(f.normal), -1.0) };
|
||||
let mut amount = 0.0f32;
|
||||
for l in &s.lights {
|
||||
let (to_l, dist) = to_light(l, f.pos);
|
||||
if attenuation(l, f.pos, to_l, dist) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let n_l = dot(n, to_l);
|
||||
if n_l <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
amount = amount.max(1.0 - light_shadow(s, l, f.pos, n_l));
|
||||
}
|
||||
[0.0, 0.0, 0.0, alpha * amount]
|
||||
}
|
||||
@@ -237,6 +237,73 @@ fn shadows_darken_the_receiver() {
|
||||
assert!(img.data[75 * 200 + 100][0] > side[0] * 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_shadows_only_draws_just_the_shadow() {
|
||||
// #1691: a shadow catcher (card or plane primitive, "Accepts Shadows: Only") in front of a
|
||||
// red backdrop card, an invisible sphere casting onto it, a point light behind the camera.
|
||||
let (mut p, cid) = project(200, 150);
|
||||
let red = p.add_item(
|
||||
"R",
|
||||
Default::default(),
|
||||
None,
|
||||
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 0.0, 0.0], width: 400, height: 400, pixel_aspect: 1.0 }),
|
||||
);
|
||||
add(&mut p, cid, LayerSource::Solid { item: red }, |l| {
|
||||
l.switches.three_d = true;
|
||||
set(l, "transform/position", Value::Vec3([100.0, 75.0, 300.0]));
|
||||
set(l, "materialOptions/acceptsShadows", Value::Enum(0));
|
||||
});
|
||||
let white = p.add_item(
|
||||
"W",
|
||||
Default::default(),
|
||||
None,
|
||||
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 400, height: 400, pixel_aspect: 1.0 }),
|
||||
);
|
||||
add(&mut p, cid, LayerSource::Solid { item: white }, |l| {
|
||||
l.switches.three_d = true;
|
||||
set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0]));
|
||||
set(l, "materialOptions/acceptsShadows", Value::Enum(2));
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
|
||||
set(l, "geometryOptions/radius", Value::Scalar(30.0));
|
||||
set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));
|
||||
set(l, "materialOptions/castsShadows", Value::Enum(2));
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| {
|
||||
set(l, "transform/position", Value::Vec3([100.0, 75.0, -300.0]));
|
||||
set(l, "lightOptions/castsShadows", Value::Bool(true));
|
||||
});
|
||||
let check = |img: &crate::Image, what: &str| {
|
||||
let (mid, side) = (img.data[75 * 200 + 100], img.data[75 * 200 + 60]);
|
||||
// Unshadowed: the catcher is see-through, the lit red card behind it shows.
|
||||
assert!(side[0] > 0.5 && side[1] < side[0] * 0.25 && side[3] > 0.99, "{what}: side {side:?}");
|
||||
// Shadowed: black over the red card.
|
||||
assert!(mid[0] < 0.02 && mid[3] > 0.99, "{what}: shadow {mid:?} vs side {side:?}");
|
||||
};
|
||||
check(&render(&p, cid), "card");
|
||||
// Nothing behind the catcher: only the shadow is opaque.
|
||||
let solo = |p: &Project| {
|
||||
let mut q = p.clone();
|
||||
q.comp_mut(cid).unwrap().layers.pop();
|
||||
let img = render(&q, cid);
|
||||
let (mid, side) = (img.data[75 * 200 + 100], img.data[75 * 200 + 60]);
|
||||
assert!(side[3] < 1e-3, "unshadowed catcher is transparent: {side:?}");
|
||||
assert!(mid[3] > 0.5 && mid[0] < 1e-3, "shadow is black: {mid:?}");
|
||||
};
|
||||
solo(&p);
|
||||
// The same with a plane primitive as the catcher.
|
||||
let layers = &mut p.comp_mut(cid).unwrap().layers;
|
||||
layers.remove(2);
|
||||
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
|
||||
set(l, "geometryOptions/width", Value::Scalar(400.0));
|
||||
set(l, "geometryOptions/height", Value::Scalar(400.0));
|
||||
set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0]));
|
||||
set(l, "materialOptions/acceptsShadows", Value::Enum(2));
|
||||
});
|
||||
check(&render(&p, cid), "plane");
|
||||
solo(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cards_and_environment_light() {
|
||||
let (mut p, cid) = project(120, 90);
|
||||
|
||||
@@ -503,7 +503,7 @@ fn collapsed_items<'a>(
|
||||
.layers
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (r.opts.guides || !l.switches.guide))
|
||||
.filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (sub.opts.guides || !l.switches.guide))
|
||||
.collect();
|
||||
let mut items = Vec::new();
|
||||
for (k, l) in nested.into_iter().enumerate() {
|
||||
|
||||
@@ -399,7 +399,8 @@ pub fn shape_kind(name: &str) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
/// Effects whose documented parameter match names are not numbered in display order:
|
||||
/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`.
|
||||
/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`. Params of
|
||||
/// these effects that After Effects doesn't have are numbered after the listed ones (#1405).
|
||||
const PARAM_NUMBERS: &[(&str, &str, usize)] = &[
|
||||
("ec.generate.fill", "fillMask", 1),
|
||||
("ec.generate.fill", "color", 2),
|
||||
@@ -408,8 +409,42 @@ const PARAM_NUMBERS: &[(&str, &str, usize)] = &[
|
||||
("ec.generate.fill", "opacity", 5),
|
||||
("ec.generate.fill", "invert", 6),
|
||||
("ec.generate.fill", "allMasks", 7),
|
||||
("ec.generate.stroke", "path", 1),
|
||||
("ec.generate.stroke", "color", 2),
|
||||
("ec.generate.stroke", "brushSize", 3),
|
||||
("ec.generate.stroke", "brushHardness", 4),
|
||||
("ec.generate.stroke", "opacity", 5),
|
||||
("ec.generate.stroke", "spacing", 6),
|
||||
("ec.generate.stroke", "paintStyle", 7),
|
||||
("ec.generate.stroke", "start", 8),
|
||||
("ec.generate.stroke", "end", 9),
|
||||
("ec.generate.stroke", "allMasks", 10),
|
||||
("ec.generate.stroke", "strokeSequentially", 11),
|
||||
// -0001 is a hidden parameter and -0003 Channel Range, which we don't have; the per-channel
|
||||
// controls are ours (After Effects edits the selected channel with the Master ones).
|
||||
("ec.color.huesaturation", "channelControl", 2),
|
||||
("ec.color.huesaturation", "hue", 4),
|
||||
("ec.color.huesaturation", "saturation", 5),
|
||||
("ec.color.huesaturation", "lightness", 6),
|
||||
("ec.color.huesaturation", "colorize", 7),
|
||||
("ec.color.huesaturation", "colorizeHue", 8),
|
||||
("ec.color.huesaturation", "colorizeSaturation", 9),
|
||||
("ec.color.huesaturation", "colorizeLightness", 10),
|
||||
];
|
||||
|
||||
/// The `-NNNN` number of the param `id` at 0-based `pos` among `fx`'s `params`.
|
||||
fn param_number(fx: &str, params: &[Node], id: &str, pos: usize) -> usize {
|
||||
let listed = |p: &str| PARAM_NUMBERS.iter().find(|(f, q, _)| *f == fx && *q == p).map(|(_, _, n)| *n);
|
||||
if let Some(n) = listed(id) {
|
||||
return n;
|
||||
}
|
||||
let Some(last) = PARAM_NUMBERS.iter().filter(|(f, _, _)| *f == fx).map(|(_, _, n)| *n).max() else {
|
||||
return pos + 1;
|
||||
};
|
||||
let unlisted_before = params.iter().take(pos).filter(|c| listed(c.match_id()).is_none()).count();
|
||||
last + unlisted_before + 1
|
||||
}
|
||||
|
||||
/// Match name of the node at the end of `chain` (layer root's children → … → node). `effect`
|
||||
/// params are numbered `<effect match name>-0001…` in parameter order, like After Effects.
|
||||
pub fn node(chain: &[&Node]) -> String {
|
||||
@@ -432,11 +467,8 @@ pub fn node(chain: &[&Node]) -> String {
|
||||
let fx = parent.and_then(Node::as_group);
|
||||
match fx {
|
||||
Some(g) => {
|
||||
let i = PARAM_NUMBERS
|
||||
.iter()
|
||||
.find(|(fx, p, _)| *fx == g.match_id && *p == id)
|
||||
.map(|(_, _, n)| *n)
|
||||
.unwrap_or_else(|| g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1);
|
||||
let pos = g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0);
|
||||
let i = param_number(&g.match_id, &g.children, id, pos);
|
||||
format!("{}-{i:04}", effect(&g.match_id))
|
||||
}
|
||||
None => own(),
|
||||
|
||||
+145
-23
@@ -50,8 +50,8 @@ mod imp {
|
||||
mod imp {
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
|
||||
use std::sync::Mutex;
|
||||
use std::net::{Shutdown, TcpListener, TcpStream, ToSocketAddrs};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::*;
|
||||
@@ -61,9 +61,18 @@ mod imp {
|
||||
Listener(TcpListener),
|
||||
}
|
||||
|
||||
/// One open socket. Reading, writing and polling lock only it, never the whole table, so a
|
||||
/// socket blocked on a slow peer doesn't hold up the others (#1248).
|
||||
struct Entry {
|
||||
sock: Mutex<Sock>,
|
||||
/// A second handle on a stream, so `close` can shut it down while another call is
|
||||
/// blocked reading or writing it.
|
||||
wake: Option<TcpStream>,
|
||||
}
|
||||
|
||||
struct Table {
|
||||
next: u32,
|
||||
socks: BTreeMap<u32, Sock>,
|
||||
socks: BTreeMap<u32, Arc<Entry>>,
|
||||
}
|
||||
|
||||
fn table() -> &'static Mutex<Table> {
|
||||
@@ -72,13 +81,31 @@ mod imp {
|
||||
}
|
||||
|
||||
fn add(s: Sock) -> u32 {
|
||||
let wake = match &s {
|
||||
Sock::Stream { s, .. } => s.try_clone().ok(),
|
||||
Sock::Listener(_) => None,
|
||||
};
|
||||
let entry = Arc::new(Entry { sock: Mutex::new(s), wake });
|
||||
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let id = t.next;
|
||||
t.next += 1;
|
||||
t.socks.insert(id, s);
|
||||
t.socks.insert(id, entry);
|
||||
id
|
||||
}
|
||||
|
||||
/// The socket `id`, if open. The table is locked only for the lookup.
|
||||
fn get(id: u32) -> Option<Arc<Entry>> {
|
||||
table().lock().unwrap_or_else(|e| e.into_inner()).socks.get(&id).cloned()
|
||||
}
|
||||
|
||||
/// Forget socket `id`; a call blocked on it fails at once.
|
||||
fn close(id: u32) {
|
||||
let entry = table().lock().unwrap_or_else(|e| e.into_inner()).socks.remove(&id);
|
||||
if let Some(w) = entry.as_ref().and_then(|e| e.wake.as_ref()) {
|
||||
let _ = w.shutdown(Shutdown::Both);
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout(p: &J) -> Duration {
|
||||
Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0))
|
||||
}
|
||||
@@ -98,8 +125,9 @@ mod imp {
|
||||
if utf8 { String::from_utf8_lossy(b).into_owned() } else { b.iter().map(|&c| c as char).collect() }
|
||||
}
|
||||
|
||||
/// Fill a stream's buffer until `done(buf)` or the timeout / end of stream.
|
||||
fn fill(s: &mut TcpStream, buf: &mut Vec<u8>, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) {
|
||||
/// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. Fails only when
|
||||
/// the read deadline can't be set.
|
||||
fn fill(s: &mut TcpStream, buf: &mut Vec<u8>, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) -> std::io::Result<()> {
|
||||
let end = Instant::now() + limit;
|
||||
let mut chunk = [0u8; 4096];
|
||||
while !done(buf) && !*eof {
|
||||
@@ -107,7 +135,7 @@ mod imp {
|
||||
if left.is_zero() {
|
||||
break;
|
||||
}
|
||||
let _ = s.set_read_timeout(Some(left.max(Duration::from_millis(1))));
|
||||
s.set_read_timeout(Some(left.max(Duration::from_millis(1))))?;
|
||||
match s.read(&mut chunk) {
|
||||
Ok(0) => *eof = true,
|
||||
Ok(n) => buf.extend_from_slice(&chunk[..n]),
|
||||
@@ -119,6 +147,7 @@ mod imp {
|
||||
Err(_) => *eof = true,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn run(op: &str, id: u32, p: &J) -> J {
|
||||
@@ -156,29 +185,27 @@ mod imp {
|
||||
Err(e) => err(format!("cannot listen on port {port}: {e}")),
|
||||
}
|
||||
}
|
||||
"close" => {
|
||||
close(id);
|
||||
json!({})
|
||||
}
|
||||
_ => {
|
||||
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if op == "close" {
|
||||
t.socks.remove(&id);
|
||||
return json!({});
|
||||
}
|
||||
let Some(sock) = t.socks.get_mut(&id) else {
|
||||
let Some(entry) = get(id) else {
|
||||
return match op {
|
||||
"connected" => json!({"value": false}),
|
||||
"eof" => json!({"value": true}),
|
||||
_ => err("the socket is not open"),
|
||||
};
|
||||
};
|
||||
match (op, sock) {
|
||||
let mut sock = entry.sock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match (op, &mut *sock) {
|
||||
("poll", Sock::Listener(l)) => match l.accept() {
|
||||
Ok((s, peer)) => {
|
||||
let _ = s.set_nonblocking(false);
|
||||
if let Err(e) = s.set_nonblocking(false) {
|
||||
return err(format!("cannot use the connection from {peer}: {e}"));
|
||||
}
|
||||
let host = peer.to_string();
|
||||
let new = Sock::Stream { s, buf: vec![], eof: false };
|
||||
let nid = t.next;
|
||||
t.next += 1;
|
||||
t.socks.insert(nid, new);
|
||||
json!({"id": nid, "host": host})
|
||||
json!({"id": add(Sock::Stream { s, buf: vec![], eof: false }), "host": host})
|
||||
}
|
||||
Err(_) => json!({"id": null}),
|
||||
},
|
||||
@@ -202,19 +229,25 @@ mod imp {
|
||||
let count = p.get("count").and_then(J::as_i64).unwrap_or(-1);
|
||||
let limit = timeout(p);
|
||||
if count < 0 {
|
||||
fill(s, buf, eof, limit, |_| false);
|
||||
if let Err(e) = fill(s, buf, eof, limit, |_| false) {
|
||||
return err(format!("cannot set the read timeout: {e}"));
|
||||
}
|
||||
let all = std::mem::take(buf);
|
||||
json!({"data": decode(&all, utf8)})
|
||||
} else {
|
||||
let n = count as usize;
|
||||
fill(s, buf, eof, limit, |b| b.len() >= n);
|
||||
if let Err(e) = fill(s, buf, eof, limit, |b| b.len() >= n) {
|
||||
return err(format!("cannot set the read timeout: {e}"));
|
||||
}
|
||||
let take = n.min(buf.len());
|
||||
let out: Vec<u8> = buf.drain(..take).collect();
|
||||
json!({"data": decode(&out, utf8)})
|
||||
}
|
||||
}
|
||||
("readln", Sock::Stream { s, buf, eof }) => {
|
||||
fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n'));
|
||||
if let Err(e) = fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')) {
|
||||
return err(format!("cannot set the read timeout: {e}"));
|
||||
}
|
||||
let line: Vec<u8> = match buf.iter().position(|&c| c == b'\n') {
|
||||
Some(i) => {
|
||||
let mut l: Vec<u8> = buf.drain(..=i).collect();
|
||||
@@ -229,9 +262,19 @@ mod imp {
|
||||
json!({"data": decode(&line, utf8)})
|
||||
}
|
||||
("write", Sock::Stream { s, .. }) => {
|
||||
// A peer that stops reading fills the buffers: give up after `timeout`
|
||||
// instead of blocking the script for good (#1248).
|
||||
let limit = timeout(p).max(Duration::from_millis(1));
|
||||
if let Err(e) = s.set_write_timeout(Some(limit)) {
|
||||
return err(format!("cannot set the write timeout: {e}"));
|
||||
}
|
||||
let data = p.get("data").and_then(J::as_str).unwrap_or("");
|
||||
match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) {
|
||||
Ok(()) => json!({"ok": true}),
|
||||
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => err(format!(
|
||||
"write timed out after {} s: the other side isn't reading (some of the data may have been sent)",
|
||||
limit.as_secs_f64()
|
||||
)),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
@@ -242,3 +285,82 @@ mod imp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod tests {
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value as J, json};
|
||||
|
||||
use super::imp::run;
|
||||
|
||||
/// A loopback peer that accepts one connection and never reads from it; returns its
|
||||
/// `host:port` and the accepted stream's keeper (drop it to let the connection go).
|
||||
fn stalled_peer() -> (String, mpsc::Sender<()>) {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let host = l.local_addr().unwrap().to_string();
|
||||
let (stop, wait) = mpsc::channel::<()>();
|
||||
std::thread::spawn(move || {
|
||||
let (_s, _): (TcpStream, _) = l.accept().unwrap();
|
||||
let _ = wait.recv_timeout(Duration::from_secs(120));
|
||||
});
|
||||
(host, stop)
|
||||
}
|
||||
|
||||
fn open(host: &str) -> u32 {
|
||||
let r = run("open", 0, &json!({"host": host, "timeout": 5}));
|
||||
r["id"].as_u64().unwrap_or_else(|| panic!("open {host}: {r}")) as u32
|
||||
}
|
||||
|
||||
/// More than the loopback buffers hold, so a peer that doesn't read stops the write.
|
||||
fn big() -> String {
|
||||
"x".repeat(64 * 1024 * 1024)
|
||||
}
|
||||
|
||||
/// Run `f` on a thread; its result, or `None` if it takes longer than `limit`.
|
||||
fn within<T: Send + 'static>(limit: Duration, f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(f());
|
||||
});
|
||||
rx.recv_timeout(limit).ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_to_a_peer_that_stops_reading_fails_after_the_timeout() {
|
||||
// #1248: it used to block for good.
|
||||
let (host, _keep) = stalled_peer();
|
||||
let id = open(&host);
|
||||
let data = big();
|
||||
let t0 = Instant::now();
|
||||
let r = within(Duration::from_secs(20), move || run("write", id, &json!({"data": data, "timeout": 1}))).expect("the write gave up");
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("timed out")), "{r}");
|
||||
assert!(t0.elapsed() < Duration::from_secs(15), "{:?}", t0.elapsed());
|
||||
run("close", id, &J::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blocked_socket_doesnt_hold_up_the_others_and_close_interrupts_it() {
|
||||
let (host_a, _keep_a) = stalled_peer();
|
||||
let (host_b, _keep_b) = stalled_peer();
|
||||
let (a, b) = (open(&host_a), open(&host_b));
|
||||
// A write on A that would wait a minute.
|
||||
let data = big();
|
||||
let (tx, blocked) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(run("write", a, &json!({"data": data, "timeout": 60})));
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
// Socket B answers meanwhile (the table isn't locked by A's write).
|
||||
let r = within(Duration::from_secs(3), move || run("connected", b, &json!({}))).expect("socket B answered while A was blocked");
|
||||
assert_eq!(r["value"], true, "{r}");
|
||||
// Closing A returns at once and ends A's write.
|
||||
within(Duration::from_secs(3), move || run("close", a, &J::Null)).expect("close returned while A was blocked");
|
||||
let r = blocked.recv_timeout(Duration::from_secs(5)).expect("A's write ended when A was closed");
|
||||
assert!(r.get("error").is_some(), "{r}");
|
||||
run("close", b, &J::Null);
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,65 @@ fn fill_parameters_use_documented_match_names() {
|
||||
assert_eq!(o.result, json!("Color|All Masks|ADBE Fill-0007"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_parameters_use_after_effects_match_names() {
|
||||
// #1405: `ADBE Stroke-0002` is Stroke's Color in After Effects, not All Masks.
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"var c = app.project.items.addComp("S", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1);
|
||||
var fx = l.property("ADBE Effect Parade").addProperty("ADBE Stroke"), out = [];
|
||||
for (var i = 1; i <= 11; i++) out.push(fx.property("ADBE Stroke-" + ("000" + i).slice(-4)).name);
|
||||
out.push(fx.property("Color").matchName); out"#,
|
||||
);
|
||||
assert_eq!(
|
||||
o.result,
|
||||
json!([
|
||||
"Path",
|
||||
"Color",
|
||||
"Brush Size",
|
||||
"Brush Hardness",
|
||||
"Opacity",
|
||||
"Spacing",
|
||||
"Paint Style",
|
||||
"Start",
|
||||
"End",
|
||||
"All Masks",
|
||||
"Stroke Sequentially",
|
||||
"ADBE Stroke-0002"
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_saturation_parameters_use_after_effects_match_names() {
|
||||
// #1405: `ADBE HUE SATURATION-0004` is Master Hue in After Effects. The per-channel controls,
|
||||
// which After Effects doesn't expose, are numbered after its parameters so none takes an AE number.
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"var c = app.project.items.addComp("H", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1);
|
||||
var fx = l.property("ADBE Effect Parade").addProperty("ADBE HUE SATURATION"), out = [];
|
||||
var n = [2, 4, 5, 6, 7, 8, 9, 10];
|
||||
for (var i = 0; i < n.length; i++) out.push(fx.property("ADBE HUE SATURATION-" + ("000" + n[i]).slice(-4)).name);
|
||||
out.push(fx.property("Red Hue").matchName); out"#,
|
||||
);
|
||||
assert_eq!(
|
||||
o.result,
|
||||
json!([
|
||||
"Channel Control",
|
||||
"Master Hue",
|
||||
"Master Saturation",
|
||||
"Master Lightness",
|
||||
"Colorize",
|
||||
"Colorize Hue",
|
||||
"Colorize Saturation",
|
||||
"Colorize Lightness",
|
||||
"ADBE HUE SATURATION-0011"
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_steps_without_a_group() {
|
||||
let mut s = session();
|
||||
@@ -1326,6 +1385,50 @@ fn key_interpolation_and_ease_apply_outside_the_active_comp() {
|
||||
assert!(v[3].as_f64().is_some_and(|x| (x - -44.5).abs() > 1.0), "{v}");
|
||||
}
|
||||
|
||||
/// #1531: a key the user selected in the property's comp stays selected, and only the named key
|
||||
/// changes (on the old code the change went to the selected key instead).
|
||||
/// The active comp's own key selection is untouched.
|
||||
#[test]
|
||||
fn a_per_key_method_in_another_comp_changes_only_its_key_and_keeps_both_selections() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var C = app.project.items.addComp('C', 100, 100, 1, 10, 20);
|
||||
var p = C.layers.addNull().transform.rotation;
|
||||
p.setValuesAtTimes([0, 1, 2], [0, 90, 180]);
|
||||
var A = app.project.items.addComp('A', 100, 100, 1, 10, 20);
|
||||
A.openInViewer();
|
||||
var q = A.layers.addNull().transform.opacity;
|
||||
q.setValuesAtTimes([0, 1], [0, 100]);
|
||||
app.run('edit.deselectAll', {});
|
||||
q.setSelectedAtKey(2, true);
|
||||
// the user selected key 3 of C earlier: C remembers it while A is active
|
||||
p.setSelectedAtKey(3, true);
|
||||
p.setInterpolationTypeAtKey(1, KeyframeInterpolationType.HOLD);
|
||||
p.setTemporalEaseAtKey(2, [new KeyframeEase(0, 80)]);
|
||||
var hold = KeyframeInterpolationType.HOLD;
|
||||
var out = [p.keyOutInterpolationType(1) == hold, p.keyOutInterpolationType(3) == hold, p.keyInInterpolationType(3) == hold,
|
||||
p.keyInTemporalEase(2)[0].influence, p.keyInTemporalEase(3)[0].influence,
|
||||
q.keySelected(1), q.keySelected(2)];
|
||||
p.removeKey(1);
|
||||
out.push(p.numKeys, p.keyTime(1), p.keyValue(1), q.keySelected(2), app.project.activeItem.name);
|
||||
// C's remembered selection is live again once C is active: still the user's key (now 2)
|
||||
C.openInViewer();
|
||||
out.push(p.keySelected(1), p.keySelected(2));
|
||||
out;
|
||||
"#,
|
||||
);
|
||||
let v = o.result.as_array().unwrap();
|
||||
let ease = |i: usize| v[i].as_f64().unwrap();
|
||||
assert_eq!(&v[..3], &[json!(true), json!(false), json!(false)], "only key 1 holds: {v:?}");
|
||||
assert!((ease(3) - 80.0).abs() < 1e-6, "key 2 eased: {v:?}");
|
||||
assert!((ease(4) - 80.0).abs() > 1e-6, "key 3 untouched: {v:?}");
|
||||
assert_eq!(&v[5..7], &[json!(false), json!(true)], "A's key selection kept: {v:?}");
|
||||
assert_eq!(&v[7..12], &[json!(2), json!(1), json!(90), json!(true), json!("A")], "key 1 removed, A still active: {v:?}");
|
||||
assert_eq!(&v[12..], &[json!(false), json!(true)], "C's key selection kept, the target key not added: {v:?}");
|
||||
}
|
||||
|
||||
/// #808: sourcePointToComp and compPointToSource follow the layer's transform.
|
||||
#[test]
|
||||
fn source_point_conversions_follow_the_layer_transform() {
|
||||
|
||||
@@ -180,7 +180,8 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() {
|
||||
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == PANEL && e["panel"] == true && e["source"] == "extension"));
|
||||
let steps = s.history.undo.len();
|
||||
set(&mut s, win, "presets", json!("Ease In-Out"));
|
||||
assert_eq!(status(&mut s, win), "Eased 3 keyframe pair(s) with \u{201c}Ease In-Out\u{201d}");
|
||||
// In-Out (the default) on every key: each pair gets the whole curve, as before.
|
||||
assert_eq!(status(&mut s, win), "Eased 5 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}");
|
||||
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
|
||||
assert_eq!(s.history.undo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset"));
|
||||
assert_eq!(s.state.selected_keys, selected, "the keys stay selected for the next preset");
|
||||
@@ -203,10 +204,298 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() {
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
let steps = s.history.undo.len();
|
||||
set(&mut s, win, "presets", json!("Linear"));
|
||||
assert_eq!(status(&mut s, win), "Select two or more neighbouring keyframes of a property");
|
||||
assert_eq!(status(&mut s, win), "Select keyframes with another keyframe next to it");
|
||||
assert_eq!(s.history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// Select only the keys at `keys` (property path, time in seconds).
|
||||
fn select_keys(s: &mut Session, l: u64, keys: &[(&str, f64)]) {
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
let keys: Vec<Value> = keys.iter().map(|(path, t)| json!({"layer": l, "path": path, "time": t})).collect();
|
||||
s.execute("keys.select", json!({"keys": keys})).unwrap();
|
||||
}
|
||||
|
||||
/// Type a curve (Out %, Out speed, In %, In speed), choose the sides to ease and click Apply.
|
||||
fn apply_typed(s: &mut Session, win: u64, mode: &str, c: [f64; 4]) {
|
||||
for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) {
|
||||
set(s, win, name, json!(v.to_string()));
|
||||
}
|
||||
set(s, win, "mode", json!(mode));
|
||||
click(s, win, "apply");
|
||||
}
|
||||
|
||||
fn eased(e: Ease, speed: f64, influence: f64) -> bool {
|
||||
close(e.speed, speed, 1e-9) && close(e.influence, influence, 1e-9)
|
||||
}
|
||||
|
||||
/// #1329: one selected keyframe eases its incoming side (In), its outgoing side (Out) or both,
|
||||
/// each scaled to the segment on that side (+100/s in, −40/s out here), without changing its
|
||||
/// neighbours, key times, values or the selection; one undo step restores it.
|
||||
#[test]
|
||||
fn one_selected_keyframe_eases_its_in_out_or_both_sides() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
|
||||
let win = open_panel(&mut s);
|
||||
// In-Out is the default.
|
||||
assert_eq!(control(&mut s, win, "mode")["selection"], json!([2]));
|
||||
let before = prop(&s, l, "transform/opacity");
|
||||
for mode in ["In", "Out", "In-Out"] {
|
||||
select_keys(&mut s, l, &[("transform/opacity", 1.0)]);
|
||||
let selected = s.state.selected_keys.clone();
|
||||
let steps = s.history.undo.len();
|
||||
apply_typed(&mut s, win, mode, [30.0, 0.5, 60.0, 2.0]);
|
||||
assert_eq!(status(&mut s, win), format!("Eased 1 keyframe(s) ({mode}) with the curve"));
|
||||
assert_eq!(s.history.undo.len(), steps + 1, "{mode}: one undo step");
|
||||
assert_eq!(s.state.selected_keys, selected, "{mode}: the selection stays");
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert_eq!((&op.keys[0], &op.keys[2]), (&before.keys[0], &before.keys[2]), "{mode}: the neighbours don't change");
|
||||
assert_eq!((op.keys[1].time, op.keys[1].value.as_f64()), (before.keys[1].time, 100.0), "{mode}");
|
||||
let k = &op.keys[1];
|
||||
let (ins, outs) = (mode != "Out", mode != "In");
|
||||
assert_eq!(k.in_interp, if ins { Interp::Bezier } else { Interp::Linear }, "{mode}");
|
||||
assert_eq!(k.out_interp, if outs { Interp::Bezier } else { Interp::Linear }, "{mode}");
|
||||
if ins {
|
||||
assert!(eased(k.in_ease[0], 2.0 * 100.0, 0.6), "{mode}: {:?}", k.in_ease);
|
||||
} else {
|
||||
assert!(close(op.value_at(at(0.5)).as_f64(), 50.0, 1e-6), "{mode}: the in side still plays linearly");
|
||||
}
|
||||
if outs {
|
||||
assert!(eased(k.out_ease[0], 0.5 * -40.0, 0.3), "{mode}: {:?}", k.out_ease);
|
||||
} else {
|
||||
assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6), "{mode}: the out side still plays linearly");
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys, "{mode}: undone");
|
||||
}
|
||||
}
|
||||
|
||||
/// A side without a neighbouring key isn't eased; when no selected key has one, nothing changes
|
||||
/// (no undo step) and the panel says why. Keys that aren't neighbours, and keys of several
|
||||
/// properties, each ease on their own.
|
||||
#[test]
|
||||
fn first_last_and_scattered_keyframes_ease_the_sides_that_have_a_segment() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
|
||||
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]);
|
||||
let win = open_panel(&mut s);
|
||||
let before = prop(&s, l, "transform/opacity");
|
||||
let c = [30.0, 0.5, 60.0, 2.0];
|
||||
for (mode, t, why) in [("In", 0.0, "before it"), ("Out", 3.0, "after it")] {
|
||||
select_keys(&mut s, l, &[("transform/opacity", t)]);
|
||||
let steps = s.history.undo.len();
|
||||
apply_typed(&mut s, win, mode, c);
|
||||
assert_eq!(status(&mut s, win), format!("Select keyframes with another keyframe {why}"));
|
||||
assert_eq!(s.history.undo.len(), steps, "{mode}: no undo step");
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
|
||||
}
|
||||
// In-Out on the first key eases only its out side.
|
||||
select_keys(&mut s, l, &[("transform/opacity", 0.0)]);
|
||||
apply_typed(&mut s, win, "In-Out", c);
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert_eq!((op.keys[0].in_interp, op.keys[0].out_interp), (Interp::Linear, Interp::Bezier));
|
||||
assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease);
|
||||
assert_eq!(op.keys[1..], before.keys[1..]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// The first and last Opacity keys (not neighbours) and the last Rotation key.
|
||||
select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 3.0), ("transform/rotation", 2.0)]);
|
||||
let steps = s.history.undo.len();
|
||||
apply_typed(&mut s, win, "In-Out", c);
|
||||
assert_eq!(status(&mut s, win), "Eased 3 keyframe(s) (In-Out) with the curve");
|
||||
assert_eq!(s.history.undo.len(), steps + 1);
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease);
|
||||
assert!(eased(op.keys[2].in_ease[0], 2.0 * -40.0, 0.6), "{:?}", op.keys[2].in_ease);
|
||||
assert_eq!(op.keys[1], before.keys[1], "the unselected middle key");
|
||||
let rot = prop(&s, l, "transform/rotation");
|
||||
assert!(eased(rot.keys[1].in_ease[0], 2.0 * 45.0, 0.6), "{:?}", rot.keys[1].in_ease);
|
||||
assert_eq!(rot.keys[0].out_interp, Interp::Linear, "the unselected neighbour");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
|
||||
assert!(prop(&s, l, "transform/rotation").keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear));
|
||||
}
|
||||
|
||||
/// A change from the pair-only panel: In-Out on a selected pair also eases the pair's outer sides
|
||||
/// that face an unselected key (the selected keys' own sides), and Out on the pair eases only
|
||||
/// out sides.
|
||||
#[test]
|
||||
fn a_selected_pair_eases_the_sides_its_mode_names() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
|
||||
let win = open_panel(&mut s);
|
||||
let before = prop(&s, l, "transform/opacity");
|
||||
let c = [30.0, 0.5, 60.0, 2.0];
|
||||
select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 1.0)]);
|
||||
apply_typed(&mut s, win, "In-Out", c);
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].in_ease[0], 200.0, 0.6));
|
||||
assert!(eased(op.keys[1].out_ease[0], -20.0, 0.3), "the side facing the unselected key: {:?}", op.keys[1].out_ease);
|
||||
assert_eq!(op.keys[2], before.keys[2]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
apply_typed(&mut s, win, "Out", c);
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].out_ease[0], -20.0, 0.3));
|
||||
assert_eq!((op.keys[0].in_interp, op.keys[1].in_interp), (Interp::Linear, Interp::Linear));
|
||||
}
|
||||
|
||||
/// Easing one side of a key leaves the other playing as it did: a Hold stays a Hold, and an
|
||||
/// auto-Bezier side keeps its shape. A preset click uses the chosen sides too.
|
||||
#[test]
|
||||
fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
|
||||
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]);
|
||||
ok(
|
||||
&mut s,
|
||||
r#"var t = app.project.activeItem.layer(1).transform, K = KeyframeInterpolationType;
|
||||
t.opacity.setInterpolationTypeAtKey(2, K.LINEAR, K.HOLD);
|
||||
t.rotation.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER);
|
||||
t.rotation.setTemporalAutoBezierAtKey(2, true);"#,
|
||||
);
|
||||
let rot = prop(&s, l, "transform/rotation");
|
||||
let after: Vec<f64> = [1.5, 2.0, 2.5].iter().map(|t| rot.value_at(at(*t)).as_f64()).collect();
|
||||
let win = open_panel(&mut s);
|
||||
set(&mut s, win, "mode", json!("In"));
|
||||
select_keys(&mut s, l, &[("transform/opacity", 1.0), ("transform/rotation", 1.0)]);
|
||||
set(&mut s, win, "presets", json!("Ease In-Out"));
|
||||
assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In) with \u{201c}Ease In-Out\u{201d}");
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert_eq!((op.keys[1].in_interp, op.keys[1].out_interp), (Interp::Bezier, Interp::Hold));
|
||||
assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5), "{:?}", op.keys[1].in_ease);
|
||||
assert_eq!(op.value_at(at(2.0)).as_f64(), 100.0, "still holds");
|
||||
let eased_rot = prop(&s, l, "transform/rotation");
|
||||
assert!(eased(eased_rot.keys[1].in_ease[0], 0.0, 0.5), "{:?}", eased_rot.keys[1].in_ease);
|
||||
for (t, want) in [1.5, 2.0, 2.5].iter().zip(after) {
|
||||
assert!(close(eased_rot.value_at(at(*t)).as_f64(), want, 1e-6), "the auto-Bezier out side at {t}");
|
||||
}
|
||||
assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed");
|
||||
}
|
||||
|
||||
/// Roving keys pass speed straight through (the engine keeps their sides linear), so an ease set
|
||||
/// on one wouldn't play: they are skipped and the panel says so, rather than reporting them eased.
|
||||
#[test]
|
||||
fn roving_keyframes_are_skipped_and_reported() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([200, 0, 0]))]);
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]);
|
||||
ok(&mut s, "app.project.activeItem.layer(1).transform.position.setRovingAtKey(2, true)");
|
||||
let pos = prop(&s, l, "transform/position");
|
||||
assert!(pos.keys[1].roving);
|
||||
let roving_at = pos.keys[1].time.seconds();
|
||||
let win = open_panel(&mut s);
|
||||
// Only the roving key: nothing changes, no undo step.
|
||||
select_keys(&mut s, l, &[("transform/position", roving_at)]);
|
||||
let steps = s.history.undo.len();
|
||||
set(&mut s, win, "presets", json!("Ease In-Out"));
|
||||
assert_eq!(status(&mut s, win), "Nothing eased: skipped 1 roving keyframe(s); turn off Roving to ease them");
|
||||
assert_eq!(s.history.undo.len(), steps);
|
||||
assert_eq!(prop(&s, l, "transform/position").keys, pos.keys);
|
||||
// With an ordinary key: that one is eased, the roving one is counted as skipped.
|
||||
select_keys(&mut s, l, &[("transform/position", roving_at), ("transform/opacity", 1.0)]);
|
||||
set(&mut s, win, "presets", json!("Linear"));
|
||||
set(&mut s, win, "presets", json!("Ease In-Out"));
|
||||
assert_eq!(
|
||||
status(&mut s, win),
|
||||
"Eased 1 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}; skipped 1 roving keyframe(s); turn off Roving to ease them"
|
||||
);
|
||||
let k = &prop(&s, l, "transform/position").keys[1];
|
||||
assert!(k.roving && k.in_interp == Interp::Linear && k.out_interp == Interp::Linear, "{k:?}");
|
||||
let op = prop(&s, l, "transform/opacity");
|
||||
assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5));
|
||||
}
|
||||
|
||||
/// Out alone leaves an auto-Bezier in side playing as it did, and undo and redo restore each state.
|
||||
#[test]
|
||||
fn out_alone_keeps_the_in_side_playing_through_undo_and_redo() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]);
|
||||
ok(
|
||||
&mut s,
|
||||
r#"var r = app.project.activeItem.layer(1).transform.rotation, K = KeyframeInterpolationType;
|
||||
r.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER);
|
||||
r.setTemporalAutoBezierAtKey(2, true);"#,
|
||||
);
|
||||
let before = prop(&s, l, "transform/rotation");
|
||||
let win = open_panel(&mut s);
|
||||
select_keys(&mut s, l, &[("transform/rotation", 1.0)]);
|
||||
apply_typed(&mut s, win, "Out", [60.0, 0.0, 33.0, 1.0]);
|
||||
let after = prop(&s, l, "transform/rotation");
|
||||
assert!(eased(after.keys[1].out_ease[0], 0.0, 0.6), "{:?}", after.keys[1].out_ease);
|
||||
for t in [0.25, 0.5, 0.75] {
|
||||
assert!(close(after.value_at(at(t)).as_f64(), before.value_at(at(t)).as_f64(), 1e-6), "the in side at {t}");
|
||||
}
|
||||
assert!(!close(after.value_at(at(2.0)).as_f64(), before.value_at(at(2.0)).as_f64(), 1e-3), "the out side changed");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/rotation").keys, before.keys);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/rotation").keys, after.keys);
|
||||
}
|
||||
|
||||
/// On a curved path, a single key's in and out speeds follow the length of the path on each side
|
||||
/// (unequal lengths and durations), and the side not eased plays as it did.
|
||||
#[test]
|
||||
fn a_single_key_on_a_curved_path_eases_along_each_side() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([100, 300, 0]))]);
|
||||
let before = prop(&s, l, "transform/position");
|
||||
let (len_in, len_out) = (effectcraft_keyframe::spatial_segment_length(&before.keys, 0), effectcraft_keyframe::spatial_segment_length(&before.keys, 1));
|
||||
let win = open_panel(&mut s);
|
||||
select_keys(&mut s, l, &[("transform/position", 1.0)]);
|
||||
apply_typed(&mut s, win, "In", [40.0, 1.5, 50.0, 2.0]);
|
||||
let pos = prop(&s, l, "transform/position");
|
||||
let k = &pos.keys[1];
|
||||
// In speed: twice the incoming segment's average (its path length over 1 s).
|
||||
assert!(k.in_ease.iter().all(|e| close(e.speed, 2.0 * len_in, 0.05 * len_in) && close(e.influence, 0.5, 1e-9)), "{:?} vs {len_in}", k.in_ease);
|
||||
// The out side (2 s along a longer path) plays as before.
|
||||
for t in [1.5, 2.0, 2.5] {
|
||||
let (a, b) = (pos.value_at(at(t)).as_vec2(), before.value_at(at(t)).as_vec2());
|
||||
assert!(close(a[0], b[0], 1e-6) && close(a[1], b[1], 1e-6), "out side at {t}: {a:?} vs {b:?}");
|
||||
}
|
||||
// Playback on the in side: arriving at twice the average speed, the last tenth of a second
|
||||
// covers more of the path than a constant speed would (len_in / 10).
|
||||
let (p9, p10) = (pos.value_at(at(0.9)).as_vec2(), pos.value_at(at(1.0)).as_vec2());
|
||||
let step = ((p10[0] - p9[0]).powi(2) + (p10[1] - p9[1]).powi(2)).sqrt();
|
||||
assert!(step > len_in / 10.0 * 1.2, "{step} vs {len_in}");
|
||||
assert!(len_out > len_in, "{len_out} {len_in}");
|
||||
}
|
||||
|
||||
/// On one key, each side scales to its own segment: per dimension, and along the motion path.
|
||||
#[test]
|
||||
fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
let l = setup(&mut s);
|
||||
// Scale: +100/−50 per second into the key, 0/+50 per second out of it.
|
||||
animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (3.0, json!([200, 150]))]);
|
||||
// Position on a straight path: 100 px/s into the key, 50 px/s out of it.
|
||||
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([200, 0, 0]))]);
|
||||
let win = open_panel(&mut s);
|
||||
select_keys(&mut s, l, &[("transform/scale", 1.0), ("transform/position", 1.0)]);
|
||||
apply_typed(&mut s, win, "In-Out", [40.0, 1.5, 50.0, 2.0]);
|
||||
assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In-Out) with the curve");
|
||||
let k = prop(&s, l, "transform/scale").keys[1].clone();
|
||||
assert!(eased(k.in_ease[0], 200.0, 0.5) && eased(k.in_ease[1], -100.0, 0.5), "{:?}", k.in_ease);
|
||||
assert!(eased(k.out_ease[0], 0.0, 0.4) && eased(k.out_ease[1], 75.0, 0.4), "{:?}", k.out_ease);
|
||||
let k = prop(&s, l, "transform/position").keys[1].clone();
|
||||
assert!(k.in_ease.iter().all(|e| close(e.speed, 200.0, 0.5) && close(e.influence, 0.5, 1e-9)), "{:?}", k.in_ease);
|
||||
assert!(k.out_ease.iter().all(|e| close(e.speed, 75.0, 0.5) && close(e.influence, 0.4, 1e-9)), "{:?}", k.out_ease);
|
||||
let pos = prop(&s, l, "transform/position");
|
||||
assert_eq!(pos.value_at(at(1.0)).as_vec2()[0], 100.0, "the key's value stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typed_curve_scales_to_each_dimension_and_follows_motion_paths() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! that keeps files in memory (the web editor's browser storage) answers for its own files.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use effectcraft_engine::{Services, Session};
|
||||
|
||||
@@ -13,14 +13,19 @@ struct Mem(Mutex<HashMap<String, Vec<u8>>>);
|
||||
|
||||
impl Services for Mem {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
self.0.lock().unwrap().get(path).cloned().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get(path)
|
||||
.cloned()
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.0.lock().unwrap().insert(path.into(), data.into());
|
||||
self.0.lock().unwrap_or_else(PoisonError::into_inner).insert(path.into(), data.into());
|
||||
Ok(())
|
||||
}
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
self.0.lock().unwrap().contains_key(path)
|
||||
self.0.lock().unwrap_or_else(PoisonError::into_inner).contains_key(path)
|
||||
}
|
||||
fn is_dir(&self, path: &str) -> bool {
|
||||
path == "/browser/footage"
|
||||
|
||||
+23
-3
@@ -509,6 +509,12 @@ struct Parser<'a, 'i> {
|
||||
vw: f64,
|
||||
vh: f64,
|
||||
skipped: Vec<String>,
|
||||
/// Elements converted so far, `<use>` copies included, and how many may be (#1236).
|
||||
work: usize,
|
||||
budget: usize,
|
||||
/// The `<use>` targets being expanded, outermost first: a reference back into one is a
|
||||
/// cycle and is skipped.
|
||||
using: Vec<roxmltree::NodeId>,
|
||||
}
|
||||
|
||||
fn is_svg_ns(n: &roxmltree::Node) -> bool {
|
||||
@@ -771,9 +777,12 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
|
||||
/// Convert an element (and its subtree) into nodes appended to `out`.
|
||||
fn node(&mut self, n: roxmltree::Node<'a, 'i>, parent: &Style, out: &mut Vec<Node>, depth: usize) {
|
||||
if depth > 64 || !is_svg_ns(&n) {
|
||||
if depth > 64 || !is_svg_ns(&n) || self.work > self.budget {
|
||||
return;
|
||||
}
|
||||
// Every element converted counts, each `<use>` copy again: a few references to groups
|
||||
// that reference each other several times can multiply into billions (#1236).
|
||||
self.work += 1;
|
||||
let tag = n.tag_name().name();
|
||||
match tag {
|
||||
"defs" | "title" | "desc" | "metadata" | "style" | "linearGradient" | "radialGradient" | "symbol" | "clipPath" | "mask" | "pattern" | "marker"
|
||||
@@ -839,9 +848,10 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
}
|
||||
"use" => {
|
||||
let Some(target) = self.href(n) else { return };
|
||||
if target == n || n.ancestors().any(|a| a == target) {
|
||||
if target == n || n.ancestors().any(|a| a == target) || self.using.contains(&target.id()) {
|
||||
return;
|
||||
}
|
||||
self.using.push(target.id());
|
||||
let x = self.attr_len(n, "x", 'x', &st);
|
||||
let y = self.attr_len(n, "y", 'y', &st);
|
||||
transform *= Affine::translate((x, y));
|
||||
@@ -870,6 +880,7 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
} else {
|
||||
self.node(target, &st, &mut children, depth + 1);
|
||||
}
|
||||
self.using.pop();
|
||||
out.push(Node::Group(Group {
|
||||
name: Self::element_name(n),
|
||||
transform,
|
||||
@@ -1071,6 +1082,9 @@ pub fn parse_transform(s: &str) -> Affine {
|
||||
/// 2 MiB thread's stack (aborting the app) at a few thousand levels; real documents stay far
|
||||
/// below this.
|
||||
const MAX_XML_DEPTH: usize = 256;
|
||||
/// The most elements an import converts, `<use>` copies included, unless the file itself has
|
||||
/// more than half as many (#1236).
|
||||
const MAX_EXPANDED_ELEMENTS: usize = 500_000;
|
||||
|
||||
/// The deepest element nesting of `text` (a quick scan that skips comments, CDATA, processing
|
||||
/// instructions, declarations and quoted attribute values).
|
||||
@@ -1144,7 +1158,9 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
|
||||
}
|
||||
}
|
||||
let vb = viewbox(root);
|
||||
let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![] };
|
||||
// Room for generous reuse, and always for every element of the file itself.
|
||||
let budget = MAX_EXPANDED_ELEMENTS.max(root.descendants().filter(|n| n.is_element()).count().saturating_mul(2));
|
||||
let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![], work: 0, budget, using: vec![] };
|
||||
let st = Style::default();
|
||||
let pct_or = |name: &str, axis: char, def: f64| -> f64 {
|
||||
match root.attribute(name) {
|
||||
@@ -1180,6 +1196,10 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
|
||||
for c in root.children() {
|
||||
p.node(c, &rst, &mut children, 1);
|
||||
}
|
||||
if p.work > p.budget {
|
||||
// Never a silently partial drawing.
|
||||
return Err(Error::Xml(format!("its <use> references expand to more than {} elements", p.budget)));
|
||||
}
|
||||
let transform = viewbox_transform(root, dw, dh);
|
||||
Ok(Doc {
|
||||
width: dw,
|
||||
|
||||
@@ -217,3 +217,63 @@ fn deep_nesting_errors_instead_of_overflowing_the_stack() {
|
||||
assert_eq!(crate::xml_depth(&s), 2);
|
||||
assert!(crate::parse(s.as_bytes()).is_ok());
|
||||
}
|
||||
|
||||
/// How deep `g`'s groups nest, and how many shapes it holds.
|
||||
fn depth_and_shapes(g: &Group) -> (usize, usize) {
|
||||
let mut depth = 0;
|
||||
let mut shapes = 0;
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Group(sub) => {
|
||||
let (d, s) = depth_and_shapes(sub);
|
||||
depth = depth.max(d + 1);
|
||||
shapes += s;
|
||||
}
|
||||
Node::Shape(_) => shapes += 1,
|
||||
Node::Image(_) => {}
|
||||
}
|
||||
}
|
||||
(depth, shapes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branching_use_references_stop_at_the_expansion_budget() {
|
||||
// Each group uses the one before twice: 30 levels are 2^30 copies of one rectangle, from a
|
||||
// file of a few kilobytes (#1236).
|
||||
let mut defs = String::from(r#"<g id="g0"><rect width="1" height="1"/></g>"#);
|
||||
for i in 1..=30 {
|
||||
defs += &format!(r##"<g id="g{i}"><use href="#g{p}"/><use href="#g{p}" x="1"/></g>"##, p = i - 1);
|
||||
}
|
||||
let svg = format!(r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><defs>{defs}</defs><use href="#g30"/></svg>"##);
|
||||
let t0 = std::time::Instant::now();
|
||||
let e = parse(svg.as_bytes()).unwrap_err();
|
||||
assert!(e.to_string().contains("expand to more than"), "{e}");
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed());
|
||||
// Below the budget the same pattern imports whole: 2^10 rectangles.
|
||||
let small = svg.replace(r##"<use href="#g30"/></svg>"##, r##"<use href="#g10"/></svg>"##);
|
||||
let doc = parse(small.as_bytes()).unwrap();
|
||||
assert_eq!(depth_and_shapes(&doc.root).1, 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutually_referencing_uses_are_cut_at_the_first_repeat() {
|
||||
// A uses B and B uses A, neither inside the other: once each, not down to the depth limit.
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><defs>
|
||||
<g id="a"><rect width="1" height="1"/><use href="#b"/></g>
|
||||
<g id="b"><rect width="2" height="2"/><use href="#a"/></g>
|
||||
</defs><use href="#a"/></svg>"##;
|
||||
let doc = parse(svg.as_bytes()).unwrap();
|
||||
let (depth, shapes) = depth_and_shapes(&doc.root);
|
||||
assert_eq!(shapes, 2, "a's and b's rectangle, once each");
|
||||
assert!(depth <= 6, "{depth}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_used_a_thousand_times_still_imports() {
|
||||
let uses: String = (0..1000).map(|i| format!(r##"<use href="#icon" x="{i}"/>"##)).collect();
|
||||
let svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><defs><symbol id="icon"><rect width="1" height="1"/><circle r="1"/><ellipse rx="1" ry="2"/></symbol></defs>{uses}</svg>"##
|
||||
);
|
||||
let doc = parse(svg.as_bytes()).unwrap();
|
||||
assert_eq!(depth_and_shapes(&doc.root).1, 3000);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ pub(crate) struct FontEnv {
|
||||
pub xdg_config_home: Option<PathBuf>,
|
||||
/// `FONTCONFIG_FILE`: the main configuration file instead of the standard ones.
|
||||
pub fontconfig_file: Option<PathBuf>,
|
||||
/// `FONTCONFIG_PATH`: ordered search folders, before the standard configuration folders.
|
||||
pub fontconfig_path: Option<std::ffi::OsString>,
|
||||
/// The main configuration files tried when `FONTCONFIG_FILE` is unset (the first that exists).
|
||||
pub fontconfig_roots: Vec<PathBuf>,
|
||||
/// Folders scanned if they exist (the usual system folders and the Flatpak host folders).
|
||||
@@ -34,6 +36,7 @@ impl FontEnv {
|
||||
xdg_data_dirs: var("XDG_DATA_DIRS"),
|
||||
xdg_config_home: var("XDG_CONFIG_HOME").map(PathBuf::from),
|
||||
fontconfig_file: var("FONTCONFIG_FILE").map(PathBuf::from),
|
||||
fontconfig_path: var("FONTCONFIG_PATH"),
|
||||
fontconfig_roots: ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(PathBuf::from).to_vec(),
|
||||
fixed: [
|
||||
"/usr/share/fonts",
|
||||
@@ -87,10 +90,7 @@ const MAX_FILE_BYTES: u64 = 1 << 20;
|
||||
/// The font folders named by the fontconfig configuration.
|
||||
pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec<PathBuf> {
|
||||
let mut st = Reader { env, out: Vec::new(), seen: HashSet::new() };
|
||||
let root = match &env.fontconfig_file {
|
||||
Some(f) => Some(f.clone()),
|
||||
None => env.fontconfig_roots.iter().find(|p| p.is_file()).cloned(),
|
||||
};
|
||||
let root = fontconfig_root(env);
|
||||
if let Some(root) = root {
|
||||
st.file(&root, 0);
|
||||
}
|
||||
@@ -102,6 +102,40 @@ pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec<PathBuf> {
|
||||
st.out
|
||||
}
|
||||
|
||||
/// Select the main file as fontconfig does: absolute names bypass the search path;
|
||||
/// relative names (or `fonts.conf`) use FONTCONFIG_PATH, then the standard folders.
|
||||
fn fontconfig_root(env: &FontEnv) -> Option<PathBuf> {
|
||||
if let Some(file) = &env.fontconfig_file
|
||||
&& file.is_absolute()
|
||||
{
|
||||
return Some(file.clone());
|
||||
}
|
||||
if env.fontconfig_file.is_none() && env.fontconfig_path.is_none() {
|
||||
return env.fontconfig_roots.iter().find(|p| readable_config(p)).cloned();
|
||||
}
|
||||
let file = env.fontconfig_file.as_deref().unwrap_or_else(|| Path::new("fonts.conf"));
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(path) = &env.fontconfig_path {
|
||||
let mut paths = std::env::split_paths(path).peekable();
|
||||
while let Some(mut dir) = paths.next() {
|
||||
// Fontconfig ignores a final empty item; an interior empty item names `/`.
|
||||
if dir.as_os_str().is_empty() {
|
||||
if paths.peek().is_none() {
|
||||
continue;
|
||||
}
|
||||
dir = PathBuf::from("/");
|
||||
}
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
dirs.extend(env.fontconfig_roots.iter().filter_map(|p| p.parent().map(Path::to_path_buf)));
|
||||
dirs.into_iter().map(|dir| dir.join(file)).find(|p| readable_config(p))
|
||||
}
|
||||
|
||||
fn readable_config(path: &Path) -> bool {
|
||||
path.is_file() && std::fs::File::open(path).is_ok()
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
env: &'a FontEnv,
|
||||
out: Vec<PathBuf>,
|
||||
@@ -371,4 +405,109 @@ mod tests {
|
||||
assert!(faces.iter().any(|f| f.family == "Noto Serif" && f.origin == "system"), "{:?}", faces.iter().map(|f| &f.family).collect::<Vec<_>>());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fontconfig_path_selects_the_first_existing_config_in_search_order() {
|
||||
let root = temp("config-search");
|
||||
let first = root.join("first");
|
||||
let second = root.join("second");
|
||||
write(&first.join("fonts.conf"), "<fontconfig><dir>/first/fonts</dir></fontconfig>");
|
||||
write(&second.join("fonts.conf"), "<fontconfig><dir>/second/fonts</dir></fontconfig>");
|
||||
let mut env = env_in(&root);
|
||||
env.fontconfig_path = Some(std::env::join_paths([root.join("missing"), first.clone(), second.clone()]).unwrap());
|
||||
assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/first/fonts")]);
|
||||
env.fontconfig_path = Some(std::env::join_paths([second, first]).unwrap());
|
||||
assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/second/fonts")]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_selected_config_uses_the_search_path_and_keeps_relative_includes() {
|
||||
let root = temp("selected-config");
|
||||
let config = root.join("config");
|
||||
write(&config.join("chosen.conf"), "<fontconfig><include>conf.d</include></fontconfig>");
|
||||
write(&config.join("conf.d/10-fonts.conf"), "<fontconfig><dir prefix=\"relative\">../../fonts</dir></fontconfig>");
|
||||
let mut env = env_in(&root);
|
||||
env.fontconfig_path = Some(config.clone().into_os_string());
|
||||
env.fontconfig_file = Some(PathBuf::from("chosen.conf"));
|
||||
assert_eq!(fontconfig_dirs(&env), [config.join("conf.d/../../fonts")]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absolute_selected_config_bypasses_the_search_path() {
|
||||
let root = temp("absolute-config");
|
||||
write(&root.join("search/fonts.conf"), "<fontconfig><dir>/wrong/fonts</dir></fontconfig>");
|
||||
write(&root.join("selected.conf"), "<fontconfig><dir>/selected/fonts</dir></fontconfig>");
|
||||
let mut env = env_in(&root);
|
||||
env.fontconfig_path = Some(root.join("search").into_os_string());
|
||||
env.fontconfig_file = Some(root.join("selected.conf"));
|
||||
assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]);
|
||||
env.fontconfig_file = Some(root.join("missing.conf"));
|
||||
assert!(fontconfig_dirs(&env).is_empty(), "an explicit missing file does not pick another config");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_search_falls_back_to_standard_folders() {
|
||||
let root = temp("default-config");
|
||||
let config = root.join("etc/fonts");
|
||||
write(&config.join("fonts.conf"), "<fontconfig><dir>/default/fonts</dir></fontconfig>");
|
||||
write(&config.join("selected.conf"), "<fontconfig><dir>/selected/fonts</dir></fontconfig>");
|
||||
let mut env = env_in(&root);
|
||||
env.fontconfig_path = Some(root.join("missing").into_os_string());
|
||||
assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/default/fonts")]);
|
||||
env.fontconfig_file = Some(PathBuf::from("selected.conf"));
|
||||
assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]);
|
||||
env.fontconfig_path = None;
|
||||
assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn configuration_search_preserves_non_unicode_folder_names() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let root = temp("non-unicode-config");
|
||||
let config = root.join(std::ffi::OsStr::from_bytes(b"config-\xff"));
|
||||
write(&config.join("fonts.conf"), "<fontconfig><dir>/configured/fonts</dir></fontconfig>");
|
||||
let mut env = env_in(&root);
|
||||
env.fontconfig_path = Some(config.into_os_string());
|
||||
assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/configured/fonts")]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn configuration_search_skips_unreadable_files() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
struct Cleanup {
|
||||
root: PathBuf,
|
||||
file: PathBuf,
|
||||
permissions: std::fs::Permissions,
|
||||
}
|
||||
impl Drop for Cleanup {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::set_permissions(&self.file, self.permissions.clone());
|
||||
let _ = std::fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
let root = temp("unreadable-config");
|
||||
let first = root.join("first/fonts.conf");
|
||||
let second = root.join("second/fonts.conf");
|
||||
write(&first, "<fontconfig><dir>/unreadable/fonts</dir></fontconfig>");
|
||||
write(&second, "<fontconfig><dir>/readable/fonts</dir></fontconfig>");
|
||||
let _cleanup = Cleanup { root: root.clone(), file: first.clone(), permissions: std::fs::metadata(&first).unwrap().permissions() };
|
||||
std::fs::set_permissions(&first, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
if std::fs::File::open(&first).is_ok() {
|
||||
eprintln!("skipped: this process can read mode-000 files");
|
||||
return;
|
||||
}
|
||||
let mut env = env_in(&root);
|
||||
env.fontconfig_roots = vec![first.clone(), second.clone()];
|
||||
env.fontconfig_path = Some(std::env::join_paths([first.parent().unwrap(), second.parent().unwrap()]).unwrap());
|
||||
assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/readable/fonts")]);
|
||||
env.fontconfig_path = None;
|
||||
assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/readable/fonts")]);
|
||||
}
|
||||
}
|
||||
+176
-13
@@ -15,7 +15,7 @@ use std::sync::{Arc, OnceLock, RwLock};
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::{FontRef, MetadataProvider};
|
||||
|
||||
use crate::sfnt::{FaceNames, read_faces, read_faces_bytes};
|
||||
use crate::sfnt::{FaceNames, NamedInstance, read_faces, read_faces_bytes};
|
||||
|
||||
/// Bundled fonts (SIL OFL 1.1; see `assets/fonts/*.attribution` and ATTRIBUTION.md).
|
||||
pub static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
@@ -60,6 +60,8 @@ pub struct FaceInfo {
|
||||
pub postscript: Option<String>,
|
||||
/// The face has variation axes (an `fvar` table), read from the table directory at scan time.
|
||||
pub variable: bool,
|
||||
/// A variable face's named instances (`Light` … `Black`), read from `fvar` at scan time.
|
||||
pub instances: Vec<NamedInstance>,
|
||||
}
|
||||
|
||||
/// Something that can list fonts (system folders, a web font picker, a project's font folder…).
|
||||
@@ -152,6 +154,7 @@ fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo {
|
||||
full_name: n.full_name,
|
||||
postscript: n.postscript,
|
||||
variable: n.variable,
|
||||
instances: n.instances,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,8 +428,14 @@ pub fn families() -> Vec<(String, Vec<String>)> {
|
||||
let mut m: std::collections::BTreeMap<String, Vec<(bool, u16, String)>> = Default::default();
|
||||
for f in all_faces() {
|
||||
let v = m.entry(f.info.family.clone()).or_default();
|
||||
if !v.iter().any(|(_, _, s)| s == &f.info.style) {
|
||||
v.push((f.info.italic, f.info.weight, f.info.style.clone()));
|
||||
// A variable face lists its named instances (`Light` … `Black`) as styles, as After
|
||||
// Effects' font menu does; its own style name is usually one of them.
|
||||
let styles = std::iter::once((f.info.italic, f.info.weight, f.info.style.clone()))
|
||||
.chain(f.info.instances.iter().map(|i| (i.italic(f.info.italic), i.weight(f.info.weight), i.style.clone())));
|
||||
for (italic, weight, style) in styles {
|
||||
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&style)) {
|
||||
v.push((italic, weight, style));
|
||||
}
|
||||
}
|
||||
}
|
||||
m.into_iter()
|
||||
@@ -446,6 +455,26 @@ pub struct Resolved {
|
||||
pub synth_italic: bool,
|
||||
/// The requested family was not found (shown as missing in the UI).
|
||||
pub missing: bool,
|
||||
/// The named instance of a variable face the request asked for (an index into the face's
|
||||
/// [`FaceInfo::instances`]); its axis values are the text's base variations (see
|
||||
/// [`instance_variations`]).
|
||||
pub instance: Option<u16>,
|
||||
}
|
||||
|
||||
/// The axis values a resolved request implies: the named instance's coordinates (sorted by tag),
|
||||
/// or none for a static face or the face's own default.
|
||||
pub fn instance_variations(r: &Resolved) -> Vec<(String, f32)> {
|
||||
let Some(i) = r.instance else { return Vec::new() };
|
||||
face(r.face).info.instances.get(i as usize).map(|n| n.coords.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `explicit` axis values over the instance's `base` ones: a tag set explicitly wins, the others
|
||||
/// keep the instance's value. Sorted by tag, as `TextDoc` keeps them.
|
||||
pub fn merge_variations(base: &[(String, f32)], explicit: &[(String, f32)]) -> Vec<(String, f32)> {
|
||||
let mut out: Vec<(String, f32)> = base.iter().filter(|(t, _)| !explicit.iter().any(|(e, _)| e.trim_end() == t.trim_end())).cloned().collect();
|
||||
out.extend(explicit.iter().cloned());
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
fn style_wants(style: &str) -> (u16, bool) {
|
||||
@@ -485,27 +514,69 @@ pub fn resolve(family: &str, style: &str) -> Resolved {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
if let Some(r) = resolve_alias(family, style) {
|
||||
return r;
|
||||
}
|
||||
// Inter is bundled; face 0 (the first bundled face) is the last resort.
|
||||
let mut r = resolve_in(DEFAULT_FAMILY, style).unwrap_or(Resolved { face: 0, synth_bold: false, synth_italic: false, missing: true });
|
||||
let mut r = resolve_in(DEFAULT_FAMILY, style).unwrap_or(Resolved { face: 0, synth_bold: false, synth_italic: false, missing: true, instance: None });
|
||||
r.missing = !family.is_empty() && !family.eq_ignore_ascii_case(DEFAULT_FAMILY);
|
||||
r
|
||||
}
|
||||
|
||||
/// Metric-compatible substitutes for common proprietary families, in order of preference (the
|
||||
/// same pairs fontconfig's `30-metric-aliases.conf` uses). Text keeps its widths and line breaks.
|
||||
const METRIC_ALIASES: &[(&str, &[&str])] = &[
|
||||
("Calibri", &["Carlito"]),
|
||||
("Cambria", &["Caladea"]),
|
||||
("Arial", &["Liberation Sans", "Arimo"]),
|
||||
("Helvetica", &["Liberation Sans", "Arimo"]),
|
||||
("Arial Narrow", &["Liberation Sans Narrow"]),
|
||||
("Times New Roman", &["Liberation Serif", "Tinos"]),
|
||||
("Times", &["Liberation Serif", "Tinos"]),
|
||||
("Courier New", &["Liberation Mono", "Cousine"]),
|
||||
("Courier", &["Liberation Mono", "Cousine"]),
|
||||
];
|
||||
|
||||
/// An installed metric-compatible substitute for a missing family. The result stays `missing`,
|
||||
/// so Find Missing Fonts still lists the requested name.
|
||||
fn resolve_alias(family: &str, style: &str) -> Option<Resolved> {
|
||||
let family = family.trim();
|
||||
let (_, subs) = METRIC_ALIASES.iter().find(|(name, _)| name.eq_ignore_ascii_case(family))?;
|
||||
subs.iter().find_map(|s| resolve_in(s, style)).map(|r| Resolved { missing: true, ..r })
|
||||
}
|
||||
|
||||
fn resolve_in(family: &str, style: &str) -> Option<Resolved> {
|
||||
let faces = all_faces();
|
||||
let fam: Vec<&Arc<Face>> = faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(family)).collect();
|
||||
if fam.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let instance_named = |f: &Face, name: &str| f.info.instances.iter().position(|i| i.style.eq_ignore_ascii_case(name)).and_then(|i| u16::try_from(i).ok());
|
||||
// A face's own style (a variable face's is its default instance) needs no axis values.
|
||||
if let Some(f) = fam.iter().find(|f| f.info.style.eq_ignore_ascii_case(style)) {
|
||||
return Some(Resolved { face: f.id, synth_bold: false, synth_italic: false, missing: false });
|
||||
return Some(Resolved { face: f.id, synth_bold: false, synth_italic: false, missing: false, instance: None });
|
||||
}
|
||||
// A named instance of a variable face (`Figtree` `Black`): that face at the instance's axis
|
||||
// values, not the nearest static weight made bold synthetically.
|
||||
if let Some((f, i)) = fam.iter().find_map(|f| Some((f, instance_named(f, style)?))) {
|
||||
return Some(Resolved { face: f.id, synth_bold: false, synth_italic: false, missing: false, instance: Some(i) });
|
||||
}
|
||||
// The nearest weight among the faces and the named instances.
|
||||
let (w, it) = style_wants(style);
|
||||
let best = fam.iter().min_by_key(|f| {
|
||||
let italic_pen = if f.info.italic == it { 0 } else { 1000 };
|
||||
italic_pen + (f.info.weight as i32 - w as i32).unsigned_abs()
|
||||
let candidates = fam.iter().flat_map(|f| {
|
||||
std::iter::once((f, None, f.info.weight, f.info.italic)).chain(
|
||||
f.info
|
||||
.instances
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(move |(i, n)| Some((f, Some(u16::try_from(i).ok()?), n.weight(f.info.weight), n.italic(f.info.italic)))),
|
||||
)
|
||||
});
|
||||
let (best, instance, weight, italic) = candidates.min_by_key(|(_, _, weight, italic)| {
|
||||
let italic_pen = if *italic == it { 0 } else { 1000 };
|
||||
italic_pen + (*weight as i32 - w as i32).unsigned_abs()
|
||||
})?;
|
||||
Some(Resolved { face: best.id, synth_bold: w >= 600 && best.info.weight <= 500, synth_italic: it && !best.info.italic, missing: false })
|
||||
Some(Resolved { face: best.id, synth_bold: w >= 600 && weight <= 500, synth_italic: it && !italic, missing: false, instance })
|
||||
}
|
||||
|
||||
/// A face whose full name or PostScript name is `name` (case-insensitive), the way After Effects
|
||||
@@ -516,11 +587,31 @@ fn resolve_by_name(name: &str) -> Option<Resolved> {
|
||||
return None;
|
||||
}
|
||||
let faces = all_faces();
|
||||
let f = faces
|
||||
let found = |f: &Arc<Face>, instance: Option<usize>| Resolved {
|
||||
face: f.id,
|
||||
synth_bold: false,
|
||||
synth_italic: false,
|
||||
missing: false,
|
||||
instance: instance.and_then(|i| u16::try_from(i).ok()),
|
||||
};
|
||||
if let Some(f) = faces
|
||||
.iter()
|
||||
.find(|f| f.info.postscript.as_deref().is_some_and(|p| p.eq_ignore_ascii_case(name)))
|
||||
.or_else(|| faces.iter().find(|f| f.info.full_name.eq_ignore_ascii_case(name)))?;
|
||||
Some(Resolved { face: f.id, synth_bold: false, synth_italic: false, missing: false })
|
||||
.or_else(|| faces.iter().find(|f| f.info.full_name.eq_ignore_ascii_case(name)))
|
||||
{
|
||||
// A variable face's own name is its default instance's (`Figtree-Light`).
|
||||
return Some(found(f, None));
|
||||
}
|
||||
// A named instance of a variable face, by its PostScript name (`Figtree-Black`, what After
|
||||
// Effects scripts use) or its full name (`Figtree Black`).
|
||||
faces.iter().find_map(|f| {
|
||||
let i = f
|
||||
.info
|
||||
.instances
|
||||
.iter()
|
||||
.position(|i| i.postscript.eq_ignore_ascii_case(name) || format!("{} {}", f.info.family, i.style).eq_ignore_ascii_case(name))?;
|
||||
Some(found(f, Some(i)))
|
||||
})
|
||||
}
|
||||
|
||||
/// The writing systems that need a dedicated fallback family: the bundled fonts cover none of
|
||||
@@ -965,6 +1056,56 @@ mod tests {
|
||||
assert_eq!(face(resolve("Inter", "Bold").face).info.style, "Bold");
|
||||
}
|
||||
|
||||
/// #1563: a variable font's named instances are styles of its family. `fontStyle = "Black"`,
|
||||
/// the instance's PostScript name (`font = "Figtree-Black"`) and its full name select the
|
||||
/// instance's axis values instead of the default instance made bold synthetically, and the
|
||||
/// font menus list every instance.
|
||||
#[test]
|
||||
fn variable_named_instances_resolve_and_list() {
|
||||
const FAMILY: &str = "EffectCraft Instance Test";
|
||||
let instances = [
|
||||
("Light", 300.0, Some("EffectCraftInstanceTest-Light")),
|
||||
("Regular", 400.0, Some("EffectCraftInstanceTest-Regular")),
|
||||
("Black", 900.0, Some("EffectCraftInstanceTest-Black")),
|
||||
("Extra Bold", 800.0, None),
|
||||
];
|
||||
assert_eq!(add_font_data(crate::sfnt::test_font::variable(FAMILY, &instances, true)).len(), 1);
|
||||
let wght = |r: &Resolved| instance_variations(r);
|
||||
// By style name.
|
||||
let black = resolve(FAMILY, "Black");
|
||||
assert!(!black.missing && !black.synth_bold && !black.synth_italic, "{black:?}");
|
||||
assert_eq!(wght(&black), vec![("wght".to_string(), 900.0)]);
|
||||
assert_eq!(wght(&resolve(FAMILY, "regular")), vec![("wght".to_string(), 400.0)]);
|
||||
// The face's own style is its default instance: no axis values to set.
|
||||
let light = resolve(FAMILY, "Light");
|
||||
assert_eq!((light.face, light.instance), (black.face, None));
|
||||
// A style the family lacks takes the nearest instance's weight, no synthetic bold.
|
||||
let bold = resolve(FAMILY, "Bold");
|
||||
assert!(!bold.synth_bold, "{bold:?}");
|
||||
assert_eq!(wght(&bold), vec![("wght".to_string(), 800.0)]);
|
||||
// By PostScript name, the font's own and the derived one, and by full name.
|
||||
let ps = resolve("EffectCraftInstanceTest-Black", "Regular");
|
||||
assert!(!ps.missing && ps.face == black.face, "{ps:?}");
|
||||
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)]);
|
||||
// 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"]);
|
||||
// Static fonts are untouched.
|
||||
assert_eq!(resolve("Inter", "Bold").instance, None);
|
||||
}
|
||||
|
||||
/// Axis values set on the text win over the instance's; the others keep the instance's.
|
||||
#[test]
|
||||
fn explicit_variations_override_the_instance() {
|
||||
let base = vec![("wdth".to_string(), 75.0), ("wght".to_string(), 900.0)];
|
||||
assert_eq!(merge_variations(&base, &[("wght".to_string(), 450.0)]), vec![("wdth".to_string(), 75.0), ("wght".to_string(), 450.0)]);
|
||||
assert_eq!(merge_variations(&base, &[]), base);
|
||||
assert_eq!(merge_variations(&[], &[("opsz".to_string(), 12.0)]), vec![("opsz".to_string(), 12.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_are_classified() {
|
||||
assert_eq!(script_of('あ'), FallbackScript::Japanese);
|
||||
@@ -1034,6 +1175,11 @@ mod tests {
|
||||
/// A minimal TrueType font built in code (no font file in the repo): U+0020 and U+E000 map to
|
||||
/// an empty glyph, U+E001 to a triangle.
|
||||
fn blank_glyph_font() -> Vec<u8> {
|
||||
test_font("Blank Glyph Test")
|
||||
}
|
||||
|
||||
/// The blank-glyph test font under another family name.
|
||||
fn test_font(family: &str) -> Vec<u8> {
|
||||
fn u16s(v: &[u16]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_be_bytes()).collect()
|
||||
}
|
||||
@@ -1061,7 +1207,7 @@ mod tests {
|
||||
hhea.extend(u16s(&[0, 3]));
|
||||
let hmtx = u16s(&[600, 0, 600, 0, 600, 0]);
|
||||
let maxp = u16s(&[1, 0, 3, 3, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let strings: Vec<Vec<u8>> = ["Blank Glyph Test", "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
|
||||
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
|
||||
let mut name = u16s(&[0, 2, 30]);
|
||||
let mut off = 0;
|
||||
for (id, st) in strings.iter().enumerate() {
|
||||
@@ -1105,6 +1251,23 @@ mod tests {
|
||||
assert_eq!(fallback_for('\u{e001}', inter), id, "fallback finds the drawn one");
|
||||
}
|
||||
|
||||
/// A Microsoft family that isn't installed renders in its installed metric-compatible
|
||||
/// substitute instead of Inter, and still reports itself missing (#1677).
|
||||
#[test]
|
||||
fn microsoft_families_resolve_to_metric_compatible_substitutes() {
|
||||
add_font_data(test_font("Carlito"));
|
||||
add_font_data(test_font("Caladea"));
|
||||
let calibri = resolve("Calibri", "Regular");
|
||||
assert_eq!(face(calibri.face).info.family, "Carlito");
|
||||
assert!(calibri.missing, "the original name stays missing for Find Missing Fonts");
|
||||
let cambria = resolve("Cambria", "Bold");
|
||||
assert_eq!(face(cambria.face).info.family, "Caladea");
|
||||
assert!(cambria.missing);
|
||||
let unknown = resolve("No Such Family 1677", "Regular");
|
||||
assert_eq!(face(unknown.face).info.family, DEFAULT_FAMILY);
|
||||
assert!(unknown.missing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_han_order_follows_the_cjk_locale() {
|
||||
// Chinese first for any Chinese tag, whether or not it names a script.
|
||||
|
||||
@@ -955,6 +955,26 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
|
||||
let para_at = |i: usize| paras.get(i).or(paras.last()).unwrap_or(&default_para);
|
||||
let width = para_at(0).width;
|
||||
let primaries: Vec<Resolved> = runs.iter().map(|(_, s)| fonts::resolve(&s.family, &s.style)).collect();
|
||||
// A named instance of a variable font (`Figtree` `Black`, or `Figtree-Black`) sets the base
|
||||
// axis values; axes set explicitly in the style keep theirs.
|
||||
let instanced: Vec<(Range<usize>, TextStyle)>;
|
||||
let runs = if primaries.iter().any(|p| p.instance.is_some()) {
|
||||
instanced = runs
|
||||
.iter()
|
||||
.zip(&primaries)
|
||||
.map(|((r, s), p)| {
|
||||
let base = fonts::instance_variations(p);
|
||||
let mut s = s.clone();
|
||||
if !base.is_empty() {
|
||||
s.variations = fonts::merge_variations(&base, &s.variations);
|
||||
}
|
||||
(r.clone(), s)
|
||||
})
|
||||
.collect();
|
||||
&instanced[..]
|
||||
} else {
|
||||
runs
|
||||
};
|
||||
let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() };
|
||||
let mut plines: Vec<(usize, ParaLine)> = Vec::new();
|
||||
for (pi, span) in paragraph_ranges(text).enumerate() {
|
||||
@@ -1406,4 +1426,28 @@ mod tests {
|
||||
assert_eq!(l.hit(xs[1] + 0.5, 0.0), 2);
|
||||
assert_eq!(l.hit(xs[0] + 50.0, 0.0), 0);
|
||||
}
|
||||
|
||||
/// #1563: text set in a named instance (`Black`) is laid out at the instance's axis values,
|
||||
/// as if they had been set as variations; axes set on the text itself keep their values.
|
||||
#[test]
|
||||
fn named_instance_sets_the_base_variations() {
|
||||
const FAMILY: &str = "EffectCraft Layout Instance Test";
|
||||
fonts::add_font_data(crate::sfnt::test_font::variable(FAMILY, &[("Light", 300.0, None), ("Black", 900.0, None)], true));
|
||||
let st = |style: &str, variations: Vec<(String, f32)>| TextStyle {
|
||||
family: FAMILY.into(),
|
||||
style: style.into(),
|
||||
size: 50.0,
|
||||
variations,
|
||||
..Default::default()
|
||||
};
|
||||
let coords = |l: &Layout| l.glyphs.iter().map(|g| crate::variable::coords(g.variations).to_vec()).collect::<Vec<_>>();
|
||||
let black = layout_uncached("Hi", &st("Black", vec![]), &ParagraphStyle::default());
|
||||
assert!(!black.glyphs.is_empty());
|
||||
assert!(coords(&black).iter().all(|c| c == &[("wght".to_string(), 900.0)]), "{:?}", coords(&black));
|
||||
let set = layout_uncached("Hi", &st("Black", vec![("wght".into(), 500.0)]), &ParagraphStyle::default());
|
||||
assert!(coords(&set).iter().all(|c| c == &[("wght".to_string(), 500.0)]), "{:?}", coords(&set));
|
||||
// A static family keeps no variations.
|
||||
let inter = layout_uncached("Hi", &TextStyle::default(), &ParagraphStyle::default());
|
||||
assert!(inter.glyphs.iter().all(|g| g.variations == 0));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ use effectcraft_keyframe::{BaselineOption, CharStyle, Composer, Direction, Justi
|
||||
pub use fonts::{FaceId, Resolved, families, resolve};
|
||||
use kurbo::{Affine, BezPath, Point};
|
||||
pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, Script, TextStyle, layout as layout_text, layout_rich, measure};
|
||||
use selectors::BasedOn;
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
@@ -134,6 +135,8 @@ pub struct TextLayout {
|
||||
pub to_layer: Affine,
|
||||
/// Byte offset of every character, plus the text length.
|
||||
pub byte_of_char: Vec<usize>,
|
||||
/// Whether each character is white space (for the selectors' Based On).
|
||||
pub space_of_char: Vec<bool>,
|
||||
pub vertical: bool,
|
||||
}
|
||||
|
||||
@@ -262,6 +265,7 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout {
|
||||
}
|
||||
in_word = !space;
|
||||
word_of_char.push(words.saturating_sub(1));
|
||||
out.space_of_char.push(space);
|
||||
nospace_of_char.push(nospace);
|
||||
if !space {
|
||||
nospace += 1;
|
||||
@@ -415,6 +419,99 @@ impl TextLayout {
|
||||
None => (0, self.chars),
|
||||
}
|
||||
}
|
||||
/// The number of selector units for Based On `based`.
|
||||
pub fn units(&self, based: BasedOn) -> usize {
|
||||
match based {
|
||||
BasedOn::Characters => self.chars,
|
||||
BasedOn::CharactersExcludingSpaces => self.chars_no_space,
|
||||
BasedOn::Words => self.words,
|
||||
BasedOn::Lines => self.lines,
|
||||
}
|
||||
}
|
||||
/// Characters `a..b` of every selector unit, in order; words and Characters Excluding Spaces
|
||||
/// leave the spaces out.
|
||||
fn unit_spans(&self, based: BasedOn) -> Vec<(usize, usize)> {
|
||||
let sp = |ci: usize| self.space_of_char.get(ci).copied().unwrap_or(false);
|
||||
match based {
|
||||
BasedOn::Characters => (0..self.chars).map(|ci| (ci, ci + 1)).collect(),
|
||||
BasedOn::CharactersExcludingSpaces => (0..self.chars).filter(|&ci| !sp(ci)).map(|ci| (ci, ci + 1)).collect(),
|
||||
BasedOn::Words => {
|
||||
let (mut out, mut start) = (vec![], None);
|
||||
for ci in 0..self.chars {
|
||||
match (sp(ci), start) {
|
||||
(false, None) => start = Some(ci),
|
||||
(true, Some(a)) => {
|
||||
out.push((a, ci));
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.extend(start.map(|a| (a, self.chars)));
|
||||
out
|
||||
}
|
||||
BasedOn::Lines => self.layout.lines.iter().map(|l| (self.char_of_byte(l.range.start), self.char_of_byte(self.layout.line_end(l)))).collect(),
|
||||
}
|
||||
}
|
||||
/// The carets at the leading and trailing edges of the unit made of characters `a..b`: before
|
||||
/// its first character, and after its last one when that is on the same line, else the line's
|
||||
/// end. Carets rather than glyphs, so the edges also fall inside ligatures and follow
|
||||
/// right-to-left runs.
|
||||
fn unit_edges(&self, (a, b): (usize, usize)) -> Option<(usize, usize)> {
|
||||
let li = self.line_of_char(a);
|
||||
let trail = if self.line_of_char(b) == li { b } else { self.char_of_byte(self.layout.line_end(self.layout.lines.get(li)?)) };
|
||||
Some((a, trail))
|
||||
}
|
||||
/// The caret at `ci` as (top, baseline, bottom) in layer space.
|
||||
fn caret_points(&self, ci: usize) -> [Point; 3] {
|
||||
let (top, bottom) = self.caret(ci);
|
||||
let (x, base, _) = self.layout.caret(self.byte(ci));
|
||||
[top, self.to_layer * Point::new(x as f64, base as f64), bottom]
|
||||
}
|
||||
/// A Range Selector bar at fraction `f` of the units (#1011): a caret-like segment as (top,
|
||||
/// baseline, bottom) in layer space, between unit boundaries in proportion. None for empty or
|
||||
/// vertical text.
|
||||
pub fn range_bar(&self, based: BasedOn, f: f64) -> Option<[Point; 3]> {
|
||||
let n = self.units(based);
|
||||
if self.vertical || n == 0 || !f.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let x = f.clamp(0.0, 1.0) * n as f64;
|
||||
let u = (x.floor() as usize).min(n - 1);
|
||||
let t = x - u as f64;
|
||||
let (a, z) = self.unit_edges(*self.unit_spans(based).get(u)?)?;
|
||||
let (lead, trail) = (self.caret_points(a), self.caret_points(z));
|
||||
Some([0, 1, 2].map(|k| lead[k].lerp(trail[k], t)))
|
||||
}
|
||||
/// The fraction of the units under a layer-space point, the inverse of [`Self::range_bar`]:
|
||||
/// on the line whose baseline is nearest, the nearest place between unit edges.
|
||||
pub fn range_fraction_at(&self, based: BasedOn, p: Point) -> Option<f64> {
|
||||
let n = self.units(based);
|
||||
if self.vertical || n == 0 {
|
||||
return None;
|
||||
}
|
||||
let q = self.to_layer.inverse() * p;
|
||||
let line = self.layout.lines.iter().enumerate().min_by(|a, b| (a.1.baseline as f64 - q.y).abs().total_cmp(&(b.1.baseline as f64 - q.y).abs()))?.0;
|
||||
let spans = self.unit_spans(based);
|
||||
let nearest = |on_line: bool| {
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
for (u, span) in spans.iter().enumerate() {
|
||||
if on_line && self.line_of_char(span.0) != line {
|
||||
continue;
|
||||
}
|
||||
let Some((a, z)) = self.unit_edges(*span) else { continue };
|
||||
let (x0, x1) = (self.caret(a).1.x, self.caret(z).1.x);
|
||||
let t = if (x1 - x0).abs() > 1e-9 { ((p.x - x0) / (x1 - x0)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let d = (x0 + (x1 - x0) * t - p.x).abs();
|
||||
if best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, (u as f64 + t) / n as f64));
|
||||
}
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
};
|
||||
// (a line without units, such as an empty one, takes the nearest unit anywhere)
|
||||
nearest(true).or_else(|| nearest(false))
|
||||
}
|
||||
/// Selection highlight quads (layer space) for characters `a..b`.
|
||||
pub fn selection_quads(&self, a: usize, b: usize) -> Vec<[Point; 4]> {
|
||||
self.layout
|
||||
@@ -634,4 +731,73 @@ mod tests {
|
||||
assert!(l.bounds[0] < -100.0 && l.bounds[2] > 100.0, "{:?}", l.bounds);
|
||||
assert!((l.bounds[0] + l.bounds[2]).abs() < 20.0);
|
||||
}
|
||||
|
||||
// #1011: a Range Selector's bars in the Composition viewer.
|
||||
fn bar_x(l: &TextLayout, based: selectors::BasedOn, f: f64) -> f64 {
|
||||
l.range_bar(based, f).unwrap()[2].x
|
||||
}
|
||||
|
||||
fn doc(text: &str) -> TextLayout {
|
||||
layout_doc(&TextDoc { text: text.into(), size: 40.0, ..Default::default() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_bars_sit_on_unit_boundaries() {
|
||||
use selectors::BasedOn::*;
|
||||
let l = doc("abcd");
|
||||
for (f, ci) in [(0.0, 0), (0.5, 2), (1.0, 4)] {
|
||||
assert!((bar_x(&l, Characters, f) - l.caret(ci).1.x).abs() < 1e-6, "{f} at caret {ci}");
|
||||
}
|
||||
let mid = (l.caret(0).1.x + l.caret(1).1.x) / 2.0;
|
||||
assert!((bar_x(&l, Characters, 0.125) - mid).abs() < 1e-6, "half way through the first character");
|
||||
// Words and Characters Excluding Spaces skip the space.
|
||||
let l = doc("ab cd");
|
||||
assert!((bar_x(&l, Words, 0.5) - l.caret(3).1.x).abs() < 1e-6, "the second word starts at c");
|
||||
assert!((bar_x(&l, Words, 0.25) - (l.caret(0).1.x + l.caret(2).1.x) / 2.0).abs() < 1e-6, "half of ab, no space");
|
||||
assert!((bar_x(&l, CharactersExcludingSpaces, 0.5) - l.caret(3).1.x).abs() < 1e-6, "c is the third of four");
|
||||
assert!(doc("").range_bar(Characters, 0.0).is_none());
|
||||
let v = layout_doc(&TextDoc { text: "ab".into(), vertical: true, ..Default::default() });
|
||||
assert!(v.range_bar(Characters, 0.0).is_none(), "vertical text has no bars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_bars_on_the_right_line_ligatures_and_rtl() {
|
||||
use selectors::BasedOn::*;
|
||||
let l = doc("ab\rcd");
|
||||
// 5 characters (the CR counts): 80% is the start of d, on the second line.
|
||||
let [top, base, bottom] = l.range_bar(Characters, 0.8).unwrap();
|
||||
assert_eq!((top, bottom), l.caret(4));
|
||||
assert!(top.y < base.y && base.y < bottom.y && (base.y - bottom.y + l.layout.lines[1].descent as f64).abs() < 1e-6, "the baseline");
|
||||
assert!(bottom.y > l.caret(0).1.y + 10.0, "below the first line");
|
||||
let [top, _, bottom] = l.range_bar(Lines, 0.5).unwrap();
|
||||
assert_eq!((top, bottom), l.caret(3), "the second line starts at c");
|
||||
// Inside a ligature ("ffl") the bars still move character by character.
|
||||
let l = doc("Offline");
|
||||
let xs: Vec<f64> = [1.0, 2.0, 3.0, 4.0].iter().map(|k| bar_x(&l, Characters, k / 7.0)).collect();
|
||||
assert!(xs.windows(2).all(|w| w[1] > w[0]), "{xs:?}");
|
||||
// Right to left: Start is on the right of the first character.
|
||||
let mut r = TextDoc { text: "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}".into(), size: 40.0, ..Default::default() };
|
||||
r.direction = effectcraft_keyframe::text_doc::Direction::Rtl;
|
||||
let r = layout_doc(&r);
|
||||
assert!(bar_x(&r, Characters, 0.0) > bar_x(&r, Characters, 0.2) && bar_x(&r, Characters, 0.2) > bar_x(&r, Characters, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_fraction_at_inverts_range_bar() {
|
||||
use selectors::BasedOn::*;
|
||||
// (Fractions off the CR, a unit with no width whose bar is the line's end.)
|
||||
for (text, based) in [("ab\rcd", Characters), ("one two\rthree", Words), ("Offline", Characters)] {
|
||||
let l = doc(text);
|
||||
for f in [0.1, 0.3, 0.9] {
|
||||
let [top, _, bottom] = l.range_bar(based, f).unwrap();
|
||||
let p = Point::new(bottom.x, (top.y + bottom.y) / 2.0);
|
||||
let back = l.range_fraction_at(based, p).unwrap();
|
||||
assert!((back - f).abs() < 1e-6, "{text}: {f} → {back}");
|
||||
}
|
||||
}
|
||||
// The nearest line by its baseline: far left of the second line is the start of c.
|
||||
let l = doc("ab\rcd");
|
||||
let base = l.caret(3).1.y - l.layout.lines[1].descent as f64;
|
||||
assert!((l.range_fraction_at(Characters, Point::new(-500.0, base)).unwrap() - 0.6).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
+192
-1
@@ -22,6 +22,38 @@ pub struct FaceNames {
|
||||
/// A variable font: it has an `fvar` table with at least one axis (the font menu's
|
||||
/// Variable Fonts filter).
|
||||
pub variable: bool,
|
||||
/// The named instances of a variable font (`fvar`): `Light`, `Black`, … with their axis
|
||||
/// values, so a style name or an instance's PostScript name selects that design-space position.
|
||||
pub instances: Vec<NamedInstance>,
|
||||
}
|
||||
|
||||
/// One named instance of a variable font (an `fvar` instance record).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NamedInstance {
|
||||
/// The subfamily name (`Black`, `Bold Italic`).
|
||||
pub style: String,
|
||||
/// The instance's PostScript name (`Figtree-Black`): the one the font names, or else the one
|
||||
/// Adobe Technical Note #5902 derives from the family's PostScript prefix and the style.
|
||||
pub postscript: String,
|
||||
/// Axis values in user units, one per axis, sorted by tag (the order `TextDoc` keeps).
|
||||
pub coords: Vec<(String, f32)>,
|
||||
}
|
||||
|
||||
impl NamedInstance {
|
||||
/// The instance's weight class: the `wght` coordinate, or `fallback` without a weight axis.
|
||||
pub fn weight(&self, fallback: u16) -> u16 {
|
||||
self.coords.iter().find(|(t, _)| t == "wght").map_or(fallback, |(_, v)| v.round().clamp(1.0, 1000.0) as u16)
|
||||
}
|
||||
|
||||
/// The instance is italic: its name says so, or its `ital` / `slnt` axis is off zero.
|
||||
pub fn italic(&self, fallback: bool) -> bool {
|
||||
let s = self.style.to_ascii_lowercase();
|
||||
let axis = |tag: &str| self.coords.iter().find(|(t, _)| t == tag).map(|(_, v)| *v);
|
||||
if s.contains("italic") || s.contains("oblique") || axis("ital").is_some_and(|v| v >= 0.5) || axis("slnt").is_some_and(|v| v != 0.0) {
|
||||
return true;
|
||||
}
|
||||
fallback && axis("ital").is_none() && axis("slnt").is_none()
|
||||
}
|
||||
}
|
||||
|
||||
fn be16(b: &[u8], o: usize) -> Option<u16> {
|
||||
@@ -99,7 +131,69 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
|
||||
italic |= sl.contains("italic") || sl.contains("oblique");
|
||||
// fvar header: version (4), axesArrayOffset (2), reserved (2), axisCount (2).
|
||||
let variable = fvar.filter(|(_, l)| *l >= 10).and_then(|(fo, _)| read_at(r, fo, 10)).and_then(|fb| be16(&fb, 8)).is_some_and(|axes| axes > 0);
|
||||
Some(FaceNames { index, family, style, full_name, weight, italic, native_family, postscript, variable })
|
||||
let instances = if variable {
|
||||
let prefix = get(25).map(|p| ps_part(&p)).filter(|p| !p.is_empty()).unwrap_or_else(|| ps_part(&family));
|
||||
fvar.and_then(|(fo, fl)| read_at(r, fo, fl)).map(|fb| named_instances(&fb, &nb, &prefix)).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Some(FaceNames { index, family, style, full_name, weight, italic, native_family, postscript, variable, instances })
|
||||
}
|
||||
|
||||
/// Most named instances read from one face: real fonts have a few dozen at most, so this only
|
||||
/// bounds a hostile `instanceCount`.
|
||||
const MAX_INSTANCES: usize = 512;
|
||||
|
||||
/// `s` with every character outside `A–Z a–z 0–9` removed (PostScript name parts, Adobe Technical
|
||||
/// Note #5902).
|
||||
fn ps_part(s: &str) -> String {
|
||||
s.chars().filter(char::is_ascii_alphanumeric).collect()
|
||||
}
|
||||
|
||||
/// The named instances of an `fvar` table (OpenType `fvar`: a 16-byte header, `axisCount` axis
|
||||
/// records of `axisSize` bytes, then `instanceCount` instance records of `instanceSize` bytes:
|
||||
/// subfamily name id, flags, one Fixed 16.16 coordinate per axis, and the PostScript name id when
|
||||
/// the record has room for it). Records that point outside the table or name no style are skipped.
|
||||
fn named_instances(fb: &[u8], nb: &[u8], ps_prefix: &str) -> Vec<NamedInstance> {
|
||||
let (Some(axes_off), Some(axis_count), Some(axis_size), Some(count), Some(size)) = (be16(fb, 4), be16(fb, 8), be16(fb, 10), be16(fb, 12), be16(fb, 14))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let (axes_off, axis_count, axis_size, size) = (axes_off as usize, axis_count as usize, axis_size as usize, size as usize);
|
||||
if axis_count == 0 || axis_size < 20 || size < 4 + 4 * axis_count {
|
||||
return Vec::new();
|
||||
}
|
||||
// Offsets in checked arithmetic: on 32-bit targets (wasm) the products of hostile 16-bit
|
||||
// counts and sizes can overflow `usize`.
|
||||
let at_of = |base: usize, i: usize, stride: usize| i.checked_mul(stride).and_then(|o| o.checked_add(base));
|
||||
let tags: Option<Vec<String>> = (0..axis_count)
|
||||
.map(|a| {
|
||||
let at = at_of(axes_off, a, axis_size)?;
|
||||
fb.get(at..at.checked_add(4)?).map(|t| String::from_utf8_lossy(t).trim_end().to_string())
|
||||
})
|
||||
.collect();
|
||||
let (Some(tags), Some(first)) = (tags, at_of(axes_off, axis_count, axis_size)) else { return Vec::new() };
|
||||
let mut out: Vec<NamedInstance> = Vec::new();
|
||||
for i in 0..(count as usize).min(MAX_INSTANCES) {
|
||||
let Some(rec) = at_of(first, i, size).and_then(|at| fb.get(at..at.checked_add(size)?)) else { break };
|
||||
let Some(style) = be16(rec, 0).and_then(|id| name_string(nb, id)).map(|s| s.trim().to_string()) else { continue };
|
||||
let coords: Option<Vec<(String, f32)>> =
|
||||
tags.iter().enumerate().map(|(a, t)| be32(rec, 4 + 4 * a).map(|v| (t.clone(), v as i32 as f32 / 65536.0))).collect();
|
||||
let Some(mut coords) = coords else { continue };
|
||||
coords.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let named_ps = (size >= 6 + 4 * axis_count)
|
||||
.then(|| be16(rec, 4 + 4 * axis_count))
|
||||
.flatten()
|
||||
.filter(|&id| id != 0xFFFF)
|
||||
.and_then(|id| name_string(nb, id))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let postscript = named_ps.unwrap_or_else(|| format!("{ps_prefix}-{}", ps_part(&style)));
|
||||
if !out.iter().any(|o| o.style.eq_ignore_ascii_case(&style)) {
|
||||
out.push(NamedInstance { style, postscript, coords });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A Windows Unicode name-table string by id in a language other than US English.
|
||||
@@ -155,6 +249,62 @@ fn name_string(b: &[u8], id: u16) -> Option<String> {
|
||||
best.map(|(_, s)| s).filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Synthetic font files for tests: just `name` and `fvar`, enough for the scan and the resolver.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_font {
|
||||
fn u16s(v: &[u16]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_be_bytes()).collect()
|
||||
}
|
||||
|
||||
/// A variable font of family `family` (name ids 1 and 2, style `Light`), one `wght` axis
|
||||
/// 100..900 (default 300) and the named `instances` (style, `wght`, PostScript name), each
|
||||
/// with its own name ids; `with_ps_ids` gives the instance records room for a PostScript name
|
||||
/// id (names without one get 0xFFFF, "none").
|
||||
pub(crate) fn variable(family: &str, instances: &[(&str, f32, Option<&str>)], with_ps_ids: bool) -> Vec<u8> {
|
||||
let mut strings: Vec<(u16, String)> = vec![(1, family.into()), (2, "Light".into())];
|
||||
let mut recs = Vec::new();
|
||||
for (i, (style, w, ps)) in instances.iter().enumerate() {
|
||||
let sid = 256 + 2 * i as u16;
|
||||
strings.push((sid, (*style).into()));
|
||||
let pid = ps.map(|p| {
|
||||
strings.push((sid + 1, p.into()));
|
||||
sid + 1
|
||||
});
|
||||
let mut r = u16s(&[sid, 0]);
|
||||
r.extend(((w * 65536.0) as i32).to_be_bytes());
|
||||
if with_ps_ids {
|
||||
r.extend(pid.unwrap_or(0xFFFF).to_be_bytes());
|
||||
}
|
||||
recs.push(r);
|
||||
}
|
||||
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
|
||||
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
|
||||
let mut off = 0;
|
||||
for (id, st) in &enc {
|
||||
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
|
||||
off += st.len() as u16;
|
||||
}
|
||||
enc.iter().for_each(|(_, st)| name.extend(st));
|
||||
let isize = if with_ps_ids { 10 } else { 8 };
|
||||
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, instances.len() as u16, isize]);
|
||||
// wght: min 100, default 300, max 900 (Fixed 16.16), flags 0, axis name id 256.
|
||||
fvar.extend(b"wght".iter().copied().chain(u16s(&[100, 0, 300, 0, 900, 0, 0, 256])));
|
||||
recs.iter().for_each(|r| fvar.extend(r));
|
||||
let tables: [(&[u8; 4], Vec<u8>); 2] = [(b"fvar", fvar), (b"name", name)];
|
||||
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
|
||||
let mut body: Vec<u8> = vec![];
|
||||
for (tag, data) in &tables {
|
||||
out.extend(*tag);
|
||||
out.extend([0u8; 4]);
|
||||
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
body.extend(data);
|
||||
}
|
||||
out.extend(body);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -225,4 +375,45 @@ mod tests {
|
||||
assert!(!read_faces_bytes(&names_font("No Axes", Some(Some(0))))[0].variable);
|
||||
assert!(!read_faces_bytes(&names_font("Cut", Some(None)))[0].variable);
|
||||
}
|
||||
|
||||
/// #1563: the scan reads a variable font's named instances: the style name, the axis values
|
||||
/// and the PostScript name the font gives it, or the one Adobe Technical Note #5902 derives
|
||||
/// (family prefix, `-`, style without spaces) when it gives none.
|
||||
#[test]
|
||||
fn reads_named_instances_from_fvar() {
|
||||
let f = read_faces_bytes(&test_font::variable(
|
||||
"Fig Test",
|
||||
&[("Light", 300.0, Some("FigTest-Light")), ("Semi Bold", 600.0, None), ("Black", 900.0, Some("FigTest-Black"))],
|
||||
true,
|
||||
));
|
||||
assert_eq!(f.len(), 1);
|
||||
let i = &f[0].instances;
|
||||
assert_eq!(i.iter().map(|x| x.style.as_str()).collect::<Vec<_>>(), ["Light", "Semi Bold", "Black"], "{i:?}");
|
||||
assert_eq!(i[2].coords, vec![("wght".to_string(), 900.0)]);
|
||||
assert_eq!(i[2].postscript, "FigTest-Black");
|
||||
assert_eq!(i[1].postscript, "FigTest-SemiBold", "derived when the font names none");
|
||||
assert_eq!((i[1].weight(400), i[1].italic(false)), (600, false));
|
||||
// Instance records without the PostScript name id field.
|
||||
let short = read_faces_bytes(&test_font::variable("Fig Test", &[("Black", 900.0, None)], false));
|
||||
assert_eq!(short[0].instances[0].postscript, "FigTest-Black");
|
||||
// A static font has none.
|
||||
assert!(read_faces_bytes(crate::fonts::INTER_BOLD)[0].instances.is_empty());
|
||||
}
|
||||
|
||||
/// Hostile `fvar` counts: records past the end of the table are dropped, never read out of
|
||||
/// bounds, and a huge instance count is bounded.
|
||||
#[test]
|
||||
fn named_instances_survive_truncated_and_hostile_fvar() {
|
||||
let mut data = test_font::variable("Cut Test", &[("Black", 900.0, None)], false);
|
||||
// instanceCount (fvar offset 44 + 12) to 0xFFFF: only the one real record is read.
|
||||
data[44 + 12] = 0xFF;
|
||||
data[44 + 13] = 0xFF;
|
||||
let f = read_faces_bytes(&data);
|
||||
assert_eq!(f.len(), 1);
|
||||
assert!(f[0].instances.len() <= 1, "{:?}", f[0].instances);
|
||||
// An axis array offset past the table: no instances, but the face still scans.
|
||||
data[44 + 4] = 0xFF;
|
||||
let f = read_faces_bytes(&data);
|
||||
assert!(f[0].variable && f[0].instances.is_empty(), "{:?}", f[0]);
|
||||
}
|
||||
}
|
||||
@@ -154,11 +154,15 @@ pub fn char_advance_delta(g: &CharGlyph, deltas: &[(String, f32)]) -> Option<f64
|
||||
Some((varied - own) as f64 * sx)
|
||||
}
|
||||
|
||||
/// The first installed face with variation axes (system fonts are scanned), for tests and
|
||||
/// demos: (face, its axes).
|
||||
/// The first installed face with variation axes and Latin glyphs (system fonts are scanned), for
|
||||
/// tests and demos: (face, its axes). Faces without an `H` or `A` (the name-and-`fvar`-only
|
||||
/// fonts tests add) are skipped.
|
||||
pub fn find_variable_face() -> Option<(FaceId, Vec<FontAxis>)> {
|
||||
fonts::scan_system();
|
||||
(0..fonts::all_faces().len()).map(|f| (f, font_axes(f))).find(|(_, a)| a.iter().any(|x| x.max > x.min))
|
||||
(0..fonts::all_faces().len())
|
||||
.filter(|&f| fonts::face(f).glyph('H').or_else(|| fonts::face(f).glyph('A')).is_some())
|
||||
.map(|f| (f, font_axes(f)))
|
||||
.find(|(_, a)| a.iter().any(|x| x.max > x.min))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+13
-3
@@ -381,9 +381,11 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp
|
||||
format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft)
|
||||
}
|
||||
TimeDisplay::AudioSamples => {
|
||||
let secs = t.0.div_euclid(TICKS_PER_SECOND);
|
||||
let rem = Tick(t.0.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate);
|
||||
format!("{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60)
|
||||
let sign = if t.0 < 0 { "-" } else { "" };
|
||||
let abs = t.0.saturating_abs();
|
||||
let secs = abs.div_euclid(TICKS_PER_SECOND);
|
||||
let rem = Tick(abs.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate);
|
||||
format!("{sign}{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60)
|
||||
}
|
||||
TimeDisplay::Seconds => format!("{:.3}", t.seconds()),
|
||||
}
|
||||
@@ -701,6 +703,14 @@ mod tests {
|
||||
assert_eq!(format_time(Tick::from_units(48_001, 48_000), r, false, TimeDisplay::AudioSamples, 48000), "00:00:01:00001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neg_samples() {
|
||||
let r = FrameRate::FPS_30;
|
||||
let fmt = |s: f64| format_time(Tick::from_seconds_f64(s), r, false, TimeDisplay::AudioSamples, 48000);
|
||||
assert_eq!(fmt(-0.5), "-00:00:00:24000");
|
||||
assert_eq!(fmt(-3700.0), "-01:01:40:00000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rational_conversions() {
|
||||
// 90 kHz MPEG timebase
|
||||
|
||||
+38
-13
@@ -125,19 +125,24 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) ->
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match language {
|
||||
"ja" => lookup(JAPANESE, command, source),
|
||||
"zh-hans" => lookup(SIMPLIFIED_CHINESE, command, source),
|
||||
"zh-hant" => lookup(TRADITIONAL_CHINESE, command, source),
|
||||
"uk" => lookup(UKRAINIAN, command, source),
|
||||
"es" => lookup(SPANISH, command, source),
|
||||
"pt-br" => lookup(PORTUGUESE, command, source),
|
||||
"ru" => lookup(RUSSIAN, command, source),
|
||||
"fr" => lookup(FRENCH, command, source),
|
||||
"de" => lookup(GERMAN, command, source),
|
||||
"cs" => lookup(CZECH, command, source),
|
||||
_ => source,
|
||||
}
|
||||
menu_catalog(language).map_or(source, |catalog| lookup(catalog, command, source))
|
||||
}
|
||||
|
||||
/// The menu catalog for `language`; `None` for English.
|
||||
fn menu_catalog(language: &str) -> Option<&'static [(&'static str, &'static str, &'static str)]> {
|
||||
Some(match language {
|
||||
"ja" => JAPANESE,
|
||||
"zh-hans" => SIMPLIFIED_CHINESE,
|
||||
"zh-hant" => TRADITIONAL_CHINESE,
|
||||
"uk" => UKRAINIAN,
|
||||
"es" => SPANISH,
|
||||
"pt-br" => PORTUGUESE,
|
||||
"ru" => RUSSIAN,
|
||||
"fr" => FRENCH,
|
||||
"de" => GERMAN,
|
||||
"cs" => CZECH,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `source` looked up in `catalog` by (command, English label); the source itself when the pair is
|
||||
@@ -146,6 +151,26 @@ fn lookup<'a>(catalog: &[(&str, &str, &'a str)], command: &str, source: &'a str)
|
||||
catalog.iter().find(|(id, en, _)| *id == command && *en == source).map(|(_, _, translated)| *translated).unwrap_or(source)
|
||||
}
|
||||
|
||||
/// The text a context menu draws for an item that runs `command` with `params` (#1308).
|
||||
///
|
||||
/// The Timeline's and the Composition panel's context menus repeat menu-bar commands, so they read
|
||||
/// the menu bar's row for the same command, and an `effect.apply` item draws the effect's name as
|
||||
/// the Effect menu does. The menu tree writes the ellipsis as `...` where the context menus write
|
||||
/// `…`. A label with no menu row (Delete, which the menu bar calls Clear) goes through the panel
|
||||
/// catalog. Only the drawn text is translated: the automation label stays the English `source`.
|
||||
pub(crate) fn context_item(command: &str, params: &serde_json::Value, source: &str) -> String {
|
||||
if command == "effect.apply"
|
||||
&& let Some(id) = params.get("effect").and_then(|v| v.as_str())
|
||||
{
|
||||
return effects::name(id, source);
|
||||
}
|
||||
let ascii = source.strip_suffix('…').map(|s| format!("{s}..."));
|
||||
let key = ascii.as_deref().unwrap_or(source);
|
||||
menu_catalog(ui::current())
|
||||
.and_then(|catalog| catalog.iter().find(|(id, en, _)| *id == command && *en == key))
|
||||
.map_or_else(|| tr(source).to_string(), |(_, _, translated)| (*translated).to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> String {
|
||||
// The generated Effect menu's entries label themselves with the effect's registry name; the
|
||||
// catalog replaces it with the interface language's (#821). The entry keeps its command and
|
||||
|
||||
@@ -221,11 +221,16 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Drag to slip the source", "ドラッグしてソースをスリップ"),
|
||||
("Drop Frame", "ドロップフレーム"),
|
||||
("Duplicate", "複製"),
|
||||
("Copy", "コピー"),
|
||||
("Paste", "ペースト"),
|
||||
("Edit Value…", "値を編集…"),
|
||||
("Remove Expression", "エクスプレッションを削除"),
|
||||
("Duration:", "デュレーション:"),
|
||||
("Edit Gradient…", "グラデーションを編集…"),
|
||||
("Edit Target: {}", "編集対象:{}"),
|
||||
("Edit in the Timeline", "タイムラインで編集"),
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"),
|
||||
("Effects", "エフェクト"),
|
||||
("Effects & Presets options", "エフェクト&プリセットのオプション"),
|
||||
("Copy Presets Folder Path", "プリセットフォルダーのパスをコピー"),
|
||||
("Copy Source Path", "ソースパスをコピー"),
|
||||
@@ -263,6 +268,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Grab bag", "いろいろ"),
|
||||
("Gradient Editor", "グラデーションエディター"),
|
||||
("Grid", "グリッド"),
|
||||
("Guide Layer", "ガイドレイヤー"),
|
||||
("Guide Layers: {}", "ガイドレイヤー:{}"),
|
||||
("Guides", "ガイド"),
|
||||
("Height:", "高さ:"),
|
||||
@@ -633,6 +639,189 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("After Effects project", "After Effectsプロジェクト"),
|
||||
("Lottie animation", "Lottieアニメーション"),
|
||||
("Project", "プロジェクト"),
|
||||
("General", "一般"),
|
||||
("Language", "言語"),
|
||||
("Levels of Undo", "取り消しレベル"),
|
||||
("Path Point and Handle Size", "パスポイントとハンドルサイズ"),
|
||||
("Recent Projects Shown", "最近使用したプロジェクトの表示数"),
|
||||
("Options", "オプション"),
|
||||
("Show Tool Tips", "ツールヒントを表示"),
|
||||
("Create Layers at Composition Start Time", "レイヤーをコンポジションの開始時間に作成"),
|
||||
("Switches Affect Nested Comps", "ネストしたコンポジションにスイッチを適用"),
|
||||
("Default Spatial Interpolation to Linear", "初期設定の空間補間法にリニアを使用"),
|
||||
("Preserve Constant Vertex and Feather Point Count when Editing Masks", "マスク編集時に一定の頂点数とぼかしポイント数を保持"),
|
||||
("Synchronize Time of All Related Items", "すべての関連アイテムの時間を同期"),
|
||||
("Expression Pick Whip Writes Compact English", "ピックウィップによる簡潔な英語でのエクスプレッションの記述"),
|
||||
("Create Split Layers Above Original Layer", "元のレイヤーの上に分割したレイヤーを作成"),
|
||||
("Use System Color Picker", "システムのカラーピッカーを使用"),
|
||||
(
|
||||
"EffectCraft always uses its own colour picker (the same on every platform and the web).",
|
||||
"EffectCraftは常に独自のカラーピッカーを使用します(すべてのプラットフォームとWeb版で同じです)。",
|
||||
),
|
||||
("Startup & Repair", "起動と修復"),
|
||||
("Show Home Screen When Launching", "起動時にホーム画面を表示"),
|
||||
("Show Home Screen When Opening a Project", "プロジェクトを開くときにホーム画面を表示"),
|
||||
("Offer to Open the Latest Auto-Save After a Crash", "クラッシュ後に最新の自動保存を開くか確認する"),
|
||||
("Window Graphics", "ウィンドウグラフィックス"),
|
||||
("Automatic", "自動"),
|
||||
(
|
||||
"Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.",
|
||||
"ウィンドウグラフィックスは次回の起動から適用されます。ウィンドウを開くときにグラフィックスドライバーがEffectCraftを停止させた場合、次回の起動ではOpenGLに切り替わります。",
|
||||
),
|
||||
("Repair", "修復"),
|
||||
("Reset Settings", "設定をリセット"),
|
||||
("Reset Keyboard Shortcuts", "キーボードショートカットをリセット"),
|
||||
("Empty Disk Cache", "ディスクキャッシュを空にする"),
|
||||
("New Project Loads Template", "新規プロジェクト読み込みテンプレート"),
|
||||
("Template Project", "テンプレートプロジェクト"),
|
||||
("Auto-Save", "自動保存"),
|
||||
("Automatically Save Projects", "プロジェクトを自動保存"),
|
||||
("Save Every", "保存の間隔"),
|
||||
("Minutes", "分"),
|
||||
("Maximum Project Versions", "プロジェクトバージョンの最大数"),
|
||||
("Auto-Save Location", "自動保存の場所"),
|
||||
("Next to Project", "プロジェクトの横"),
|
||||
("Custom Location", "ユーザー定義の場所"),
|
||||
("Save When Starting Render Queue", "レンダーキューの開始時に保存"),
|
||||
("Save Now", "今すぐ保存"),
|
||||
("Motion Path", "モーションパス"),
|
||||
("All Keyframes", "すべてのキーフレーム"),
|
||||
("No Motion Path", "モーションパスなし"),
|
||||
("No More Than (Seconds)", "次の秒数以下"),
|
||||
("No More Than (Keyframes)", "次のキーフレーム以下"),
|
||||
("Seconds", "秒"),
|
||||
("Keyframes", "キーフレーム"),
|
||||
("Display", "表示"),
|
||||
("Show Rendering Progress in Info Panel and Flowchart", "情報パネルとフローチャートにレンダリングの進行状況を表示"),
|
||||
("Hardware Accelerate Composition, Layer and Footage Panels", "ハードウェアによるコンポジション、レイヤー、フッテージパネルの高速化"),
|
||||
("Panels are always drawn by the GPU (wgpu).", "パネルは常にGPU(wgpu)で描画されます。"),
|
||||
("Previews", "プレビュー"),
|
||||
("Adaptive Resolution Limit", "適応解像度の上限"),
|
||||
("Show Internal Wireframes", "内部ワイヤーフレームを表示"),
|
||||
("Cache Frames When Idle", "アイドル時にフレームをキャッシュ"),
|
||||
("Viewer Zoom Quality", "ズームの画質"),
|
||||
("Faster", "高速"),
|
||||
("More Accurate", "精度を優先"),
|
||||
("Display Color Space", "表示色空間"),
|
||||
("GPU Information...", "GPU 情報..."),
|
||||
("Appearance", "外観"),
|
||||
("Brightness", "明るさ"),
|
||||
("UI Scale", "UI スケール"),
|
||||
("Labels and Colors", "ラベルとカラー"),
|
||||
("Use Label Color for Layer Handles and Paths", "レイヤーハンドルとパスにラベルカラーを使用"),
|
||||
("Use Label Color for Related Tabs", "ラベルカラーを関連するタブに使用"),
|
||||
("Cycle Mask Colors", "新規マスクに別のカラーを使用"),
|
||||
("Use Gradients", "グラデーションを使用"),
|
||||
("Menu Bar", "メニューバー"),
|
||||
("Grids & Guides", "グリッド&ガイド"),
|
||||
("Gridline Every", "グリッドの間隔"),
|
||||
("Subdivisions", "分割数"),
|
||||
("Style", "スタイル"),
|
||||
("Lines", "線"),
|
||||
("Dashed Lines", "破線"),
|
||||
("Dots", "ドット"),
|
||||
("Horizontal", "水平方向"),
|
||||
("Vertical", "垂直方向"),
|
||||
("Safe Margins", "セーフマージン"),
|
||||
("Action-safe", "アクションセーフ"),
|
||||
("Title-safe", "タイトルセーフ"),
|
||||
("Labels", "ラベル"),
|
||||
("Text Engine", "テキストエンジン"),
|
||||
("Latin", "ラテン"),
|
||||
("South Asian and Middle Eastern", "南アジアおよび中東"),
|
||||
("Show Font Preview", "フォントのプレビューを表示"),
|
||||
("Number of Recent Fonts to Display", "最近使用したフォントの表示数"),
|
||||
("Show Font Names in English", "フォント名を英語で表示"),
|
||||
("Still Footage", "静止画フッテージ"),
|
||||
("Length of Composition", "コンポジションの長さ"),
|
||||
("Duration (Seconds)", "デュレーション(秒)"),
|
||||
("Still Duration", "静止画のデュレーション"),
|
||||
("Sequence Footage", "シーケンスフッテージ"),
|
||||
("Report Missing Frames", "不明なフレームをレポート"),
|
||||
("Automatically Reload Footage", "フッテージの自動再読み込み"),
|
||||
("Off", "オフ"),
|
||||
("Non-Sequence Footage", "非シーケンスフッテージ"),
|
||||
("All Footage", "すべてのフッテージ"),
|
||||
("Ask User", "ユーザーに確認"),
|
||||
("Guess", "推測"),
|
||||
("Ignore", "無視"),
|
||||
("Straight", "そのまま"),
|
||||
("Premultiplied", "プリマルチプライ"),
|
||||
("Default Drag Import As", "複数アイテムのドラッグ読み込みの初期設定"),
|
||||
("Footage", "フッテージ"),
|
||||
("Composition - Retain Layer Sizes", "コンポジション - レイヤーサイズを保持"),
|
||||
("Export", "書き出し"),
|
||||
("Default Output Folder", "既定の出力フォルダー"),
|
||||
("Segment Sequences", "シーケンスをセグメント化"),
|
||||
("Files per Segment", "セグメントあたりのファイル数"),
|
||||
("files", "ファイル"),
|
||||
("Segment Movie Files", "ムービーファイルをセグメント化"),
|
||||
("Segment Size", "セグメントサイズ"),
|
||||
("Append Bit Depth to File Name", "ファイル名にビット深度を追加"),
|
||||
("Audio", "オーディオ"),
|
||||
("Audio Hardware", "オーディオハードウェア"),
|
||||
("Preview Sample Rate", "サンプルレート"),
|
||||
("Audio Output Mapping", "オーディオ出力マッピング"),
|
||||
("Left", "左"),
|
||||
("Right", "右"),
|
||||
("channel", "チャンネル"),
|
||||
("Disk", "ディスク"),
|
||||
("Disk Cache", "ディスクキャッシュ"),
|
||||
("Enable Disk Cache", "ディスクキャッシュを有効にする"),
|
||||
("Maximum Disk Cache Size", "最大ディスクキャッシュサイズ"),
|
||||
("Disk Cache Folder", "ディスクキャッシュフォルダー"),
|
||||
("Media Cache", "メディアキャッシュ"),
|
||||
("Database and Cache Folder", "データベースとキャッシュのフォルダー"),
|
||||
("Conformed Audio Folder", "適合オーディオフォルダー"),
|
||||
("Memory & CPU", "メモリと CPU"),
|
||||
("RAM Reserved for Other Applications", "他のアプリケーション用に確保する RAM"),
|
||||
("Cache Budgets", "キャッシュの割り当て"),
|
||||
("Layer Cache", "レイヤーキャッシュ"),
|
||||
("Footage Frame Cache", "フッテージフレームキャッシュ"),
|
||||
("Preview (RAM) Cache", "プレビュー(RAM)キャッシュ"),
|
||||
("Reduce Cache Size When System Is Low on Memory", "システムのメモリが不足しているときにキャッシュサイズを減らす"),
|
||||
("Video", "ビデオ"),
|
||||
("Enable Video Preview Output", "ビデオプレビュー出力を有効にする"),
|
||||
("Video Device", "ビデオデバイス"),
|
||||
("Floating Window", "フローティングウィンドウ"),
|
||||
("Full Screen", "全画面"),
|
||||
("Video Output During Playback", "再生中のビデオ出力"),
|
||||
("Mirror on Computer Monitor", "コンピューターモニターにミラーリング"),
|
||||
("Disable Video Output When in Background", "バックグラウンド動作時のビデオ出力を無効にする"),
|
||||
("Default 3D Renderer", "既定の 3D レンダラー"),
|
||||
("Show 3D Reference Axes", "3D参照軸を表示"),
|
||||
("Extended Viewer", "拡張ビューアー"),
|
||||
("Realtime Shadows in Draft", "ドラフトのリアルタイムシャドウ"),
|
||||
("Scripting & Expressions", "スクリプトとエクスプレッション"),
|
||||
("Application Scripting", "アプリケーションのスクリプト"),
|
||||
("Allow Scripts to Write Files and Access Network", "スクリプトによるファイルへの書き込みとネットワークへのアクセスを許可"),
|
||||
("Warn User When Executing Files", "ファイルの実行時にカーソルに警告"),
|
||||
("Enable JavaScript Debugger", "JavaScript デバッガーを使用"),
|
||||
("Expressions Editor", "エクスプレッションエディター"),
|
||||
("Font Size", "フォントサイズ"),
|
||||
("Syntax Highlighting", "構文のハイライト"),
|
||||
("Line Numbers", "行番号を表示"),
|
||||
("Auto-complete", "オートコンプリート"),
|
||||
("Bracket Matching", "対応する括弧をハイライト"),
|
||||
("Word Wrap", "折り返し"),
|
||||
("Show Expression Error Banner", "式のエラーがある場合に警告バナーを表示"),
|
||||
(
|
||||
"Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.",
|
||||
"エクスプレッションはJavaScriptエンジンを使用します。スクリプトのエラーはファイルと行をスクリプトコンソールに報告します。ステップ実行デバッガーはありません。",
|
||||
),
|
||||
("Roto Brush", "ロトブラシ"),
|
||||
("Segmentation Model", "セグメンテーションモデル"),
|
||||
(
|
||||
"Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.",
|
||||
"ロトブラシ2.0と3.0はここで選んだモデルを使用します(バージョン1.0は常に従来のエンジンを使用します)。モデルはオープンソースで、要求したときだけダウンロードされ、公開されているSHA-256と照合されます。",
|
||||
),
|
||||
("Face Tracking", "フェイストラッキング"),
|
||||
("Face Model", "フェイスモデル"),
|
||||
(
|
||||
"Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.",
|
||||
"トラッカーパネルのフェイストラッキング(輪郭のみと詳細機能)はここで選んだモデルを使用します。モデルはオープンソースで、要求したときだけダウンロードされ、公開されているSHA-256と照合されます。",
|
||||
),
|
||||
("Match System", "システムに合わせる"),
|
||||
("Total Offset", "合計オフセット"),
|
||||
("Per Layer", "レイヤーごと"),
|
||||
("Track", "トラック"),
|
||||
@@ -767,11 +956,16 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Drag to slip the source", "拖动可滑移源"),
|
||||
("Drop Frame", "丢帧"),
|
||||
("Duplicate", "重复"),
|
||||
("Copy", "复制"),
|
||||
("Paste", "粘贴"),
|
||||
("Edit Value…", "编辑值…"),
|
||||
("Remove Expression", "移除表达式"),
|
||||
("Duration:", "持续时间:"),
|
||||
("Edit Gradient…", "编辑渐变…"),
|
||||
("Edit Target: {}", "编辑目标:{}"),
|
||||
("Edit in the Timeline", "在时间轴中编辑"),
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"),
|
||||
("Effects", "效果"),
|
||||
("Effects & Presets options", "效果和预设选项"),
|
||||
("Copy Presets Folder Path", "复制预设文件夹路径"),
|
||||
("Copy Source Path", "复制源路径"),
|
||||
@@ -809,6 +1003,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Grab bag", "列表"),
|
||||
("Gradient Editor", "渐变编辑器"),
|
||||
("Grid", "网格"),
|
||||
("Guide Layer", "参考线图层"),
|
||||
("Guide Layers: {}", "参考图层:{}"),
|
||||
("Guides", "参考线"),
|
||||
("Height:", "高度:"),
|
||||
@@ -1501,11 +1696,16 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Drag to slip the source", "拖曳可滑移源"),
|
||||
("Drop Frame", "丟格"),
|
||||
("Duplicate", "重複"),
|
||||
("Copy", "拷貝"),
|
||||
("Paste", "貼上"),
|
||||
("Edit Value…", "編輯值…"),
|
||||
("Remove Expression", "移除運算式"),
|
||||
("Duration:", "持續時間:"),
|
||||
("Edit Gradient…", "編輯漸變…"),
|
||||
("Edit Target: {}", "編輯目標:{}"),
|
||||
("Edit in the Timeline", "在時間軸中編輯"),
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"),
|
||||
("Effects", "效果"),
|
||||
("Effects & Presets options", "效果和預設集選項"),
|
||||
("Copy Presets Folder Path", "複製預設集資料夾路徑"),
|
||||
("Copy Source Path", "複製來源路徑"),
|
||||
@@ -1543,6 +1743,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Grab bag", "清單"),
|
||||
("Gradient Editor", "漸變編輯器"),
|
||||
("Grid", "格點"),
|
||||
("Guide Layer", "參考圖層"),
|
||||
("Guide Layers: {}", "參考圖層:{}"),
|
||||
("Guides", "參考線"),
|
||||
("Height:", "高度:"),
|
||||
@@ -2238,11 +2439,16 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Drag to slip the source", "Перетягніть, щоб зсунути матеріал у межах шару"),
|
||||
("Drop Frame", "Пропуск номерів"),
|
||||
("Duplicate", "Дублювати"),
|
||||
("Copy", "Копіювати"),
|
||||
("Paste", "Вставити"),
|
||||
("Edit Value…", "Редагувати значення…"),
|
||||
("Remove Expression", "Вилучити вираз"),
|
||||
("Duration:", "Тривалість:"),
|
||||
("Edit Gradient…", "Редагувати градієнт…"),
|
||||
("Edit Target: {}", "Ціль редагування: {}"),
|
||||
("Edit in the Timeline", "Редагувати на шкалі часу"),
|
||||
("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."),
|
||||
("Effects", "Ефекти"),
|
||||
("Effects & Presets options", "Параметри ефектів і наборів"),
|
||||
("Copy Presets Folder Path", "Копіювати шлях до папки наборів"),
|
||||
("Copy Source Path", "Копіювати шлях до джерела"),
|
||||
@@ -2280,6 +2486,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Grab bag", "Список"),
|
||||
("Gradient Editor", "Редактор градієнта"),
|
||||
("Grid", "Сітка"),
|
||||
("Guide Layer", "Допоміжний шар"),
|
||||
("Guide Layers: {}", "Допоміжні шари: {}"),
|
||||
("Guides", "Напрямні"),
|
||||
("Height:", "Висота:"),
|
||||
@@ -2792,11 +2999,16 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
("Drag to slip the source", "Arraste para deslizar a origem"),
|
||||
("Drop Frame", "Drop frame"),
|
||||
("Duplicate", "Duplicar"),
|
||||
("Copy", "Copiar"),
|
||||
("Paste", "Colar"),
|
||||
("Edit Value…", "Editar valor…"),
|
||||
("Remove Expression", "Remover expressão"),
|
||||
("Duration:", "Duração:"),
|
||||
("Edit Gradient…", "Editar gradiente…"),
|
||||
("Edit Target: {}", "Editar destino: {}"),
|
||||
("Edit in the Timeline", "Editar na linha do tempo"),
|
||||
("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."),
|
||||
("Effects", "Efeitos"),
|
||||
("Effects & Presets options", "Opções de Efeitos e predefinições"),
|
||||
("Copy Presets Folder Path", "Copiar caminho da pasta de predefinições"),
|
||||
("Copy Source Path", "Copiar caminho da origem"),
|
||||
@@ -2832,6 +3044,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
("Frame Target:", "Destino do quadro:"),
|
||||
("Grab bag", "Lista"),
|
||||
("Grid", "Grade"),
|
||||
("Guide Layer", "Camada de guia"),
|
||||
("Guide Layers: {}", "Camadas de guia: {}"),
|
||||
("Guides", "Guias"),
|
||||
("Height:", "Altura:"),
|
||||
@@ -3244,11 +3457,16 @@ const CZECH: &[(&str, &str)] = &[
|
||||
("Drag to slip the source", "Tažením posunete zdroj v čase"),
|
||||
("Drop Frame", "Vynechávat čísla snímků"),
|
||||
("Duplicate", "Duplikovat"),
|
||||
("Copy", "Kopírovat"),
|
||||
("Paste", "Vložit"),
|
||||
("Edit Value…", "Upravit hodnotu…"),
|
||||
("Remove Expression", "Odstranit výraz"),
|
||||
("Duration:", "Délka trvání:"),
|
||||
("Edit Gradient…", "Upravit přechod…"),
|
||||
("Edit Target: {}", "Upravit cíl: {}"),
|
||||
("Edit in the Timeline", "Upravit na časové ose"),
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."),
|
||||
("Effects", "Efekty"),
|
||||
("Effects & Presets options", "Možnosti efektů a přednastavení"),
|
||||
("Copy Presets Folder Path", "Kopírovat cestu ke složce přednastavení"),
|
||||
("Copy Source Path", "Kopírovat cestu ke zdroji"),
|
||||
@@ -3285,6 +3503,7 @@ const CZECH: &[(&str, &str)] = &[
|
||||
("Freeze", "Zmrazit"),
|
||||
("Grab bag", "Mozaika"),
|
||||
("Grid", "Mřížka"),
|
||||
("Guide Layer", "Pomocná vrstva"),
|
||||
("Guide Layers: {}", "Pomocné vrstvy: {}"),
|
||||
("Guides", "Vodítka"),
|
||||
("Height:", "Výška:"),
|
||||
@@ -3803,11 +4022,16 @@ const SPANISH: &[(&str, &str)] = &[
|
||||
("Drag to slip the source", "Arrastra para deslizar el origen"),
|
||||
("Drop Frame", "Drop frame"),
|
||||
("Duplicate", "Duplicar"),
|
||||
("Copy", "Copiar"),
|
||||
("Paste", "Pegar"),
|
||||
("Edit Value…", "Editar valor…"),
|
||||
("Remove Expression", "Quitar expresión"),
|
||||
("Duration:", "Duración:"),
|
||||
("Edit Gradient…", "Editar degradado…"),
|
||||
("Edit Target: {}", "Destino de la edición: {}"),
|
||||
("Edit in the Timeline", "Editar en la cronología"),
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft no se cerró normalmente la última vez."),
|
||||
("Effects", "Efectos"),
|
||||
("Effects & Presets options", "Opciones de Efectos y ajustes preestablecidos"),
|
||||
("Effects: {}", "Efectos: {}"),
|
||||
("Empty folder", "Carpeta vacía"),
|
||||
@@ -4324,6 +4548,7 @@ mod tests {
|
||||
set_current("cs");
|
||||
assert_eq!(tr("Composition Name:"), "Název kompozice:");
|
||||
assert_eq!(tr("Cancel"), "Zrušit");
|
||||
assert_eq!(tr("Guide Layer"), "Pomocná vrstva", "the layer context menus' item, as Layer ▸ Guide Layer");
|
||||
assert_eq!(tr("my layer.mp4"), "my layer.mp4");
|
||||
assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Použití zástupných souborů: Best");
|
||||
assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Krok 1 z 3: Colour");
|
||||
|
||||
@@ -163,8 +163,10 @@ pub struct Hooks {
|
||||
pub app_action: Option<Box<dyn Fn(&str) -> bool>>,
|
||||
/// Shows a command's file dialog without blocking the frame and calls `done` with the
|
||||
/// answer (the host then asks for a repaint). Used in place of the blocking pickers above for
|
||||
/// the commands' dialogs (File ▸ Import, Open, Save As…) where a modal dialog's nested event
|
||||
/// loop inside a frame is unsafe: on macOS a drag over the window then aborted the app (#511).
|
||||
/// the commands' dialogs (File ▸ Import, Open, Save As…) where blocking the frame is unsafe or
|
||||
/// leaves the window unresponsive: on macOS a nested modal event loop there aborted the app on
|
||||
/// a drag over the window (#511), and on Linux a blocking desktop-portal dialog never pumps
|
||||
/// events, so the compositor marks the window as not responding (#1275).
|
||||
pub file_dialog_async: Option<Box<dyn Fn(FileDialog, DialogDone)>>,
|
||||
/// Tells the user the graphics device was lost and the window is closing, once. `saved` is
|
||||
/// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed.
|
||||
|
||||
@@ -255,7 +255,7 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec<u64> {
|
||||
/// `down`, `left`, `right`), 10 with Shift (`up10`…), as After Effects does, so zoomed in
|
||||
/// they move by fractions of a comp pixel. From any panel except Project (which owns the
|
||||
/// arrow keys for its own keyboard navigation, #961); not while typing, with a dialog or
|
||||
/// menu open, or with mask points selected.
|
||||
/// menu open. With mask points selected the arrows move the points instead (#1288).
|
||||
fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result<Value, String> {
|
||||
let (side, n) = match dir.strip_suffix("10") {
|
||||
Some(side) => (side, 10.0),
|
||||
@@ -269,15 +269,34 @@ fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result<Val
|
||||
_ => return Err(format!("unknown nudge `{dir}`")),
|
||||
};
|
||||
let st = &app.session.state;
|
||||
if app.ui.focused == PanelKind::Project || st.selected_layers.is_empty() || st.text_edit.is_some() || !st.selected_vertices.is_empty() {
|
||||
if app.ui.focused == PanelKind::Project || (st.selected_layers.is_empty() && st.selected_vertices.is_empty()) || st.text_edit.is_some() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Screen pixels per comp pixel.
|
||||
let zoom = (app.ui.viewer.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx)) * ctx.pixels_per_point()) as f64;
|
||||
let k = if zoom.is_finite() && zoom > 1e-6 { 1.0 / zoom } else { 1.0 };
|
||||
if let Some(layer) = st.selected_vertices.first().map(|v| v.layer) {
|
||||
let d = vertex_delta(app, layer, [x * k, y * k]);
|
||||
return run_engine(app, ctx, "mask.moveVertices", json!({"delta": d}));
|
||||
}
|
||||
run_engine(app, ctx, "layer.nudge", json!({"x": x * k, "y": y * k}))
|
||||
}
|
||||
|
||||
/// A comp-space step `d` in the layer space of `layer`, where mask points live. Like a drag of
|
||||
/// the points in the viewer, every selected point takes the first one's layer space.
|
||||
fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerId, d: [f64; 2]) -> [f64; 2] {
|
||||
use effectcraft_engine::geom::vec2;
|
||||
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 (l2c, _) = crate::panels::viewer::l2c(&ectx, l);
|
||||
l2c.inverse().map_or(d, |inv| {
|
||||
let v = inv.apply_vec(vec2(d[0], d[1]));
|
||||
[v.x, v.y]
|
||||
})
|
||||
}
|
||||
|
||||
fn no_params(p: &Value) -> bool {
|
||||
p.as_object().is_none_or(|m| m.is_empty())
|
||||
}
|
||||
@@ -300,6 +319,17 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
if id == "layer.newText" && no_params(¶ms) {
|
||||
return run_engine(app, ctx, id, json!({"text": "", "edit": true}));
|
||||
}
|
||||
// A new folder (File ▸ New, its shortcut, the Project panel's button and context menu) opens
|
||||
// with its name ready to type, as in After Effects (#1658). The command selects it.
|
||||
// `engine.execute` keeps the headless default.
|
||||
if id == "project.newFolder" && no_params(¶ms) {
|
||||
let r = run_engine(app, ctx, id, params);
|
||||
if r.is_ok() {
|
||||
app.show_panel(PanelKind::Project);
|
||||
crate::panels::project::begin_rename(app, ctx);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
if no_params(¶ms) && app.ui.focused == PanelKind::Project {
|
||||
match id {
|
||||
"edit.duplicate" => return run_engine(app, ctx, "project.duplicate", json!({})),
|
||||
@@ -1366,7 +1396,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||
"keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])),
|
||||
"essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")),
|
||||
"essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])),
|
||||
"file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])),
|
||||
"file.setProxy" => {
|
||||
("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"]))
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if params.get(key).is_some()
|
||||
@@ -1381,9 +1413,11 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||
"mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "dpx", "cin", "wav", "aif", "aiff", "mp3",
|
||||
"flac", "ogg", "opus", "svg", "pdf", "ai", "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv",
|
||||
];
|
||||
// A host whose modal dialogs would run a nested event loop inside this frame (macOS: a
|
||||
// drag over the window then re-entered winit's event handler and aborted, #511) shows the
|
||||
// dialog without blocking; the command continues when the answer arrives.
|
||||
// A host whose blocking dialogs are unsafe inside this frame shows the dialog without
|
||||
// blocking — macOS: the nested event loop re-entered winit's handler on a drag over the
|
||||
// window and aborted the app (#511); Linux: the desktop portal's answer never gets pumped,
|
||||
// so the compositor marks the window as not responding (#1275) — and the command continues
|
||||
// when the answer arrives.
|
||||
if let Some(spawn) = app.hooks.file_dialog_async.as_ref() {
|
||||
let request = match ask {
|
||||
Ask::Folder => crate::FileDialog::Folder,
|
||||
@@ -2512,6 +2546,21 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tga_is_available_in_import_replace_and_proxy_dialogs() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
let queue = pending.clone();
|
||||
app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog)));
|
||||
for command in ["file.import", "file.importMultiple", "file.replaceFootage", "file.setProxy"] {
|
||||
assert_eq!(invoke(&mut app, &ctx, command, json!({})).unwrap(), json!({"dialog": command}));
|
||||
let crate::FileDialog::Open { exts, .. } = pending.lock().unwrap().pop().unwrap() else { panic!("not an open dialog") };
|
||||
assert!(exts.iter().any(|ext| ext == "tga"), "{command}: {exts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_items_cover_the_tree() {
|
||||
assert_eq!(menus().last(), Some(&"Help"));
|
||||
|
||||
@@ -272,7 +272,15 @@ fn prop_row(
|
||||
let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(vx - 6.0, r.max.y));
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name);
|
||||
p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text);
|
||||
ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click());
|
||||
let name_response = ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click());
|
||||
if name_response.double_clicked() {
|
||||
app.ui.timeline.search.clear();
|
||||
app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Property(uid));
|
||||
if let Err(e) = crate::menus::frontend(app, ui.ctx(), "timeline.revealProps", json!({"props": [{"layer": layer.id.0, "prop": uid}]})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
app.show_panel(crate::dock::PanelKind::Timeline);
|
||||
}
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name);
|
||||
let merge = format!("ec-{uid}");
|
||||
// With several layers selected the edit applies to the same effect property of each (#491).
|
||||
@@ -377,6 +385,11 @@ fn prop_row(
|
||||
let mut nc = c.clone();
|
||||
nc[d] = nv;
|
||||
set(actions, json!(nc));
|
||||
// Typed into this component only: other selected layers keep the rest of
|
||||
// their own value (#1434).
|
||||
if let Some((_, a)) = actions.last_mut() {
|
||||
a["dims"] = json!([d]);
|
||||
}
|
||||
}
|
||||
x = vr.max.x + 8.0;
|
||||
}
|
||||
@@ -1279,7 +1292,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let lid = layer.id.0;
|
||||
let mut picked_label: Option<usize> = None;
|
||||
let mut item = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, params: serde_json::Value| {
|
||||
if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
|
||||
if ui.add_enabled(enabled, egui::Button::new(crate::i18n::context_item(cmd, ¶ms, label))).clicked() {
|
||||
actions.push((cmd.into(), params));
|
||||
ui.close();
|
||||
}
|
||||
@@ -1394,14 +1407,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.set_clip_rect(panel_clip);
|
||||
let content_h = y + scroll.offset - body.min.y;
|
||||
scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t);
|
||||
// Drop effects here.
|
||||
// Drop effects and animation presets here.
|
||||
if let Some(payload) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx)
|
||||
&& ui.rect_contains_pointer(rect)
|
||||
&& let crate::panels::DragPayload::Effect(e) = payload.as_ref()
|
||||
&& let Some((id, params)) = match payload.as_ref() {
|
||||
crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer.id.0]}))),
|
||||
crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer.id.0)),
|
||||
_ => None,
|
||||
}
|
||||
{
|
||||
p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
actions.push(("effect.apply".into(), json!({"effect": e, "layers": [layer.id.0]})));
|
||||
actions.push((id.into(), params));
|
||||
egui::DragAndDrop::clear_payload(&ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! filters, animation presets on/off, categories or alphabetical, refresh), Favorites (star an
|
||||
//! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation and transition
|
||||
//! presets and the user's `.ecpreset` files) and the effect categories. Double-click applies to the
|
||||
//! selected layers; dragging an effect onto a layer (timeline, viewer, Effect Controls) applies
|
||||
//! it there.
|
||||
//! selected layers; dragging an effect or an animation preset onto a layer (timeline, viewer,
|
||||
//! Effect Controls) applies it there.
|
||||
|
||||
use crate::i18n::tr;
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
@@ -11,7 +11,7 @@ use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::panels::DragPayload;
|
||||
use crate::panels::{DragPayload, PresetRef};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
@@ -108,10 +108,8 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: &
|
||||
|
||||
enum Apply {
|
||||
Effect(String),
|
||||
TextPreset(String),
|
||||
/// A built-in animation preset (Transitions…) by id.
|
||||
Builtin(String),
|
||||
PresetFile(String),
|
||||
/// A text, built-in (Transitions…) or user file animation preset.
|
||||
Preset(PresetRef),
|
||||
}
|
||||
|
||||
/// A folder row; returns whether it is open.
|
||||
@@ -195,7 +193,7 @@ fn effect_row(
|
||||
}
|
||||
app.auto.add(&format!("{auto_prefix}.{}", e.id), r, e.name);
|
||||
// Favourite star (always shown when starred, else on hover).
|
||||
let fav = app.ui.effects_favorites.contains(e.id);
|
||||
let fav = app.session.prefs.is_effect_favorite(e.id);
|
||||
let sresp = ui.interact(star.intersect(list), egui::Id::new((auto_prefix, e.id, "star")), Sense::click());
|
||||
if fav || resp.hovered() || sresp.hovered() {
|
||||
let gold = Color32::from_rgb(0xe8, 0xc0, 0x40);
|
||||
@@ -203,11 +201,9 @@ fn effect_row(
|
||||
}
|
||||
app.auto.add(&format!("{auto_prefix}.{}.star", e.id), star, if fav { "Remove from Favorites" } else { "Add to Favorites" });
|
||||
if sresp.clicked() {
|
||||
if fav {
|
||||
app.ui.effects_favorites.remove(e.id);
|
||||
} else {
|
||||
app.ui.effects_favorites.insert(e.id.to_string());
|
||||
}
|
||||
// Kept in the settings, so the stars are there next time (#1692).
|
||||
app.session.prefs.toggle_effect_favorite(e.id);
|
||||
app.session.save_prefs();
|
||||
return;
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
@@ -218,7 +214,7 @@ fn effect_row(
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset row (no drag; double-click applies).
|
||||
/// A preset row: double-click applies to the selected layers; drag it onto a layer to apply it there.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn preset_row(
|
||||
app: &mut EffectcraftApp,
|
||||
@@ -229,7 +225,7 @@ fn preset_row(
|
||||
name: &str,
|
||||
auto_id: &str,
|
||||
query: &str,
|
||||
on_apply: impl FnOnce() -> Apply,
|
||||
preset: PresetRef,
|
||||
apply: &mut Option<Apply>,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
@@ -238,15 +234,18 @@ fn preset_row(
|
||||
if r.max.y < list.min.y || r.min.y > list.max.y {
|
||||
return;
|
||||
}
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click());
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click_and_drag());
|
||||
if resp.hovered() {
|
||||
lp.rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 58.0, r.center().y), vec2(12.0, 12.0)), Icon::Keyframe, t.text_dim);
|
||||
highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t);
|
||||
app.auto.add(auto_id, r, name);
|
||||
if resp.drag_started() {
|
||||
egui::DragAndDrop::set_payload(ui.ctx(), DragPayload::Preset { preset: preset.clone(), name: name.to_string() });
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
*apply = Some(on_apply());
|
||||
*apply = Some(Apply::Preset(preset));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +363,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let lookup = |id: &str| reg.iter().find(|e| e.id == id);
|
||||
|
||||
// Favorites and Recently Used.
|
||||
let favs: Vec<_> = app.ui.effects_favorites.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect();
|
||||
let favs: Vec<_> = app.session.prefs.effects_favorites.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect();
|
||||
if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, "favorites", "Favorites", 0.0, searching, Some(Icon::Star)) {
|
||||
let mut favs = favs;
|
||||
favs.sort_by_key(|e| e.name);
|
||||
@@ -397,7 +396,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
|
||||
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) {
|
||||
for (id, name) in text {
|
||||
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, || Apply::TextPreset(id.clone()), &mut apply);
|
||||
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, PresetRef::Text(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
for cat in &categories {
|
||||
@@ -405,7 +404,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
continue;
|
||||
}
|
||||
for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) {
|
||||
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, || Apply::Builtin(id.clone()), &mut apply);
|
||||
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, PresetRef::Builtin(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) {
|
||||
@@ -419,7 +418,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
&f.name,
|
||||
&format!("effects.preset.file.{}", f.name),
|
||||
&query,
|
||||
|| Apply::PresetFile(f.path.clone()),
|
||||
PresetRef::File(f.path.clone()),
|
||||
&mut apply,
|
||||
);
|
||||
}
|
||||
@@ -457,10 +456,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let content = y + scroll.offset - list.min.y;
|
||||
scroll.end(ui, &mut app.auto, "effects.scroll", content, &t);
|
||||
if let Some(DragPayload::Effect(id)) = egui::DragAndDrop::payload::<DragPayload>(&ctx).as_deref()
|
||||
&& let Some(pos) = ctx.pointer_hover_pos()
|
||||
if let Some(name) = egui::DragAndDrop::payload::<DragPayload>(&ctx).and_then(|p| match p.as_ref() {
|
||||
DragPayload::Effect(id) => Some(effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default()),
|
||||
DragPayload::Preset { name, .. } => Some(name.clone()),
|
||||
_ => None,
|
||||
}) && let Some(pos) = ctx.pointer_hover_pos()
|
||||
{
|
||||
let name = effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default();
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("dnd-fx")));
|
||||
let g = painter.layout_no_wrap(name, Tokens::ui(12.0), t.text);
|
||||
let r = Rect::from_min_size(pos + vec2(12.0, 8.0), g.size() + vec2(16.0, 8.0));
|
||||
@@ -475,9 +476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let (cmd, params) = match &apply {
|
||||
Apply::Effect(id) => ("effect.apply", json!({"effect": id})),
|
||||
Apply::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})),
|
||||
Apply::Builtin(id) => ("anim.applyBuiltinPreset", json!({"preset": id})),
|
||||
Apply::PresetFile(path) => ("anim.applyPreset", json!({"path": path})),
|
||||
Apply::Preset(preset) => preset.command(),
|
||||
};
|
||||
match app.session.execute(cmd, params) {
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
|
||||
@@ -32,7 +32,7 @@ fn gradient_paints<'a>(g: &'a PropGroup, depth: usize, out: &mut Vec<&'a PropGro
|
||||
}
|
||||
|
||||
/// `g` or something inside it is selected on `layer`.
|
||||
fn selected_in(g: &PropGroup, layer: LayerId, selected: &[(LayerId, u64)]) -> bool {
|
||||
pub(crate) fn selected_in(g: &PropGroup, layer: LayerId, selected: &[(LayerId, u64)]) -> bool {
|
||||
fn contains(g: &PropGroup, uid: u64, depth: usize) -> bool {
|
||||
g.uid == uid || (depth < 16 && g.children.iter().any(|n| n.uid() == uid || matches!(n, Node::Group(c) if contains(c, uid, depth + 1))))
|
||||
}
|
||||
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user