mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-07 02:51:24 +00:00
Merge remote-tracking branch 'origin/main' into pr-1472
# Conflicts: # contributors/people.toml
This commit is contained in:
commit
23d56e7fea
56 files changed
+2835
-352
No files matched your search
@@ -0,0 +1,33 @@
|
||||
# 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:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
Generated
+147
@@ -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"
|
||||
@@ -1478,6 +1493,7 @@ dependencies = [
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-text",
|
||||
@@ -1485,6 +1501,7 @@ dependencies = [
|
||||
"kurbo",
|
||||
"miniz_oxide 0.8.9",
|
||||
"skrifa",
|
||||
"wasm-bindgen-test",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
@@ -2678,6 +2695,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 +3218,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 +3372,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 +3829,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 +4849,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 +5243,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 +5391,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 +5570,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" }
|
||||
@@ -204,3 +205,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 |
|
||||
@@ -80,4 +80,5 @@ real_name = "Moein Saharkhiz"
|
||||
display_name = "onlymoein"
|
||||
|
||||
[people.arnav-hiwarkar]
|
||||
sync_github_name = true
|
||||
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();
|
||||
|
||||
@@ -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)
|
||||
})?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -407,6 +411,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 +444,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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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}")),
|
||||
|
||||
@@ -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.
|
||||
+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"))
|
||||
}
|
||||
}
|
||||
+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]);
|
||||
}
|
||||
@@ -376,3 +376,31 @@ fn postscript_radix_numbers_outside_2_to_36_are_not_numbers() {
|
||||
let _ = parse(src.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_page_indices_have_a_printable_error() {
|
||||
let text = Error::PageOutOfRange(usize::MAX).to_string();
|
||||
assert!(text.starts_with("the PDF has no page "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postscript_idiv_and_mod_of_i64_min_by_minus_one_give_zero() {
|
||||
// `i64::MIN / -1` and `i64::MIN % -1` overflow, and `/` and `%` panic on them in debug and
|
||||
// release builds. `as i64` turns any dividend at or below -2^63 into `i64::MIN` and turns
|
||||
// a divisor of -1.5 into -1.
|
||||
for (prog, want) in [
|
||||
("-9223372036854775808 -1 idiv", 0),
|
||||
("-9223372036854775808 -1 mod", 0),
|
||||
("-99999999999999999999 -1.5 idiv", 0),
|
||||
("2 63 exp neg -1 mod", 0),
|
||||
// The result is 0 for a divisor that truncates to 0. Other quotients are truncated
|
||||
// toward zero, and a remainder has the sign of the dividend.
|
||||
("7 0 idiv", 0),
|
||||
("7 0.5 mod", 0),
|
||||
("-7 2 idiv", -3),
|
||||
("-7 2 mod", -1),
|
||||
] {
|
||||
let doc = eps(format!("{prog} {want} eq {{ 0 0 10 10 rectfill }} if\n").as_bytes());
|
||||
assert_eq!(shape_names(&doc).len(), 1, "{prog}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! The shared object adapter preserves scalar references and bounds both tree and graph input.
|
||||
|
||||
use crate::{Error, parse};
|
||||
|
||||
fn document(extra: &str, tail: &[(u32, u16, String)]) -> Vec<u8> {
|
||||
let content = "/GS gs 1 0 0 rg 0 0 20 10 re f";
|
||||
let mut objects = vec![
|
||||
(1, 0, format!("<< /Type /Catalog /Pages 2 0 R {extra} >>")),
|
||||
(2, 0, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()),
|
||||
(3, 0, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20 10] /Resources << /ExtGState << /GS << /ca 5 7 R >> >> >> /Contents 4 0 R >>".into()),
|
||||
(4, 0, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())),
|
||||
(5, 7, "0.25".into()),
|
||||
];
|
||||
objects.extend_from_slice(tail);
|
||||
let mut data = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = std::collections::BTreeMap::new();
|
||||
for (number, generation, value) in objects {
|
||||
offsets.insert(number, (data.len(), generation));
|
||||
data.extend_from_slice(format!("{number} {generation} obj\n{value}\nendobj\n").as_bytes());
|
||||
}
|
||||
let xref = data.len();
|
||||
let count = offsets.keys().last().copied().unwrap() + 1;
|
||||
data.extend_from_slice(format!("xref\n0 {count}\n0000000000 65535 f \n").as_bytes());
|
||||
for number in 1..count {
|
||||
let entry = offsets.get(&number).map_or_else(|| "0000000000 65535 f \n".into(), |(offset, generation)| format!("{offset:010} {generation:05} n \n"));
|
||||
data.extend_from_slice(entry.as_bytes());
|
||||
}
|
||||
data.extend_from_slice(format!("trailer\n<< /Size {count} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonzero_generation_scalar_keeps_the_drawing_opacity() {
|
||||
let doc = parse(&document("", &[])).unwrap();
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
let pixel = effectcraft_svg::rasterize(&doc, 20, 10, 1.0).get(10, 5);
|
||||
assert!((pixel[0] - 0.25).abs() < 0.001 && (pixel[3] - 0.25).abs() < 0.001, "{pixel:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_cyclic_reference_graph_does_not_expand_recursively() {
|
||||
let objects: Vec<_> = (10..1010).map(|id| (id, 0, format!("<< /Next {} 0 R >>", if id == 1009 { 10 } else { id + 1 }))).collect();
|
||||
let plain = parse(&document("", &[])).unwrap();
|
||||
let cyclic = parse(&document("/Extra 10 0 R", &objects)).unwrap();
|
||||
assert_eq!(plain, cyclic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessive_inline_nesting_returns_an_error_instead_of_partial_art() {
|
||||
let extra = format!("/Extra {}0{}", "[".repeat(80), "]".repeat(80));
|
||||
let bytes = document(&extra, &[]);
|
||||
// The shared parser rejects the catalog before the adapter's depth limit runs.
|
||||
// Its Invalid result follows the existing NoPages mapping, with no partial document.
|
||||
assert_eq!(crate::page_count(&bytes), 0);
|
||||
assert_eq!(parse(&bytes), Err(Error::NoPages));
|
||||
}
|
||||
|
||||
fn object_stream(entries: &[(u32, &str)]) -> String {
|
||||
let (mut header, mut body) = (String::new(), String::new());
|
||||
for (number, value) in entries {
|
||||
header.push_str(&format!("{number} {} ", body.len()));
|
||||
body.push_str(value);
|
||||
body.push(' ');
|
||||
}
|
||||
let first = header.len();
|
||||
let content = header + &body;
|
||||
format!("<< /Type /ObjStm /N {} /First {first} /Length {} >>\nstream\n{content}\nendstream", entries.len(), content.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_unreferenced_compressed_objects_do_not_block_repair() {
|
||||
let objects = vec![(20, 0, object_stream(&[(10, "<< /Value 1 >>"), (30, "null")])), (21, 0, object_stream(&[(30, "42")]))];
|
||||
let doc = parse(&document("/Missing 10 0 R", &objects)).unwrap();
|
||||
assert!(doc.skipped.iter().any(|warning| warning.contains("recovered PDF object from an object stream: 10 0")));
|
||||
assert!(matches!(parse(&document("/Missing 30 0 R", &objects)), Err(Error::Read(message)) if message.contains("ambiguous recovered object 30")));
|
||||
}
|
||||
@@ -216,7 +216,7 @@ struct FxHost<'r, 'a, 'c> {
|
||||
/// whose effects run on the comp below).
|
||||
origin: [f64; 2],
|
||||
/// Index of the effect being rendered (bounds `self_at` to effects before it).
|
||||
index: std::sync::atomic::AtomicUsize,
|
||||
index: usize,
|
||||
original: Option<&'c Buf>,
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
if self.r.depth > MAX_FX_DEPTH || self.layer.switches.adjustment {
|
||||
return None;
|
||||
}
|
||||
let n = effects.min(self.index.load(std::sync::atomic::Ordering::Relaxed));
|
||||
let n = effects.min(self.index);
|
||||
let t = self.layer.comp_time(Tick::from_seconds_f64(layer_time));
|
||||
let sub = self.r.nested();
|
||||
sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone())
|
||||
@@ -337,8 +337,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
}
|
||||
|
||||
fn params_at(&self, layer_time: f64) -> Option<Params> {
|
||||
let i = self.index.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let g = self.layer.effects()?.groups().nth(i)?;
|
||||
let g = self.layer.effects()?.groups().nth(self.index)?;
|
||||
let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time)));
|
||||
Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr)))
|
||||
}
|
||||
@@ -1158,7 +1157,7 @@ impl<'a> Renderer<'a> {
|
||||
buf
|
||||
});
|
||||
}
|
||||
let host = FxHost { r: self, ctx, layer, origin, index: Default::default(), original: original.as_ref() };
|
||||
let host = FxHost { r: self, ctx, layer, origin, index: 0, original: original.as_ref() };
|
||||
let env = EffectEnv {
|
||||
masks: &mask_shapes,
|
||||
host: Some(&host),
|
||||
@@ -1192,12 +1191,21 @@ impl<'a> Renderer<'a> {
|
||||
{
|
||||
let run: Vec<_> = stack[k..].iter().take_while(|(_, _, s)| s.gpu && a.supports_effect(s.id)).collect();
|
||||
let params: Vec<Params> = run.iter().map(|(_, g, _)| self.effect_params(ctx, layer, g)).collect();
|
||||
let hosts: Vec<_> = run.iter().map(|(i, _, _)| FxHost { index: *i, ..host }).collect();
|
||||
let steps: Vec<FxStep> = run
|
||||
.iter()
|
||||
.zip(¶ms)
|
||||
.map(|((i, g, s), params)| FxStep {
|
||||
.zip(&hosts)
|
||||
.map(|(((i, g, s), params), host)| FxStep {
|
||||
spec: s,
|
||||
ctx: EffectCtx { params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env: EffectEnv { effect_index: *i, ..env } },
|
||||
ctx: EffectCtx {
|
||||
params,
|
||||
time: lt.seconds(),
|
||||
layer_size,
|
||||
seed: g.uid as u32,
|
||||
adjustment,
|
||||
env: EffectEnv { host: Some(host), effect_index: *i, ..env },
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let t0 = web_time::Instant::now();
|
||||
@@ -1212,8 +1220,8 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
k += 1;
|
||||
let params = self.effect_params(ctx, layer, g);
|
||||
host.index.store(i, std::sync::atomic::Ordering::Relaxed);
|
||||
let env = EffectEnv { effect_index: i, ..env };
|
||||
let host = FxHost { index: i, ..host };
|
||||
let env = EffectEnv { host: Some(&host), effect_index: i, ..env };
|
||||
let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env };
|
||||
let t0 = web_time::Instant::now();
|
||||
target.cpu(&mut |buf| {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
use kurbo::{Affine, BezPath, Rect, Shape as _};
|
||||
|
||||
use crate::{Doc, Group, Node};
|
||||
|
||||
impl Doc {
|
||||
/// Geometric artwork bounds in document pixels, including strokes and clipping.
|
||||
/// Opacity and soft masks do not change the artwork's dimensions.
|
||||
pub fn artwork_bounds(&self) -> Option<Rect> {
|
||||
let page = Rect::new(0.0, 0.0, self.width, self.height);
|
||||
group_bounds(&self.root, Affine::IDENTITY, 0, page)
|
||||
}
|
||||
}
|
||||
|
||||
fn path_bounds(path: BezPath) -> Option<Rect> {
|
||||
if path.elements().is_empty() || !path.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let r = path.bounding_box();
|
||||
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
|
||||
}
|
||||
|
||||
fn group_bounds(group: &Group, transform: Affine, depth: usize, page: Rect) -> Option<Rect> {
|
||||
if depth > 256 {
|
||||
return Some(page);
|
||||
}
|
||||
let transform = transform * group.transform;
|
||||
if !transform.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut bounds: Option<Rect> = None;
|
||||
for node in &group.children {
|
||||
let rect = match node {
|
||||
Node::Group(g) => group_bounds(g, transform, depth + 1, page),
|
||||
Node::Image(i) => {
|
||||
let rect = Rect::new(0.0, 0.0, f64::from(i.width), f64::from(i.height));
|
||||
path_bounds(transform * i.transform * rect.to_path(0.01))
|
||||
}
|
||||
Node::Shape(s) => {
|
||||
let m = transform * s.transform;
|
||||
let path = s.geom.to_path();
|
||||
let mut rect = s.fill.as_ref().and_then(|_| path_bounds(m * &path));
|
||||
if let Some(stroke) = &s.stroke {
|
||||
let scale = m.determinant().abs().sqrt();
|
||||
if scale.is_finite()
|
||||
&& path.is_finite()
|
||||
&& let Some(outline) = effectcraft_path::stroke_outline(std::slice::from_ref(&path), &stroke.style(), scale)
|
||||
&& let Some(r) = path_bounds(m * outline)
|
||||
{
|
||||
rect = Some(rect.map_or(r, |b| b.union(r)));
|
||||
}
|
||||
}
|
||||
rect
|
||||
}
|
||||
};
|
||||
if let Some(r) = rect {
|
||||
bounds = Some(bounds.map_or(r, |b| b.union(r)));
|
||||
}
|
||||
}
|
||||
let mut bounds = bounds?;
|
||||
for (clip, _) in &group.clip {
|
||||
bounds = bounds.intersect(path_bounds(transform * clip)?);
|
||||
}
|
||||
(bounds.width() > 0.0 && bounds.height() > 0.0).then_some(bounds)
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod bounds;
|
||||
mod color;
|
||||
mod pathdata;
|
||||
mod raster;
|
||||
@@ -108,6 +109,28 @@ pub struct Stroke {
|
||||
pub dash: Option<(Vec<f64>, f64)>,
|
||||
}
|
||||
|
||||
impl Stroke {
|
||||
fn style(&self) -> effectcraft_path::StrokeStyle {
|
||||
effectcraft_path::StrokeStyle {
|
||||
width: self.width,
|
||||
cap: match self.cap {
|
||||
Cap::Butt => effectcraft_path::Cap::Butt,
|
||||
Cap::Round => effectcraft_path::Cap::Round,
|
||||
Cap::Square => effectcraft_path::Cap::Square,
|
||||
},
|
||||
join: match self.join {
|
||||
Join::Miter => effectcraft_path::Join::Miter,
|
||||
Join::Round => effectcraft_path::Join::Round,
|
||||
Join::Bevel => effectcraft_path::Join::Bevel,
|
||||
},
|
||||
miter: self.miter,
|
||||
dash: self.dash.clone(),
|
||||
taper: None,
|
||||
wave: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape geometry in its user space (basic shapes are kept so they can become parametric shape
|
||||
/// layer items).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -405,23 +405,7 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) {
|
||||
paint(dst, &cov, fill, (opacity * s.fill_opacity) as f32, m, bbox);
|
||||
}
|
||||
if let Some(st) = &s.stroke {
|
||||
let style = effectcraft_path::StrokeStyle {
|
||||
width: st.width,
|
||||
cap: match st.cap {
|
||||
crate::Cap::Butt => effectcraft_path::Cap::Butt,
|
||||
crate::Cap::Round => effectcraft_path::Cap::Round,
|
||||
crate::Cap::Square => effectcraft_path::Cap::Square,
|
||||
},
|
||||
join: match st.join {
|
||||
crate::Join::Miter => effectcraft_path::Join::Miter,
|
||||
crate::Join::Round => effectcraft_path::Join::Round,
|
||||
crate::Join::Bevel => effectcraft_path::Join::Bevel,
|
||||
},
|
||||
miter: st.miter,
|
||||
dash: st.dash.clone(),
|
||||
taper: None,
|
||||
wave: None,
|
||||
};
|
||||
let style = st.style();
|
||||
let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h);
|
||||
paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox);
|
||||
}
|
||||
|
||||
@@ -68,8 +68,8 @@ pub struct JobWaiter {
|
||||
pub result: Value,
|
||||
/// Ids of the jobs it started (`jobs.list` ids: `render`, `track`, `roto`…).
|
||||
pub jobs: Vec<String>,
|
||||
/// Length of [`effectcraft_engine::Session::offload_log`] when it started.
|
||||
pub log_len: usize,
|
||||
/// Absolute completion-log position when it started (including evicted entries).
|
||||
pub log_cursor: usize,
|
||||
}
|
||||
|
||||
/// Commands that block with `wait` (and its default).
|
||||
@@ -86,7 +86,7 @@ fn execute_engine(app: &mut EffectcraftApp, id: &str, params: Value) -> Result<O
|
||||
return app.session.execute_checked(id, params).map(ok).map_err(|e| e.to_string());
|
||||
}
|
||||
let before: Vec<String> = app.session.jobs().into_iter().map(|j| j.id).collect();
|
||||
let log_len = app.session.offload_log.len();
|
||||
let log_cursor = app.session.offload_log_start.saturating_add(app.session.offload_log.len());
|
||||
let mut p = params.clone();
|
||||
p["wait"] = json!(false);
|
||||
let result = app.session.execute_checked(id, p).map_err(|e| e.to_string())?;
|
||||
@@ -94,7 +94,7 @@ fn execute_engine(app: &mut EffectcraftApp, id: &str, params: Value) -> Result<O
|
||||
if jobs.is_empty() {
|
||||
return Ok(ok(result));
|
||||
}
|
||||
Ok(Outcome::AwaitJobs(JobWaiter { command: id.to_string(), params, result, jobs, log_len }))
|
||||
Ok(Outcome::AwaitJobs(JobWaiter { command: id.to_string(), params, result, jobs, log_cursor }))
|
||||
}
|
||||
|
||||
impl JobWaiter {
|
||||
@@ -108,8 +108,13 @@ impl JobWaiter {
|
||||
pub fn finish(self, app: &mut EffectcraftApp) -> Value {
|
||||
let s = &mut app.session;
|
||||
s.poll_render();
|
||||
let errors: Vec<String> = s.offload_log.iter().skip(self.log_len).filter_map(|(_, e)| e.clone()).collect();
|
||||
if let Some(e) = errors.first() {
|
||||
let error = s
|
||||
.offload_log
|
||||
.iter()
|
||||
.skip(self.log_cursor.saturating_sub(s.offload_log_start))
|
||||
.filter(|(kind, _)| self.jobs.iter().any(|id| id == kind.job_id()))
|
||||
.find_map(|(_, e)| e.as_ref());
|
||||
if let Some(e) = error {
|
||||
return json!({"ok": false, "error": e});
|
||||
}
|
||||
let mut r = self.result;
|
||||
|
||||
@@ -228,6 +228,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"),
|
||||
("Effects & Presets options", "エフェクト&プリセットのオプション"),
|
||||
("Copy Presets Folder Path", "プリセットフォルダーのパスをコピー"),
|
||||
("Copy Source Path", "ソースパスをコピー"),
|
||||
("Effects: {}", "エフェクト:{}"),
|
||||
("Empty folder", "空のフォルダー"),
|
||||
("Enable Depth of Field", "被写界深度を有効化"),
|
||||
@@ -772,6 +773,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"),
|
||||
("Effects & Presets options", "效果和预设选项"),
|
||||
("Copy Presets Folder Path", "复制预设文件夹路径"),
|
||||
("Copy Source Path", "复制源路径"),
|
||||
("Effects: {}", "效果:{}"),
|
||||
("Empty folder", "空文件夹"),
|
||||
("Enable Depth of Field", "启用景深"),
|
||||
@@ -1504,6 +1506,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"),
|
||||
("Effects & Presets options", "效果和預設集選項"),
|
||||
("Copy Presets Folder Path", "複製預設集資料夾路徑"),
|
||||
("Copy Source Path", "複製來源路徑"),
|
||||
("Effects: {}", "效果:{}"),
|
||||
("Empty folder", "空資料夾"),
|
||||
("Enable Depth of Field", "啟用景深"),
|
||||
@@ -2239,6 +2242,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."),
|
||||
("Effects & Presets options", "Параметри ефектів і наборів"),
|
||||
("Copy Presets Folder Path", "Копіювати шлях до папки наборів"),
|
||||
("Copy Source Path", "Копіювати шлях до джерела"),
|
||||
("Effects: {}", "Ефекти: {}"),
|
||||
("Empty folder", "Порожня тека"),
|
||||
("Enable Depth of Field", "Увімкнути глибину різкості"),
|
||||
@@ -2791,6 +2795,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."),
|
||||
("Effects & Presets options", "Opções de Efeitos e predefinições"),
|
||||
("Copy Presets Folder Path", "Copiar caminho da pasta de predefinições"),
|
||||
("Copy Source Path", "Copiar caminho da origem"),
|
||||
("Effects: {}", "Efeitos: {}"),
|
||||
("Empty folder", "Pasta vazia"),
|
||||
("Enable Depth of Field", "Ativar profundidade de campo"),
|
||||
@@ -3241,6 +3246,7 @@ const CZECH: &[(&str, &str)] = &[
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."),
|
||||
("Effects & Presets options", "Možnosti efektů a přednastavení"),
|
||||
("Copy Presets Folder Path", "Kopírovat cestu ke složce přednastavení"),
|
||||
("Copy Source Path", "Kopírovat cestu ke zdroji"),
|
||||
("Effects: {}", "Efekty: {}"),
|
||||
("Empty folder", "Prázdná složka"),
|
||||
("Enable Depth of Field", "Povolit hloubku ostrosti"),
|
||||
@@ -3653,6 +3659,7 @@ const CZECH: &[(&str, &str)] = &[
|
||||
/// order. Terms follow the Spanish menu catalog (composición, material de archivo, cola de procesamiento,
|
||||
/// ajuste preestablecido, método abreviado). Written from the English source.
|
||||
const SPANISH: &[(&str, &str)] = &[
|
||||
("Copy Source Path", "Copiar ruta de origen"),
|
||||
("Appearance Settings…", "Ajustes de apariencia…"),
|
||||
("Add to favorites", "Añadir a favoritos"),
|
||||
("Remove from favorites", "Quitar de favoritos"),
|
||||
|
||||
@@ -291,7 +291,7 @@ fn only_comp(p: &Value) -> Option<effectcraft_engine::project::ItemId> {
|
||||
|
||||
/// Execute a UI or engine command by id.
|
||||
pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
if id == "effects.copyPresetsFolderPath" {
|
||||
if matches!(id, "effects.copyPresetsFolderPath" | "mediaBrowser.copySourcePath") {
|
||||
return frontend(app, ctx, id, params);
|
||||
}
|
||||
// Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of
|
||||
@@ -880,6 +880,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
let now = ctx.input(|i| i.time);
|
||||
let v = &mut app.ui.viewer;
|
||||
Ok(match id {
|
||||
"mediaBrowser.copySourcePath" => {
|
||||
let path = p.get("path").and_then(Value::as_str).filter(|path| !path.is_empty()).ok_or("A source path is required")?;
|
||||
ctx.copy_text(path.to_string());
|
||||
json!({"path": path})
|
||||
}
|
||||
"effects.copyPresetsFolderPath" => {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err("The web app has no local presets folder".into());
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use crate::i18n::tr;
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::panels::DragPayload;
|
||||
@@ -228,7 +229,7 @@ fn preset_row(
|
||||
name: &str,
|
||||
auto_id: &str,
|
||||
query: &str,
|
||||
on_apply: Apply,
|
||||
on_apply: impl FnOnce() -> Apply,
|
||||
apply: &mut Option<Apply>,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
@@ -245,22 +246,22 @@ fn preset_row(
|
||||
highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t);
|
||||
app.auto.add(auto_id, r, name);
|
||||
if resp.double_clicked() {
|
||||
*apply = Some(on_apply);
|
||||
*apply = Some(on_apply());
|
||||
}
|
||||
}
|
||||
|
||||
/// User preset files, rescanned every few seconds (and on Refresh List).
|
||||
fn user_presets(ctx: &egui::Context, force: bool) -> Vec<PresetFile> {
|
||||
fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec<PresetFile>) -> Arc<Vec<PresetFile>> {
|
||||
let id = egui::Id::new("fx-user-presets");
|
||||
let now = ctx.input(|i| i.time);
|
||||
if !force
|
||||
&& let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, std::sync::Arc<Vec<PresetFile>>)>(id))
|
||||
&& let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(id))
|
||||
&& now - at < 5.0
|
||||
{
|
||||
return (*v).clone();
|
||||
return v;
|
||||
}
|
||||
let v = presets_dir().map(|d| scan_presets(&d)).unwrap_or_default();
|
||||
ctx.data_mut(|d| d.insert_temp(id, (now, std::sync::Arc::new(v.clone()))));
|
||||
let v = Arc::new(scan());
|
||||
ctx.data_mut(|d| d.insert_temp(id, (now, v.clone())));
|
||||
v
|
||||
}
|
||||
|
||||
@@ -288,6 +289,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add("effects.menu", mr, "Effects & Presets options");
|
||||
let view = app.ui.effects_view.clone();
|
||||
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
|
||||
let copy_label = format!(" {}", tr("Copy Presets Folder Path"));
|
||||
let menu = vec![
|
||||
mark(view.depth == "all", "Show Effects for All Color Depths"),
|
||||
mark(view.depth == "32", "Show 32 bpc Effects Only"),
|
||||
@@ -301,7 +303,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
" Refresh List".into(),
|
||||
" Clear Recently Used".into(),
|
||||
"-".into(),
|
||||
format!(" {}", tr("Copy Presets Folder Path")),
|
||||
copy_label.clone(),
|
||||
];
|
||||
let mut force_scan = false;
|
||||
let separators = menu.iter().filter(|label| *label == "-").count();
|
||||
@@ -314,7 +316,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.separator();
|
||||
} else {
|
||||
let resp = ui.selectable_label(false, label);
|
||||
if i == 12 {
|
||||
if label == ©_label {
|
||||
app.auto.add("effects.copyPresetsFolderPath", resp.rect, "Copy Presets Folder Path");
|
||||
}
|
||||
if resp.clicked() {
|
||||
@@ -330,6 +332,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let Some(i) = choice {
|
||||
let v = &mut app.ui.effects_view;
|
||||
match i {
|
||||
i if menu.get(i) == Some(©_label) => {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
0 => v.depth = "all".into(),
|
||||
1 => v.depth = "32".into(),
|
||||
2 => v.depth = "gpu".into(),
|
||||
@@ -338,11 +345,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
7 => v.alphabetical = true,
|
||||
9 => force_scan = true,
|
||||
10 => app.ui.effects_recent.clear(),
|
||||
12 => {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -389,13 +391,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
categories.push(c);
|
||||
}
|
||||
}
|
||||
let files: Vec<PresetFile> =
|
||||
user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
|
||||
let user = user_presets(&ctx, force_scan, || presets_dir().map(|d| scan_presets(&d)).unwrap_or_default());
|
||||
let files: Vec<&PresetFile> = user.iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
|
||||
let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty();
|
||||
if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
|
||||
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) {
|
||||
for (id, name) in text {
|
||||
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply);
|
||||
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, || Apply::TextPreset(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
for cat in &categories {
|
||||
@@ -403,7 +405,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
continue;
|
||||
}
|
||||
for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) {
|
||||
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply);
|
||||
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, || Apply::Builtin(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) {
|
||||
@@ -417,7 +419,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
&f.name,
|
||||
&format!("effects.preset.file.{}", f.name),
|
||||
&query,
|
||||
Apply::PresetFile(f.path.clone()),
|
||||
|| Apply::PresetFile(f.path.clone()),
|
||||
&mut apply,
|
||||
);
|
||||
}
|
||||
@@ -536,6 +538,64 @@ mod tests {
|
||||
let v = scan_presets(&d);
|
||||
assert_eq!(v.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), vec!["bounce", "Zoom In"]);
|
||||
assert!(scan_presets(&d.join("missing")).is_empty());
|
||||
let ctx = egui::Context::default();
|
||||
let set_time = |time| {
|
||||
let mut output = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |_| {});
|
||||
output.textures_delta.clear();
|
||||
};
|
||||
let scans = std::cell::Cell::new(0);
|
||||
let scan = || {
|
||||
scans.set(scans.get() + 1);
|
||||
scan_presets(&d)
|
||||
};
|
||||
set_time(1.0);
|
||||
let first = user_presets(&ctx, false, scan);
|
||||
assert_eq!(first.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["bounce", "Zoom In"]);
|
||||
std::fs::remove_file(d.join("bounce.ECPRESET")).unwrap();
|
||||
std::fs::write(d.join("Another.ecpreset"), "{}").unwrap();
|
||||
set_time(5.999);
|
||||
let hit = user_presets(&ctx, false, scan);
|
||||
assert!(Arc::ptr_eq(&first, &hit));
|
||||
assert_eq!(hit.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["bounce", "Zoom In"]);
|
||||
assert_eq!(scans.get(), 1);
|
||||
set_time(6.0);
|
||||
let expired = user_presets(&ctx, false, scan);
|
||||
assert_eq!(expired.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Another", "Zoom In"]);
|
||||
std::fs::remove_file(d.join("Another.ecpreset")).unwrap();
|
||||
let refreshed = user_presets(&ctx, true, scan);
|
||||
assert_eq!(refreshed.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Zoom In"]);
|
||||
assert_eq!(scans.get(), 3);
|
||||
assert_eq!(first.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["bounce", "Zoom In"]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "warm custom-preset cache benchmark"]
|
||||
fn user_preset_cache_benchmark() {
|
||||
let ctx = egui::Context::default();
|
||||
let files = user_presets(&ctx, false, || {
|
||||
(0..1000).map(|i| PresetFile { name: format!("Preset {i:04}"), path: format!("/presets/Preset {i:04}.ecpreset") }).collect()
|
||||
});
|
||||
assert_eq!(files.first().unwrap().name, "Preset 0000");
|
||||
assert_eq!(files.last().unwrap().name, "Preset 0999");
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
let (_, shared) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(egui::Id::new("fx-user-presets"))).unwrap();
|
||||
std::hint::black_box((*shared).clone());
|
||||
}
|
||||
before.push(start.elapsed());
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(user_presets(&ctx, false, Vec::new));
|
||||
}
|
||||
after.push(start.elapsed());
|
||||
}
|
||||
assert!(Arc::ptr_eq(&files, &user_presets(&ctx, false, Vec::new)));
|
||||
before.sort();
|
||||
after.sort();
|
||||
println!("1,000 custom presets, median per warm cache lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000);
|
||||
}
|
||||
}
|
||||
@@ -228,7 +228,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
"file.import" if !has(p, &["importAs", "page"]) && is_pdf(p) => (
|
||||
"Import PDF / Illustrator File".into(),
|
||||
vec![
|
||||
Field::choice("importAs", "Import Kind", &[("Footage", json!("footage")), ("Composition", json!("composition"))], 0),
|
||||
Field::choice(
|
||||
"importAs",
|
||||
"Import Kind",
|
||||
&[
|
||||
("Footage", json!("footage")),
|
||||
("Composition", json!("composition")),
|
||||
("Composition - Retain Layer Sizes", json!("compositionLayerSizes")),
|
||||
],
|
||||
0,
|
||||
),
|
||||
Field::num("page", "Page", 1.0),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -15,6 +15,7 @@ use effectcraft_engine::media_browser as mb;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::DragPayload;
|
||||
use super::panel_kit as kit;
|
||||
@@ -24,6 +25,7 @@ use crate::{EffectcraftApp, widgets};
|
||||
// ---------------------------------------------------------------- Media Browser
|
||||
|
||||
type Thumb = Option<(egui::TextureHandle, [u32; 2])>;
|
||||
type Listing = (f64, Arc<Result<Vec<mb::Entry>, String>>);
|
||||
|
||||
/// Thumbnail of a file (cached per path; `None` while not decoded or not decodable).
|
||||
fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut u32) -> Thumb {
|
||||
@@ -76,11 +78,11 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Listing cached per folder for a second.
|
||||
let lid = egui::Id::new(("mb-list", dir.clone(), st.importable_only));
|
||||
let now = ctx.input(|i| i.time);
|
||||
let listing: Option<(f64, Result<Vec<mb::Entry>, String>)> = ctx.data(|d| d.get_temp(lid));
|
||||
let listing: Option<Listing> = ctx.data(|d| d.get_temp(lid));
|
||||
let entries = match listing {
|
||||
Some((at, l)) if now - at < 1.0 => l,
|
||||
_ => {
|
||||
let l = b.list(&dir, st.importable_only);
|
||||
let l = Arc::new(b.list(&dir, st.importable_only));
|
||||
ctx.data_mut(|d| d.insert_temp(lid, (now, l.clone())));
|
||||
l
|
||||
}
|
||||
@@ -115,7 +117,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
if kit::button(app, ui, Rect::from_min_size(pos2(ax, y), vec2(w, 20.0)), &format!("mediaBrowser.action.{id}"), label, false) {
|
||||
kit::exec(app, "mediaBrowser.action", json!({"action": id}));
|
||||
ctx.data_mut(|d| d.remove::<(f64, Result<Vec<mb::Entry>, String>)>(lid));
|
||||
ctx.data_mut(|d| d.remove::<Listing>(lid));
|
||||
}
|
||||
ax -= 6.0;
|
||||
}
|
||||
@@ -150,7 +152,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// Files.
|
||||
let list = Rect::from_min_max(pos2(side.max.x + 1.0, side.min.y), rect.max);
|
||||
let entries = match entries {
|
||||
let entries = match entries.as_ref() {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
p.text(list.center(), Align2::CENTER_CENTER, e, Tokens::ui(12.0), t.danger);
|
||||
@@ -209,6 +211,14 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
egui::DragAndDrop::set_payload(&ctx, DragPayload::Files(vec![e.path.clone()]));
|
||||
}
|
||||
crate::widgets::context_menu(&resp, |ui| {
|
||||
let copy = ui.button(tr("Copy Source Path"));
|
||||
app.auto.add(&format!("mediaBrowser.entry.{}.copySourcePath", e.name), copy.rect, "Copy Source Path");
|
||||
if copy.clicked() {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "mediaBrowser.copySourcePath", json!({"path": e.path})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
if !e.is_dir && ui.button(tr("Import")).clicked() {
|
||||
actions.push(("mediaBrowser.import", json!({"paths": [e.path]})));
|
||||
ui.close();
|
||||
@@ -409,3 +419,52 @@ pub fn import_card(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect) -> f
|
||||
}
|
||||
full.max.y - 16.0 - bottom
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "Media Browser warm listing cache benchmark"]
|
||||
fn listing_cache_benchmark() {
|
||||
let ctx = egui::Context::default();
|
||||
let entries: Vec<_> = (0..1000)
|
||||
.map(|i| mb::Entry {
|
||||
name: format!("Clip {i:04}.mov"),
|
||||
path: format!("/Shoot/作品 Clip {i:04}.mov"),
|
||||
is_dir: false,
|
||||
size: 1,
|
||||
modified: None,
|
||||
kind: Some("video"),
|
||||
})
|
||||
.collect();
|
||||
let old_id = egui::Id::new("old-list");
|
||||
let new_id = egui::Id::new("new-list");
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(old_id, (0.0, Ok::<_, String>(entries.clone())));
|
||||
d.insert_temp(new_id, (0.0, Arc::new(Ok::<_, String>(entries))));
|
||||
});
|
||||
let (_, original) = ctx.data(|d| d.get_temp::<Listing>(new_id)).unwrap();
|
||||
assert_eq!(original.as_ref().as_ref().unwrap()[0].name, "Clip 0000.mov");
|
||||
let mut before = Vec::new();
|
||||
let mut after = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
let _ = std::hint::black_box(ctx.data(|d| d.get_temp::<(f64, Result<Vec<mb::Entry>, String>)>(old_id)).unwrap());
|
||||
}
|
||||
before.push(start.elapsed());
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
std::hint::black_box(ctx.data(|d| d.get_temp::<Listing>(new_id)).unwrap());
|
||||
}
|
||||
after.push(start.elapsed());
|
||||
}
|
||||
let (_, cached) = ctx.data(|d| d.get_temp::<Listing>(new_id)).unwrap();
|
||||
assert!(Arc::ptr_eq(&original, &cached));
|
||||
assert_eq!(cached.as_ref().as_ref().unwrap().last().unwrap().name, "Clip 0999.mov");
|
||||
before.sort();
|
||||
after.sort();
|
||||
println!("1,000 Media Browser entries, median per warm cache lookup: {:?} -> {:?}", before[2] / 1000, after[2] / 1000);
|
||||
}
|
||||
}
|
||||
@@ -84,11 +84,14 @@ pub const COLUMNS: [(&str, &str, f32); 6] = [
|
||||
|
||||
/// Sort siblings by the Project panel's sort column; ties fall back to the name.
|
||||
pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) {
|
||||
if col == "size" {
|
||||
sort_by_size(items, desc, file_size);
|
||||
return;
|
||||
}
|
||||
let name = |i: &Item| i.name.to_lowercase();
|
||||
items.sort_by(|a, b| {
|
||||
let o = match col {
|
||||
"type" => a.type_name().cmp(b.type_name()),
|
||||
"size" => file_size(a).cmp(&file_size(b)),
|
||||
"fps" => a.frame_rate().map(|r| r.as_f64()).partial_cmp(&b.frame_rate().map(|r| r.as_f64())).unwrap_or(std::cmp::Ordering::Equal),
|
||||
"duration" => a.duration().cmp(&b.duration()),
|
||||
"path" => file_path(a).cmp(file_path(b)),
|
||||
@@ -101,6 +104,15 @@ pub fn sort_items(items: &mut [&Item], col: &str, desc: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
fn sort_by_size(items: &mut [&Item], desc: bool, mut size_of: impl FnMut(&Item) -> Option<u64>) {
|
||||
let mut key = |it: &&Item| (size_of(it), it.name.to_lowercase());
|
||||
if desc {
|
||||
items.sort_by_cached_key(|it| std::cmp::Reverse(key(it)));
|
||||
} else {
|
||||
items.sort_by_cached_key(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a drop at a row lands: into a folder row, else into the row's folder; `None` row = root.
|
||||
pub fn drop_folder(project: &effectcraft_engine::project::Project, row: Option<ItemId>) -> Option<ItemId> {
|
||||
let it = project.item(row?)?;
|
||||
@@ -1113,6 +1125,136 @@ pub fn set_column(cols: &mut Vec<String>, key: &str, on: bool) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_engine::project::{Footage, Project};
|
||||
|
||||
struct SizeFiles(std::path::PathBuf);
|
||||
|
||||
impl SizeFiles {
|
||||
fn new() -> Self {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("effectcraft-size-{}-{n}", std::process::id()));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn add(&self, project: &mut Project, name: &str, bytes: Option<u64>) {
|
||||
let path = self.0.join(name);
|
||||
if let Some(bytes) = bytes {
|
||||
std::fs::File::create(&path).unwrap().set_len(bytes).unwrap();
|
||||
}
|
||||
project.add_item(name, Label::Aqua, None, ItemKind::Footage(Footage { path: path.to_string_lossy().into_owned(), ..Default::default() }));
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SizeFiles {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_keys_are_sampled_once_with_stable_ties() {
|
||||
let mut project = Project::default();
|
||||
for name in ["Zeta", "alpha", "ALPHA", "Missing", "Zero"] {
|
||||
project.add_item(name, Label::Aqua, None, ItemKind::Folder);
|
||||
}
|
||||
for (desc, expected) in [(false, ["Missing", "Zero", "alpha", "ALPHA", "Zeta"]), (true, ["Zeta", "alpha", "ALPHA", "Zero", "Missing"])] {
|
||||
let mut reads = std::collections::BTreeMap::new();
|
||||
let mut rows = project.children(None);
|
||||
sort_by_size(&mut rows, desc, |item| {
|
||||
*reads.entry(item.id).or_insert(0) += 1;
|
||||
match item.name.as_str() {
|
||||
"Zeta" => Some(30),
|
||||
"alpha" | "ALPHA" => Some(10),
|
||||
"Zero" => Some(0),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), expected);
|
||||
assert_eq!(reads.len(), 5);
|
||||
assert!(reads.values().all(|&count| count == 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_sort_reads_files_again_after_they_change() {
|
||||
let files = SizeFiles::new();
|
||||
let mut project = Project::default();
|
||||
project.add_item("Folder", Label::Aqua, None, ItemKind::Folder);
|
||||
files.add(&mut project, "Missing", None);
|
||||
files.add(&mut project, "Zero", Some(0));
|
||||
files.add(&mut project, "Small", Some(10));
|
||||
files.add(&mut project, "Large", Some(20));
|
||||
let mut rows = project.children(None);
|
||||
sort_items(&mut rows, "size", false);
|
||||
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Folder", "Missing", "Zero", "Small", "Large"]);
|
||||
sort_items(&mut rows, "size", true);
|
||||
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Large", "Small", "Zero", "Missing", "Folder"]);
|
||||
std::fs::OpenOptions::new().write(true).open(files.0.join("Small")).unwrap().set_len(30).unwrap();
|
||||
sort_items(&mut rows, "size", false);
|
||||
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Folder", "Missing", "Zero", "Large", "Small"]);
|
||||
std::fs::remove_file(files.0.join("Large")).unwrap();
|
||||
sort_items(&mut rows, "size", false);
|
||||
assert_eq!(rows.iter().map(|item| item.name.as_str()).collect::<Vec<_>>(), ["Folder", "Large", "Missing", "Zero", "Small"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_sort_accepts_empty_and_singleton_lists() {
|
||||
let mut project = Project::default();
|
||||
let id = project.add_item("Only", Label::Aqua, None, ItemKind::Folder);
|
||||
for desc in [false, true] {
|
||||
sort_items(&mut [], "size", desc);
|
||||
let mut rows = project.children(None);
|
||||
sort_items(&mut rows, "size", desc);
|
||||
assert_eq!(rows.iter().map(|item| item.id).collect::<Vec<_>>(), [id]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "filesystem benchmark; run with --ignored --nocapture"]
|
||||
fn size_sort_benchmark() {
|
||||
let files = SizeFiles::new();
|
||||
let mut project = Project::default();
|
||||
for i in 0..1000 {
|
||||
files.add(&mut project, &format!("Clip {i:04}"), Some((i * 137) % 1000 + 1));
|
||||
}
|
||||
let original = project.children(None);
|
||||
for item in &original {
|
||||
assert!(file_size(item).is_some());
|
||||
}
|
||||
let mut before_times = Vec::new();
|
||||
let mut after_times = Vec::new();
|
||||
let mut before_reads = 0;
|
||||
let mut after_reads = 0;
|
||||
for _ in 0..5 {
|
||||
let mut before = original.clone();
|
||||
before_reads = 0;
|
||||
let start = std::time::Instant::now();
|
||||
before.sort_by(|a, b| {
|
||||
before_reads += 2;
|
||||
file_size(a).cmp(&file_size(b)).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
before_times.push(start.elapsed());
|
||||
let mut after = original.clone();
|
||||
after_reads = 0;
|
||||
let start = std::time::Instant::now();
|
||||
sort_by_size(&mut after, false, |item| {
|
||||
after_reads += 1;
|
||||
file_size(item)
|
||||
});
|
||||
after_times.push(start.elapsed());
|
||||
assert_eq!(before.iter().map(|item| item.id).collect::<Vec<_>>(), after.iter().map(|item| item.id).collect::<Vec<_>>());
|
||||
assert_eq!(after_reads, 1000);
|
||||
assert!(before_reads > after_reads);
|
||||
}
|
||||
before_times.sort();
|
||||
after_times.sort();
|
||||
println!(
|
||||
"1,000 footage files; warmed metadata; five paired sorts\nmetadata reads/sort: {before_reads} -> {after_reads}\nmedian sort time: {:?} -> {:?}",
|
||||
before_times[2], after_times[2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ae_duration_and_size_formats() {
|
||||
|
||||
@@ -584,3 +584,28 @@ fn a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation() {
|
||||
assert!(h.state().auto.find("form.field.missingFrames").is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn illustrator_import_offers_and_applies_retain_layer_sizes() {
|
||||
let path = std::env::temp_dir().join(format!("effectcraft-retain-ui-{}.ai", std::process::id()));
|
||||
std::fs::write(&path, b"%PDF-1.4\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 120 80] /Contents 4 0 R >> endobj\n4 0 obj << >> stream\n1 0 0 rg 20 15 30 20 re f\nendstream endobj\n%%EOF").unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(effectcraft_host::session()));
|
||||
h.run_steps(3);
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({"paths": [path]})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Import PDF / Illustrator File").is_some());
|
||||
click_id(&mut h, "form.field.importAs");
|
||||
h.get_by_label("Composition - Retain Layer Sizes").click();
|
||||
h.run_steps(2);
|
||||
click_id(&mut h, "form.ok");
|
||||
step_until(&mut h, |h| h.state().session.active_comp().is_some());
|
||||
let s = &h.state().session;
|
||||
let comp = s.active_comp().unwrap();
|
||||
assert_eq!((comp.width, comp.height), (120, 80));
|
||||
assert_eq!(comp.layers.len(), 1);
|
||||
let effectcraft_engine::project::LayerSource::Footage { item } = comp.layers[0].source else { panic!("footage") };
|
||||
let effectcraft_engine::project::ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") };
|
||||
assert_eq!((f.width, f.height), (30, 20));
|
||||
assert!(f.layer.as_ref().unwrap().layer_size);
|
||||
}
|
||||
@@ -126,6 +126,102 @@ fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: egui::Mod
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_and_paste_key_subsets_across_position_rotation_and_scale() {
|
||||
for graph in [false, true] {
|
||||
let (mut h, id, _) = harness();
|
||||
let paths = ["transform/position", "transform/rotation", "transform/scale"];
|
||||
for (path, times, values) in [
|
||||
(paths[0], [0.5, 1.0], [json!([100, 200, 0]), json!([300, 400, 0])]),
|
||||
(paths[1], [0.5, 1.5], [json!(500), json!(900)]),
|
||||
(paths[2], [0.0, 1.0], [json!([1500, 1600, 100]), json!([1700, 1800, 100])]),
|
||||
] {
|
||||
for (time, value) in times.into_iter().zip(values) {
|
||||
h.state_mut().session.execute("prop.addKey", json!({"layer": id.0, "path": path, "time": time, "value": value})).unwrap();
|
||||
}
|
||||
}
|
||||
let uids: Vec<u64> = paths.iter().map(|path| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(path).unwrap().uid).collect();
|
||||
let ctx = h.ctx.clone();
|
||||
for command in ["timeline.reveal.position", "timeline.revealAdd.rotation", "timeline.revealAdd.scale"] {
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, command, json!({})).unwrap();
|
||||
}
|
||||
if graph {
|
||||
h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap();
|
||||
for uid in &uids {
|
||||
h.state_mut().session.execute("prop.select", json!({"layer": id.0, "prop": uid, "add": true, "selectKeys": false})).unwrap();
|
||||
}
|
||||
h.state_mut().ui.timeline.graph_editor = true;
|
||||
h.state_mut().ui.timeline.graph_mode = "value".into();
|
||||
}
|
||||
h.run_steps(4);
|
||||
let key_positions = |h: &Harness<'_, EffectcraftApp>, uid: u64| {
|
||||
if !graph {
|
||||
return keys(h, uid);
|
||||
}
|
||||
let mut positions: Vec<Pos2> = h
|
||||
.state()
|
||||
.auto
|
||||
.query(&format!("timeline.graph.key.{uid}.0."))
|
||||
.iter()
|
||||
.map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0))
|
||||
.collect();
|
||||
positions.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
positions
|
||||
};
|
||||
let shift = egui::Modifiers { shift: true, ..Default::default() };
|
||||
for include_scale in [true, false] {
|
||||
h.state_mut().session.execute("keys.select", json!({"keys": []})).unwrap();
|
||||
h.run_steps(2);
|
||||
let original: Vec<_> =
|
||||
paths.iter().map(|path| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(path).unwrap().keys.clone()).collect();
|
||||
let positions = key_positions(&h, uids[0]);
|
||||
let rotations = key_positions(&h, uids[1]);
|
||||
let scales = key_positions(&h, uids[2]);
|
||||
click_with(&mut h, scales[0], Default::default());
|
||||
click_with(&mut h, positions[0], shift);
|
||||
click_with(&mut h, rotations[1], shift);
|
||||
if !include_scale {
|
||||
click_with(&mut h, scales[1], shift);
|
||||
click_with(&mut h, scales[0], shift);
|
||||
click_with(&mut h, scales[1], shift);
|
||||
}
|
||||
let count = if include_scale { 3 } else { 2 };
|
||||
assert_eq!(h.state().session.state.selected_keys.len(), count);
|
||||
assert_eq!(h.state().session.state.selected_props.len(), 3);
|
||||
h.input_mut().events.push(Event::Copy);
|
||||
h.step();
|
||||
let note = format!("EffectCraft: {count} keyframes");
|
||||
assert!(h.output().platform_output.commands.iter().any(|command| matches!(command, egui::OutputCommand::CopyText(text) if text == ¬e)));
|
||||
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(2.0));
|
||||
let undo_steps = h.state().session.history.undo.len();
|
||||
h.input_mut().events.push(Event::Paste(note));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.history.undo.len(), undo_steps + 1);
|
||||
for (path, time, value) in
|
||||
[(paths[0], if include_scale { 2.5 } else { 2.0 }, json!([100.0, 200.0, 0.0])), (paths[1], if include_scale { 3.5 } else { 3.0 }, json!(900.0))]
|
||||
{
|
||||
let p = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(path).unwrap();
|
||||
assert_eq!(p.keys.len(), 3);
|
||||
assert_eq!(p.keys[2].time.seconds(), time);
|
||||
assert_eq!(p.keys[2].value.to_json(), value);
|
||||
}
|
||||
let scale = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(paths[2]).unwrap();
|
||||
if include_scale {
|
||||
assert_eq!(scale.keys.len(), 3);
|
||||
assert_eq!(scale.keys[2].time.seconds(), 2.0);
|
||||
assert_eq!(scale.keys[2].value.to_json(), json!([1500.0, 1600.0, 100.0]));
|
||||
} else {
|
||||
assert_eq!(scale.keys, original[2]);
|
||||
}
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
for (path, expected) in paths.iter().zip(&original) {
|
||||
assert_eq!(&h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop(path).unwrap().keys, expected);
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_toggles_and_ctrl_click_switches_interpolation() {
|
||||
let (mut h, id, uid) = harness();
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use effectcraft_engine::{
|
||||
Session,
|
||||
media_browser::{Browser, Entry, VirtualTree},
|
||||
};
|
||||
use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind};
|
||||
use egui::{Event, Modifiers, PointerButton, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Default)]
|
||||
struct MutableBrowser {
|
||||
tree: Mutex<VirtualTree>,
|
||||
offline: AtomicBool,
|
||||
}
|
||||
|
||||
impl Browser for MutableBrowser {
|
||||
fn home(&self) -> String {
|
||||
"/Shoot".into()
|
||||
}
|
||||
fn list(&self, dir: &str, only: bool) -> Result<Vec<Entry>, String> {
|
||||
if self.offline.load(Ordering::Relaxed) {
|
||||
return Err("Folder temporarily unavailable".into());
|
||||
}
|
||||
self.tree.lock().unwrap().list(dir, only)
|
||||
}
|
||||
fn actions(&self) -> Vec<(String, String)> {
|
||||
vec![("refresh".into(), "Refresh".into())]
|
||||
}
|
||||
fn action(&self, id: &str) -> Result<Value, String> {
|
||||
assert_eq!(id, "refresh");
|
||||
self.tree.lock().unwrap().add_file("/Shoot/From action.csv", "/files/action.csv", 1, None);
|
||||
Ok(json!({}))
|
||||
}
|
||||
}
|
||||
|
||||
type Listing = (f64, Arc<Result<Vec<Entry>, String>>);
|
||||
|
||||
fn listing(h: &Harness<'_, EffectcraftApp>, only: bool) -> Listing {
|
||||
let dir = h.state().session.state.media_browser.folder.clone().unwrap_or_else(|| "/Shoot".into());
|
||||
h.ctx.data(|d| d.get_temp(egui::Id::new(("mb-list", dir, only)))).unwrap()
|
||||
}
|
||||
|
||||
fn names(list: &Arc<Result<Vec<Entry>, String>>) -> Vec<&str> {
|
||||
list.as_ref().as_ref().unwrap().iter().map(|e| e.name.as_str()).collect()
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}"));
|
||||
let pos = pos2(e.rect[0] + 8.0, e.rect[1] + e.rect[3] / 2.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(pos));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn at(h: &mut Harness<'_, EffectcraftApp>, time: f64) {
|
||||
h.input_mut().time = Some(time);
|
||||
h.step();
|
||||
h.input_mut().time = Some(time);
|
||||
h.step();
|
||||
h.input_mut().time = None;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_cache_refreshes_filters_navigates_and_recovers_from_errors() {
|
||||
let browser = Arc::new(MutableBrowser::default());
|
||||
{
|
||||
let mut tree = browser.tree.lock().unwrap();
|
||||
tree.add_file("/Shoot/Clip.csv", "/files/clip.csv", 1, None);
|
||||
tree.add_file("/Shoot/notes.txt", "/files/notes.txt", 1, None);
|
||||
tree.add_file("/Shoot/Sub/Child.csv", "/files/child.csv", 1, None);
|
||||
}
|
||||
let mut session = Session { browser: Some(browser.clone()), ..Default::default() };
|
||||
session.prefs.general.language = "en".into();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| {
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.ui.start_screen = false;
|
||||
app.show_panel(PanelKind::MediaBrowser);
|
||||
app.ui.maximized = Some(PanelKind::MediaBrowser);
|
||||
app
|
||||
});
|
||||
h.run_steps(3);
|
||||
let (time, first) = listing(&h, false);
|
||||
assert_eq!(names(&first), ["Sub", "Clip.csv", "notes.txt"]);
|
||||
assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some());
|
||||
browser.tree.lock().unwrap().add_file("/Shoot/Added.csv", "/files/added.csv", 2, Some(5));
|
||||
at(&mut h, time + 0.999);
|
||||
assert!(Arc::ptr_eq(&first, &listing(&h, false).1));
|
||||
assert!(h.state().auto.find("mediaBrowser.entry.Added.csv").is_none());
|
||||
at(&mut h, time + 1.0);
|
||||
let refreshed = listing(&h, false).1;
|
||||
assert_eq!(names(&refreshed), ["Sub", "Added.csv", "Clip.csv", "notes.txt"]);
|
||||
assert!(h.state().auto.find("mediaBrowser.entry.Added.csv").is_some());
|
||||
assert_eq!(names(&first), ["Sub", "Clip.csv", "notes.txt"]);
|
||||
click(&mut h, "mediaBrowser.importableOnly");
|
||||
assert!(h.state().session.state.media_browser.importable_only);
|
||||
assert_eq!(names(&listing(&h, true).1), ["Sub", "Added.csv", "Clip.csv"]);
|
||||
assert!(h.state().auto.find("mediaBrowser.entry.notes.txt").is_none());
|
||||
click(&mut h, "mediaBrowser.importableOnly");
|
||||
assert!(!h.state().session.state.media_browser.importable_only);
|
||||
assert!(Arc::ptr_eq(&refreshed, &listing(&h, false).1));
|
||||
click(&mut h, "mediaBrowser.action.refresh");
|
||||
assert_eq!(names(&listing(&h, false).1), ["Sub", "Added.csv", "Clip.csv", "From action.csv", "notes.txt"]);
|
||||
assert!(h.state().auto.find("mediaBrowser.entry.From action.csv").is_some());
|
||||
assert_eq!(names(&refreshed), ["Sub", "Added.csv", "Clip.csv", "notes.txt"]);
|
||||
h.run_steps(40);
|
||||
click(&mut h, "mediaBrowser.entry.Sub");
|
||||
click(&mut h, "mediaBrowser.entry.Sub");
|
||||
assert_eq!(h.state().session.state.media_browser.folder.as_deref(), Some("/Shoot/Sub"));
|
||||
assert_eq!(names(&listing(&h, false).1), ["Child.csv"]);
|
||||
assert!(h.state().auto.find("mediaBrowser.entry.Child.csv").is_some());
|
||||
browser.tree.lock().unwrap().remove("/Shoot/Sub/Child.csv");
|
||||
let time = listing(&h, false).0;
|
||||
at(&mut h, time + 1.0);
|
||||
assert_eq!(names(&listing(&h, false).1), Vec::<&str>::new());
|
||||
assert!(h.state().auto.find("mediaBrowser.entry.Child.csv").is_none());
|
||||
click(&mut h, "mediaBrowser.up");
|
||||
assert_eq!(h.state().session.state.media_browser.folder.as_deref(), Some("/Shoot"));
|
||||
assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some());
|
||||
browser.offline.store(true, Ordering::Relaxed);
|
||||
let time = listing(&h, false).0;
|
||||
at(&mut h, time + 1.0);
|
||||
let (time, error) = listing(&h, false);
|
||||
assert_eq!(error.as_ref(), &Err("Folder temporarily unavailable".into()));
|
||||
assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "Folder temporarily unavailable")));
|
||||
assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_none());
|
||||
browser.offline.store(false, Ordering::Relaxed);
|
||||
browser.tree.lock().unwrap().remove("/Shoot/notes.txt");
|
||||
at(&mut h, time + 0.999);
|
||||
assert!(Arc::ptr_eq(&error, &listing(&h, false).1));
|
||||
at(&mut h, time + 1.0);
|
||||
assert_eq!(names(&listing(&h, false).1), ["Sub", "Added.csv", "Clip.csv", "From action.csv"]);
|
||||
assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some());
|
||||
if let Ok(path) = std::env::var("MEDIA_BROWSER_SNAPSHOT") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::{
|
||||
Session,
|
||||
media_browser::{Browser, Entry, VirtualTree},
|
||||
};
|
||||
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp, control, dock::PanelKind};
|
||||
use egui::{Event, Modifiers, OutputCommand, PointerButton, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct TestBrowser(VirtualTree);
|
||||
impl Browser for TestBrowser {
|
||||
fn home(&self) -> String {
|
||||
"/Media".into()
|
||||
}
|
||||
fn list(&self, dir: &str, only: bool) -> Result<Vec<Entry>, String> {
|
||||
self.0.list(dir, only)
|
||||
}
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str, button: PointerButton) -> Vec<String> {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}"));
|
||||
let pos = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(pos));
|
||||
h.step();
|
||||
let mut copied = Vec::new();
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
copied.extend(h.output().platform_output.commands.iter().filter_map(|c| match c {
|
||||
OutputCommand::CopyText(text) => Some(text.clone()),
|
||||
_ => None,
|
||||
}));
|
||||
}
|
||||
copied
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_folder_and_virtual_paths_copy_from_the_context_menu_without_document_changes() {
|
||||
let cases = [
|
||||
("作品 clip.csv", "/Users/example/Shoot 01/作品 clip.csv"),
|
||||
("Notes.txt", "./relative notes.txt"),
|
||||
("Virtual.csv", "browser://assets/opaque%20作品.csv?version=2"),
|
||||
("Folder 作品", "/Media/Folder 作品"),
|
||||
];
|
||||
let mut tree = VirtualTree::default();
|
||||
for (name, path) in cases.iter().take(3) {
|
||||
tree.add_file(&format!("/Media/{name}"), path, 1, None);
|
||||
}
|
||||
tree.add_dir("/Media/Folder 作品");
|
||||
let mut session = Session { browser: Some(Arc::new(TestBrowser(tree))), ..Default::default() };
|
||||
session.prefs.general.language = "en".into();
|
||||
session.execute("comp.new", json!({"width": 64, "height": 36, "duration": 2})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| {
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.ui.start_screen = false;
|
||||
app.show_panel(PanelKind::MediaBrowser);
|
||||
app.ui.maximized = Some(PanelKind::MediaBrowser);
|
||||
app
|
||||
});
|
||||
h.run_steps(3);
|
||||
let project = h.state().session.project.clone();
|
||||
let revision = h.state().session.revision;
|
||||
let history = h.state().session.history.undo.len();
|
||||
let browser = serde_json::to_value(&h.state().session.state.media_browser).unwrap();
|
||||
for (name, path) in cases {
|
||||
let id = format!("mediaBrowser.entry.{name}.copySourcePath");
|
||||
assert!(h.state().auto.find(&id).is_none());
|
||||
assert!(click(&mut h, &format!("mediaBrowser.entry.{name}"), PointerButton::Secondary).is_empty());
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find(&id).is_some());
|
||||
if name == "作品 clip.csv"
|
||||
&& let Ok(file) = std::env::var("MEDIA_BROWSER_COPY_SNAPSHOT")
|
||||
{
|
||||
h.render().unwrap().save(file).unwrap();
|
||||
}
|
||||
assert_eq!(click(&mut h, &id, PointerButton::Primary), [path]);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find(&id).is_none());
|
||||
assert_eq!(h.state().session.project, project);
|
||||
assert_eq!(h.state().session.revision, revision);
|
||||
assert_eq!(h.state().session.history.undo.len(), history);
|
||||
assert_eq!(serde_json::to_value(&h.state().session.state.media_browser).unwrap(), browser);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_path_control_command_preserves_the_path_and_rejects_bad_arguments() {
|
||||
let mut app = EffectcraftApp::new(Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
let project = app.session.project.clone();
|
||||
let revision = app.session.revision;
|
||||
let history = app.session.history.undo.len();
|
||||
for params in [json!({"path": " ./作品 clip.csv "}), json!({}), json!({"path": ""}), json!({"path": 42}), json!({"path": null})] {
|
||||
let mut reply = None;
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let (request, _rx) = ControlRequest::new("ui.menu.invoke", json!({"id": "mediaBrowser.copySourcePath", "params": params}));
|
||||
reply = match control::handle(&mut app, ui.ctx(), &request) {
|
||||
control::Outcome::Done(value) => Some(value),
|
||||
_ => panic!("expected immediate reply"),
|
||||
};
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let copied: Vec<_> = out
|
||||
.platform_output
|
||||
.commands
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
OutputCommand::CopyText(text) => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if params.get("path").and_then(Value::as_str).is_some_and(|s| !s.is_empty()) {
|
||||
assert_eq!(reply, Some(json!({"ok": true, "result": {"path": " ./作品 clip.csv "}})));
|
||||
assert_eq!(copied, [" ./作品 clip.csv "]);
|
||||
} else {
|
||||
assert_eq!(reply, Some(json!({"ok": false, "error": "A source path is required"})));
|
||||
assert!(copied.is_empty());
|
||||
}
|
||||
assert_eq!(app.session.project, project);
|
||||
assert_eq!(app.session.revision, revision);
|
||||
assert_eq!(app.session.history.undo.len(), history);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Effects & Presets: copy the configured folder path through the contents menu.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::panels::effects_presets::PresetFile;
|
||||
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp, control, dock::PanelKind};
|
||||
use egui::{Event, Modifiers, OutputCommand, PointerButton, pos2};
|
||||
use egui_kittest::Harness;
|
||||
@@ -18,8 +19,9 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) -> Vec<String> {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}"));
|
||||
let pos = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
|
||||
let mut copied = Vec::new();
|
||||
h.input_mut().events.push(Event::PointerMoved(pos));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerMoved(pos));
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for command in &h.output().platform_output.commands {
|
||||
@@ -34,26 +36,29 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) -> Vec<String> {
|
||||
#[test]
|
||||
fn presets_menu_copies_the_configured_folder_without_editing_the_project() {
|
||||
let expected = expected_folder();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| {
|
||||
let mut app = EffectcraftApp::new(Session::default());
|
||||
app.ui.start_screen = false;
|
||||
app.show_panel(PanelKind::EffectsPresets);
|
||||
app
|
||||
});
|
||||
h.run_steps(3);
|
||||
let before = serde_json::to_value(&h.state().session.project).unwrap();
|
||||
let revision = h.state().session.revision;
|
||||
assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none());
|
||||
click(&mut h, "effects.menu");
|
||||
h.run_steps(3);
|
||||
if let Ok(path) = std::env::var("PRESETS_FOLDER_SNAPSHOT") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
for language in ["en", "ja"] {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| {
|
||||
let mut app = EffectcraftApp::new(Session::default());
|
||||
app.session.prefs.general.language = language.into();
|
||||
app.ui.start_screen = false;
|
||||
app.show_panel(PanelKind::EffectsPresets);
|
||||
app
|
||||
});
|
||||
h.run_steps(3);
|
||||
let before = serde_json::to_value(&h.state().session.project).unwrap();
|
||||
let revision = h.state().session.revision;
|
||||
assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none());
|
||||
click(&mut h, "effects.menu");
|
||||
h.run_steps(3);
|
||||
if let Ok(path) = std::env::var("PRESETS_FOLDER_SNAPSHOT") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
}
|
||||
assert_eq!(click(&mut h, "effects.copyPresetsFolderPath"), [expected.as_str()]);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none());
|
||||
assert_eq!(serde_json::to_value(&h.state().session.project).unwrap(), before);
|
||||
assert_eq!(h.state().session.revision, revision);
|
||||
}
|
||||
assert_eq!(click(&mut h, "effects.copyPresetsFolderPath"), [expected]);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().auto.find("effects.copyPresetsFolderPath").is_none());
|
||||
assert_eq!(serde_json::to_value(&h.state().session.project).unwrap(), before);
|
||||
assert_eq!(h.state().session.revision, revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -88,3 +93,74 @@ fn presets_folder_control_command_returns_the_path_or_a_configuration_error() {
|
||||
assert_eq!(copied, [expected.as_str()]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_preset_rows_search_apply_and_undo_the_selected_definition() {
|
||||
struct Files(std::path::PathBuf);
|
||||
impl Drop for Files {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let files = Files(std::env::temp_dir().join(format!(
|
||||
"ec-preset-rows-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
||||
)));
|
||||
std::fs::create_dir(&files.0).unwrap();
|
||||
let mut entries = Vec::new();
|
||||
for (name, value) in [("Custom Cache A", 35.0), ("Custom Cache B", 70.0)] {
|
||||
let path = files.0.join(format!("{name}.ecpreset"));
|
||||
let preset = json!({"effectcraftAnimationPreset": 1, "name": name, "properties": [{"path": "transform/opacity", "value": {"t": "Scalar", "v": value}}], "effects": []});
|
||||
std::fs::write(&path, serde_json::to_vec(&preset).unwrap()).unwrap();
|
||||
entries.push(PresetFile { name: name.into(), path: path.to_string_lossy().into_owned() });
|
||||
}
|
||||
let mut session = Session::default();
|
||||
session.prefs.general.language = "en".into();
|
||||
session.execute("comp.new", json!({"width": 64, "height": 36, "duration": 2})).unwrap();
|
||||
session.execute("layer.newText", json!({"text": "Cache"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| {
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.ui.start_screen = false;
|
||||
app.show_panel(PanelKind::EffectsPresets);
|
||||
app
|
||||
});
|
||||
h.run_steps(3);
|
||||
let shared = std::sync::Arc::new(entries);
|
||||
let now = h.ctx.input(|i| i.time);
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("fx-user-presets"), (now, shared.clone())));
|
||||
let search = |h: &mut Harness<'_, EffectcraftApp>, text: &str| {
|
||||
click(h, "effects.search");
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
|
||||
h.input_mut().events.push(Event::Text(text.into()));
|
||||
h.run_steps(40);
|
||||
};
|
||||
search(&mut h, "Typewriter");
|
||||
assert!(h.state().auto.find("effects.preset.text.typewriter").is_some());
|
||||
assert!(h.state().auto.find("effects.preset.file.Custom Cache A").is_none());
|
||||
click(&mut h, "effects.preset.text.typewriter");
|
||||
click(&mut h, "effects.preset.text.typewriter");
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().groups().count(), 1);
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
for (name, expected) in [("Custom Cache A", 35.0), ("Custom Cache B", 70.0)] {
|
||||
search(&mut h, name);
|
||||
let id = format!("effects.preset.file.{name}");
|
||||
let history = h.state().session.history.undo.len();
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), 100.0);
|
||||
click(&mut h, &id);
|
||||
click(&mut h, &id);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), expected);
|
||||
assert_eq!(h.state().session.history.undo.len(), history + 1);
|
||||
if let Ok(dir) = std::env::var("PRESET_CACHE_SNAPSHOTS") {
|
||||
h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap();
|
||||
}
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), 100.0);
|
||||
}
|
||||
let (_, cached) = h.ctx.data(|d| d.get_temp::<(f64, std::sync::Arc<Vec<PresetFile>>)>(egui::Id::new("fx-user-presets"))).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&shared, &cached));
|
||||
assert_eq!(shared.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Custom Cache A", "Custom Cache B"]);
|
||||
}
|
||||
@@ -219,3 +219,45 @@ fn waiting_commands_reply_when_their_offloaded_job_ends() {
|
||||
let v = reply.try_recv().expect("immediate reply");
|
||||
assert_eq!(v["result"]["rendering"], true, "{v}");
|
||||
}
|
||||
|
||||
/// Completion errors belong to the waiting command's jobs, including when several
|
||||
/// different analyses complete in the same UI frame.
|
||||
#[test]
|
||||
fn waiting_commands_report_only_their_own_completion_errors() {
|
||||
use effectcraft_engine::offload::JobKind;
|
||||
use effectcraft_ui_egui::control::JobWaiter;
|
||||
|
||||
for (kind, job_id) in [
|
||||
(JobKind::Render, "render"),
|
||||
(JobKind::Warp, "warp"),
|
||||
(JobKind::WarpPlan, "warpPlan"),
|
||||
(JobKind::Camera, "camera"),
|
||||
(JobKind::RotoFreeze, "roto"),
|
||||
(JobKind::RotoPropagate, "roto"),
|
||||
(JobKind::Track, "track"),
|
||||
(JobKind::MaskTrack, "maskTrack"),
|
||||
(JobKind::ContentFill, "contentFill"),
|
||||
] {
|
||||
let other = if kind == JobKind::Track { JobKind::Warp } else { JobKind::Track };
|
||||
let mut app = EffectcraftApp::new(Session::default());
|
||||
app.session.offload_log = vec![(kind, Some("old error".into())), (other, Some("unrelated failure".into())), (kind, None)];
|
||||
let waiter = || JobWaiter { command: "analysis".into(), params: json!({}), result: json!({"running": true}), jobs: vec![job_id.into()], log_cursor: 1 };
|
||||
let result = waiter().finish(&mut app);
|
||||
assert_eq!(result, json!({"ok": true, "result": {"running": false}}), "{kind:?}");
|
||||
app.session.offload_log.push((kind, Some("nothing was applied".into())));
|
||||
let result = waiter().finish(&mut app);
|
||||
assert_eq!(result, json!({"ok": false, "error": "nothing was applied"}), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_command_sees_its_error_after_the_completion_log_rolls_over() {
|
||||
use effectcraft_engine::offload::JobKind;
|
||||
use effectcraft_ui_egui::control::JobWaiter;
|
||||
let mut app = EffectcraftApp::new(Session::default());
|
||||
app.session.offload_log = vec![(JobKind::Warp, None); 31];
|
||||
app.session.offload_log.push((JobKind::Track, Some("nothing was applied".into())));
|
||||
app.session.offload_log_start = 1;
|
||||
let waiter = JobWaiter { command: "track.analyze".into(), params: json!({}), result: json!({}), jobs: vec!["track".into()], log_cursor: 32 };
|
||||
assert_eq!(waiter.finish(&mut app), json!({"ok": false, "error": "nothing was applied"}));
|
||||
}
|
||||
+14
-9
@@ -10,15 +10,6 @@ user can do, an agent can do too. There are three ways in:
|
||||
| **CLI** (`effectcraft-cli exec/get/set/...`) | one-shot scripting and CI; JSON output with `--json` | no |
|
||||
| **Control channel** (`effectcraft --control 9877`) | driving and seeing the live UI; see [control-protocol.md](control-protocol.md) | yes |
|
||||
|
||||
## Find the presets folder
|
||||
|
||||
The native Effects & Presets panel's options menu includes **Copy Presets Folder Path**. It copies
|
||||
the folder the panel scans: `EC_PRESETS_DIR` when set, otherwise `~/Documents/EffectCraft/Presets`
|
||||
using `HOME` or `USERPROFILE`. Relative paths are resolved to an absolute path. The folder may
|
||||
not exist yet; this action does not create it.
|
||||
Over the live UI control channel, `ui.menu.invoke {"id":"effects.copyPresetsFolderPath"}` copies
|
||||
the same path and returns `{path}`. The web app reports that it has no local presets folder.
|
||||
|
||||
## MCP setup
|
||||
|
||||
Build once with `cargo build --release -p effectcraft-cli`, then register the server. For Claude
|
||||
@@ -52,6 +43,11 @@ Desktop, Cursor and the like) take the same `command` and `args`.
|
||||
- **Bridge** (`["mcp", "--bridge", "9877"]`): drives a running `effectcraft --control 9877`, so you
|
||||
see every change live. Bridge mode adds `screenshot` and the `ui_*` tools.
|
||||
|
||||
After a broken connection, the bridge retries explicitly read-only control queries once.
|
||||
Engine commands, UI edits and unknown methods whose transmission or reply fails report an
|
||||
unknown outcome: the app may already have applied them. Inspect the app before explicitly
|
||||
retrying. A later call reconnects; timeouts and malformed replies are never automatically resent.
|
||||
|
||||
### From an installed release
|
||||
|
||||
The release packages ship `effectcraft-cli` alongside the desktop app, so no build is needed:
|
||||
@@ -714,3 +710,12 @@ open the demo project; without it the app starts with an empty project) and use
|
||||
mode or the raw control channel. A good loop is: `ui_elements` to find an id, `ui_click` or `ui_drag`
|
||||
to act, then `screenshot {"panel":"Timeline"}` to check the result. `render_frame` shows the
|
||||
composition itself at any zoom.
|
||||
|
||||
### Effects & Presets
|
||||
|
||||
The native Effects & Presets panel's options menu includes **Copy Presets Folder Path**. It copies
|
||||
the folder the panel scans: `EC_PRESETS_DIR` when set, otherwise `~/Documents/EffectCraft/Presets`
|
||||
using `HOME` or `USERPROFILE`. Relative paths are resolved to an absolute path. The folder may
|
||||
not exist yet; this action does not create it.
|
||||
Over the live UI control channel, `ui.menu.invoke {"id":"effects.copyPresetsFolderPath"}` copies
|
||||
the same path and returns `{path}`. The web app reports that it has no local presets folder.
|
||||
@@ -721,3 +721,5 @@ JSON parsing is now the bulk of open), and rendering 5,000-layer comps faster.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Status line added; crate table checked against `crates/` (every crate listed); effect count and VP9 encoder description corrected |
|
||||
|
||||
PDF document structure uses the `craft-pdf` read feature through an app-owned bounded object adapter. EffectCraft retains its drawing tree, fonts, content/image filters and EPS interpreter; ordinary object/xref limits remain 256 MiB on 64-bit and 64 MiB on 32-bit/WASM, including repair.
|
||||
@@ -37,6 +37,7 @@ printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params
|
||||
|---|---|---|
|
||||
| `engine.execute` | `{command, params?}` | The command's result. Every engine command (`effectcraft-cli commands`) runs exactly as it does headless, so it never opens a dialog: `comp.new {}` creates a default comp. UI-only ids (`tool.*`, `view.*`, `window.*`, `playback.*`, `timeline.*`, `app.*`) fall through to the menu dispatcher. Unknown top-level params are rejected, and the error lists the accepted keys. |
|
||||
| `ui.menu.invoke` | `{id, params?}` | Like a menu click: commands with empty params may open their dialog (New Composition, Solid Settings, file pickers). |
|
||||
| Copy a Media Browser source path | `ui.menu.invoke {id: "mediaBrowser.copySourcePath", params: {path}}` | Copies the exact non-empty path string to the clipboard and returns `{path}`. Supports file/folder paths and virtual source identifiers. Missing, empty or non-string paths return an error. |
|
||||
| UI commands (via either) | | `timeline.column {column, visible?}` (av, keys, label, num, comment, switches, modes, parent, in, out, duration, stretch), `timeline.sourceName {value?}`, `timeline.search {query}`, `timeline.reveal.<kind>` / `timeline.revealAdd.<kind>` (the property shortcuts and Shift+shortcut: position, scale, rotation, opacity, anchor, effects, masks, feather, levels, animated, and the double-press sets maskPath, maskOpacity, timeRemap, expressions, material, paint, selected, missingEffects, waveform), `timeline.keyAt.<anchor\|position\|scale\|rotation\|opacity>` (Alt+Shift+A/P/S/R/T), `timeline.twirlSelected` (Ctrl/Cmd+`), `timeline.collapseAll`, `flowchart.options {layers?, effects?, solids?, direction?: lr\|tb, comp?}`, `flowchart.graph {comp?}` → `{nodes, edges}`, `window.workspace {name}` (all After Effects workspaces). |
|
||||
| `engine.commands` | `{filter?, enabledOnly?}` | `[{id, label, menu, shortcut, params, enabled, why}]` |
|
||||
| `ui.menu.list` | `{}` | The menu tree with enablement. |
|
||||
|
||||
+6
-3
@@ -1,4 +1,4 @@
|
||||
//! Workspace automation: `cargo xtask <layers|assets|wasm|web|ico|ci>`.
|
||||
//! Workspace automation: `cargo xtask <layers|assets|pdf-deps|wasm|web|ico|ci>`.
|
||||
//!
|
||||
//! - `layers`: enforces the dependency layering of `docs/architecture.md` §1 (downward-only edges,
|
||||
//! listed same-layer edges, no UI/OS crates below L5).
|
||||
@@ -9,12 +9,13 @@
|
||||
//! - `assets`: every asset file (image, icon, font, LUT, audio, video…) has a complete
|
||||
//! `<file>.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1).
|
||||
//! - `ico <out.ico> <in.png>…`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`).
|
||||
//! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm.
|
||||
//! - `ci`: fmt check, clippy -D warnings, tests, PDF dependency policy, layers, assets, wasm.
|
||||
//! - `bench [--bin CLI] [--quick] [--compare OLD.jsonl] …`: render benchmark of generated scenarios
|
||||
//! (memory, CPU, wall time) into `target/bench/results.jsonl` (`bench.rs`, docs/testing.md).
|
||||
|
||||
mod bench;
|
||||
mod ico;
|
||||
mod pdf_deps;
|
||||
mod version;
|
||||
|
||||
use std::process::{Command, ExitCode};
|
||||
@@ -410,6 +411,7 @@ fn ci() -> Result<(), String> {
|
||||
run(Command::new(cargo).args(["fmt", "--check"]))?;
|
||||
run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--release", "--", "-D", "warnings"]))?;
|
||||
run(Command::new(cargo).args(["test", "--workspace", "--release"]))?;
|
||||
pdf_deps::run(&root())?;
|
||||
layers()?;
|
||||
assets()?;
|
||||
wasm()
|
||||
@@ -421,6 +423,7 @@ fn main() -> ExitCode {
|
||||
"layers" => layers(),
|
||||
"wasm" => wasm(),
|
||||
"assets" => assets(),
|
||||
"pdf-deps" => pdf_deps::run(&root()),
|
||||
"ci" => ci(),
|
||||
"web" => web(&std::env::args().skip(2).collect::<Vec<_>>()),
|
||||
"bench" => bench::run(&root(), &std::env::args().skip(2).collect::<Vec<_>>()),
|
||||
@@ -432,7 +435,7 @@ fn main() -> ExitCode {
|
||||
let rest: Vec<String> = std::env::args().skip(2).collect();
|
||||
version::run(&root(), &rest.iter().map(String::as_str).collect::<Vec<_>>())
|
||||
}
|
||||
_ => Err("usage: cargo xtask <layers|assets|wasm|web [--dev] [--serve PORT]|ico OUT.ico IN.png…|version [set X.Y.Z]|ci|bench [--bin CLI] [--quick] [--project F --comp C] [--compare OLD.jsonl] (--help)>".into()),
|
||||
_ => Err("usage: cargo xtask <layers|assets|pdf-deps|wasm|web [--dev] [--serve PORT]|ico OUT.ico IN.png…|version [set X.Y.Z]|ci|bench [--bin CLI] [--quick] [--project F --comp C] [--compare OLD.jsonl] (--help)>".into()),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Pin the shared parser and reject interpreter features that would embed backend assets.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const REV: &str = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d";
|
||||
const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("hayro-syntax", "0.7.2")];
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
|
||||
.current_dir(root)
|
||||
.args(["metadata", "--locked", "--format-version", "1"])
|
||||
.output()
|
||||
.map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr)));
|
||||
}
|
||||
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
validate(&metadata)?;
|
||||
println!("PDF backend: pinned parser only; no Hayro interpreter, rasterizer or bundled CMaps");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate(metadata: &Value) -> Result<(), String> {
|
||||
let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?;
|
||||
let nodes = metadata.pointer("/resolve/nodes").and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?;
|
||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}");
|
||||
for (name, version) in PACKAGES {
|
||||
let matches: Vec<_> = packages.iter().filter(|p| p["name"] == *name).collect();
|
||||
if matches.len() != 1 || matches[0]["version"] != *version || matches[0]["source"] != source {
|
||||
return Err(format!("{name}: expected exactly {version} from {source}"));
|
||||
}
|
||||
}
|
||||
for (name, allowed) in [("craft-pdf", "read"), ("hayro-syntax", "std")] {
|
||||
let package = packages.iter().find(|p| p["name"] == name).ok_or_else(|| format!("missing {name}"))?;
|
||||
let node = nodes.iter().find(|n| n["id"] == package["id"]).ok_or_else(|| format!("{name}: missing resolved node"))?;
|
||||
let features = node["features"].as_array().ok_or_else(|| format!("{name}: missing resolved features"))?;
|
||||
if features.len() != 1 || features.first().and_then(Value::as_str) != Some(allowed) {
|
||||
return Err(format!("{name}: expected only the {allowed} feature"));
|
||||
}
|
||||
}
|
||||
for package in packages {
|
||||
let name = package["name"].as_str().unwrap_or("");
|
||||
let active = nodes.iter().any(|node| node["id"] == package["id"]);
|
||||
if active && matches!(name, "hayro" | "hayro-interpret" | "hayro-cmap" | "krilla") {
|
||||
return Err(format!("{name}: EffectCraft must use craft-pdf with only the read feature"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn metadata() -> Value {
|
||||
let packages: Vec<_> = PACKAGES.iter().map(|(name, version)| {
|
||||
json!({"id": name, "name": name, "version": version, "source": format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}")})
|
||||
}).collect();
|
||||
json!({"packages": packages, "resolve": {"nodes": [
|
||||
{"id": "hayro-syntax", "features": ["std"]}, {"id": "craft-pdf", "features": ["read"]}
|
||||
]}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_the_pinned_parser_only_graph() {
|
||||
assert!(validate(&metadata()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unpinned_duplicate_and_missing_parsers() {
|
||||
let mut m = metadata();
|
||||
m["packages"][0]["source"] = json!("registry");
|
||||
assert!(validate(&m).is_err());
|
||||
let mut m = metadata();
|
||||
let duplicate = m["packages"][0].clone();
|
||||
m["packages"].as_array_mut().unwrap().push(duplicate);
|
||||
assert!(validate(&m).is_err());
|
||||
m["packages"].as_array_mut().unwrap().retain(|p| p["name"] != "hayro-syntax");
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_shared_reader_source_drift() {
|
||||
let mut m = metadata();
|
||||
m["packages"][0]["source"] = Value::Null;
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_extra_features_and_missing_active_nodes() {
|
||||
for (index, feature) in [(0, "images"), (1, "write"), (1, "raster")] {
|
||||
let mut m = metadata();
|
||||
m["resolve"]["nodes"][index]["features"].as_array_mut().unwrap().push(json!(feature));
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
let mut m = metadata();
|
||||
m["resolve"]["nodes"].as_array_mut().unwrap().pop();
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_backend_asset_features_and_missing_resolution() {
|
||||
for name in ["hayro", "hayro-interpret", "hayro-cmap", "krilla"] {
|
||||
let mut m = metadata();
|
||||
m["packages"].as_array_mut().unwrap().push(json!({"id": name, "name": name}));
|
||||
m["resolve"]["nodes"].as_array_mut().unwrap().push(json!({"id": name}));
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
let mut m = metadata();
|
||||
m["resolve"] = Value::Null;
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user