mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 02:57:34 -06:00
Merge remote-tracking branch 'origin/main' into pr-1482
# Conflicts: # crates/pdf/src/cff.rs
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
Generated
+148
@@ -747,6 +747,12 @@ dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.5.1"
|
||||
@@ -962,6 +968,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1440,6 +1455,7 @@ dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -1478,6 +1494,7 @@ dependencies = [
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-text",
|
||||
@@ -1485,6 +1502,7 @@ dependencies = [
|
||||
"kurbo",
|
||||
"miniz_oxide 0.8.9",
|
||||
"skrifa",
|
||||
"wasm-bindgen-test",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
@@ -2678,6 +2696,40 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-ccitt"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jpeg2000"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
|
||||
dependencies = [
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"stringprep",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -3167,6 +3219,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minicov"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3311,6 +3373,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
@@ -3759,6 +3830,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -4773,6 +4850,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
@@ -5156,6 +5244,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
@@ -5298,6 +5392,21 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
@@ -5462,6 +5571,45 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.261.0"
|
||||
|
||||
@@ -11,6 +11,7 @@ rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/effectcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.1.0", default-features = false, features = ["read"] }
|
||||
# internal
|
||||
effectcraft-time = { path = "crates/time" }
|
||||
effectcraft-geom = { path = "crates/geom" }
|
||||
@@ -91,6 +92,9 @@ filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a
|
||||
filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned
|
||||
# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that.
|
||||
symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] }
|
||||
|
||||
# gpu (the same wgpu eframe / egui-wgpu use)
|
||||
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
|
||||
@@ -204,3 +208,6 @@ opt-level = 3
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[patch.crates-io]
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.7.2" }
|
||||
|
||||
@@ -15,3 +15,8 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt.
|
||||
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
|
||||
The optional PDF document reader uses craft-pdf and Hayro-syntax 0.7.2, MIT OR Apache-2.0.
|
||||
The shared patched syntax source is centralized in storytold/craft-libs, transferred from
|
||||
PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no
|
||||
Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters.
|
||||
|
||||
@@ -154,6 +154,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges |
|
||||
| 2026-10-10 | minor | Hours to ≈ 95% for each audience |
|
||||
| 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) |
|
||||
| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
|
||||
| 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md |
|
||||
| 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 |
|
||||
|
||||
@@ -263,8 +263,18 @@ fn value_arg(s: &str) -> Value {
|
||||
serde_json::from_str(s).unwrap_or_else(|_| json!(s))
|
||||
}
|
||||
|
||||
/// The command line as text. `std::env::args` panics on an argument that is not Unicode (a
|
||||
/// Latin-1 file name on Linux). Every argument is used as a string, and a lossy copy would read or
|
||||
/// write another file than the one named, so such an argument is a usage error naming it.
|
||||
fn utf8_args(raw: impl IntoIterator<Item = std::ffi::OsString>) -> Result<Vec<String>, String> {
|
||||
raw.into_iter().map(|a| a.into_string().map_err(|a| format!("argument `{}` is not valid Unicode", a.to_string_lossy()))).collect()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||
let raw = match utf8_args(std::env::args_os().skip(1)) {
|
||||
Ok(raw) => raw,
|
||||
Err(e) => fail_usage(&e),
|
||||
};
|
||||
if matches!(raw.first().map(String::as_str), Some("--version" | "-V" | "version")) {
|
||||
say!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
@@ -1270,6 +1280,18 @@ mod tests {
|
||||
assert!(a.flag("--work-area") && a.flag("--json"));
|
||||
}
|
||||
|
||||
/// `std::env::args` panicked on an argument that is not Unicode; it is a usage error naming
|
||||
/// it, and Unicode arguments pass through unchanged.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_arguments_are_refused() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let args = [OsString::from("render-frame"), OsString::from("--out"), OsString::from_vec(b"caf\xe9.png".to_vec())];
|
||||
assert_eq!(utf8_args(args).unwrap_err(), "argument `caf\u{fffd}.png` is not valid Unicode");
|
||||
assert_eq!(utf8_args([OsString::from("info"), OsString::from("caf\u{e9}.png")]).unwrap(), ["info", "caf\u{e9}.png"]);
|
||||
}
|
||||
|
||||
/// Every `--option` the CLI reads is declared in VALUED or FLAGS, so none is refused.
|
||||
#[test]
|
||||
fn every_option_read_is_declared() {
|
||||
|
||||
@@ -25,12 +25,19 @@ fn ok_json(args: &[&str]) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
/// `std::fs::canonicalize` without Windows' `\\?\` verbatim prefix, which a child's `current_dir()` never reports.
|
||||
fn canonical(path: &std::path::Path) -> std::io::Result<PathBuf> {
|
||||
let path = std::fs::canonicalize(path)?;
|
||||
let plain = path.to_str().and_then(|s| s.strip_prefix(r"\\?\")).filter(|s| s.as_bytes().get(1) == Some(&b':'));
|
||||
Ok(plain.map(PathBuf::from).unwrap_or(path))
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
// macOS's `/var` is a symlink to `/private/var`: a child process resolves its working
|
||||
// directory through `current_dir()`, which is canonical, so compare canonical paths.
|
||||
std::fs::canonicalize(&d).unwrap_or(d).join(name)
|
||||
canonical(&d).unwrap_or(d).join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -160,6 +167,21 @@ fn unknown_options_are_usage_errors() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An argument that is not Unicode (a Latin-1 file name on Linux) is a usage error (exit 2)
|
||||
/// naming it, before anything runs: `std::env::args` panicked on one (exit 101).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_arguments_are_usage_errors() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png");
|
||||
let out = bin().args(["render-frame", "--out"]).arg(name).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "{err}");
|
||||
assert!(err.contains("argument `caf\u{fffd}.png` is not valid Unicode"), "{err}");
|
||||
assert!(!err.contains("panicked"), "{err}");
|
||||
assert!(out.stdout.is_empty(), "nothing ran");
|
||||
}
|
||||
|
||||
/// A reader that closes stdout before the CLI writes (`| head`) is not a crash: the command
|
||||
/// still does its work and exits 0, without a panic (#167).
|
||||
#[test]
|
||||
@@ -260,7 +282,7 @@ fn render_out_is_relative_to_the_working_directory() {
|
||||
std::fs::create_dir_all(&proj).unwrap();
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
// current_dir resolves aliases such as macOS /var -> /private/var.
|
||||
let root = root.canonicalize().unwrap();
|
||||
let root = canonical(&root).unwrap();
|
||||
let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere"));
|
||||
let project = proj.join("x.ecproj");
|
||||
let p = project.to_str().unwrap();
|
||||
|
||||
@@ -75,6 +75,10 @@ sync_github_name = true
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.moecr7]
|
||||
[people.onlymoein]
|
||||
real_name = "Moein Saharkhiz"
|
||||
display_name = "onlymoein"
|
||||
|
||||
[people.arnav-hiwarkar]
|
||||
display_name = "arnav-hiwarkar"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Blocking client for the desktop app's JSON-lines control channel (`docs/control-protocol.md`).
|
||||
//! One persistent loopback connection, reconnected once if the app went away.
|
||||
//! One persistent loopback connection. Only explicitly read-only queries can be resent.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
@@ -50,16 +50,20 @@ impl BridgeClient {
|
||||
|
||||
/// Call a control method; returns the reply's `result`, or the app's `error` as [`Error::App`].
|
||||
pub fn call(&mut self, method: &str, params: Value) -> Result<Value> {
|
||||
let retry_read =
|
||||
matches!(method, "engine.commands" | "ui.menu.list" | "ui.inspect" | "ui.elements" | "ui.viewer.locate" | "ui.viewer.hit" | "ui.timeline.locate")
|
||||
|| (method == "ui.playback" && params["action"] == "status");
|
||||
let unknown = |error| Error::Bridge(format!("`{method}` request outcome is unknown: {error}; inspect the app before retrying"));
|
||||
let mut last = Error::Bridge("not attempted".into());
|
||||
for attempt in 0..2 {
|
||||
if self.conn.is_none() {
|
||||
self.conn = Some(self.connect()?);
|
||||
}
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
self.next_id = self.next_id.checked_add(1).ok_or_else(|| Error::Bridge("control request id space exhausted".into()))?;
|
||||
let Some((r, w)) = self.conn.as_mut() else { continue };
|
||||
let line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
|
||||
// `Ok(Err(_))`: the connection is dead before a reply (safe to resend once);
|
||||
// `Ok(Err(_))`: transmission/reply failed (the app may have applied the request);
|
||||
// `Err(_)`: a timeout or bad reply (never resent: the app may have run the request).
|
||||
let res = (|| -> Result<std::result::Result<Value, Error>> {
|
||||
if let Err(e) = w.write_all(line.as_bytes()) {
|
||||
@@ -80,8 +84,11 @@ impl BridgeClient {
|
||||
Ok(Ok(v)) if v.get("ok").and_then(Value::as_bool) == Some(true) => return Ok(v.get("result").cloned().unwrap_or(Value::Null)),
|
||||
Ok(Ok(v)) => return Err(Error::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())),
|
||||
Ok(Err(e)) => {
|
||||
// Stale connection (app restarted): reconnect and resend once.
|
||||
self.conn = None;
|
||||
if !retry_read {
|
||||
return Err(unknown(e));
|
||||
}
|
||||
// A read-only query can be repeated after a stale connection or lost reply.
|
||||
last = e;
|
||||
if attempt == 1 {
|
||||
break;
|
||||
@@ -90,7 +97,7 @@ impl BridgeClient {
|
||||
Err(e) => {
|
||||
// The stream may still deliver the late reply: start fresh next call.
|
||||
self.conn = None;
|
||||
return Err(e);
|
||||
return Err(if retry_read { e } else { unknown(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,6 +511,128 @@ fn bridge_rejects_non_loopback() {
|
||||
assert!(Backend::bridge("localhost:9877").is_ok());
|
||||
}
|
||||
|
||||
/// The app applies the first request but loses its reply. The next connection can be either
|
||||
/// an automatic retry or an explicit follow-up; the returned counts distinguish them.
|
||||
fn lost_reply_server(first_reply: Option<&'static str>) -> (String, std::thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut session = Session::default();
|
||||
let mut mutations = 0;
|
||||
for attempt in 0..2 {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
|
||||
let mut output = stream.try_clone().unwrap();
|
||||
let mut input = String::new();
|
||||
BufReader::new(stream).read_line(&mut input).unwrap();
|
||||
let request: Value = serde_json::from_str(&input).unwrap();
|
||||
let result = if request["method"] == "engine.execute" {
|
||||
mutations += 1;
|
||||
session.execute(request["params"]["command"].as_str().unwrap(), request["params"]["params"].clone()).unwrap()
|
||||
} else if request["method"] == "ui.inspect" || (request["method"] == "ui.playback" && request["params"]["action"] == "status") {
|
||||
json!({"mutations": mutations, "requests": attempt + 1, "comps": session.project.comps().count()})
|
||||
} else {
|
||||
mutations += 1;
|
||||
json!({"mutations": mutations})
|
||||
};
|
||||
if attempt == 1 {
|
||||
writeln!(output, "{}", json!({"id": request["id"], "ok": true, "result": result})).unwrap();
|
||||
} else if let Some(reply) = first_reply {
|
||||
writeln!(output, "{reply}").unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
(addr, worker)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_does_not_replay_a_mutation_when_its_reply_is_lost() {
|
||||
let (addr, worker) = lost_reply_server(None);
|
||||
let mut backend = Backend::bridge(&addr).unwrap();
|
||||
let error = backend.exec("comp.new", json!({"width": 64, "height": 48, "duration": 1})).unwrap_err();
|
||||
assert!(error.to_string().contains("outcome is unknown"), "{error}");
|
||||
let state = backend.control("ui.inspect", json!({})).unwrap();
|
||||
assert_eq!(state["mutations"], 1, "the first command was applied exactly once");
|
||||
assert_eq!(state["comps"], 1);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_retries_read_only_queries_after_a_lost_reply() {
|
||||
let (addr, worker) = lost_reply_server(None);
|
||||
let mut backend = Backend::bridge(&addr).unwrap();
|
||||
let state = backend.control("ui.inspect", json!({})).unwrap();
|
||||
assert_eq!(state["requests"], 2);
|
||||
assert_eq!(state["mutations"], 0);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_reports_unknown_outcome_after_a_malformed_mutation_reply() {
|
||||
let (addr, worker) = lost_reply_server(Some("{invalid reply"));
|
||||
let mut backend = Backend::bridge(&addr).unwrap();
|
||||
let error = backend.exec("comp.new", json!({"width": 64, "height": 48, "duration": 1})).unwrap_err();
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("outcome is unknown") && message.contains("bad reply"), "{message}");
|
||||
assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["comps"], 1);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_does_not_assume_an_unknown_method_is_read_only() {
|
||||
let (addr, worker) = lost_reply_server(None);
|
||||
let mut backend = Backend::bridge(&addr).unwrap();
|
||||
let error = backend.control("future.customOperation", json!({})).unwrap_err();
|
||||
assert!(error.to_string().contains("outcome is unknown"), "{error}");
|
||||
assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["mutations"], 1);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_playback_edits_and_default_toggle_are_not_replayed() {
|
||||
for params in [json!({}), json!({"action": "toggle"}), json!({"action": "play"}), json!({"action": "stop"})] {
|
||||
let (addr, worker) = lost_reply_server(None);
|
||||
let mut backend = Backend::bridge(&addr).unwrap();
|
||||
let error = backend.control("ui.playback", params).unwrap_err();
|
||||
assert!(error.to_string().contains("outcome is unknown"), "{error}");
|
||||
assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["mutations"], 1);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_playback_status_can_be_retried() {
|
||||
let (addr, worker) = lost_reply_server(None);
|
||||
let mut backend = Backend::bridge(&addr).unwrap();
|
||||
let state = backend.control("ui.playback", json!({"action": "status"})).unwrap();
|
||||
assert_eq!(state["requests"], 2);
|
||||
assert_eq!(state["mutations"], 0);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_connect_failure_can_be_retried_when_the_app_starts() {
|
||||
let reserved = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = reserved.local_addr().unwrap();
|
||||
drop(reserved);
|
||||
let mut backend = Backend::bridge(&addr.to_string()).unwrap();
|
||||
let error = backend.control("ui.inspect", json!({})).unwrap_err();
|
||||
assert!(error.to_string().contains("cannot connect"), "{error}");
|
||||
assert!(!error.to_string().contains("outcome is unknown"), "nothing was sent");
|
||||
let listener = TcpListener::bind(addr).unwrap();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
|
||||
let mut output = stream.try_clone().unwrap();
|
||||
let mut input = String::new();
|
||||
BufReader::new(stream).read_line(&mut input).unwrap();
|
||||
let request: Value = serde_json::from_str(&input).unwrap();
|
||||
writeln!(output, "{}", json!({"id": request["id"], "ok": true, "result": {"ready": true}})).unwrap();
|
||||
});
|
||||
assert_eq!(backend.control("ui.inspect", json!({})).unwrap()["ready"], true);
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_ui_and_history_tools() {
|
||||
let mut sess = Session::default();
|
||||
|
||||
@@ -589,7 +589,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("meshColor", "Mesh Color", col(0.5, 0.5, 0.5), ParamUi::Color),
|
||||
// The strokes (see `LiquifyStroke`); written by the `liquify.stroke` command.
|
||||
p("distortionMesh", "Distortion Mesh", Value::Str(String::new()), ParamUi::Hidden),
|
||||
p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Hidden),
|
||||
p("distortionMeshOffset", "Distortion Mesh Offset", Value::Vec2([0.0, 0.0]), ParamUi::Point),
|
||||
p("distortionPercentage", "Distortion Percentage", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("distortionDisplayQuality", "Distortion Display Quality", Value::Enum(1), popup(&["Draft", "Smooth"])),
|
||||
],
|
||||
@@ -672,6 +672,35 @@ mod tests {
|
||||
assert_eq!(zero.img.data, img.data);
|
||||
let again = run_fx("ec.distort.liquify", &[("distortionMesh", Value::Str(s.to_line()))], img.clone(), 0.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
// #1194: Distortion Mesh Offset is an exposed, editable control (AE's Distortion ▸
|
||||
// Offset) and it moves the mesh: the pattern rendered at x renders at x + offset.
|
||||
let spec = crate::find("ec.distort.liquify").unwrap();
|
||||
assert!(
|
||||
spec.params.iter().any(|ps| ps.id == "distortionMeshOffset" && ps.ui == effectcraft_project::ParamUi::Point),
|
||||
"offset is shown in Effect Controls"
|
||||
);
|
||||
let shifted = run_fx(
|
||||
"ec.distort.liquify",
|
||||
&[("distortionMesh", Value::Str(s.to_line())), ("distortionMeshOffset", Value::Vec2([20.0, 0.0]))],
|
||||
img.clone(),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
assert_ne!(shifted.img.data, out.img.data, "the offset changes the render");
|
||||
// The displaced region's centroid moves right by the offset (the mesh is sampled at
|
||||
// layer minus offset), and the row far left of the shifted mesh is back to untouched.
|
||||
let centroid = |o: &Image| -> f32 {
|
||||
let (mut wsum, mut msum) = (0.0f32, 0.0f32);
|
||||
for (i, (p, q)) in o.data.iter().zip(img.data.iter()).enumerate() {
|
||||
let d: f32 = (0..4).map(|k| (p[k] - q[k]).abs()).sum();
|
||||
wsum += d;
|
||||
msum += d * (i % 64) as f32;
|
||||
}
|
||||
if wsum > 1e-6 { msum / wsum } else { 0.0 }
|
||||
};
|
||||
let (co, cs) = (centroid(&out.img), centroid(&shifted.img));
|
||||
assert!((cs - co - 20.0).abs() < 5.0, "centroids {co} -> {cs}");
|
||||
assert_eq!(shifted.img.get(5, 32), img.get(5, 32), "left of the shifted mesh: untouched");
|
||||
// Reconstruction undoes the warp; freeze protects.
|
||||
let mut rec = s.clone();
|
||||
rec.tool = 9;
|
||||
|
||||
@@ -138,7 +138,10 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let n = (effectcraft_raster::hash_noise(x as u32, y as u32, seed) - 0.5) * jitter * (8.0 / 255.0);
|
||||
c = c.map(|v| v + n);
|
||||
}
|
||||
*px = gen_blend(*px, [c[0] * op, c[1] * op, c[2] * op, op], mode);
|
||||
// None keeps the layer's alpha (#1381), as the Gradient Ramp does: the gradient fills
|
||||
// a shape or text, not its bounding box.
|
||||
let a = if gen_mode(mode).is_none() { op * px[3] } else { op };
|
||||
*px = gen_blend(*px, [c[0] * a, c[1] * a, c[2] * a, a], mode);
|
||||
}
|
||||
});
|
||||
b
|
||||
@@ -420,6 +423,25 @@ mod generator_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_color_none_stays_inside_the_layer_alpha() {
|
||||
// #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box.
|
||||
let mut img = Image::filled(40, 24, [1.0, 0.0, 0.0, 1.0]);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
let a = [0.0, 0.5, 1.0][i % 40 / 14];
|
||||
*p = p.map(|c| c * a);
|
||||
}
|
||||
for op in [100.0, 50.0] {
|
||||
let o = run_fx("ec.generate.fourcolor", &[("opacity", num(op))], img.clone(), 0.0, EffectEnv::default());
|
||||
let k = op as f32 / 100.0;
|
||||
for (src, out) in img.data.iter().zip(&o.img.data) {
|
||||
assert!((src[3] * k - out[3]).abs() < 1e-6, "opacity {op}: alpha {} became {}", src[3], out[3]);
|
||||
assert!(out[..3].iter().all(|&c| c <= out[3] + 1e-6), "opacity {op}: premultiplied colour above alpha");
|
||||
}
|
||||
assert!(o.img.data.iter().any(|p| p[1] > 0.1), "opacity {op}: the gradient replaces the layer's colour");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkerboard_blending_none_shows_pattern_only_and_size_from_width() {
|
||||
let set = [("width", num(8.0)), ("height", num(2.0))];
|
||||
|
||||
@@ -239,6 +239,8 @@ fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) {
|
||||
// A path typed as text, or set only by commands, that got a Choose… button (Apply Color
|
||||
// LUT's LUT, the OCIO files, #514).
|
||||
(ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => pr.ui = ps.ui.clone(),
|
||||
// An internal point that became a control (Liquify's Distortion Mesh Offset, #1194).
|
||||
(ParamUi::Hidden, ParamUi::Point) => pr.ui = ParamUi::Point,
|
||||
// Same kind of value: take the spec's hints (units, ranges).
|
||||
(a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(),
|
||||
_ => {}
|
||||
@@ -514,6 +516,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1194: Liquify's Distortion Mesh Offset was hidden; old projects show it, keeping its value.
|
||||
#[test]
|
||||
fn a_hidden_point_that_became_a_control_is_shown() {
|
||||
let mut next = 1;
|
||||
let (spec, mut g) = instance("ec.distort.liquify", &mut next);
|
||||
let pr = g.get_mut("distortionMeshOffset").unwrap();
|
||||
pr.ui = ParamUi::Hidden;
|
||||
pr.value = Value::Vec2([20.0, -5.0]);
|
||||
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 100.0]));
|
||||
let pr = g.get("distortionMeshOffset").unwrap();
|
||||
assert_eq!((&pr.ui, &pr.value), (&ParamUi::Point, &Value::Vec2([20.0, -5.0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_moves_params_into_twirl_down_groups() {
|
||||
let spec = find(crate::warp_stab::ID).unwrap();
|
||||
|
||||
@@ -228,9 +228,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Keyframes selected → copy keys (pasted at the CTI).
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
s.state.link_clipboard = None;
|
||||
s.state.effect_clipboard.clear();
|
||||
s.state.contents_clipboard.clear();
|
||||
clear_clipboards(s);
|
||||
return s.execute("keys.copy", json!({}));
|
||||
}
|
||||
// Effects selected (Effect Controls / timeline) → copy the effects.
|
||||
@@ -256,12 +254,14 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Edit ▸ Copy with properties selected (Position, or the Transform group), as in After
|
||||
/// Effects: an animated property copies its keyframes (the selected ones, else all of them) and
|
||||
/// an unanimated one its value, for Paste onto the same properties of other layers (#493).
|
||||
/// Effects: an animated property copies its keyframes and an unanimated one its value, for Paste
|
||||
/// onto the same properties of other layers (#493). With keyframes selected only those are copied,
|
||||
/// even across several highlighted properties (#1552); a selected Transform group copies them all.
|
||||
/// `None` when no property (or Transform group) is selected.
|
||||
fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
let Some(comp) = s.active_comp() else { return Ok(None) };
|
||||
let mut leaves = vec![];
|
||||
let mut copy_all_keys = s.state.selected_keys.is_empty();
|
||||
for (lid, uid) in &s.state.selected_props {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
if l.props.find(*uid).is_some() {
|
||||
@@ -269,6 +269,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
} else if let Some(g) = l.props.find_group(*uid)
|
||||
&& l.props.match_path_of(*uid).as_deref() == Some("transform")
|
||||
{
|
||||
copy_all_keys = true;
|
||||
g.walk("", &mut |_, pr| leaves.push((*lid, pr.uid)));
|
||||
}
|
||||
}
|
||||
@@ -286,7 +287,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
{
|
||||
values.push((path, pr.value.clone()));
|
||||
}
|
||||
} else if !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) {
|
||||
} else if copy_all_keys && !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) {
|
||||
keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +391,7 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
||||
Ok(b) if effectcraft_psd::is_psd(&b) => b,
|
||||
Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => {
|
||||
// PDF / Illustrator / EPS: one layer per file layer.
|
||||
match import_vector_comp(s, &path, &b, page) {
|
||||
match import_vector_comp(s, &path, &b, page, retain) {
|
||||
Ok((comp, made)) => {
|
||||
comps.push(comp);
|
||||
items.extend(made);
|
||||
@@ -540,7 +540,7 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec<u8>, retai
|
||||
|
||||
/// Import page `page` of a PDF / Illustrator / EPS file as a composition (one undo step).
|
||||
/// Returns (comp, items).
|
||||
fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> Result<(u64, Vec<u64>)> {
|
||||
fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32, retain: bool) -> Result<(u64, Vec<u64>)> {
|
||||
let mut name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into());
|
||||
if page > 0 {
|
||||
name = format!("{name} (Page {})", page + 1);
|
||||
@@ -549,7 +549,7 @@ fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> R
|
||||
let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
|
||||
let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs));
|
||||
let (comp, folder, items) = s.edit("Import", None, |proj, st| {
|
||||
let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, rate, duration).map_err(EngineError::Other)?;
|
||||
let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, retain, (rate, duration)).map_err(EngineError::Other)?;
|
||||
st.project_selection = vec![r.0];
|
||||
Ok(r)
|
||||
})?;
|
||||
|
||||
@@ -356,13 +356,12 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"effects": found}))
|
||||
}
|
||||
"fonts" => {
|
||||
let families = crate::text_families();
|
||||
let mut found = vec![];
|
||||
for (cid, c) in s.project.comps() {
|
||||
for l in &c.layers {
|
||||
if let Some(pr) = l.props.prop("text/sourceText")
|
||||
&& let Some(doc) = pr.value.as_text()
|
||||
&& !families.iter().any(|f| f.eq_ignore_ascii_case(&doc.font))
|
||||
&& effectcraft_text::resolve(&doc.font, &doc.style).missing
|
||||
{
|
||||
found.push(json!({"comp": cid.0, "layer": l.id.0, "font": doc.font}));
|
||||
}
|
||||
|
||||
@@ -126,17 +126,18 @@ pub(crate) fn find_moved(services: &dyn Services, project_dir: &str, f: &Footage
|
||||
})
|
||||
}
|
||||
|
||||
/// The folder a project file is in.
|
||||
/// The folder a project file is in, with the path's own separator and its leading `/` or UNC `\\`.
|
||||
pub(crate) fn project_dir(project_path: &str) -> Option<String> {
|
||||
let p = parts(project_path);
|
||||
if p.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut dir = join(if project_path.starts_with('/') { "/" } else { "" }, p.get(..p.len() - 1)?);
|
||||
if !project_path.starts_with('/') {
|
||||
dir = dir.trim_start_matches(['/', '\\']).to_string();
|
||||
}
|
||||
Some(dir)
|
||||
let dir = p.get(..p.len().checked_sub(1)?).filter(|d| !d.is_empty())?;
|
||||
let root = if project_path.starts_with("\\\\") {
|
||||
"\\\\"
|
||||
} else if project_path.starts_with('/') {
|
||||
"/"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
Some(format!("{root}{}", dir.join(&sep_of(project_path).to_string())))
|
||||
}
|
||||
|
||||
/// Relink `found` in one undo step and say how many.
|
||||
@@ -262,6 +263,10 @@ mod tests {
|
||||
);
|
||||
assert_eq!(project_dir("/x/y/p.ecproj").as_deref(), Some("/x/y"));
|
||||
assert_eq!(project_dir("p.ecproj"), None);
|
||||
// Windows paths keep their separator, and a UNC share its `\\`.
|
||||
assert_eq!(project_dir("C:\\Work\\Job\\p.ecproj").as_deref(), Some("C:\\Work\\Job"));
|
||||
assert_eq!(project_dir("\\\\server\\share\\Job\\p.ecproj").as_deref(), Some("\\\\server\\share\\Job"));
|
||||
assert_eq!(moved_candidates("\\\\server\\share\\Job", "D:\\Old\\Job\\shots\\a.png")[2], "\\\\server\\share\\Job\\shots\\a.png");
|
||||
// Hostile input doesn't panic.
|
||||
for (a, b) in [("", ""), ("/", "/"), ("x", ""), ("\\\\", "//"), ("/a", "/a/b/c")] {
|
||||
let _ = moved_root(a, b);
|
||||
@@ -302,11 +307,11 @@ mod tests {
|
||||
let b = add(&mut s, "/gone/proj/shots/b.mov");
|
||||
let m = add(&mut s, "/gone/proj/audio/m.wav");
|
||||
let other = add(&mut s, "/elsewhere/c.mov");
|
||||
let target = new.join("shots/a.mov").to_string_lossy().to_string();
|
||||
let target = new.join("shots").join("a.mov").to_string_lossy().to_string();
|
||||
let r = s.execute("footage.relink", json!({"item": a.0, "path": target})).unwrap();
|
||||
assert_eq!(r["relinked"], json!([a.0, b.0, m.0]), "{r}");
|
||||
assert_eq!(r["stillMissing"], 1);
|
||||
assert_eq!(path_of(&s, m), new.join("audio/m.wav").to_string_lossy());
|
||||
assert_eq!(path_of(&s, m), new.join("audio").join("m.wav").to_string_lossy());
|
||||
assert_eq!(path_of(&s, other), "/elsewhere/c.mov");
|
||||
// One undo step brings every path back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
@@ -6,6 +6,7 @@ use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Layer, LayerSource, Node, PropGroup};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_comp, layer_mut, layer_p, str_p};
|
||||
use crate::{Result, Session, cmd, query};
|
||||
@@ -13,8 +14,10 @@ use crate::{Result, Session, cmd, query};
|
||||
/// The original text animator presets (`presets/text_animators.json`).
|
||||
pub const PRESETS_JSON: &str = include_str!("../../presets/text_animators.json");
|
||||
|
||||
fn presets() -> Vec<Value> {
|
||||
serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
|
||||
fn presets() -> &'static [Value] {
|
||||
static PRESETS: OnceLock<Vec<Value>> = OnceLock::new();
|
||||
PRESETS
|
||||
.get_or_init(|| serde_json::from_str::<Value>(PRESETS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// (id, name) of every preset.
|
||||
@@ -303,7 +306,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?.to_string();
|
||||
let preset = presets()
|
||||
.into_iter()
|
||||
.iter()
|
||||
.find(|x| x.get("id").and_then(Value::as_str) == Some(&want) || x.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case(&want)))
|
||||
.ok_or_else(|| bad(C, format!("no preset {want}")))?;
|
||||
let name = preset.get("name").and_then(Value::as_str).unwrap_or("Preset").to_string();
|
||||
|
||||
+32
-14
@@ -164,32 +164,50 @@ impl ConfigStore for DirConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// The temporary file an atomic write of `path` goes through.
|
||||
pub fn temp_path(path: &Path) -> PathBuf {
|
||||
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
|
||||
name.push(".tmp");
|
||||
path.with_file_name(name)
|
||||
/// A new, empty temporary file next to `path` for an atomic write of it, and its path. The name
|
||||
/// is unique to this process and this write (`.effectcraft-<pid>-<n>.tmp`) and the file is created
|
||||
/// exclusively, so two writers never share one and nothing already at that name (a file, a
|
||||
/// symbolic link) is opened or followed (#1233). It ends in `.tmp`, so a file left by a hard
|
||||
/// kill never counts as an auto-save. Its bounded name does not include the destination's name,
|
||||
/// so a valid long destination name still fits the filesystem's component limit.
|
||||
fn create_temp(path: &Path) -> std::io::Result<(std::fs::File, PathBuf)> {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let mut last = None;
|
||||
for _ in 0..16 {
|
||||
let n = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = path.with_file_name(format!(".effectcraft-{}-{n}.tmp", std::process::id()));
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => return Ok((f, tmp)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last.unwrap_or_else(|| std::io::Error::other("no free temporary file name")))
|
||||
}
|
||||
|
||||
/// Write `data` to `path` atomically: write a temporary file next to it, flush it to disk, then
|
||||
/// rename it over `path`. A crash at any point leaves either the old file or the new one, never a
|
||||
/// torn file.
|
||||
/// torn file, and concurrent writers each publish a whole file of their own.
|
||||
pub fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
atomic_write_with(path, data, |tmp, data| {
|
||||
atomic_write_with(path, data, |f, data| {
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::File::create(tmp)?;
|
||||
f.write_all(data)?;
|
||||
f.sync_all()
|
||||
})
|
||||
}
|
||||
|
||||
/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write). A
|
||||
/// failed write removes the temporary file and leaves `path` untouched.
|
||||
pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&Path, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> {
|
||||
let tmp = temp_path(path);
|
||||
if let Err(e) = write(&tmp, data) {
|
||||
/// [`atomic_write`] with an injectable temp-file writer (tests simulate a crash mid-write); it is
|
||||
/// given the temporary file, already created. A failed write or rename removes the temporary
|
||||
/// file and leaves `path` untouched.
|
||||
pub fn atomic_write_with(path: &Path, data: &[u8], write: impl FnOnce(&mut std::fs::File, &[u8]) -> std::io::Result<()>) -> std::io::Result<()> {
|
||||
let (mut f, tmp) = create_temp(path)?;
|
||||
let written = write(&mut f, data);
|
||||
// Closed before the rename (Windows can't rename an open file).
|
||||
drop(f);
|
||||
if let Err(e) = written.and_then(|()| std::fs::rename(&tmp, path)) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ mod tests {
|
||||
std::fs::write(d.join("Job/shots/a.png"), b"x").unwrap();
|
||||
// Saved on another machine: the footage sat next to the project in /Users/x/Job.
|
||||
let mut s = session_with(&["/Users/x/Job/shots/a.png", "/Users/x/elsewhere/b.png"]);
|
||||
let path = d.join("Job/p.ecproj");
|
||||
let path = d.join("Job").join("p.ecproj");
|
||||
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let mut o = Session::default();
|
||||
o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
@@ -269,7 +269,7 @@ mod tests {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(paths.contains(&d.join("Job/shots/a.png").to_string_lossy().to_string()), "{paths:?}");
|
||||
assert!(paths.contains(&d.join("Job").join("shots").join("a.png").to_string_lossy().to_string()), "{paths:?}");
|
||||
assert!(!o.is_dirty(), "finding the files is not an edit");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,12 @@ fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u
|
||||
Some((u32::try_from(found.index).ok()?, size))
|
||||
}
|
||||
|
||||
fn vector_size(s: &Session, f: &Footage) -> Option<(u32, u32)> {
|
||||
let bytes = s.services.read_file(&f.path).ok()?;
|
||||
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)?.ok()?;
|
||||
(!doc.root.children.is_empty()).then(|| doc.pixel_size())
|
||||
}
|
||||
|
||||
/// Footage `f` as its file is now, given the importer's fresh look at the file (`probed`).
|
||||
fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
|
||||
let mut nf = f.clone();
|
||||
@@ -89,6 +95,7 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
|
||||
match &f.layer {
|
||||
// A smart object's size is its embedded file's, recorded at import.
|
||||
Some(l) if l.embedded.is_some() => (w, h) = (f.width, f.height),
|
||||
Some(_) if crate::vector::is_vector(f) => (w, h) = vector_size(s, f).unwrap_or((f.width, f.height)),
|
||||
Some(l) => match psd_layer(s, &f.path, l) {
|
||||
Some((index, size)) => {
|
||||
if let Some(nl) = nf.layer.as_mut() {
|
||||
|
||||
@@ -495,6 +495,8 @@ pub struct Session {
|
||||
/// Offloaded jobs that ended, newest last (kind, error): how a caller waiting for one
|
||||
/// (`wait: true` over the control channel in the browser) learns its outcome.
|
||||
pub offload_log: Vec<(offload::JobKind, Option<String>)>,
|
||||
/// Number of older completion entries evicted from the bounded offload log.
|
||||
pub offload_log_start: usize,
|
||||
/// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`].
|
||||
pub tasks: Vec<jobs::Task>,
|
||||
/// Finished tasks, newest last (Progress panel, `jobs.list`).
|
||||
@@ -598,6 +600,7 @@ impl Default for Session {
|
||||
offload: None,
|
||||
offloaded: vec![],
|
||||
offload_log: vec![],
|
||||
offload_log_start: 0,
|
||||
browser: None,
|
||||
storage: None,
|
||||
tasks: vec![],
|
||||
|
||||
@@ -173,7 +173,7 @@ impl VirtualTree {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
sort_entries(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,10 @@ pub struct Entry {
|
||||
pub kind: Option<&'static str>,
|
||||
}
|
||||
|
||||
fn sort_entries(entries: &mut [Entry]) {
|
||||
entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase()));
|
||||
}
|
||||
|
||||
const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"];
|
||||
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"];
|
||||
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
|
||||
@@ -251,7 +255,7 @@ pub fn list(dir: &str, importable_only: bool) -> Result<Vec<Entry>, String> {
|
||||
Some(Entry { name, path, is_dir: md.is_dir(), size: if md.is_dir() { 0 } else { md.len() }, modified: unix(md.modified()), kind })
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
sort_entries(&mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -312,7 +316,9 @@ pub fn footage_metadata(f: &Footage) -> Value {
|
||||
"pixelAspect": f.pixel_aspect,
|
||||
"frameRate": f.frame_rate.as_f64(),
|
||||
"duration": f.duration.seconds(),
|
||||
"frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.duration)) } else { None },
|
||||
// Rounded like the media pool's frame count: Matroska stores the duration in whole
|
||||
// milliseconds, a hair short of the last frame at 30 fps (#1429).
|
||||
"frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.frame_rate.snap_nearest(f.duration))) } else { None },
|
||||
"hasVideo": f.has_video,
|
||||
"hasAudio": f.has_audio,
|
||||
"alpha": format!("{:?}", f.alpha),
|
||||
@@ -407,6 +413,10 @@ mod tests {
|
||||
assert_eq!(media.len(), 3);
|
||||
assert_eq!(parent(&dir.join("Shots").to_string_lossy()).as_deref(), Some(d.as_str()));
|
||||
assert!(list(&dir.join("nope").to_string_lossy(), false).is_err());
|
||||
std::fs::write(dir.join("Ω.csv"), b"x").unwrap();
|
||||
std::fs::write(dir.join("作品.csv"), b"x").unwrap();
|
||||
let all = list(&d, false).unwrap();
|
||||
assert_eq!(all.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["Shots", "A.mov", "b.png", "notes.txt", "Ω.csv", "作品.csv"]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -436,6 +446,61 @@ mod tests {
|
||||
assert_eq!(vparent("/"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listings_keep_folders_first_and_stable_unicode_order() {
|
||||
let mut tree = VirtualTree::default();
|
||||
for name in ["作品.csv", "ä.csv", "Ä.csv", "İ.csv", "B.csv", "a.csv", "A.csv"] {
|
||||
tree.add_file(&format!("/Shoot/{name}"), &format!("/files/{name}"), 1, None);
|
||||
}
|
||||
for name in ["zFolder", "Alpha"] {
|
||||
tree.add_dir(&format!("/Shoot/{name}"));
|
||||
}
|
||||
let entries = tree.list("/Shoot", true).unwrap();
|
||||
assert_eq!(
|
||||
entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
|
||||
["Alpha", "zFolder", "A.csv", "a.csv", "B.csv", "İ.csv", "Ä.csv", "ä.csv", "作品.csv"]
|
||||
);
|
||||
assert_eq!(entries[2].path, "/files/A.csv");
|
||||
let mut reversed = entries.iter().rev().cloned().collect::<Vec<_>>();
|
||||
sort_entries(&mut reversed);
|
||||
assert_eq!(
|
||||
reversed.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
|
||||
["Alpha", "zFolder", "a.csv", "A.csv", "B.csv", "İ.csv", "ä.csv", "Ä.csv", "作品.csv"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Media Browser filename sorting benchmark"]
|
||||
fn directory_sort_benchmark() {
|
||||
let entries: Vec<_> = (0..1000)
|
||||
.map(|i| Entry {
|
||||
name: format!("作品 {} {:04}.mov", if i % 2 == 0 { "Clip" } else { "clip" }, i * 7919 % 1000),
|
||||
path: format!("/Shoot/{i}.mov"),
|
||||
is_dir: i % 7 == 0,
|
||||
size: 1,
|
||||
modified: None,
|
||||
kind: Some("video"),
|
||||
})
|
||||
.collect();
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let mut old = entries.clone();
|
||||
let start = std::time::Instant::now();
|
||||
old.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
before.push(start.elapsed());
|
||||
let mut new = entries.clone();
|
||||
let start = std::time::Instant::now();
|
||||
sort_entries(&mut new);
|
||||
after.push(start.elapsed());
|
||||
assert_eq!(old, new);
|
||||
std::hint::black_box(new);
|
||||
}
|
||||
before.sort();
|
||||
after.sort();
|
||||
println!("1,000 filename sort, median: {:?} -> {:?}", before[2], after[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_dates() {
|
||||
assert_eq!(iso_date(0), "1970-01-01T00:00:00Z");
|
||||
|
||||
@@ -171,6 +171,20 @@ pub enum JobKind {
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
/// The corresponding `jobs.list` id, also used by waiting control commands.
|
||||
pub fn job_id(self) -> &'static str {
|
||||
match self {
|
||||
JobKind::Render => "render",
|
||||
JobKind::Warp => "warp",
|
||||
JobKind::WarpPlan => "warpPlan",
|
||||
JobKind::Camera => "camera",
|
||||
JobKind::RotoFreeze | JobKind::RotoPropagate => "roto",
|
||||
JobKind::Track => "track",
|
||||
JobKind::MaskTrack => "maskTrack",
|
||||
JobKind::ContentFill => "contentFill",
|
||||
}
|
||||
}
|
||||
|
||||
/// The undo step an analysis result becomes.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -699,6 +713,9 @@ pub struct OffloadedJob {
|
||||
/// Segmentations received (Roto Brush propagation).
|
||||
pub segs: u64,
|
||||
started: web_time::Instant,
|
||||
/// The analysed group as the job started: its result is merged with what changed since
|
||||
/// ([`merge_group`]).
|
||||
base: Option<Box<PropGroup>>,
|
||||
}
|
||||
|
||||
/// A Render Queue job running in a worker (see [`crate::render_queue::RenderJob`]).
|
||||
@@ -730,6 +747,7 @@ impl Session {
|
||||
}
|
||||
let id = next_id();
|
||||
let inbox = Arc::new(Inbox::default());
|
||||
let base = self.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.props.find_group(uid)).cloned().map(Box::new);
|
||||
let req = WorkerRequest { id, project: self.project.to_json(), files: footage_files(&self.project), job };
|
||||
off.start(req, inbox.clone())?;
|
||||
self.offloaded.push(OffloadedJob {
|
||||
@@ -742,6 +760,7 @@ impl Session {
|
||||
progress: OffloadProgress::default(),
|
||||
segs: 0,
|
||||
started: web_time::Instant::now(),
|
||||
base,
|
||||
});
|
||||
self.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
Ok(())
|
||||
@@ -779,7 +798,7 @@ impl Session {
|
||||
j.progress.done = done;
|
||||
j.progress.total = total;
|
||||
}
|
||||
WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)),
|
||||
WorkerReply::Group { comp, layer, group, message } => results.push((j.id, j.kind, comp, layer, group, message, j.base.clone())),
|
||||
WorkerReply::Fill { plan, files } => fills.push((plan, files)),
|
||||
WorkerReply::WarpPlan { key, summary } => effectcraft_effects::warp_stab::store_summary(key, *summary),
|
||||
WorkerReply::Segs { segs } => {
|
||||
@@ -802,7 +821,7 @@ impl Session {
|
||||
Err(e) => self.events.push(Event::Toast { message: format!("Content-Aware Fill: {e}"), error: true }),
|
||||
}
|
||||
}
|
||||
for (kind, comp, layer, group, message) in results {
|
||||
for (id, kind, comp, layer, group, message, base) in results {
|
||||
let uid = group.uid;
|
||||
let r = self.edit(kind.label(), None, |p, _| {
|
||||
let g = p
|
||||
@@ -810,12 +829,31 @@ impl Session {
|
||||
.and_then(|c| c.layer_mut(layer))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.ok_or_else(|| crate::EngineError::Other("the analysed property was removed during the analysis".into()))?;
|
||||
*g = *group;
|
||||
// Edits made while the job ran stay (#1249): only what the analysis changed is
|
||||
// taken from its result.
|
||||
*g = match base {
|
||||
Some(base) => merge_group(&base, g, &group).ok_or_else(|| {
|
||||
crate::EngineError::Other(format!(
|
||||
"“{}” was changed during the analysis where its result changes it too, so nothing was applied: analyse again",
|
||||
g.name
|
||||
))
|
||||
})?,
|
||||
None => *group,
|
||||
};
|
||||
Ok(())
|
||||
});
|
||||
match r {
|
||||
Ok(()) => self.events.push(Event::Toast { message, error: false }),
|
||||
Err(e) => self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }),
|
||||
Err(e) => {
|
||||
// Completion consumers read the job's error, not its toasts. Keep an apply
|
||||
// rejection on the originating job; Done logs and reports it just like a
|
||||
// WorkerReply::Failed, without failing another concurrent analysis.
|
||||
if let Some(job) = self.offloaded.iter_mut().find(|j| j.id == id) {
|
||||
job.progress.error = Some(e.to_string());
|
||||
} else {
|
||||
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut done = vec![];
|
||||
@@ -830,6 +868,7 @@ impl Session {
|
||||
self.offload_log.push((kind, p.error.clone().or_else(|| p.cancelled.then(|| "stopped".to_string()))));
|
||||
if self.offload_log.len() > 32 {
|
||||
self.offload_log.remove(0);
|
||||
self.offload_log_start = self.offload_log_start.saturating_add(1);
|
||||
}
|
||||
if let Some(e) = p.error {
|
||||
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
|
||||
@@ -880,6 +919,76 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// `live` if only it changed since `base`, `result` if only that did (or both the same way);
|
||||
/// `None` when both changed it differently.
|
||||
fn pick<T: PartialEq + Clone>(base: &T, live: &T, result: &T) -> Option<T> {
|
||||
if live == base {
|
||||
Some(result.clone())
|
||||
} else if result == base || result == live {
|
||||
Some(live.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn node_uid(n: &effectcraft_project::Node) -> Uid {
|
||||
match n {
|
||||
effectcraft_project::Node::Prop(p) => p.uid,
|
||||
effectcraft_project::Node::Group(g) => g.uid,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge an analysis `result` into the `live` group (#1249). `base` is the group as the job
|
||||
/// started. The group's own fields and each property are taken from whichever side changed
|
||||
/// them, groups are merged child by child (matched by uid), and a child added or removed by one
|
||||
/// side is added or removed. `None` when both sides changed the same field or property
|
||||
/// differently, one side removed a child the other changed, or both reordered or added and
|
||||
/// removed children differently: a conflict.
|
||||
pub(crate) fn merge_group(base: &PropGroup, live: &PropGroup, result: &PropGroup) -> Option<PropGroup> {
|
||||
use effectcraft_project::Node;
|
||||
if live == base {
|
||||
return Some(result.clone());
|
||||
}
|
||||
if result == base || result == live {
|
||||
return Some(live.clone());
|
||||
}
|
||||
let header = |g: &PropGroup| PropGroup { children: vec![], ..g.clone() };
|
||||
let mut out = pick(&header(base), &header(live), &header(result))?;
|
||||
let ids = |g: &PropGroup| g.children.iter().map(node_uid).collect::<Vec<_>>();
|
||||
let (bi, li, ri) = (ids(base), ids(live), ids(result));
|
||||
// The children's order and set: from the side that changed them.
|
||||
let order = if li == bi {
|
||||
ri
|
||||
} else if ri == bi || ri == li {
|
||||
li
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
fn find(g: &PropGroup, u: Uid) -> Option<&effectcraft_project::Node> {
|
||||
g.children.iter().find(|n| node_uid(n) == u)
|
||||
}
|
||||
// A child one side removed must be unchanged on the other, or that side's edit would be lost.
|
||||
for u in bi.iter().filter(|u| !order.contains(u)) {
|
||||
let b = find(base, *u);
|
||||
if [find(live, *u), find(result, *u)].into_iter().flatten().any(|n| Some(n) != b) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for u in order {
|
||||
let node = match (find(base, u), find(live, u), find(result, u)) {
|
||||
(Some(Node::Group(b)), Some(Node::Group(l)), Some(Node::Group(r))) => Node::Group(merge_group(b, l, r)?),
|
||||
(Some(b), Some(l), Some(r)) => pick(b, l, r)?,
|
||||
// Added by one side, or by both alike.
|
||||
(None, Some(l), None) => l.clone(),
|
||||
(None, None, Some(r)) => r.clone(),
|
||||
(None, Some(l), Some(r)) if l == r => l.clone(),
|
||||
_ => return None,
|
||||
};
|
||||
out.children.push(node);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -892,6 +1001,69 @@ mod tests {
|
||||
a
|
||||
}
|
||||
|
||||
/// A group with properties `a` (uid 2) and `b` (uid 3) and a sub-group (uid 4) holding `c`
|
||||
/// (uid 5).
|
||||
fn group() -> PropGroup {
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Node, Property};
|
||||
let mut sub = PropGroup::new(4, "sub", "Sub");
|
||||
sub.children.push(Node::Prop(Property::new(5, "c", "C", KV::Scalar(0.0))));
|
||||
let mut g = PropGroup::new(1, "fx", "Effect");
|
||||
g.children.push(Node::Prop(Property::new(2, "a", "A", KV::Scalar(0.0))));
|
||||
g.children.push(Node::Prop(Property::new(3, "b", "B", KV::Scalar(0.0))));
|
||||
g.children.push(Node::Group(sub));
|
||||
g
|
||||
}
|
||||
|
||||
fn set(g: &mut PropGroup, uid: Uid, v: f64) {
|
||||
g.find_mut(uid).unwrap().value = effectcraft_keyframe::Value::Scalar(v);
|
||||
}
|
||||
|
||||
fn get(g: &PropGroup, uid: Uid) -> f64 {
|
||||
match g.find(uid).unwrap().value {
|
||||
effectcraft_keyframe::Value::Scalar(v) => v,
|
||||
ref v => panic!("{v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_results_merge_with_edits_made_meanwhile() {
|
||||
use effectcraft_project::Node;
|
||||
let base = group();
|
||||
// The user changes `a` and switches the effect off; the analysis writes `b` and `c`.
|
||||
let mut live = base.clone();
|
||||
set(&mut live, 2, 1.0);
|
||||
live.enabled = false;
|
||||
let mut result = base.clone();
|
||||
set(&mut result, 3, 2.0);
|
||||
set(&mut result, 5, 3.0);
|
||||
let m = merge_group(&base, &live, &result).unwrap();
|
||||
assert_eq!((get(&m, 2), get(&m, 3), get(&m, 5), m.enabled), (1.0, 2.0, 3.0, false));
|
||||
// Unchanged on either side: the other side wins whole.
|
||||
assert_eq!(merge_group(&base, &base, &result).unwrap(), result);
|
||||
assert_eq!(merge_group(&base, &live, &base).unwrap(), live);
|
||||
// Both changed `b`, differently: a conflict (nothing is applied). The same way: fine.
|
||||
let mut both = live.clone();
|
||||
set(&mut both, 3, 9.0);
|
||||
assert!(merge_group(&base, &both, &result).is_none());
|
||||
set(&mut both, 3, 2.0);
|
||||
assert_eq!(get(&merge_group(&base, &both, &result).unwrap(), 3), 2.0);
|
||||
// The analysis adds a child (a result group) while the user edits `a`: both kept.
|
||||
let mut added = result.clone();
|
||||
added.children.push(Node::Group(PropGroup::new(6, "out", "Output")));
|
||||
let m = merge_group(&base, &live, &added).unwrap();
|
||||
assert!(m.find_group(6).is_some() && get(&m, 2) == 1.0);
|
||||
// The analysis drops the sub-group the user didn't touch: dropped.
|
||||
let mut dropped = result.clone();
|
||||
dropped.children.retain(|n| !matches!(n, Node::Group(g) if g.uid == 4));
|
||||
let m = merge_group(&base, &live, &dropped).unwrap();
|
||||
assert!(m.find_group(4).is_none() && get(&m, 2) == 1.0);
|
||||
// …but if the user changed something in it, that's a conflict.
|
||||
let mut touched = live.clone();
|
||||
set(&mut touched, 5, 7.0);
|
||||
assert!(merge_group(&base, &touched, &dropped).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_round_trips() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -362,8 +362,15 @@ impl Session {
|
||||
// Settings ▸ Export ▸ Default Output Folder, else the project's folder.
|
||||
let folder = self.prefs.export.default_output_folder.trim();
|
||||
let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder));
|
||||
let base =
|
||||
base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty()));
|
||||
// A project saved under a bare filename has the empty path as its parent. It sits in the
|
||||
// working directory, so resolve against that rather than falling through to the
|
||||
// unsaved-project default below (#1427).
|
||||
let base = base.or_else(|| {
|
||||
self.path
|
||||
.as_deref()
|
||||
.and_then(|s| std::path::Path::new(s).parent())
|
||||
.map(|d| if d.as_os_str().is_empty() { std::path::PathBuf::from(".") } else { d.to_path_buf() })
|
||||
});
|
||||
// Unsaved project: ~/Movies, else the home folder (as FilmCraft's exports do). The working directory
|
||||
// is `/` for an app started from the macOS Finder, where nothing can be written (#446).
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(std::path::PathBuf::from);
|
||||
|
||||
@@ -280,6 +280,28 @@ fn import_export_helpers() {
|
||||
assert_eq!(p.recent_fonts_shown(), ["A".to_string(), "C".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_queue_resolves_a_bare_project_filename_against_the_working_directory() {
|
||||
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem};
|
||||
let mut s = session();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let mut item = RenderQueueItem::new(1, cid);
|
||||
item.output.output = "clip.mp4".into();
|
||||
item.output.format = OutputFormat::H264;
|
||||
|
||||
// `--project ./proj.ecproj`: the parent is `.`, so the output goes beside the project.
|
||||
s.path = Some("./proj.ecproj".into());
|
||||
let dotted = s.resolve_output(&item).unwrap();
|
||||
|
||||
// `--project proj.ecproj`: the parent is the empty path, which must mean the same folder
|
||||
// rather than the unsaved-project default (#1427).
|
||||
s.path = Some("proj.ecproj".into());
|
||||
let bare = s.resolve_output(&item).unwrap();
|
||||
|
||||
assert_eq!(PathBuf::from(&bare), PathBuf::from(&dotted), "a bare filename resolved to {bare} instead of {dotted}");
|
||||
assert_eq!(PathBuf::from(&bare), PathBuf::from("./clip.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_queue_uses_default_folder_bit_depth_names_and_segments() {
|
||||
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem};
|
||||
|
||||
@@ -412,6 +412,162 @@ impl Deferred {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1249: a setting changed while the analysis runs in a worker survives its result, which
|
||||
/// used to replace the whole effect with the copy the job started from.
|
||||
#[test]
|
||||
fn an_edit_made_during_an_offloaded_analysis_survives_its_result() {
|
||||
let (mut s, clip, solid) = setup(shaky);
|
||||
s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "Warp Stabilizer"})).unwrap();
|
||||
set_param(&mut s, clip, "stabilization/smoothness", json!(37.5));
|
||||
let off = std::sync::Arc::new(Deferred::default());
|
||||
s.offload = Some(off.clone());
|
||||
s.execute_checked("warp.analyze", json!({"layer": clip.0})).unwrap();
|
||||
// While the job runs: another setting of the same effect.
|
||||
set_param(&mut s, clip, "stabilization/smoothness", json!(12.5));
|
||||
let fx = |s: &Session| s.active_comp().unwrap().layer(clip).unwrap().props.prop("effects/#1/stabilization/smoothness").unwrap().value.clone();
|
||||
off.run(s.footage.clone());
|
||||
assert!(s.poll_offload());
|
||||
assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { error: true, .. })), "applied without a conflict");
|
||||
let st = s.execute("warp.status", json!({})).unwrap();
|
||||
assert_eq!(st["analyzed"], true, "the analysis landed: {st}");
|
||||
assert_eq!(fx(&s), KV::Scalar(12.5), "the edit made during the analysis stays");
|
||||
// One undo step takes the analysis back and keeps the edit.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(fx(&s), KV::Scalar(12.5));
|
||||
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], false);
|
||||
}
|
||||
|
||||
fn point_track_pose(f: u32) -> ([f64; 2], f64, f64) {
|
||||
([150.0 + 2.0 * f as f64, 110.0 + f as f64], 0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Hold an actual point-tracking worker request so commands can edit its target before the
|
||||
/// serialized worker reply is delivered.
|
||||
fn held_point_track() -> (Session, LayerId, Uid, std::sync::Arc<Deferred>) {
|
||||
let (mut s, clip, solid) = setup(patch_frames(point_track_pose));
|
||||
s.execute("edit.clear", json!({"layers": [solid.0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
|
||||
let tracker = s.execute("track.motion", json!({})).unwrap()["tracker"].as_u64().unwrap();
|
||||
s.execute("track.setPoint", json!({"point": 1, "center": point_track_pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap();
|
||||
let off = std::sync::Arc::new(Deferred::default());
|
||||
s.offload = Some(off.clone());
|
||||
s.execute_checked("track.analyze", json!({"direction": "forward"})).unwrap();
|
||||
assert_eq!(off.kinds(), [crate::offload::JobKind::Track]);
|
||||
(s, clip, tracker, off)
|
||||
}
|
||||
|
||||
fn tracked_center(s: &Session, clip: LayerId, tracker: Uid) -> Uid {
|
||||
s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap().track_points().next().unwrap().get("featureCenter").unwrap().uid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offloaded_conflict_preserves_state_and_reports_completion_failure() {
|
||||
let (mut s, clip, tracker, off) = held_point_track();
|
||||
let center = tracked_center(&s, clip, tracker);
|
||||
s.execute("prop.set", json!({"layer": clip.0, "prop": center, "value": [180.0, 140.0]})).unwrap();
|
||||
s.execute("prop.renameGroup", json!({"layer": clip.0, "prop": tracker, "name": "Redo survives"})).unwrap();
|
||||
assert!(s.undo());
|
||||
let before = s.project.clone();
|
||||
let state = serde_json::to_value(&s.state).unwrap();
|
||||
let steps = |s: &Session| {
|
||||
let entries =
|
||||
|v: &[(String, std::sync::Arc<effectcraft_project::Project>)]| v.iter().map(|(label, p)| (label.clone(), p.to_json())).collect::<Vec<_>>();
|
||||
(
|
||||
entries(&s.history.undo),
|
||||
entries(&s.history.redo),
|
||||
s.history.branches.iter().map(|b| (b.parent.to_json(), entries(&b.steps))).collect::<Vec<_>>(),
|
||||
s.history.merge_key.clone(),
|
||||
)
|
||||
};
|
||||
let history = steps(&s);
|
||||
let inbox = s.offloaded(crate::offload::JobKind::Track).unwrap().inbox.clone();
|
||||
off.run(s.footage.clone());
|
||||
let (done, replies): (Vec<_>, Vec<_>) = inbox.take().into_iter().partition(|r| matches!(r, crate::offload::WorkerReply::Done));
|
||||
assert_eq!(done.len(), 1);
|
||||
for reply in replies {
|
||||
inbox.push(reply);
|
||||
}
|
||||
s.drain_events();
|
||||
assert!(s.poll_offload());
|
||||
let pending = s.offloaded(crate::offload::JobKind::Track).unwrap();
|
||||
assert!(pending.progress.error.as_ref().is_some_and(|e| e.contains("nothing was applied")));
|
||||
assert!(!pending.progress.finished);
|
||||
assert!(s.offload_log.is_empty(), "the error is kept before completion is logged");
|
||||
// The current completion remains identifiable after the bounded log evicts an old entry.
|
||||
s.offload_log = vec![(crate::offload::JobKind::Warp, None); 32];
|
||||
for reply in done {
|
||||
inbox.push(reply);
|
||||
}
|
||||
assert!(s.poll_offload());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.project, &before), "a rejected result changes no document content");
|
||||
assert_eq!(serde_json::to_value(&s.state).unwrap(), state);
|
||||
assert_eq!(steps(&s), history);
|
||||
let errors: Vec<_> = s
|
||||
.drain_events()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
crate::Event::Toast { error: true, message } => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(errors.len(), 1, "the apply rejection is reported once: {errors:?}");
|
||||
assert!(errors[0].contains("nothing was applied"));
|
||||
assert_eq!(s.offload_log.len(), 32);
|
||||
assert_eq!(s.offload_log_start, 1);
|
||||
let (kind, error) = s.offload_log.last().unwrap();
|
||||
assert_eq!(*kind, crate::offload::JobKind::Track);
|
||||
assert!(error.as_ref().is_some_and(|e| e.contains("nothing was applied")), "a rejected result must report failure to JobWaiter");
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap().name, "Redo survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offloaded_disjoint_edit_reports_success_and_keeps_one_undo_step() {
|
||||
let (mut s, clip, tracker, off) = held_point_track();
|
||||
s.execute("prop.renameGroup", json!({"layer": clip.0, "prop": tracker, "name": "Live track"})).unwrap();
|
||||
s.execute("prop.setGroupEnabled", json!({"layer": clip.0, "prop": tracker, "value": false})).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
off.run(s.footage.clone());
|
||||
s.drain_events();
|
||||
assert!(s.poll_offload());
|
||||
let layer = s.active_comp().unwrap().layer(clip).unwrap();
|
||||
let track = layer.props.find_group(tracker).unwrap();
|
||||
assert_eq!(track.name, "Live track");
|
||||
assert!(!track.enabled);
|
||||
assert_eq!(track.track_points().next().unwrap().get("featureCenter").unwrap().keys.len(), 25);
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
assert_eq!(s.offload_log.last(), Some(&(crate::offload::JobKind::Track, None)));
|
||||
assert!(!s.drain_events().iter().any(|e| matches!(e, crate::Event::Toast { error: true, .. })));
|
||||
let applied = s.project.to_json();
|
||||
assert!(s.undo());
|
||||
let track = s.active_comp().unwrap().layer(clip).unwrap().props.find_group(tracker).unwrap();
|
||||
assert_eq!(track.name, "Live track");
|
||||
assert!(!track.enabled);
|
||||
assert!(track.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty());
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.project.to_json(), applied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offloaded_conflict_does_not_fail_a_concurrent_analysis() {
|
||||
let (mut s, clip, tracker, off) = held_point_track();
|
||||
let effect = s.execute("effect.apply", json!({"effect": "Warp Stabilizer", "layers": [clip.0]})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
s.execute_checked("warp.analyze", json!({"layer": clip.0, "effect": effect})).unwrap();
|
||||
assert_eq!(off.kinds(), [crate::offload::JobKind::Track, crate::offload::JobKind::Warp]);
|
||||
let center = tracked_center(&s, clip, tracker);
|
||||
s.execute("prop.set", json!({"layer": clip.0, "prop": center, "value": [180.0, 140.0]})).unwrap();
|
||||
off.run(s.footage.clone());
|
||||
assert!(s.poll_offload());
|
||||
let track = s.offload_log.iter().find(|(kind, _)| *kind == crate::offload::JobKind::Track).unwrap();
|
||||
assert!(track.1.as_ref().is_some_and(|e| e.contains("nothing was applied")));
|
||||
let warp = s.offload_log.iter().find(|(kind, _)| *kind == crate::offload::JobKind::Warp).unwrap();
|
||||
assert!(warp.1.is_none(), "the other analysis completed successfully: {warp:?}");
|
||||
assert_eq!(s.execute("warp.status", json!({"layer": clip.0, "effect": effect})).unwrap()["analyzed"], true);
|
||||
assert!(s.active_comp().unwrap().layer(clip).unwrap().props.find(center).unwrap().keys.is_empty());
|
||||
}
|
||||
|
||||
/// M13.32: where jobs run elsewhere (the browser), `warp.status` never solves the
|
||||
/// stabilization plan on the page's thread (seconds for a long Subspace Warp clip): the
|
||||
/// analysis job sends the plan's summary with its result, and a settings change has the plan
|
||||
|
||||
@@ -433,8 +433,10 @@ fn copy_a_selected_property_pastes_onto_the_same_property() {
|
||||
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
let transform = layer(&s, a).props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": a, "prop": transform})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
@@ -706,6 +708,21 @@ fn sequence_layers() {
|
||||
assert_eq!(layer(&s, c).props.prop("transform/opacity").unwrap().keys.len(), 2);
|
||||
}
|
||||
|
||||
/// File ▸ Dependencies ▸ Find Missing Fonts lists a text layer only when no face matches its
|
||||
/// font by family, PostScript or full name.
|
||||
#[test]
|
||||
fn fonts_named_by_their_postscript_or_full_name_are_not_missing() {
|
||||
let mut s = comp();
|
||||
// The first three name bundled faces by family, PostScript and full name; no face has the fourth.
|
||||
let fonts = ["Noto Serif", "Inter-Bold", "Inter SemiBold", "No Such Font Family 123"];
|
||||
let ids: Vec<u64> = fonts.iter().map(|f| s.execute("layer.newText", json!({"text": "Aa", "font": f})).unwrap()["layer"].as_u64().unwrap()).collect();
|
||||
let r = s.execute("file.findMissing", json!({"what": "fonts"})).unwrap();
|
||||
let listed: Vec<(u64, &str)> = r["fonts"].as_array().unwrap().iter().map(|f| (f["layer"].as_u64().unwrap(), f["font"].as_str().unwrap())).collect();
|
||||
assert_eq!(listed, [(ids[3], fonts[3])], "{r}");
|
||||
let f = s.execute("text.fontFeatures", json!({"layer": ids[1]})).unwrap();
|
||||
assert_eq!((&f["family"], &f["style"], &f["missing"]), (&json!("Inter"), &json!("Bold"), &json!(false)), "{f}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_menu_project_ops() {
|
||||
let mut s = comp();
|
||||
@@ -1556,6 +1573,31 @@ fn interpret_footage_loop_extends_video_layers() {
|
||||
assert_eq!(s.project.item(effectcraft_project::ItemId(p)).unwrap().duration(), Some(Tick::from_seconds_f64(6.0)));
|
||||
}
|
||||
|
||||
/// #1068: the same for an interpreted PNG sequence — the loop count survives interpretation and
|
||||
/// a freshly dragged layer plays every cycle.
|
||||
#[test]
|
||||
fn interpret_footage_loop_extends_sequence_layers() {
|
||||
let mut s = comp();
|
||||
let p = s.execute("file.importPlaceholder", json!({"name": "Seq", "width": 320, "height": 240, "duration": 0.4})).unwrap()["item"].as_u64().unwrap();
|
||||
let id = effectcraft_project::ItemId(p);
|
||||
// Make it a sequence like the importer's grouped PNGs.
|
||||
s.edit("Seq", None, |proj, _| {
|
||||
if let Some(ItemKind::Footage(f)) = proj.item_mut(id).map(|i| &mut i.kind) {
|
||||
f.kind = effectcraft_project::FootageKind::Sequence;
|
||||
f.sequence = (0..12).map(|n| format!("/tmp/seq_{n:04}.png")).collect();
|
||||
f.sync_sequence_duration();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.state.project_selection = vec![id];
|
||||
s.execute("file.interpretFootage", json!({"loop": 15.0})).unwrap();
|
||||
let l = s.execute("layer.addItem", json!({"item": p})).unwrap()["layer"].as_u64().unwrap();
|
||||
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap();
|
||||
let dur = (layer.out_point - layer.in_point).seconds();
|
||||
assert!((dur - 6.0).abs() < 0.05, "15 loops of a 0.4 s sequence: {dur}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentage_guides_follow_the_comp_size() {
|
||||
let mut s = comp();
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with, temp_path};
|
||||
use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with};
|
||||
use crate::prefs::{PREFS_FILE, Prefs};
|
||||
use crate::shortcuts::{DEFAULT_PRESET, Keymaps, normalize};
|
||||
use crate::{Session, autosave};
|
||||
@@ -548,6 +548,66 @@ fn frontend_commands_are_bindable_with_panel_scope() {
|
||||
|
||||
// ---------------------------------------------------------------- auto-save / recovery
|
||||
|
||||
/// The names in `d`, sorted.
|
||||
fn names(d: &std::path::Path) -> Vec<String> {
|
||||
let mut v: Vec<String> = std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn atomic_write_overwrites_a_long_valid_filename() {
|
||||
let d = tmp("atomic-long-name");
|
||||
// The old `<name>.tmp` fits a 255-byte component limit for this 251-byte project name.
|
||||
// Including the full name plus a process id and counter made an otherwise valid save fail.
|
||||
let name = format!("{}.ecproj", "x".repeat(244));
|
||||
assert_eq!(name.len(), 251);
|
||||
let f = d.join(&name);
|
||||
std::fs::write(&f, b"original project").unwrap();
|
||||
let replacement = b"replacement project\0bytes\xff";
|
||||
atomic_write(&f, replacement).unwrap();
|
||||
assert_eq!(std::fs::read(&f).unwrap(), replacement);
|
||||
assert_eq!(names(&d), [name], "no temporary file is left after overwriting the project");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_atomic_writes_each_publish_a_whole_file() {
|
||||
// Writers of the same file used to share one `.tmp`: they truncated each other's data and
|
||||
// the later rename failed once the first had moved it away (#1233).
|
||||
let d = tmp("atomic-concurrent");
|
||||
let f = d.join("shared.ecproj");
|
||||
for round in 0..10 {
|
||||
let contents: Vec<Vec<u8>> = (0..8u8).map(|w| vec![b'a' + w; 256 * 1024 + usize::from(w) * 1000]).collect();
|
||||
std::thread::scope(|sc| {
|
||||
for c in &contents {
|
||||
let f = &f;
|
||||
sc.spawn(move || atomic_write(f, c).expect("every writer succeeds"));
|
||||
}
|
||||
});
|
||||
let got = std::fs::read(&f).unwrap();
|
||||
assert!(contents.contains(&got), "round {round}: the file is one writer's whole data ({} bytes)", got.len());
|
||||
assert_eq!(names(&d), ["shared.ecproj"], "round {round}: no temporary file is left");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn atomic_write_does_not_follow_a_link_at_the_old_temp_name() {
|
||||
let d = tmp("atomic-link");
|
||||
let f = d.join("p.ecproj");
|
||||
let victim = d.join("victim.txt");
|
||||
std::fs::write(&victim, b"keep").unwrap();
|
||||
// The temporary name used to be `<name>.tmp`, opened with File::create (following links).
|
||||
std::os::unix::fs::symlink(&victim, d.join("p.ecproj.tmp")).unwrap();
|
||||
atomic_write(&f, b"new project").unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"keep", "the link's target is untouched");
|
||||
assert_eq!(std::fs::read(&f).unwrap(), b"new project");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_survives_a_crash_mid_write() {
|
||||
let d = tmp("atomic");
|
||||
@@ -555,12 +615,12 @@ fn atomic_write_survives_a_crash_mid_write() {
|
||||
atomic_write(&f, b"version 1").unwrap();
|
||||
// The writer dies halfway through the temporary file.
|
||||
let r = atomic_write_with(&f, b"version 2 is much longer", |tmp, data| {
|
||||
std::fs::write(tmp, &data[..5])?;
|
||||
std::io::Write::write_all(tmp, &data[..5])?;
|
||||
Err(std::io::Error::other("power cut"))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(std::fs::read(&f).unwrap(), b"version 1");
|
||||
assert!(!temp_path(&f).exists(), "the partial temp file is cleaned up");
|
||||
assert_eq!(names(&d), ["p.ecproj"], "the partial temp file is cleaned up");
|
||||
// A temp file left by a hard kill doesn't count as an auto-save.
|
||||
std::fs::write(d.join("p auto-save 1.ecproj.tmp"), b"junk").unwrap();
|
||||
assert!(autosave::existing(&d, "p").is_empty());
|
||||
|
||||
@@ -164,6 +164,64 @@ fn presets_apply_render_and_round_trip() {
|
||||
assert!(matches(tg.sub("pathOptions").unwrap()).contains(&"forceAlignment".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_text_presets_keep_the_catalog_and_application_independent() {
|
||||
let expected = [
|
||||
("typewriter", "Typewriter"),
|
||||
("fadeUpCharacters", "Fade Up Characters"),
|
||||
("bounceInWords", "Bounce In Words"),
|
||||
("trackingIn", "Tracking In"),
|
||||
("scramble", "Scramble"),
|
||||
("blurIn", "Blur In"),
|
||||
("jitter", "Jitter"),
|
||||
("dropInLines", "Drop In Lines"),
|
||||
];
|
||||
let (mut s, layer) = session();
|
||||
let catalog = s.execute("text.presets", json!({})).unwrap();
|
||||
let mut returned = catalog.clone();
|
||||
returned[0]["name"] = json!("Edited copy");
|
||||
let project = s.project.clone();
|
||||
for _ in 0..2 {
|
||||
let names = crate::text_presets();
|
||||
assert_eq!(names.iter().map(|(id, name)| (id.as_str(), name.as_str())).collect::<Vec<_>>(), expected);
|
||||
for (id, _) in expected {
|
||||
let result = s.execute("layer.applyTextPreset", json!({"layer": layer, "preset": id})).unwrap();
|
||||
assert!(!result["animators"].as_array().unwrap().is_empty(), "{id}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project, project, "{id}: undo restores the document");
|
||||
}
|
||||
assert_eq!(s.execute("text.presets", json!({})).unwrap(), catalog);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "warm text-preset listing benchmark"]
|
||||
fn text_preset_listing_benchmark() {
|
||||
let old = || {
|
||||
let v: serde_json::Value = serde_json::from_str(include_str!("../presets/text_animators.json")).unwrap();
|
||||
let presets = v["presets"].as_array().unwrap().clone();
|
||||
presets.iter().map(|p| (p["id"].as_str().unwrap().to_string(), p["name"].as_str().unwrap().to_string())).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(old(), crate::text_presets());
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(old());
|
||||
}
|
||||
before.push(start.elapsed());
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(crate::text_presets());
|
||||
}
|
||||
after.push(start.elapsed());
|
||||
}
|
||||
before.sort();
|
||||
after.sort();
|
||||
println!("Text preset names, median per warm lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menus_have_text_animation_entries() {
|
||||
let (s, _) = session();
|
||||
|
||||
@@ -108,6 +108,85 @@ fn keyframe_copy_paste_keeps_relative_timing_and_crosses_properties() {
|
||||
assert!(e.is_err() || prop(&s, l2, "transform/position").keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_selected_keys_excludes_deselected_properties_and_stale_values() {
|
||||
let (mut s, source) = setup();
|
||||
s.execute("prop.set", json!({"layer": source, "path": "transform/opacity", "value": 25})).unwrap();
|
||||
select_prop_keys(&mut s, source, "transform/opacity");
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
assert_eq!(s.state.value_clipboard.len(), 1);
|
||||
animate(&mut s, source, "transform/position", &[(0.0, json!([10, 20, 0])), (1.0, json!([30, 40, 0]))]);
|
||||
animate(&mut s, source, "transform/rotation", &[(0.0, json!(15)), (1.0, json!(90))]);
|
||||
animate(&mut s, source, "transform/scale", &[(0.0, json!([50, 60, 100])), (1.0, json!([150, 160, 100]))]);
|
||||
select_prop_keys(&mut s, source, "transform/scale");
|
||||
s.execute(
|
||||
"keys.select",
|
||||
json!({"keys": [{"layer": source, "path": "transform/position", "time": 0}, {"layer": source, "path": "transform/rotation", "time": 1}], "add": true, "selectProperties": true}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"keys.select",
|
||||
json!({"keys": [{"layer": source, "path": "transform/scale", "time": 0}, {"layer": source, "path": "transform/scale", "time": 1}], "toggle": true, "selectProperties": true}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("keys.easyEase", json!({})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
assert_eq!(s.state.selected_props.len(), 3);
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!(2));
|
||||
assert!(s.state.value_clipboard.is_empty());
|
||||
let target = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("time.set", json!({"time": 2})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [target]})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
undo_redo_roundtrip(
|
||||
&mut s,
|
||||
|s| {
|
||||
for (path, time, value) in [("transform/position", 2.0, KV::Vec3([10.0, 20.0, 0.0])), ("transform/rotation", 3.0, KV::Scalar(90.0))] {
|
||||
let p = prop(s, target, path);
|
||||
assert_eq!(p.keys.len(), 1);
|
||||
let k = &p.keys[0];
|
||||
assert_eq!((k.time.seconds(), &k.value), (time, &value));
|
||||
assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier));
|
||||
assert_eq!(k.in_ease[0], effectcraft_keyframe::Ease::EASY);
|
||||
assert_eq!(k.out_ease[0], effectcraft_keyframe::Ease::EASY);
|
||||
}
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
assert_eq!(prop(s, target, "transform/scale").value, KV::Vec3([100.0; 3]));
|
||||
assert!(prop(s, target, "transform/scale").keys.is_empty());
|
||||
assert_eq!(prop(s, target, "transform/opacity").value, KV::Scalar(100.0));
|
||||
},
|
||||
|s| {
|
||||
assert!(prop(s, target, "transform/position").keys.is_empty());
|
||||
assert!(prop(s, target, "transform/rotation").keys.is_empty());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_mixed_animated_and_static_properties_keeps_the_static_value() {
|
||||
let (mut s, source) = setup();
|
||||
animate(&mut s, source, "transform/position", &[(0.0, json!([10, 20, 0])), (1.0, json!([30, 40, 0]))]);
|
||||
s.execute("prop.set", json!({"layer": source, "path": "transform/rotation", "value": 45})).unwrap();
|
||||
select_prop_keys(&mut s, source, "transform/position");
|
||||
s.execute("prop.select", json!({"layer": source, "path": "transform/rotation", "add": true})).unwrap();
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!({"keys": 2, "values": 1}));
|
||||
let target = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("time.set", json!({"time": 2})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [target]})).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
assert_eq!(prop(&s, target, "transform/rotation").value, KV::Scalar(45.0));
|
||||
let position = prop(&s, target, "transform/position");
|
||||
assert_eq!(
|
||||
position.keys.iter().map(|k| (k.time.seconds(), k.value.clone())).collect::<Vec<_>>(),
|
||||
vec![(2.0, KV::Vec3([10.0, 20.0, 0.0])), (3.0, KV::Vec3([30.0, 40.0, 0.0]))]
|
||||
);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(prop(&s, target, "transform/position").keys.is_empty());
|
||||
assert_eq!(prop(&s, target, "transform/rotation").value, KV::Scalar(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_removes_keys_and_paste_restores_them() {
|
||||
let (mut s, l) = setup();
|
||||
|
||||
@@ -271,7 +271,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::
|
||||
Some(
|
||||
effectcraft_pdf::parse_page(bytes, page as usize)
|
||||
.map(|d| match layer {
|
||||
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
|
||||
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
|
||||
None => d,
|
||||
})
|
||||
.map_err(|e| format!("{path}: {e}")),
|
||||
@@ -279,7 +279,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::
|
||||
}
|
||||
|
||||
/// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the
|
||||
/// page: one footage item per file layer (each showing only that layer, continuously
|
||||
/// page: one footage item per file layer (optionally cropped to its artwork, continuously
|
||||
/// rasterisable), top layer first. Returns (comp, folder, items).
|
||||
pub fn import_vector_comp(
|
||||
proj: &mut Project,
|
||||
@@ -287,11 +287,12 @@ pub fn import_vector_comp(
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
page: u32,
|
||||
rate: effectcraft_time::FrameRate,
|
||||
duration: effectcraft_time::Tick,
|
||||
retain: bool,
|
||||
timing: (effectcraft_time::FrameRate, effectcraft_time::Tick),
|
||||
) -> Result<(ItemId, ItemId, Vec<ItemId>), String> {
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer};
|
||||
let (rate, duration) = timing;
|
||||
let doc = effectcraft_pdf::parse_page(bytes, page as usize).map_err(|e| format!("{path}: {e}"))?;
|
||||
let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF");
|
||||
let (w, h) = doc.pixel_size();
|
||||
@@ -301,24 +302,33 @@ pub fn import_vector_comp(
|
||||
let mut items = vec![];
|
||||
let names = effectcraft_pdf::layer_names(&doc);
|
||||
for (i, lname) in names.iter().enumerate() {
|
||||
let (size, offset) = if retain {
|
||||
let (layer, offset) = effectcraft_pdf::layer_doc_with_sizes(&doc, i, true);
|
||||
(layer.pixel_size(), offset)
|
||||
} else {
|
||||
((w, h), [0.0, 0.0])
|
||||
};
|
||||
let f = Footage {
|
||||
path: path.to_string(),
|
||||
kind: FootageKind::Still,
|
||||
width: w,
|
||||
height: h,
|
||||
width: size.0,
|
||||
height: size.1,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: rate,
|
||||
has_video: true,
|
||||
alpha: AlphaMode::Straight,
|
||||
loop_count: 1,
|
||||
codec: codec.into(),
|
||||
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }),
|
||||
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: retain, ..Default::default() }),
|
||||
page,
|
||||
..Default::default()
|
||||
};
|
||||
let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f));
|
||||
items.push(id);
|
||||
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None);
|
||||
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, size, None);
|
||||
if retain && let Some(position) = l.props.prop_mut("transform/position") {
|
||||
position.value = Value::Vec3([offset[0] + f64::from(size.0) / 2.0, offset[1] + f64::from(size.1) / 2.0, 0.0]);
|
||||
}
|
||||
l.name = comp.unique_layer_name(lname);
|
||||
comp.layers.insert(0, l);
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ const MAX_COLLAPSED_DEPTH: usize = 8;
|
||||
fn visible_layers<'a>(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterator<Item = &'a Layer> {
|
||||
let comp = ctx.comp;
|
||||
let time = ctx.time;
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time));
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
|
||||
comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id))
|
||||
}
|
||||
|
||||
|
||||
@@ -613,3 +613,28 @@ fn wav_and_aiff_audio_only() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An AIFF EffectCraft renders imports back (#1428): plain 16-bit and AIFF-C `fl32`.
|
||||
#[test]
|
||||
fn rendered_aiff_imports_back() {
|
||||
use effectcraft_export::render_queue::AudioFormat;
|
||||
let d = out_dir("aiff-import");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 2.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
for (format, name) in [(AudioFormat::S16, "s16.aif"), (AudioFormat::F32, "f32.aif")] {
|
||||
// 44.1 kHz, not the 48 kHz the samples are read back at: a wrong rate shows in the duration.
|
||||
let om = OutputModule { audio_sample_rate: 44_100, audio_channels: 2, audio_format: format, ..OutputModule::for_format(OutputFormat::Aiff) };
|
||||
let path = d.join(name);
|
||||
assert!(run(&p, cid, &pool, &om, &path).audio);
|
||||
let f = effectcraft_media::probe(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert!(f.has_audio && !f.has_video, "{name}");
|
||||
assert_eq!(f.duration, Tick::from_seconds_f64(1.2), "{name}: 52 920 frames at 44.1 kHz");
|
||||
// Tone on the left, silence on the right: the two channels are read as two.
|
||||
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
|
||||
let (l, r) = (rms(s.iter().step_by(2).copied()), rms(s.iter().skip(1).step_by(2).copied()));
|
||||
assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}");
|
||||
assert!(r < 0.01, "{name}: right RMS {r}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,6 +917,12 @@ impl GpuContext {
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Entries in the layer upload cache.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn upload_entries(&self) -> usize {
|
||||
self.uploads.lock().map(|u| u.map.len()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Upload a (layer cache) buffer, reusing the texture while the same `Arc` is alive: static
|
||||
/// layers upload once, not every frame.
|
||||
pub(crate) fn upload_buf(&self, buf: &Arc<Buf>) -> Option<GpuImage> {
|
||||
@@ -939,14 +945,14 @@ impl GpuContext {
|
||||
u.bytes -= old.bytes;
|
||||
}
|
||||
u.bytes += bytes;
|
||||
// Drop textures of freed buffers, then the least recently used over budget.
|
||||
// Drop textures of freed buffers at once (a layer whose buffer changes every frame
|
||||
// makes a new one each time, and nothing can hit the old one again), then the least
|
||||
// recently used over budget.
|
||||
let budget = self.upload_budget();
|
||||
if u.bytes > budget {
|
||||
let dead: Vec<usize> = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect();
|
||||
for k in dead {
|
||||
if let Some(e) = u.map.remove(&k) {
|
||||
u.bytes -= e.bytes;
|
||||
}
|
||||
let dead: Vec<usize> = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect();
|
||||
for k in dead {
|
||||
if let Some(e) = u.map.remove(&k) {
|
||||
u.bytes -= e.bytes;
|
||||
}
|
||||
}
|
||||
if u.bytes > budget && !self.upload_full_warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
|
||||
@@ -510,8 +510,10 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let n = (hash_noise(u32(p.x), u32(p.y), P.u[0].y) - 0.5) * jitter * (8.0 / 255.0);
|
||||
c += vec3<f32>(n);
|
||||
}
|
||||
let op = P.f[8].z;
|
||||
textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4<f32>(c * op, op), P.u[0].x));
|
||||
let o = textureLoad(src, p, 0);
|
||||
// None keeps the layer's alpha, as the CPU path does (#1381).
|
||||
let op = select(P.f[8].z, P.f[8].z * o.w, P.u[0].x == 255u);
|
||||
textureStore(out, p, gen_blend(o, vec4<f32>(c * op, op), P.u[0].x));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Noise / Add Grain
|
||||
|
||||
@@ -1044,3 +1044,19 @@ fn static_effect_layers_draw_from_the_cache() {
|
||||
assert!(frames[1] == frames[2]);
|
||||
check("static effect layer vs CPU", compare_at(&s, opts(), t(2)), 0.0);
|
||||
}
|
||||
|
||||
/// The layer upload cache drops the textures of freed buffers when it inserts, not only once
|
||||
/// it is over budget (an animated layer makes a new buffer every frame).
|
||||
#[test]
|
||||
fn upload_cache_drops_textures_of_freed_buffers() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let img = effectcraft_raster::Image::new(256, 256);
|
||||
let before = g.ctx.upload_entries();
|
||||
for _ in 0..6 {
|
||||
let buf = std::sync::Arc::new(effectcraft_effects::Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 });
|
||||
let up = g.ctx.upload_buf(&buf).expect("upload");
|
||||
drop((up, buf));
|
||||
}
|
||||
let kept = g.ctx.upload_entries();
|
||||
assert!(kept <= before + 1, "the upload cache kept {kept} textures of freed buffers (had {before})");
|
||||
}
|
||||
|
||||
@@ -203,6 +203,28 @@ fn echo_and_posterize_time() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn echo_after_star_burst_preserves_the_stars() {
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||||
for operator in 0..7 {
|
||||
for posterize in [false, true] {
|
||||
let mut s = Scene::new(depth);
|
||||
let mut l = s.solid([1.0; 3], 97, 61);
|
||||
s.effect(&mut l, "ec.sim.ccstarburst", &[("gridSpacing", n(8.0)), ("size", n(40.0))]);
|
||||
s.effect(&mut l, "ec.time.echo", &[("numberOfEchoes", n(2.0)), ("echoTime", n(-0.1)), ("decay", n(0.6)), ("echoOperator", e(operator))]);
|
||||
if posterize {
|
||||
s.effect(&mut l, "ec.time.posterizetime", &[("frameRate", n(4.0))]);
|
||||
}
|
||||
s.push(l);
|
||||
let t = Tick::from_seconds_f64(0.6);
|
||||
let label = format!("Star Burst + Echo {operator} posterize={posterize} {depth:?}");
|
||||
check(&label, compare_at(&s, opts(), t), 0.0);
|
||||
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_levels_match_cpu_division() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
|
||||
@@ -73,6 +73,9 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut
|
||||
let levels = r.pipe().levels;
|
||||
let mut i = 0;
|
||||
while i < visible.len() {
|
||||
// A device that ran out of memory (or was lost) on an earlier layer fails every later
|
||||
// one: give the frame back to the CPU now instead of drawing the rest of the comp.
|
||||
e.g.check_health().ok()?;
|
||||
if visible[i].is_3d() {
|
||||
let mut j = i;
|
||||
while j < visible.len() && visible[j].is_3d() {
|
||||
|
||||
@@ -290,7 +290,8 @@ mod tests {
|
||||
let mut s = super::session();
|
||||
s.exporter = Some(Arc::new(super::FileExporter { sink: Some(Arc::new(move |p: &str, d: Vec<u8>| g.lock().unwrap().push((p.to_string(), d)))) }));
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let dir = "/no-such-dir-effectcraft-sink-test";
|
||||
// Rooted without a drive letter is not absolute on Windows, where the engine joins it to the home drive.
|
||||
let dir = if cfg!(windows) { "C:/no-such-dir-effectcraft-sink-test" } else { "/no-such-dir-effectcraft-sink-test" };
|
||||
s.execute(
|
||||
"renderQueue.add",
|
||||
json!({"format": "gif", "output": format!("{dir}/a.gif"), "resolution": 0.0625, "timeSpan": "custom", "start": 0.0, "end": 0.2}),
|
||||
|
||||
@@ -38,14 +38,14 @@ fn replacing_a_missing_file_relinks_the_others_that_moved_with_it() {
|
||||
let before = footage_paths(&s);
|
||||
assert_eq!(before.len(), 2, "{before:?}");
|
||||
// The job folder moves; both files go missing.
|
||||
let new = root.join("Archive/Job");
|
||||
let new = root.join("Archive").join("Job");
|
||||
std::fs::create_dir_all(root.join("Archive")).unwrap();
|
||||
std::fs::rename(&old, &new).unwrap();
|
||||
let r = s.execute("footage.check", json!({"wait": true})).unwrap();
|
||||
assert_eq!(r["missing"], 2, "{r}");
|
||||
// Locating one finds the other.
|
||||
let a = before.iter().find(|(_, p, _)| p.ends_with("a.png")).unwrap().0;
|
||||
let r = s.execute("file.replaceFootage", json!({"item": a, "path": new.join("stills/a.png").to_string_lossy()})).unwrap();
|
||||
let r = s.execute("file.replaceFootage", json!({"item": a, "path": new.join("stills").join("a.png").to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["relinked"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let after = footage_paths(&s);
|
||||
assert!(after.iter().all(|(_, p, missing)| p.starts_with(&*new.to_string_lossy()) && !missing), "{after:?}");
|
||||
|
||||
@@ -260,23 +260,25 @@ fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() {
|
||||
// Import As: Composition — one layer per file layer, top first, together like the page.
|
||||
let path = tmp("layers.ai");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["Art", "Sky"]);
|
||||
assert_eq!((comp.width, comp.height), (120, 80));
|
||||
let art = comp.layers[0].id.0;
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
let d = mean_diff(&img, &direct);
|
||||
assert!(d < 1.0 / 255.0, "layered comp vs page {d}");
|
||||
// Each layer's footage shows only its layer.
|
||||
s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap();
|
||||
let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
assert_eq!(sky.get(5, 5), img.get(5, 5));
|
||||
let p = sky.get(60, 30);
|
||||
assert!(p[2] > p[0], "only sky under the art {p:?}");
|
||||
for import_as in ["composition", "compositionLayerSizes"] {
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap();
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["Art", "Sky"]);
|
||||
assert_eq!((comp.width, comp.height), (120, 80));
|
||||
let art = comp.layers[0].id.0;
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
let d = mean_diff(&img, &direct);
|
||||
assert!(d < 1.0 / 255.0, "layered comp vs page {d}");
|
||||
// Each layer's footage shows only its layer.
|
||||
s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap();
|
||||
let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
assert_eq!(sky.get(5, 5), img.get(5, 5));
|
||||
let p = sky.get(60, 30);
|
||||
assert!(p[2] > p[0], "only sky under the art {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -522,3 +524,75 @@ fn shapes_from_vector_layer_keeps_images_as_footage_layers() {
|
||||
assert!((0..3).all(|k| (a[k] - b[k]).abs() < 0.05), "({x}, {y}): {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn retain_pdf(width: u32) -> Vec<u8> {
|
||||
effectcraft_pdf::write::pdf(
|
||||
&[
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> >> /Contents 4 0 R >>".into(), None),
|
||||
(
|
||||
4,
|
||||
"<< >>".into(),
|
||||
Some(
|
||||
format!("/OC /L0 BDC q 20 15 {width} 20 re W n 1 0 0 rg 0 0 120 80 re f Q EMC /OC /L1 BDC 0 0 1 RG 4 w 70 40 m 100 40 l S EMC")
|
||||
.into_bytes(),
|
||||
),
|
||||
),
|
||||
(5, "<< /Type /OCG /Name (Box) >>".into(), None),
|
||||
(6, "<< /Type /OCG /Name (Line) >>".into(), None),
|
||||
],
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_composition_retains_layer_sizes_and_positions() {
|
||||
use effectcraft_project::{ItemKind, LayerSource, Project};
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
for name in ["retain.ai", "retain.pdf"] {
|
||||
let path = tmp(name);
|
||||
let bytes = retain_pdf(30);
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
assert_eq!((comp.width, comp.height), (120, 80));
|
||||
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Line", "Box"]);
|
||||
let mut ids = vec![];
|
||||
for (name, size, anchor, position) in [("Box", (30, 20), [15.0, 10.0], [35.0, 55.0]), ("Line", (30, 4), [15.0, 2.0], [85.0, 40.0])] {
|
||||
let l = comp.layer_by_name(name).unwrap();
|
||||
let LayerSource::Footage { item } = l.source else { panic!("footage") };
|
||||
let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") };
|
||||
assert_eq!((f.width, f.height), size, "{name} uses its artwork bounds");
|
||||
assert!(f.layer.as_ref().unwrap().layer_size);
|
||||
assert_eq!(l.props.prop("transform/anchor").unwrap().value.as_vec2(), anchor);
|
||||
assert_eq!(l.props.prop("transform/position").unwrap().value.as_vec2(), position);
|
||||
let frame = s.footage.frame(item, f, Tick::ZERO).unwrap();
|
||||
assert_eq!((frame.width, frame.height), size);
|
||||
let sharp = s.footage.vector_frame(item, f, 2.0).unwrap();
|
||||
assert_eq!((sharp.width, sharp.height), (size.0 * 2, size.1 * 2));
|
||||
assert!(sharp.get(1, 1)[3] > 0.99);
|
||||
ids.push(item);
|
||||
}
|
||||
let direct = effectcraft_svg::rasterize(&effectcraft_pdf::parse(&bytes).unwrap(), 120, 80, 1.0);
|
||||
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001);
|
||||
let saved = s.project.to_json();
|
||||
s.project = std::sync::Arc::new(Project::from_json(&saved).unwrap());
|
||||
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "reopened project");
|
||||
let box_id = s.active_comp().unwrap().layer_by_name("Box").unwrap().id.0;
|
||||
s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [box_id]})).unwrap();
|
||||
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "shape conversion preserves placement");
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
std::fs::write(&path, retain_pdf(40)).unwrap();
|
||||
s.execute_checked("file.reloadFootage", json!({"items": ids})).unwrap();
|
||||
let ItemKind::Footage(f) = &s.project.item(ids[0]).unwrap().kind else { panic!("footage") };
|
||||
assert_eq!((f.width, f.height), (40, 20), "reload refreshes the layer's own dimensions");
|
||||
let frame = s.footage.frame(ids[0], f, Tick::ZERO).unwrap();
|
||||
assert_eq!((frame.width, frame.height), (40, 20));
|
||||
assert!(frame.get(39, 10)[0] > 0.99, "reload invalidates cached pixels");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! A WebM rendered by EffectCraft and imported back reports its real frame count (#1429). Matroska
|
||||
//! stores the duration in whole milliseconds, so at 30 fps a 1-frame file lasts 33 ms, just short
|
||||
//! of one 33.33 ms frame.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-host-webm-meta-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_webm_at_30_fps_reports_every_frame() {
|
||||
for n in [1u32, 4] {
|
||||
let mut s = effectcraft_host::session();
|
||||
let out = tmp(&format!("frames-{n}.webm"));
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "BG", "color": "#142850"})).unwrap();
|
||||
let span = json!({"comp": "Main", "format": "webm", "output": out, "timeSpan": "custom", "start": 0, "duration": n as f64 / 30.0});
|
||||
s.execute("renderQueue.add", span).unwrap();
|
||||
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
|
||||
let r = s.execute("file.import", json!({"paths": [out.clone()]})).unwrap();
|
||||
let m = s.execute("item.metadata", json!({"item": r["items"][0]})).unwrap();
|
||||
let _ = std::fs::remove_file(&out);
|
||||
assert_eq!(m["item"]["frames"], json!(n), "{n} rendered frames: {m}");
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ filmcraft-isobmff = { workspace = true }
|
||||
filmcraft-frame = { workspace = true }
|
||||
filmcraft-color = { workspace = true }
|
||||
filmcraft-time = { workspace = true }
|
||||
# AIFF import (#1428): enables the aiff reader of filmcraft-codecs' symphonia.
|
||||
symphonia = { workspace = true }
|
||||
exr = { workspace = true }
|
||||
gif = { workspace = true }
|
||||
image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] }
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn vector_doc(
|
||||
Some(
|
||||
effectcraft_pdf::parse_page(bytes, page as usize)
|
||||
.map(|d| match layer {
|
||||
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
|
||||
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
|
||||
None => d,
|
||||
})
|
||||
.map_err(|e| format!("{path}: {e}")),
|
||||
|
||||
@@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result<Model> {
|
||||
let Some(key) = it.next() else { continue };
|
||||
let bad = || perr(format!("STL line {}: expected three numbers", ln + 1));
|
||||
match key.to_ascii_lowercase().as_str() {
|
||||
"solid" => {
|
||||
if name.is_none() {
|
||||
name = Some(line.get(5..).unwrap_or("").trim().to_string());
|
||||
}
|
||||
"solid" if name.is_none() => {
|
||||
name = Some(line.get(5..).unwrap_or("").trim().to_string());
|
||||
}
|
||||
"facet" => {
|
||||
// `facet normal nx ny nz`
|
||||
|
||||
@@ -10,6 +10,7 @@ rust-version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-pdf.workspace = true
|
||||
effectcraft-svg = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
effectcraft-text = { workspace = true }
|
||||
@@ -23,3 +24,6 @@ zune-jpeg = "0.5"
|
||||
# Encodes JPEG fixtures for the DCT image test.
|
||||
image = { workspace = true }
|
||||
effectcraft-raster = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
+45
-10
@@ -4,7 +4,9 @@ Vector footage from PDF files, PDF-compatible Adobe Illustrator files (`.ai`, wh
|
||||
documents) and Encapsulated PostScript (`.eps`). Files parse into the same render tree as SVG
|
||||
(`effectcraft_svg::Doc`), so they rasterise at any scale (Continuously Rasterize), convert to shape
|
||||
layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as compositions with one
|
||||
layer per file layer (File ▸ Import ▸ Composition).
|
||||
layer per file layer (File ▸ Import ▸ Composition). Composition - Retain Layer Sizes uses each
|
||||
layer's artwork bounds and keeps its placement on the page. Images keep their rectangular bounds;
|
||||
clipping paths constrain the bounds, while soft masks do not change them.
|
||||
|
||||
Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file
|
||||
structure, filters, content streams, graphics state, colour spaces, functions, shadings, text,
|
||||
@@ -14,20 +16,23 @@ Charstring Format) for CFF font programs; **Adobe Type 1 Font Format** (version
|
||||
font programs; the **PostScript Language Reference, third edition** and the **Encapsulated
|
||||
PostScript File Format Specification 3.0** for EPS; **ITU-T T.4** (07/2003) and **ITU-T T.6**
|
||||
(11/1988) for CCITT fax images. TrueType font programs are read through skrifa and JPEG (DCT)
|
||||
images through zune-jpeg (both permissively licensed, pure Rust). No third-party
|
||||
PDF/PostScript/font/codec code was read; test files (including the CFF and Type 1 fonts and the
|
||||
CCITT data) are generated in code ([`write`](src/write.rs) and the tests). One CCITT fixture was
|
||||
images through zune-jpeg (both permissively licensed, pure Rust). PDF document/object loading uses the shared `craft-pdf` parser described below. The retained
|
||||
drawing, PostScript, font and filter implementations were developed from the public
|
||||
specifications without copying third-party implementations. Test files (including the CFF
|
||||
and Type 1 fonts and the CCITT data) are generated in code ([`write`](src/write.rs) and the tests). One CCITT fixture was
|
||||
produced once by libtiff's `tiffcp` (an external encoder used only as a fixture generator, like
|
||||
ffmpeg elsewhere) to check the code tables against an independent implementation.
|
||||
|
||||
## What is read
|
||||
|
||||
- **Structure:** indirect objects found by scanning (damaged or incremental cross-reference
|
||||
sections do not matter), object streams, the page tree with inherited resources, MediaBox /
|
||||
CropBox and `/Rotate`. Filters: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85,
|
||||
RunLength, CCITTFax (Group 3 one- and two-dimensional, Group 4; `K`, `Columns`, `Rows`,
|
||||
`EndOfLine`, `EncodedByteAlign`, `EndOfBlock`, `BlackIs1`; damaged data keeps the rows
|
||||
decoded before the damage). Encrypted files are refused.
|
||||
- **Structure:** `craft-pdf` resolves cross-reference tables/streams and indirect objects into
|
||||
the app's existing object model. References retain object and generation numbers. A bounded
|
||||
compatibility repair recovers referenced objects from object streams when damaged xrefs omit
|
||||
them; every recovery or unresolved reference is reported in `Doc::skipped` (up to 64 messages).
|
||||
The existing page tree preserves inherited resources, MediaBox / CropBox and `/Rotate`.
|
||||
Content/image filters remain app-owned: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85,
|
||||
RunLength and CCITTFax. Encrypted files remain refused; `page_count` returns zero when the
|
||||
reader refuses a document.
|
||||
- **Content:** `q`/`Q`, `cm`, line width / cap / join / miter / dash, ExtGState (`CA`, `ca`, `LW`,
|
||||
`LC`, `LJ`, `ML`, `D`), all path construction and painting operators (non-zero and even-odd),
|
||||
clipping (`W`, `W*`) as clipping groups, colour in DeviceGray / RGB / CMYK, ICCBased (by
|
||||
@@ -129,3 +134,33 @@ ffmpeg elsewhere) to check the code tables against an independent implementation
|
||||
layer; the shapes drawn between images go to shape layers of their own (`<name> Outlines
|
||||
<n>`), so the layer stack keeps the document's paint order. Soft masks, and the clips and
|
||||
masks of images, are not converted (the command's `skipped` list says so).
|
||||
|
||||
## Shared parser setup and bounds
|
||||
|
||||
The workspace pins `craft-pdf` to `craft-libs` Git revision
|
||||
`ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d`, with `default-features = false` and only the explicit `read`
|
||||
feature. This excludes the backend's bundled fonts and Adobe CMaps and preserves EffectCraft's
|
||||
font and asset policies. The shared parser does not change the supported drawing operators,
|
||||
image codecs, continuously rasterized SVG tree, editable shapes, or EPS interpreter.
|
||||
This revision removes an unchecked lazy-array read and preserves later values and pages after
|
||||
sign-only numeric entries; the app exercises those inputs through its object adapter, public
|
||||
page parser and rasterizer on native and WASM targets.
|
||||
|
||||
The root Cargo patch pins Hayro-syntax to the same `storytold/craft-libs` revision. `cargo xtask pdf-deps` checks this graph and refuses
|
||||
an enabled Hayro interpreter, rasterizer or CMap dependency. The shared revision must be
|
||||
published before a fresh app checkout can resolve it; no sibling checkout is required.
|
||||
|
||||
Input is limited to 512 MiB. The object adapter admits at most 100,000 document/referenced
|
||||
objects, 1,000,000 converted values and 64 inline nesting levels, with a 512 MiB retained
|
||||
object-data budget. References are queued once rather than recursively expanded. Native
|
||||
unwinding panics during shared loading or lazy object conversion become an error and discard
|
||||
partial state. These limits are not an overall process-memory or execution-time guarantee;
|
||||
WASM aborts and process-wide allocation failures cannot be caught. Missing or repaired objects
|
||||
are reported rather than silently presented as a complete import.
|
||||
|
||||
The shared parser receives EffectCraft's existing ordinary object/xref stream limit at open:
|
||||
256 MiB on 64-bit targets and 64 MiB on 32-bit targets, including WASM. Immutable document
|
||||
limits apply during normal parsing, initial xrefs, fallback and repair. Malformed object-stream
|
||||
compatibility recovery also obeys that limit and the adapter's remaining retained-data budget.
|
||||
Content/image filter decoding still uses EffectCraft's existing bounded filter chain; the
|
||||
retained-data cap is not a bound on every parser cache or temporary allocation.
|
||||
|
||||
+38
-4
@@ -538,6 +538,10 @@ impl Cff {
|
||||
|
||||
/// A glyph's outline in glyph space (`font_matrix` maps it to text space) and its advance.
|
||||
pub fn outline(&self, gid: u16) -> Option<(BezPath, f64)> {
|
||||
self.outline_depth(gid, 0)
|
||||
}
|
||||
|
||||
fn outline_depth(&self, gid: u16, depth: usize) -> Option<(BezPath, f64)> {
|
||||
let r = self.charstrings.get(gid as usize)?.clone();
|
||||
let fd = self.fdselect.get(gid as usize).copied().unwrap_or(0) as usize;
|
||||
let fd = fd.min(self.lsubrs.len() - 1);
|
||||
@@ -552,13 +556,17 @@ impl Cff {
|
||||
_ => self.widths[fd].0,
|
||||
};
|
||||
let mut path = st.path;
|
||||
// endchar seac: base and accent glyphs by standard code.
|
||||
if let Some((adx, ady, b, a)) = st.seac {
|
||||
// endchar seac: base and accent glyphs by standard code. The nesting is bounded like the
|
||||
// Type 1 interpreter's: a font whose accented glyphs seac into each other (or into
|
||||
// themselves) must not recurse until the stack overflows.
|
||||
if let Some((adx, ady, b, a)) = st.seac
|
||||
&& depth < 2
|
||||
{
|
||||
let find = |code: u8| crate::encoding::Base::Standard.name(code).and_then(|n| self.gid_by_name(&n));
|
||||
if let Some((bp, _)) = find(b).and_then(|g| self.outline(g)) {
|
||||
if let Some((bp, _)) = find(b).and_then(|g| self.outline_depth(g, depth + 1)) {
|
||||
path.extend(bp);
|
||||
}
|
||||
if let Some((ap, _)) = find(a).and_then(|g| self.outline(g)) {
|
||||
if let Some((ap, _)) = find(a).and_then(|g| self.outline_depth(g, depth + 1)) {
|
||||
path.extend(kurbo::Affine::translate((adx, ady)) * ap);
|
||||
}
|
||||
}
|
||||
@@ -1054,6 +1062,32 @@ mod tests {
|
||||
assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0));
|
||||
}
|
||||
|
||||
/// An `endchar` seac whose base and accent name its own glyph used to recurse until the
|
||||
/// stack overflowed and aborted the app; the depth limit cuts it off like the Type 1
|
||||
/// interpreter's.
|
||||
#[test]
|
||||
fn seac_cycles_are_cut_off_not_a_stack_overflow() {
|
||||
let enc = |v: i32| -> Vec<u8> {
|
||||
if (-107..=107).contains(&v) {
|
||||
vec![(v + 139) as u8]
|
||||
} else {
|
||||
let mut o = vec![28];
|
||||
o.extend_from_slice(&(v as i16).to_be_bytes());
|
||||
o
|
||||
}
|
||||
};
|
||||
// `w adx ady bchar achar endchar`: both components are 'A' (standard code 65), this glyph.
|
||||
let mut cs = vec![];
|
||||
for v in [500, 0, 0, 65, 65] {
|
||||
cs.extend(enc(v));
|
||||
}
|
||||
cs.push(14);
|
||||
let font = write_test_cff(&[(".notdef", vec![14]), ("A", cs)]);
|
||||
let cff = Cff::parse(&font).unwrap();
|
||||
let (p, _) = cff.outline(cff.gid_by_name("A").unwrap()).unwrap();
|
||||
assert!(p.elements().is_empty());
|
||||
}
|
||||
|
||||
/// A CFF whose top DICT points its Private DICT at offsets near `usize::MAX`: `size` and `off`
|
||||
/// come from the file, and reading the Private DICT must not overflow `off + size`.
|
||||
#[test]
|
||||
|
||||
@@ -208,10 +208,10 @@ pub fn name_to_unicode(name: &str) -> Option<char> {
|
||||
if let Some((_, u)) = NAMES.iter().find(|(n, _)| *n == base) {
|
||||
return char::from_u32(*u);
|
||||
}
|
||||
if let Some(h) = base.strip_prefix("uni")
|
||||
&& h.len() >= 4
|
||||
{
|
||||
return u32::from_str_radix(&h[..4], 16).ok().and_then(char::from_u32);
|
||||
// `get(..4)` also insists on a character boundary: a name like `uni\u{20AC}\u{20AC}` used to
|
||||
// panic slicing inside a multi-byte character.
|
||||
if let Some(x) = base.strip_prefix("uni").and_then(|h| h.get(..4)) {
|
||||
return u32::from_str_radix(x, 16).ok().and_then(char::from_u32);
|
||||
}
|
||||
if let Some(h) = base.strip_prefix('u')
|
||||
&& (4..=6).contains(&h.len())
|
||||
@@ -386,4 +386,12 @@ mod tests {
|
||||
assert_eq!(unicode_to_name('Ω'), "Omega");
|
||||
assert_eq!(unicode_to_name('中'), "uni4E2D");
|
||||
}
|
||||
|
||||
/// A glyph name like `uni\u{20AC}\u{20AC}` (multi-byte UTF-8 after `uni`) must not slice
|
||||
/// inside a character.
|
||||
#[test]
|
||||
fn multibyte_glyph_names_do_not_panic() {
|
||||
assert_eq!(name_to_unicode("uni\u{20AC}\u{20AC}"), None);
|
||||
assert_eq!(name_to_unicode("uni\u{20AC}"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,10 @@ pub(crate) fn inline_len(file: &File, d: &Dict, res: Option<&Dict>) -> Option<us
|
||||
let mask = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true)));
|
||||
let bpc = if mask { 1 } else { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize };
|
||||
let n = if mask { 1 } else { file.get(d, "ColorSpace").map(|c| color_space(file, c, res).components()).unwrap_or(1) };
|
||||
Some((w * n * bpc).div_ceil(8) * h)
|
||||
// `Width`/`Height` come straight from the file and can be enormous: fall back to the
|
||||
// `EI` scan (the caller's `None` branch) instead of overflowing the arithmetic.
|
||||
let row = w.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8);
|
||||
row.checked_mul(h)
|
||||
}
|
||||
|
||||
/// Decode an image's samples (all filters, including DCT). Returns (bytes, components from
|
||||
|
||||
+30
-6
@@ -19,6 +19,7 @@ mod image;
|
||||
mod object;
|
||||
mod page;
|
||||
mod ps;
|
||||
mod read;
|
||||
mod shading;
|
||||
mod type1;
|
||||
pub mod write;
|
||||
@@ -33,6 +34,7 @@ pub enum Error {
|
||||
NoPages,
|
||||
PageOutOfRange(usize),
|
||||
NoBoundingBox,
|
||||
Read(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
@@ -41,8 +43,9 @@ impl std::fmt::Display for Error {
|
||||
Error::NotVector => write!(f, "not a PDF, Illustrator or EPS file"),
|
||||
Error::Encrypted => write!(f, "encrypted PDF files are not supported"),
|
||||
Error::NoPages => write!(f, "the PDF has no pages"),
|
||||
Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n + 1),
|
||||
Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n.saturating_add(1)),
|
||||
Error::NoBoundingBox => write!(f, "the EPS file has no %%BoundingBox"),
|
||||
Error::Read(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,7 +102,7 @@ pub fn parse_page(bytes: &[u8], index: usize) -> Result<Doc, Error> {
|
||||
/// Number of pages (1 for EPS).
|
||||
pub fn page_count(bytes: &[u8]) -> usize {
|
||||
match sniff(bytes) {
|
||||
Some(Format::Pdf) => page::pages(&object::File::parse(bytes)).len(),
|
||||
Some(Format::Pdf) => object::File::parse(bytes).map(|file| page::pages(&file).len()).unwrap_or(0),
|
||||
Some(Format::Eps) => 1,
|
||||
None => 0,
|
||||
}
|
||||
@@ -140,10 +143,7 @@ fn finish(nodes: Vec<Node>, flags: Vec<bool>, skipped: Vec<String>, width: f64,
|
||||
}
|
||||
|
||||
fn parse_pdf(bytes: &[u8], index: usize) -> Result<Doc, Error> {
|
||||
let file = object::File::parse(bytes);
|
||||
if file.encrypted() {
|
||||
return Err(Error::Encrypted);
|
||||
}
|
||||
let file = object::File::parse(bytes)?;
|
||||
let pages = page::pages(&file);
|
||||
if pages.is_empty() {
|
||||
return Err(Error::NoPages);
|
||||
@@ -160,6 +160,7 @@ fn parse_pdf(bytes: &[u8], index: usize) -> Result<Doc, Error> {
|
||||
};
|
||||
let content = page::page_content(&file, pg);
|
||||
let mut it = page::Interp::new(&file, pg.bbox);
|
||||
it.b.skipped.extend(file.repairs.iter().cloned());
|
||||
it.run(&content, &pg.resources, Affine::IDENTITY, 0);
|
||||
let (nodes, flags, skipped) = it.b.finish();
|
||||
Ok(finish(nodes, flags, skipped, dw.max(1.0), dh.max(1.0), to_doc))
|
||||
@@ -194,6 +195,27 @@ pub fn layer_doc(doc: &Doc, index: usize) -> Doc {
|
||||
d
|
||||
}
|
||||
|
||||
/// One layer, optionally cropped to its artwork, and its original document-space top-left.
|
||||
pub fn layer_doc_with_sizes(doc: &Doc, index: usize, retain: bool) -> (Doc, [f64; 2]) {
|
||||
let mut d = layer_doc(doc, index);
|
||||
if !retain {
|
||||
return (d, [0.0, 0.0]);
|
||||
}
|
||||
let page = kurbo::Rect::new(0.0, 0.0, doc.width, doc.height);
|
||||
let bounds = d.artwork_bounds().map(|b| b.intersect(page)).filter(|b| b.width() > 0.0 && b.height() > 0.0);
|
||||
let (x, y, w, h) = match bounds {
|
||||
Some(b) => {
|
||||
let (x, y) = (b.x0.floor(), b.y0.floor());
|
||||
(x, y, b.x1.ceil() - x, b.y1.ceil() - y)
|
||||
}
|
||||
None => (0.0, 0.0, 1.0, 1.0),
|
||||
};
|
||||
d.width = w;
|
||||
d.height = h;
|
||||
d.root.transform = Affine::translate((-x, -y)) * d.root.transform;
|
||||
(d, [x, y])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
@@ -201,4 +223,6 @@ mod tests_groups;
|
||||
#[cfg(test)]
|
||||
mod tests_import;
|
||||
#[cfg(test)]
|
||||
mod tests_reader;
|
||||
#[cfg(test)]
|
||||
mod tests_shading;
|
||||
|
||||
+14
-159
@@ -1,9 +1,6 @@
|
||||
//! PDF objects (ISO 32000-1 §7.2–7.5): the lexer, the object model, stream filters and the
|
||||
//! indirect-object table.
|
||||
//!
|
||||
//! The table is built by scanning the file for `n g obj` headers (later definitions win, as
|
||||
//! incremental updates do) and by unpacking object streams, so damaged or rewritten
|
||||
//! cross-reference sections do not matter.
|
||||
//! Local object model, content-stream lexer and stream filters. Document objects are read
|
||||
//! through the shared parser in `read`; the local lexer remains for content operators and
|
||||
//! PostScript-related data.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -191,7 +188,7 @@ impl<'a> Lexer<'a> {
|
||||
}
|
||||
|
||||
/// `n g R` after a number inside arrays and dictionaries.
|
||||
fn maybe_ref(&mut self, v: Obj) -> Obj {
|
||||
pub(super) fn maybe_ref(&mut self, v: Obj) -> Obj {
|
||||
let Obj::Num(n) = v else { return v };
|
||||
if n.fract() != 0.0 || n < 0.0 {
|
||||
return v;
|
||||
@@ -312,11 +309,10 @@ pub fn parse_num(w: &[u8]) -> Option<f64> {
|
||||
/// growing buffer can reserve about twice the limit.
|
||||
const MAX_INFLATED: usize = if cfg!(target_pointer_width = "32") { 256 << 20 } else { 1 << 30 };
|
||||
|
||||
/// The tighter bound for object streams, which [`File::parse`] decodes eagerly (all of them,
|
||||
/// before any page is chosen). They hold only other objects' dictionaries, arrays, numbers and
|
||||
/// strings, so real ones are far smaller; PdfCraft uses 256 MiB for the object and
|
||||
/// cross-reference streams it decodes while loading. A quarter on 32-bit targets, as above.
|
||||
const MAX_OBJECT_STREAM: usize = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 };
|
||||
/// The bound for ordinary object/xref streams and malformed object-stream recovery.
|
||||
/// The shared parser receives this immutable limit when the document is opened;
|
||||
/// 32-bit targets retain the app's quarter-sized limit.
|
||||
pub(crate) const MAX_OBJECT_STREAM: usize = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 };
|
||||
|
||||
/// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT,
|
||||
/// JBIG2) give `None`.
|
||||
@@ -624,151 +620,14 @@ fn predict(data: &[u8], p: &Dict, max: usize) -> Option<Vec<u8>> {
|
||||
|
||||
/// Every indirect object of a PDF file, plus the trailer dictionaries found.
|
||||
pub struct File {
|
||||
pub objects: HashMap<u32, Obj>,
|
||||
pub objects: HashMap<(u32, u16), Obj>,
|
||||
pub trailers: Vec<Dict>,
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
if from >= hay.len() || needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
|
||||
pub repairs: Vec<String>,
|
||||
}
|
||||
|
||||
impl File {
|
||||
pub fn parse(data: &[u8]) -> File {
|
||||
let mut f = File { objects: HashMap::new(), trailers: vec![] };
|
||||
let mut i = 0;
|
||||
while let Some(p) = find(data, b"obj", i) {
|
||||
i = p + 3;
|
||||
if p >= 3 && &data[p - 3..p] == b"end" {
|
||||
continue;
|
||||
}
|
||||
if data.get(p + 3).is_some_and(|c| !is_white(*c) && !is_delim(*c)) {
|
||||
continue;
|
||||
}
|
||||
// Walk back over "num gen ".
|
||||
let mut q = p;
|
||||
let back_ws = |q: &mut usize| {
|
||||
let s = *q;
|
||||
while *q > 0 && is_white(data[*q - 1]) {
|
||||
*q -= 1;
|
||||
}
|
||||
*q < s
|
||||
};
|
||||
let back_digits = |q: &mut usize| {
|
||||
let e = *q;
|
||||
while *q > 0 && data[*q - 1].is_ascii_digit() {
|
||||
*q -= 1;
|
||||
}
|
||||
(*q < e).then(|| std::str::from_utf8(&data[*q..e]).ok()?.parse::<u32>().ok()).flatten()
|
||||
};
|
||||
if !back_ws(&mut q) {
|
||||
continue;
|
||||
}
|
||||
let Some(_gen) = back_digits(&mut q) else { continue };
|
||||
if !back_ws(&mut q) {
|
||||
continue;
|
||||
}
|
||||
let Some(num) = back_digits(&mut q) else { continue };
|
||||
if q > 0 && !is_white(data[q - 1]) && !is_delim(data[q - 1]) {
|
||||
continue;
|
||||
}
|
||||
let mut lx = Lexer::new(data, p + 3);
|
||||
let Some(obj) = lx.next() else { continue };
|
||||
let obj = lx.maybe_ref(obj);
|
||||
let obj = match obj {
|
||||
Obj::Dict(d) => {
|
||||
let save = lx.pos;
|
||||
lx.skip_ws();
|
||||
if data.get(lx.pos..).is_some_and(|d| d.starts_with(b"stream")) {
|
||||
let mut s = lx.pos + 6;
|
||||
if data.get(s) == Some(&b'\r') {
|
||||
s += 1;
|
||||
}
|
||||
if data.get(s) == Some(&b'\n') {
|
||||
s += 1;
|
||||
}
|
||||
let declared = d.get("Length").and_then(Obj::num).map(|l| l as usize);
|
||||
let s = s.min(data.len());
|
||||
let end = match declared.filter(|l| {
|
||||
s.checked_add(*l).is_some_and(|e| e <= data.len()) && {
|
||||
let mut k = s + l;
|
||||
while k < data.len() && is_white(data[k]) {
|
||||
k += 1;
|
||||
}
|
||||
data[k..].starts_with(b"endstream")
|
||||
}
|
||||
}) {
|
||||
Some(l) => s + l,
|
||||
None => {
|
||||
let mut e = find(data, b"endstream", s).unwrap_or(data.len());
|
||||
if e > s && data[e - 1] == b'\n' {
|
||||
e -= 1;
|
||||
}
|
||||
if e > s && data[e - 1] == b'\r' {
|
||||
e -= 1;
|
||||
}
|
||||
e
|
||||
}
|
||||
};
|
||||
let end = end.max(s);
|
||||
i = end;
|
||||
Obj::Stream(d, data[s..end].to_vec())
|
||||
} else {
|
||||
lx.pos = save;
|
||||
Obj::Dict(d)
|
||||
}
|
||||
}
|
||||
o => o,
|
||||
};
|
||||
if let Obj::Stream(d, _) = &obj
|
||||
&& d.get("Type").and_then(Obj::name) == Some("XRef")
|
||||
{
|
||||
f.trailers.push(d.clone());
|
||||
}
|
||||
f.objects.insert(num, obj);
|
||||
}
|
||||
// Trailer dictionaries of classic cross-reference sections.
|
||||
let mut i = 0;
|
||||
while let Some(p) = find(data, b"trailer", i) {
|
||||
i = p + 7;
|
||||
let mut lx = Lexer::new(data, i);
|
||||
if let Some(Obj::Dict(d)) = lx.next() {
|
||||
f.trailers.push(d);
|
||||
}
|
||||
}
|
||||
// Object streams.
|
||||
let streams: Vec<(Dict, Vec<u8>)> = f
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|o| match o {
|
||||
Obj::Stream(d, raw) if d.get("Type").and_then(Obj::name) == Some("ObjStm") => Some((d.clone(), raw.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (d, raw) in streams {
|
||||
let Some(dec) = decode_stream_within(&f, &d, &raw, MAX_OBJECT_STREAM) else { continue };
|
||||
let n = d.get("N").and_then(Obj::num).unwrap_or(0.0) as usize;
|
||||
let first = d.get("First").and_then(Obj::num).unwrap_or(0.0) as usize;
|
||||
let mut lx = Lexer::new(&dec, 0);
|
||||
let mut heads = vec![];
|
||||
for _ in 0..n.min(100_000) {
|
||||
let (Some(Obj::Num(num)), Some(Obj::Num(off))) = (lx.next(), lx.next()) else { break };
|
||||
heads.push((num as u32, off as usize));
|
||||
}
|
||||
for (num, off) in heads {
|
||||
if f.objects.contains_key(&num) || first + off >= dec.len() {
|
||||
continue;
|
||||
}
|
||||
let mut lx = Lexer::new(&dec, first + off);
|
||||
if let Some(o) = lx.next() {
|
||||
let o = lx.maybe_ref(o);
|
||||
f.objects.insert(num, o);
|
||||
}
|
||||
}
|
||||
}
|
||||
f
|
||||
pub fn parse(data: &[u8]) -> Result<File, crate::Error> {
|
||||
crate::read::open(data)
|
||||
}
|
||||
|
||||
/// Follow references (bounded).
|
||||
@@ -776,7 +635,7 @@ impl File {
|
||||
let mut o = o;
|
||||
for _ in 0..32 {
|
||||
match o {
|
||||
Obj::Ref(n, _) => match self.objects.get(n) {
|
||||
Obj::Ref(n, g) => match self.objects.get(&(*n, *g)) {
|
||||
Some(x) => o = x,
|
||||
None => return &Obj::Null,
|
||||
},
|
||||
@@ -810,13 +669,9 @@ impl File {
|
||||
return Some(d);
|
||||
}
|
||||
}
|
||||
let mut cats: Vec<(&u32, &Obj)> =
|
||||
let mut cats: Vec<(&(u32, u16), &Obj)> =
|
||||
self.objects.iter().filter(|(_, o)| matches!(o, Obj::Dict(d) if d.get("Type").and_then(Obj::name) == Some("Catalog"))).collect();
|
||||
cats.sort_by_key(|(n, _)| **n);
|
||||
cats.last().and_then(|(_, o)| o.dict())
|
||||
}
|
||||
|
||||
pub fn encrypted(&self) -> bool {
|
||||
self.trailers.iter().any(|t| t.contains_key("Encrypt"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,7 +515,7 @@ impl<'a> Interp<'a> {
|
||||
&& (p + 2 == data.len() || crate::object::is_white(data[p + 2]))
|
||||
};
|
||||
let end = match crate::image::inline_len(self.file, &d, Some(res)) {
|
||||
Some(len) => (start + len).min(data.len()),
|
||||
Some(len) => start.saturating_add(len).min(data.len()),
|
||||
None => {
|
||||
let mut p = start;
|
||||
while p + 1 < data.len() && !is_ei(p) {
|
||||
|
||||
+4
-14
@@ -700,20 +700,10 @@ impl Interp {
|
||||
a / b
|
||||
}
|
||||
}
|
||||
"idiv" => {
|
||||
if b as i64 == 0 {
|
||||
0.0
|
||||
} else {
|
||||
((a as i64) / (b as i64)) as f64
|
||||
}
|
||||
}
|
||||
"mod" => {
|
||||
if b as i64 == 0 {
|
||||
0.0
|
||||
} else {
|
||||
((a as i64) % (b as i64)) as f64
|
||||
}
|
||||
}
|
||||
// `checked_*` give 0 both for a zero divisor and for the one overflowing
|
||||
// case, `i64::MIN / -1` (`/` and `%` panic on it in every build).
|
||||
"idiv" => (a as i64).checked_div(b as i64).unwrap_or(0) as f64,
|
||||
"mod" => (a as i64).checked_rem(b as i64).unwrap_or(0) as f64,
|
||||
"atan" => a.atan2(b).to_degrees().rem_euclid(360.0),
|
||||
"exp" => a.powf(b),
|
||||
"max" => a.max(b),
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Adapter from the shared PDF syntax API to the existing drawing interpreter's object model.
|
||||
//! Only reachable objects are retained, references keep their generation, and native parser
|
||||
//! panics (including lazy object reads) discard the entire partially populated adapter.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::syntax::object::{Dict as PdfDict, MaybeRef, Name, ObjRef, Object, ObjectIdentifier, Stream};
|
||||
use craft_pdf::syntax::xref::XRef;
|
||||
|
||||
use crate::Error;
|
||||
use crate::object::{Dict, File, Lexer, Obj};
|
||||
|
||||
const MAX_OBJECTS: usize = 100_000;
|
||||
const MAX_VALUES: usize = 1_000_000;
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const MAX_BYTES: usize = 512 * 1024 * 1024;
|
||||
|
||||
fn limit(what: &str) -> Error {
|
||||
Error::Read(format!("PDF exceeds the {what} import limit"))
|
||||
}
|
||||
|
||||
fn allocation() -> Error {
|
||||
Error::Read("PDF object storage could not be allocated".into())
|
||||
}
|
||||
|
||||
fn open_options() -> craft_pdf::OpenOptions {
|
||||
craft_pdf::OpenOptions {
|
||||
decode_limits: craft_pdf::syntax::DecodeLimits::default().with_max_decoded_stream(crate::object::MAX_OBJECT_STREAM),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open(bytes: &[u8]) -> Result<File, Error> {
|
||||
craft_pdf::check_input_size_with(bytes.len(), &open_options()).map_err(|e| Error::Read(e.to_string()))?;
|
||||
catch_unwind(AssertUnwindSafe(|| open_inner(bytes))).map_err(|_| Error::Read("PDF parser failed internally".into()))?
|
||||
}
|
||||
|
||||
fn open_inner(bytes: &[u8]) -> Result<File, Error> {
|
||||
// Preserve the app's refusal of encrypted PDFs, including empty-user-password files.
|
||||
// Classic trailer dictionaries use the existing lexer; xref-stream dictionaries below
|
||||
// are read by the shared parser. This does not parse document objects a second time.
|
||||
let mut markers = 0;
|
||||
for (offset, word) in bytes.windows(7).enumerate() {
|
||||
if word == b"trailer" {
|
||||
markers += 1;
|
||||
if markers > MAX_OBJECTS {
|
||||
return Err(limit("trailer count"));
|
||||
}
|
||||
if let Some(Obj::Dict(dict)) = Lexer::new(bytes, offset + 7).next()
|
||||
&& dict.contains_key("Encrypt")
|
||||
{
|
||||
return Err(Error::Encrypted);
|
||||
}
|
||||
}
|
||||
}
|
||||
let pdf = craft_pdf::open_slice_with_options(bytes, None, &open_options()).map_err(|error| match error {
|
||||
craft_pdf::OpenError::Load(craft_pdf::syntax::LoadPdfError::Decryption(_)) => Error::Encrypted,
|
||||
craft_pdf::OpenError::Load(craft_pdf::syntax::LoadPdfError::Invalid) => Error::NoPages,
|
||||
other => Error::Read(other.to_string()),
|
||||
})?;
|
||||
let mut object_streams = Vec::new();
|
||||
for (index, object) in pdf.objects().into_iter().enumerate() {
|
||||
if index >= MAX_OBJECTS {
|
||||
return Err(limit("100000 objects"));
|
||||
}
|
||||
if let Object::Stream(stream) = object {
|
||||
let dict = stream.dict();
|
||||
if dict.get::<Name<'_>>(b"Type").is_some_and(|name| name.as_ref() == b"XRef") && dict.contains_key(b"Encrypt") {
|
||||
return Err(Error::Encrypted);
|
||||
}
|
||||
if dict.get::<Name<'_>>(b"Type").is_some_and(|name| name.as_ref() == b"ObjStm") {
|
||||
object_streams.try_reserve(1).map_err(|_| allocation())?;
|
||||
object_streams.push(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut reader = Reader {
|
||||
xref: pdf.xref(),
|
||||
pending: VecDeque::new(),
|
||||
seen: HashSet::new(),
|
||||
values_left: MAX_VALUES,
|
||||
bytes_left: MAX_BYTES,
|
||||
object_streams,
|
||||
recovery: None,
|
||||
};
|
||||
let root = pdf.xref().root_id();
|
||||
let root = reader.reference(ObjRef::new(root.obj_number, root.gen_number))?;
|
||||
let mut file = File { objects: HashMap::new(), trailers: vec![Dict::from([("Root".into(), root)])], repairs: Vec::new() };
|
||||
while let Some(id) = reader.pending.pop_front() {
|
||||
let Some(key) = key(id) else { continue };
|
||||
let object = match reader.xref.get::<Object<'_>>(id) {
|
||||
Some(value) => reader.object(value, 0)?,
|
||||
None => {
|
||||
let recovered = reader.recover(id)?;
|
||||
if file.repairs.len() < 64 {
|
||||
let action = if recovered.is_some() { "recovered PDF object from an object stream" } else { "unresolved PDF object" };
|
||||
file.repairs.push(format!("{action}: {} {}", id.obj_number, id.gen_number));
|
||||
}
|
||||
recovered.unwrap_or(Obj::Null)
|
||||
}
|
||||
};
|
||||
file.objects.try_reserve(1).map_err(|_| allocation())?;
|
||||
file.objects.insert(key, object);
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn key(id: ObjectIdentifier) -> Option<(u32, u16)> {
|
||||
Some((u32::try_from(id.obj_number).ok()?, u16::try_from(id.gen_number).ok()?))
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
xref: &'a XRef,
|
||||
pending: VecDeque<ObjectIdentifier>,
|
||||
seen: HashSet<ObjectIdentifier>,
|
||||
values_left: usize,
|
||||
bytes_left: usize,
|
||||
object_streams: Vec<Stream<'a>>,
|
||||
recovery: Option<HashMap<u32, Option<Recovered>>>,
|
||||
}
|
||||
|
||||
struct Recovered {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
fn value(&mut self) -> Result<(), Error> {
|
||||
self.values_left = self.values_left.checked_sub(1).ok_or_else(|| limit("1000000 values"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bytes(&mut self, bytes: &[u8]) -> Result<Vec<u8>, Error> {
|
||||
self.bytes_left = self.bytes_left.checked_sub(bytes.len()).ok_or_else(|| limit("512 MiB object data"))?;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(bytes.len()).map_err(|_| allocation())?;
|
||||
out.extend_from_slice(bytes);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The previous importer could recover compressed objects incorrectly marked free in
|
||||
/// an otherwise readable xref. Retain that narrow repair only for unresolved references,
|
||||
/// with a warning, bounded decoding and the existing content/object lexer.
|
||||
fn recover(&mut self, id: ObjectIdentifier) -> Result<Option<Obj>, Error> {
|
||||
if id.gen_number != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if self.recovery.is_none() {
|
||||
let mut recovered: HashMap<u32, Option<Recovered>> = HashMap::new();
|
||||
for stream in std::mem::take(&mut self.object_streams) {
|
||||
let Some(count) = stream.dict().get::<u32>(b"N") else { continue };
|
||||
let Some(first) = stream.dict().get::<u32>(b"First") else { continue };
|
||||
if count as usize > MAX_OBJECTS.saturating_sub(recovered.len()) {
|
||||
return Err(limit("100000 recovered objects"));
|
||||
}
|
||||
let decoded = match stream.decoded_within(self.bytes_left.min(crate::object::MAX_OBJECT_STREAM)) {
|
||||
Ok(data) => data,
|
||||
Err(_) => continue,
|
||||
};
|
||||
self.bytes_left = self.bytes_left.checked_sub(decoded.len()).ok_or_else(|| limit("512 MiB object data"))?;
|
||||
let decoded = Arc::new(decoded.into_owned());
|
||||
let mut lexer = Lexer::new(&decoded, 0);
|
||||
let mut offsets = Vec::new();
|
||||
for _ in 0..count {
|
||||
self.value()?;
|
||||
let (Some(Obj::Num(number)), Some(Obj::Num(offset))) = (lexer.next(), lexer.next()) else { break };
|
||||
if !number.is_finite() || number.fract() != 0.0 || !(0.0..=f64::from(u32::MAX)).contains(&number) {
|
||||
continue;
|
||||
}
|
||||
if !offset.is_finite() || offset.fract() != 0.0 || offset < 0.0 || offset > decoded.len() as f64 {
|
||||
continue;
|
||||
}
|
||||
let Some(start) = (first as usize).checked_add(offset as usize).filter(|start| *start < decoded.len()) else { continue };
|
||||
offsets.try_reserve(1).map_err(|_| allocation())?;
|
||||
offsets.push((start, number as u32));
|
||||
}
|
||||
offsets.sort_unstable();
|
||||
for (index, &(start, number)) in offsets.iter().enumerate() {
|
||||
let end = offsets.get(index + 1).map_or(decoded.len(), |(next, _)| *next);
|
||||
recovered.try_reserve(1).map_err(|_| allocation())?;
|
||||
recovered.entry(number).and_modify(|entry| *entry = None).or_insert_with(|| Some(Recovered { bytes: Arc::clone(&decoded), start, end }));
|
||||
}
|
||||
}
|
||||
self.recovery = Some(recovered);
|
||||
}
|
||||
let Some(number) = u32::try_from(id.obj_number).ok() else { return Ok(None) };
|
||||
let Some(location) = self.recovery.as_ref().and_then(|r| r.get(&number)) else { return Ok(None) };
|
||||
let Some(location) = location else { return Err(Error::Read(format!("PDF has ambiguous recovered object {number}"))) };
|
||||
let bytes = Arc::clone(&location.bytes);
|
||||
let Some(bytes) = bytes.get(location.start..location.end) else { return Ok(None) };
|
||||
let mut lexer = Lexer::new(bytes, 0);
|
||||
let Some(object) = lexer.next() else { return Ok(None) };
|
||||
let object = lexer.maybe_ref(object);
|
||||
self.register_recovered(&object, 0)?;
|
||||
Ok(Some(object))
|
||||
}
|
||||
|
||||
fn register_recovered(&mut self, object: &Obj, depth: usize) -> Result<(), Error> {
|
||||
self.value()?;
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(limit("64 nested objects"));
|
||||
}
|
||||
let bytes = match object {
|
||||
Obj::Name(text) | Obj::Op(text) => text.len(),
|
||||
Obj::Str(bytes) => bytes.len(),
|
||||
Obj::Array(values) => {
|
||||
for value in values {
|
||||
self.register_recovered(value, depth + 1)?;
|
||||
}
|
||||
0
|
||||
}
|
||||
Obj::Dict(dict) | Obj::Stream(dict, _) => {
|
||||
for (name, value) in dict {
|
||||
self.bytes_left = self.bytes_left.checked_sub(name.len()).ok_or_else(|| limit("512 MiB object data"))?;
|
||||
self.register_recovered(value, depth + 1)?;
|
||||
}
|
||||
0
|
||||
}
|
||||
Obj::Ref(number, generation) => {
|
||||
if let Ok(number) = i32::try_from(*number) {
|
||||
self.reference(ObjRef::new(number, i32::from(*generation)))?;
|
||||
}
|
||||
0
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
self.bytes_left = self.bytes_left.checked_sub(bytes).ok_or_else(|| limit("512 MiB object data"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn name(&mut self, bytes: &[u8]) -> Result<String, Error> {
|
||||
// Lossy UTF-8 expansion is at most three output bytes per source byte.
|
||||
let bound = bytes.len().checked_mul(3).ok_or_else(|| limit("name length"))?;
|
||||
self.bytes_left = self.bytes_left.checked_sub(bound).ok_or_else(|| limit("512 MiB object data"))?;
|
||||
Ok(String::from_utf8_lossy(bytes).into_owned())
|
||||
}
|
||||
|
||||
fn reference(&mut self, reference: ObjRef) -> Result<Obj, Error> {
|
||||
self.value()?;
|
||||
let id = ObjectIdentifier::new(reference.obj_number, reference.gen_number);
|
||||
let Some((number, generation)) = key(id) else { return Ok(Obj::Null) };
|
||||
if !self.seen.contains(&id) {
|
||||
if self.seen.len() >= MAX_OBJECTS {
|
||||
return Err(limit("100000 referenced objects"));
|
||||
}
|
||||
self.seen.try_reserve(1).map_err(|_| allocation())?;
|
||||
self.pending.try_reserve(1).map_err(|_| allocation())?;
|
||||
self.seen.insert(id);
|
||||
self.pending.push_back(id);
|
||||
}
|
||||
Ok(Obj::Ref(number, generation))
|
||||
}
|
||||
|
||||
fn maybe(&mut self, object: MaybeRef<Object<'_>>, depth: usize) -> Result<Obj, Error> {
|
||||
match object {
|
||||
MaybeRef::Ref(reference) => self.reference(reference),
|
||||
MaybeRef::NotRef(object) => self.object(object, depth),
|
||||
}
|
||||
}
|
||||
|
||||
fn dictionary(&mut self, dict: &PdfDict<'_>, depth: usize) -> Result<Dict, Error> {
|
||||
let mut out = Dict::new();
|
||||
for (name, object) in dict.entries() {
|
||||
let name = self.name(name.as_ref())?;
|
||||
let object = self.maybe(object, depth + 1)?;
|
||||
out.try_reserve(1).map_err(|_| allocation())?;
|
||||
out.insert(name, object);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn object(&mut self, object: Object<'_>, depth: usize) -> Result<Obj, Error> {
|
||||
self.value()?;
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(limit("64 nested objects"));
|
||||
}
|
||||
Ok(match object {
|
||||
Object::Null(_) => Obj::Null,
|
||||
Object::Boolean(value) => Obj::Bool(value),
|
||||
Object::Number(value) => Obj::Num(value.as_f64()),
|
||||
Object::String(value) => Obj::Str(self.bytes(value.as_bytes())?),
|
||||
Object::Name(value) => Obj::Name(self.name(value.as_ref())?),
|
||||
Object::Array(array) => {
|
||||
let mut out = Vec::new();
|
||||
for value in array.raw_iter() {
|
||||
let value = self.maybe(value, depth + 1)?;
|
||||
out.try_reserve(1).map_err(|_| allocation())?;
|
||||
out.push(value);
|
||||
}
|
||||
Obj::Array(out)
|
||||
}
|
||||
Object::Dict(dict) => Obj::Dict(self.dictionary(&dict, depth)?),
|
||||
Object::Stream(stream) => {
|
||||
let dict = self.dictionary(stream.dict(), depth)?;
|
||||
Obj::Stream(dict, self.bytes(&stream.raw_data())?)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod limit_tests {
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn shared_reader_preserves_tolerant_array_numbers_and_later_pages() {
|
||||
use crate::object::Obj;
|
||||
|
||||
// Array validation accepts these sign/dot-only zero tokens. Previously '+'
|
||||
// reached an unchecked lazy-iterator read: native loading returned an internal
|
||||
// parser error and WASM could abort before reaching the following object.
|
||||
for token in ["+", "-", ".", "+.", "-."] {
|
||||
for numeric_kid in [false, true] {
|
||||
let kids = if numeric_kid { format!("{token} 3 0 R") } else { "3 0 R".into() };
|
||||
let bytes = crate::write::pdf(
|
||||
&[
|
||||
(1, format!("<< /Type /Catalog /Pages 2 0 R /Probe [{token} 3] >>"), None),
|
||||
(2, format!("<< /Type /Pages /Kids [{kids}] /Count 1 >>"), None),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] /Resources << >> /Contents 4 0 R >>".into(), None),
|
||||
(4, "<< >>".into(), Some(b"1 0 0 rg 0 0 40 20 re f\n".to_vec())),
|
||||
],
|
||||
1,
|
||||
);
|
||||
let file = super::open(&bytes).unwrap();
|
||||
assert_eq!(file.catalog().unwrap().get("Probe").and_then(Obj::array), Some([Obj::Num(0.0), Obj::Num(3.0)].as_slice()));
|
||||
assert_eq!(crate::page_count(&bytes), 1, "token {token}, numeric kid {numeric_kid}");
|
||||
let doc = crate::parse(&bytes).unwrap();
|
||||
assert_eq!((doc.width, doc.height), (40.0, 20.0));
|
||||
assert_eq!(doc.flatten().len(), 1);
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
assert_eq!(effectcraft_svg::rasterize(&doc, 40, 20, 1.0).get(20, 10), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(crate::parse_page(&bytes, 1), Err(crate::Error::PageOutOfRange(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn shared_reader_preserves_the_target_stream_budget() {
|
||||
let options = super::open_options();
|
||||
let expected = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 };
|
||||
assert_eq!(options.decode_limits.max_decoded_stream(), expected);
|
||||
assert_eq!(options.max_input_bytes, 512 << 20);
|
||||
let bytes = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF";
|
||||
let pdf = craft_pdf::open_slice_with_options(bytes, None, &options).unwrap();
|
||||
assert_eq!(pdf.decode_limits().max_decoded_stream(), expected);
|
||||
}
|
||||
}
|
||||
+75
-5
@@ -48,7 +48,10 @@ fn pdf_paths_clip_gradient_and_layers() {
|
||||
assert_eq!(page_count(&bytes), 1);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
assert_eq!((doc.width, doc.height), (200.0, 100.0));
|
||||
assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}");
|
||||
assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}, skipped: {:?}", doc.skipped);
|
||||
if objstm {
|
||||
assert!(doc.skipped.iter().any(|warning| warning.contains("recovered PDF object")), "the malformed xref repair is reported");
|
||||
}
|
||||
assert!(doc.skipped.contains(&"text (no font)".to_string()), "{:?}", doc.skipped);
|
||||
let (w, h) = doc.pixel_size();
|
||||
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
|
||||
@@ -196,7 +199,7 @@ fn nested_bomb_in_the_second_flate_filter_is_refused() {
|
||||
const MAX: usize = 1 << 20;
|
||||
let flate = object::Obj::Name("FlateDecode".into());
|
||||
let chain: object::Dict = [("Filter".to_string(), object::Obj::Array(vec![flate.clone(), flate]))].into_iter().collect();
|
||||
let file = object::File::parse(b"");
|
||||
let file = object_file(vec![]);
|
||||
let mut nested = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 16 << 20], 6);
|
||||
nested.resize(MAX - 1, 0);
|
||||
let raw = miniz_oxide::deflate::compress_to_vec_zlib(&nested, 6);
|
||||
@@ -206,7 +209,7 @@ fn nested_bomb_in_the_second_flate_filter_is_refused() {
|
||||
|
||||
fn bounded_filter(dict: &str, raw: &[u8], max: usize) -> Option<Vec<u8>> {
|
||||
let object::Obj::Dict(d) = object::Lexer::new(dict.as_bytes(), 0).next().unwrap() else { panic!("not a dictionary") };
|
||||
object::decode_stream_within(&object::File::parse(b""), &d, raw, max)
|
||||
object::decode_stream_within(&object_file(vec![]), &d, raw, max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -660,9 +663,31 @@ fn tiling_patterns_coloured_and_uncoloured() {
|
||||
assert_eq!(at(115, 15)[3], 0.0);
|
||||
}
|
||||
|
||||
/// Build isolated interpreter objects without presenting a function or colour space as a
|
||||
/// document catalog. Complete-document parsing is covered by the page and reader fixtures.
|
||||
fn object_file(objs: Objs) -> object::File {
|
||||
let objects = objs
|
||||
.into_iter()
|
||||
.map(|(number, body, stream)| {
|
||||
let mut lexer = object::Lexer::new(body.as_bytes(), 0);
|
||||
let value = lexer.next().unwrap();
|
||||
let value = lexer.maybe_ref(value);
|
||||
let value = match stream {
|
||||
Some(bytes) => {
|
||||
let object::Obj::Dict(dict) = value else { panic!("stream fixture must have a dictionary") };
|
||||
object::Obj::Stream(dict, bytes)
|
||||
}
|
||||
None => value,
|
||||
};
|
||||
((number, 0), value)
|
||||
})
|
||||
.collect();
|
||||
object::File { objects, trailers: vec![], repairs: vec![] }
|
||||
}
|
||||
|
||||
/// Object 1 of a file holding just `objs`, read as a function.
|
||||
fn read_function(objs: Objs) -> color::Func {
|
||||
color::Func::read(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0))
|
||||
color::Func::read(&object_file(objs), &object::Obj::Ref(1, 0))
|
||||
}
|
||||
|
||||
/// A sampled (type 0) function, read from a file holding just its stream.
|
||||
@@ -672,7 +697,7 @@ fn read_sampled_function(dict: &str, data: Vec<u8>) -> color::Func {
|
||||
|
||||
/// Object 1 of a file holding just `objs`, read as a colour space.
|
||||
fn read_color_space(objs: Objs) -> color::Cs {
|
||||
color::color_space(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0), None)
|
||||
color::color_space(&object_file(objs), &object::Obj::Ref(1, 0), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1020,3 +1045,48 @@ fn multiple_pages_and_calculator_shadings() {
|
||||
assert!(img.get(5, 25)[0] < 0.1 && img.get(95, 25)[0] > 0.9, "{:?} {:?}", img.get(5, 25), img.get(95, 25));
|
||||
assert_eq!(parse_page(&bytes, 2), Err(Error::PageOutOfRange(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_layers_keep_strokes_clips_and_empty_layers() {
|
||||
let doc = parse(&sample_pdf(false)).unwrap();
|
||||
for i in 0..layer_names(&doc).len() {
|
||||
let full = layer_doc(&doc, i);
|
||||
let (cropped, offset) = layer_doc_with_sizes(&doc, i, true);
|
||||
let (w, h) = cropped.pixel_size();
|
||||
let image = effectcraft_svg::rasterize(&cropped, w, h, 1.0);
|
||||
let original = effectcraft_svg::rasterize(&full, 200, 100, 1.0);
|
||||
for y in 0..100 {
|
||||
for x in 0..200 {
|
||||
let a = image.get(x - offset[0] as i64, y - offset[1] as i64);
|
||||
let b = original.get(x, y);
|
||||
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-5), "layer {i} at {x},{y}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let (empty, _) = layer_doc_with_sizes(&doc, usize::MAX, true);
|
||||
assert_eq!(empty.pixel_size(), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_layers_measure_transformed_artwork_without_rasterizing() {
|
||||
let doc = effectcraft_svg::parse(br#"<svg width="120" height="80"><g transform="translate(60,0)"><rect x="10" y="20" width="30" height="10" transform="rotate(90)"/></g><path d="M 10 20 L 30 20" fill="none" stroke="red" stroke-width="4" stroke-linecap="square"/></svg>"#).unwrap();
|
||||
let (rotated, offset) = layer_doc_with_sizes(&doc, 0, true);
|
||||
assert_eq!(offset, [30.0, 10.0]);
|
||||
assert_eq!(rotated.pixel_size(), (10, 30));
|
||||
let (stroked, offset) = layer_doc_with_sizes(&doc, 1, true);
|
||||
assert_eq!(offset, [8.0, 18.0]);
|
||||
assert_eq!(stroked.pixel_size(), (24, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_layers_preserve_artwork_beyond_the_bounds_depth_limit() {
|
||||
let mut doc = effectcraft_svg::parse(br#"<svg width="120" height="80"><rect x="20" y="15" width="30" height="20"/></svg>"#).unwrap();
|
||||
for _ in 0..300 {
|
||||
let mut group = Group::new("nested");
|
||||
group.children = std::mem::take(&mut doc.root.children);
|
||||
doc.root.children.push(Node::Group(group));
|
||||
}
|
||||
let (cropped, offset) = layer_doc_with_sizes(&doc, 0, true);
|
||||
assert_eq!(cropped.pixel_size(), (120, 80), "unknown bounds keep the full page");
|
||||
assert_eq!(offset, [0.0, 0.0]);
|
||||
}
|
||||
|
||||
@@ -263,6 +263,22 @@ fn hostile_structures_return_errors_or_draw_nothing() {
|
||||
assert!(!doc.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_image_with_absurd_dimensions_falls_back_to_ei_scan() {
|
||||
// `/W 5000000000000000000` overflows the row math (`* bpc` alone exceeds usize::MAX):
|
||||
// `inline_len` must return `None` (scan for `EI`) instead of overflowing; the oversized
|
||||
// image itself is then refused by size.
|
||||
let bytes = page_pdf("q 10 0 0 10 0 0 cm BI /W 5000000000000000000 /H 100 /CS /G /BPC 8 ID \0 EI Q", "", vec![]);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
let _ = render(&doc);
|
||||
assert!(!doc.skipped.is_empty());
|
||||
// A length just under `usize::MAX` passes the row math; past byte 4096 of the content the
|
||||
// caller's `start + len` would then overflow.
|
||||
let content = format!("{}q 10 0 0 10 0 0 cm BI /W 2305843009213693440 /H 8 /CS /G /BPC 8 ID | ||||