diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..2095acad7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,27 @@
+name: CI
+
+# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
+# suite's shared gate workflow in craft-common-actions.
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+# A newer push to a PR branch cancels its older run; every push to main runs to the end.
+concurrency:
+ group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ gate:
+ # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
+ uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
+ with:
+ packages: >-
+ pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
+ libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+ libgl1-mesa-dev libasound2-dev
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index f8ac8a300..8967f9431 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -2,12 +2,10 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
-# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
-# or dependency change: a drifted dependency still shows up within a day, and always before a
-# release ships. PRs that change the FreeBSD packaging itself still run it.
+# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
+# request: a drifted dependency still shows up within a day, and always before a release ships.
+# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
- schedule:
- - cron: "47 2 * * *"
push:
branches: [release]
pull_request:
@@ -15,6 +13,8 @@ on:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
+ schedule:
+ - cron: "47 2 * * *"
workflow_dispatch:
concurrency:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 808e1628d..3cfbdc8b0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,15 +10,14 @@ on:
type: string
default: ""
-permissions:
- contents: write
-
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
+ permissions:
+ contents: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 000000000..e78fe1ea3
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,21 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ # A push to main is the run that publishes coverage; superseded runs there would never finish.
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+jobs:
+ sonarcloud:
+ # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ secrets: inherit
diff --git a/AGENTS.md b/AGENTS.md
index aad29c94a..24abee6a1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -105,6 +105,8 @@ reproduce an image (for example, point lists traced from someone else's icon).
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
+
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
[craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md).
diff --git a/CLAUDE.md b/CLAUDE.md
index a7c47fe3f..e52386942 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -16,6 +16,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
- **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`).
- **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32.
- **Exact time:** `effectcraft_time::Tick` (254 016 000 000/s). Keyframe times are **layer time**.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Property tree:** everything animatable is a `Property` in the layer's `PropGroup` tree, addressed by paths (`transform/position`, `effects/#1/blurriness`, `@uid`).
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP dispatch by id.
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde.
diff --git a/Cargo.lock b/Cargo.lock
index ee9c99cf2..9bed551d4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -803,6 +803,15 @@ dependencies = [
"rand_core 0.10.1",
]
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -1163,7 +1172,7 @@ dependencies = [
[[package]]
name = "effectcraft"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"cpal",
"effectcraft-engine",
@@ -1188,7 +1197,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1200,14 +1209,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1223,7 +1232,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"proptest",
"serde",
@@ -1231,8 +1240,9 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
+ "chrono",
"effectcraft-color",
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1248,8 +1258,9 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
+ "chrono",
"craft-docpath",
"effectcraft-color",
"effectcraft-effects",
@@ -1282,7 +1293,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1312,7 +1323,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1329,7 +1340,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"proptest",
"serde",
@@ -1337,7 +1348,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1359,14 +1370,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1391,7 +1402,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1410,7 +1421,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1422,7 +1433,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1440,7 +1451,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"craft-docpath",
"effectcraft-model",
@@ -1469,7 +1480,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-geom",
"image",
@@ -1481,14 +1492,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1500,7 +1511,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1516,7 +1527,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1528,7 +1539,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1541,14 +1552,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1557,7 +1568,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1579,7 +1590,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1596,7 +1607,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
@@ -1605,7 +1616,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1617,7 +1628,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1629,7 +1640,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"proptest",
"serde",
@@ -1637,7 +1648,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1649,8 +1660,9 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
+ "chrono",
"craft-ui",
"effectcraft-color",
"effectcraft-engine",
@@ -1681,11 +1693,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
-version = "0.13.0"
+version = "0.16.0"
[[package]]
name = "effectcraft-web"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -6625,9 +6637,11 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.13.0"
+version = "0.16.0"
dependencies = [
+ "chrono",
"serde_json",
+ "tempfile",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 8c6f978ef..d71d29337 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
-version = "0.13.0"
+version = "0.16.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -52,6 +52,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
# std::time::Instant/SystemTime on native; the browser clock on wasm32 (std's panic there).
web-time = "1"
@@ -137,9 +139,12 @@ type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
+debug = false
[profile.dev]
opt-level = 1
+# Panics and backtraces keep file and line; full debug info only made the many test binaries large.
+debug = "line-tables-only"
# Pixel hot loops: keep debug builds interactive.
[profile.dev.package.effectcraft-raster]
diff --git a/README.md b/README.md
index 9dc985ee1..5fa28f0bb 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
+
+
+
+
EffectCraft
diff --git a/ROADMAP.md b/ROADMAP.md
index e3dc2792c..40f3c5a5f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -97,7 +97,7 @@ UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/lo
| Korean | none | 0% |
| Vietnamese | none | 0% |
-Also shipped: Traditional Chinese (partial, 39%), Ukrainian (partial, 27%), Czech (partial, 27%) and Russian (menus only, 15%). No language has had
+Also shipped: Traditional Chinese (partial, 39%), Ukrainian (partial, 27%), Czech (partial, 27%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 15%). No other language has had
native-speaker review.
## Upcoming
@@ -140,6 +140,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new
`docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`,
`ui-parity.md`, `effects-parity.md` and `scripting-parity.md`.
+- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker.
- 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations.
- 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese.
- 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English.
diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs
similarity index 100%
rename from apps/effectcraft-cli/tests/cli.rs
rename to apps/effectcraft-cli/tests/it/cli.rs
diff --git a/apps/effectcraft-cli/tests/it/main.rs b/apps/effectcraft-cli/tests/it/main.rs
new file mode 100644
index 000000000..17149f545
--- /dev/null
+++ b/apps/effectcraft-cli/tests/it/main.rs
@@ -0,0 +1,4 @@
+//! The crate's integration tests, one binary (each module is a former `tests/.rs`).
+
+mod cli;
+mod qa_template;
diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/it/qa_template.rs
similarity index 100%
rename from apps/effectcraft-cli/tests/qa_template.rs
rename to apps/effectcraft-cli/tests/it/qa_template.rs
diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml
index 6c30f9a56..28ea2f369 100644
--- a/apps/effectcraft-web/Cargo.toml
+++ b/apps/effectcraft-web/Cargo.toml
@@ -50,6 +50,7 @@ web-sys = { version = "0.3", features = [
"HtmlElement",
"HtmlInputElement",
"Location",
+ "Navigator",
"Url",
"UrlSearchParams",
"Window",
diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js
index 5c438550e..12cb90bfa 100644
--- a/apps/effectcraft-web/js/host.js
+++ b/apps/effectcraft-web/js/host.js
@@ -494,8 +494,12 @@ export async function cacheRead(name) {
/// resolves with its size.
export async function cacheWrite(fullName, bytes) {
const [dir, name] = await entryDir(fullName);
- const canMove = typeof FileSystemHandle !== "undefined" && "move" in FileSystemHandle.prototype;
- const tmp = canMove ? `${name}.tmp.${Math.random().toString(36).slice(2)}` : name;
+ const canMove =
+ typeof FileSystemHandle !== "undefined" &&
+ "move" in FileSystemHandle.prototype &&
+ typeof crypto !== "undefined" &&
+ typeof crypto.randomUUID === "function";
+ const tmp = canMove ? `${name}.tmp.${crypto.randomUUID()}` : name;
const fh = await dir.getFileHandle(tmp, { create: true });
if (fh.createSyncAccessHandle) {
const h = await fh.createSyncAccessHandle();
diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs
index 1a6952408..3f462bd2a 100644
--- a/apps/effectcraft-web/src/app.rs
+++ b/apps/effectcraft-web/src/app.rs
@@ -184,6 +184,11 @@ fn install_hooks(app: &mut EffectcraftApp) {
#[wasm_bindgen]
pub async fn start(canvas_id: String) -> Result<(), JsValue> {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
+ // Match System follows `?lang=` in the address, then the browser's languages; a language
+ // chosen in Settings still wins.
+ let navigator = web_sys::window().map(|w| w.navigator());
+ let browser = navigator.iter().flat_map(|n| n.languages().iter().filter_map(|v| v.as_string()).collect::>());
+ effectcraft_ui_egui::i18n::set_system_locales(query_value("lang"), browser.collect());
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
let empty = query_flag("empty");
@@ -257,6 +262,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
if n > 0 {
app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers")))));
}
+ app.frames.set_virtual_media(files::STORE.media_epoch(), Arc::new(|path| files::STORE.file_identity(path)));
let (tx, rx) = std::sync::mpsc::channel();
api::set_sender(tx);
app = app.with_control(rx);
diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs
index 523fd03c8..c7f350526 100644
--- a/apps/effectcraft-web/src/frames.rs
+++ b/apps/effectcraft-web/src/frames.rs
@@ -416,6 +416,7 @@ fn post_reply(r: &FrameReply, px: Option<&[u8]>) {
fn new_server() -> FrameServer {
let mut s = FrameServer::new(crate::worker::pool(), Some(Arc::new(effectcraft_expr::Expressions)));
s.set_layer_store(Some(Arc::new(effectcraft_engine::render::PrefetchStore::default())));
+ s.set_virtual_id(Some(Arc::new(|path| crate::files::STORE.file_identity(path))));
s
}
diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs
index b3a858def..a44a2eac7 100644
--- a/apps/effectcraft-web/src/store.rs
+++ b/apps/effectcraft-web/src/store.rs
@@ -20,6 +20,7 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_engine::config::{ConfigStore, FileOps};
@@ -45,6 +46,8 @@ pub struct Entry {
/// Which write this is: new with every write of the key, so a file replaced by another of
/// the same size still reads as changed (workers are sent it again, #218).
pub version: u64,
+ /// FNV-1a of `data`, filled the first time a disk-cache key asks for it.
+ hash: Option,
}
/// A queued write for the browser storage: `data` = `None` deletes the key.
@@ -69,13 +72,13 @@ impl Mirror {
/// An entry read from the browser's storage at startup (nothing to flush).
pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) {
let version = self.next_version();
- self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version });
+ self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version, hash: None });
}
pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) {
let was_persisted = self.entries.get(key).is_some_and(|e| e.persist);
let version = self.next_version();
- self.entries.insert(key.to_string(), Entry { data, modified, persist, version });
+ self.entries.insert(key.to_string(), Entry { data, modified, persist, version, hash: None });
if persist || was_persisted {
self.touch(key);
}
@@ -149,14 +152,31 @@ pub fn key_path(key: &str) -> Option<&str> {
key.strip_prefix(FILES).filter(|p| p.starts_with('/'))
}
+/// FNV-1a 64. Computed when a disk-cache key first needs the file, not on every write.
+fn content_hash(data: &[u8]) -> u64 {
+ let mut h: u64 = 0xcbf2_9ce4_8422_2325;
+ for &b in data {
+ h ^= u64::from(b);
+ h = h.wrapping_mul(0x0000_0100_0000_01b3);
+ }
+ h
+}
+
/// The persistent store: a [`Mirror`] and a hook that wakes the flusher after changes.
-#[derive(Default)]
pub struct Store {
pub mirror: Mutex,
/// Called after every change that needs flushing (the web host schedules a flush).
pub on_change: OnceLock,
/// Clock in milliseconds since the Unix epoch (tests pin it).
pub clock: OnceLock f64>,
+ /// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash.
+ media_epoch: Arc,
+}
+
+impl Default for Store {
+ fn default() -> Self {
+ Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) }
+ }
}
impl Store {
@@ -179,12 +199,40 @@ impl Store {
/// Store a file under `path` (persisted unless `persist` is false: render outputs).
pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) {
let now = self.now();
- self.lock().put(&file_key(path), data, now, persist);
+ let key = file_key(path);
+ let replaced = self.lock().get(&key).is_none_or(|e| !Arc::ptr_eq(&e.data, &data));
+ self.lock().put(&key, data, now, persist);
+ if replaced {
+ self.media_epoch.fetch_add(1, Ordering::Relaxed);
+ }
if persist {
self.changed();
}
}
+ /// The generation preview cache lookups key on. It changes when [`Self::put_file`] writes
+ /// a different blob, and it is not a content hash.
+ pub fn media_epoch(&self) -> Arc {
+ self.media_epoch.clone()
+ }
+
+ /// Size and content hash of the file under `path`. The hash is stored on the entry so a
+ /// later key does not walk the bytes again.
+ pub fn file_identity(&self, path: &str) -> Option<(u64, u64)> {
+ let key = file_key(path);
+ let mut mirror = self.lock();
+ let entry = mirror.entries.get(&key)?;
+ if let Some(hash) = entry.hash {
+ return Some((entry.data.len() as u64, hash));
+ }
+ let hash = content_hash(&entry.data);
+ let len = entry.data.len() as u64;
+ if let Some(entry) = mirror.entries.get_mut(&key) {
+ entry.hash = Some(hash);
+ }
+ Some((len, hash))
+ }
+
pub fn file(&self, path: &str) -> Option> {
self.lock().get(&file_key(path)).map(|e| e.data.clone())
}
@@ -446,6 +494,11 @@ mod tests {
assert_ne!(a, b);
assert_eq!(&data[..], b"blue");
assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it");
+ let epoch = s.media_epoch().load(Ordering::Relaxed);
+ let id = s.file_identity("/collision.png").expect("identity");
+ assert_eq!(id, (4, content_hash(b"blue")));
+ assert_eq!(s.file_identity("/collision.png"), Some(id), "the hash is kept on the entry");
+ assert!(epoch >= 2, "each new blob bumps the generation the preview keys on");
}
/// A write that failed is flushed again with the key's latest state (#215).
diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js
index 8c7efc2e5..4acbeeeeb 100644
--- a/apps/effectcraft-web/web/sw.js
+++ b/apps/effectcraft-web/web/sw.js
@@ -32,13 +32,15 @@ self.addEventListener("fetch", (event) => {
if (req.method !== "GET" || url.origin !== self.location.origin) return;
event.respondWith(
(async () => {
- const cache = await caches.open(CACHE);
- // The page itself regardless of its query (?empty, ?demo…).
- const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
- const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" });
- if (hit) return hit;
+ const cache = await caches.open(CACHE).catch(() => null);
+ if (cache) {
+ // The page itself regardless of its query (?empty, ?demo…).
+ const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
+ const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" }).catch(() => null);
+ if (hit) return hit;
+ }
const res = await fetch(req);
- if (res.ok && res.type === "basic") cache.put(req, res.clone());
+ if (cache && res.ok && res.type === "basic") void cache.put(req, res.clone()).catch(() => {});
return res;
})(),
);
diff --git a/crates/av1enc/README.md b/crates/av1enc/README.md
index 6ced20ba1..c922c95eb 100644
--- a/crates/av1enc/README.md
+++ b/crates/av1enc/README.md
@@ -4,8 +4,8 @@ Clean-room, pure-Rust AV1 encoder for EffectCraft's MP4 / WebM export (layer L0,
no `unsafe`, builds for `wasm32-unknown-unknown`).
Output is decoded **bit-exactly** by libdav1d (`ffmpeg -c:v libdav1d`, checked in
-`tests/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived
-`filmcraft-av1` decoder (`tests/roundtrip.rs`): every decoded frame equals the encoder's own
+`tests/it/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived
+`filmcraft-av1` decoder (`tests/it/roundtrip.rs`): every decoded frame equals the encoder's own
reconstruction, so prediction never drifts.
## What is implemented
diff --git a/crates/av1enc/tests/bench.rs b/crates/av1enc/tests/it/bench.rs
similarity index 97%
rename from crates/av1enc/tests/bench.rs
rename to crates/av1enc/tests/it/bench.rs
index 6577f8f9f..b29817e1a 100644
--- a/crates/av1enc/tests/bench.rs
+++ b/crates/av1enc/tests/it/bench.rs
@@ -1,7 +1,7 @@
//! Speed / efficiency measurements on ffmpeg-generated test sources (ignored by default):
-//! `cargo test -p effectcraft-av1enc --test bench -- --ignored --nocapture`.
+//! `cargo test -p effectcraft-av1enc --test it bench:: -- --ignored --nocapture`.
-mod common;
+use crate::common;
use std::process::Command;
use std::time::Instant;
diff --git a/crates/av1enc/tests/common/mod.rs b/crates/av1enc/tests/it/common/mod.rs
similarity index 100%
rename from crates/av1enc/tests/common/mod.rs
rename to crates/av1enc/tests/it/common/mod.rs
diff --git a/crates/av1enc/tests/ffmpeg_oracle.rs b/crates/av1enc/tests/it/ffmpeg_oracle.rs
similarity index 99%
rename from crates/av1enc/tests/ffmpeg_oracle.rs
rename to crates/av1enc/tests/it/ffmpeg_oracle.rs
index 66f380ac2..b35cbf496 100644
--- a/crates/av1enc/tests/ffmpeg_oracle.rs
+++ b/crates/av1enc/tests/it/ffmpeg_oracle.rs
@@ -2,7 +2,7 @@
//! bit for bit. Skipped when `ffmpeg` (with libdav1d) is not installed. ffmpeg's native `av1`
//! decoder only drives hardware decoders, so it is not used.
-mod common;
+use crate::common;
use std::process::Command;
diff --git a/crates/av1enc/tests/it/main.rs b/crates/av1enc/tests/it/main.rs
new file mode 100644
index 000000000..c66bacde5
--- /dev/null
+++ b/crates/av1enc/tests/it/main.rs
@@ -0,0 +1,6 @@
+//! The crate's integration tests, one binary (each module is a former `tests/.rs`).
+
+mod bench;
+mod common;
+mod ffmpeg_oracle;
+mod roundtrip;
diff --git a/crates/av1enc/tests/roundtrip.rs b/crates/av1enc/tests/it/roundtrip.rs
similarity index 99%
rename from crates/av1enc/tests/roundtrip.rs
rename to crates/av1enc/tests/it/roundtrip.rs
index 1e777e509..282880e08 100644
--- a/crates/av1enc/tests/roundtrip.rs
+++ b/crates/av1enc/tests/it/roundtrip.rs
@@ -1,7 +1,7 @@
//! Round trips through FilmCraft's spec-derived AV1 decoder: decoded frames must equal the
//! encoder's own reconstruction bit for bit.
-mod common;
+use crate::common;
use common::*;
use effectcraft_av1enc::{EncoderConfig, RateControl};
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
index 1fae0ea90..5545f390d 100644
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -141,7 +141,11 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
}
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
- let h6 = h.rem_euclid(1.0) * 6.0;
+ let mut h6 = h.rem_euclid(1.0) * 6.0;
+ // In f32, a tiny negative hue wraps to exactly 1.0; treat it as 0.
+ if h6 >= 6.0 {
+ h6 = 0.0;
+ }
let i = h6.floor();
let f = h6 - i;
let p = v * (1.0 - s);
@@ -252,6 +256,12 @@ impl Label {
mod tests {
use super::*;
+ #[test]
+ fn hsv_to_rgb_tiny_negative_hue_is_red() {
+ let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0);
+ assert!(r > 0.99 && g < 0.01 && b < 0.01, "{r} {g} {b}");
+ }
+
#[test]
fn srgb_roundtrip() {
for i in 0..=100 {
diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml
index c37d09949..bb26d3127 100644
--- a/crates/effects/Cargo.toml
+++ b/crates/effects/Cargo.toml
@@ -21,4 +21,5 @@ rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+chrono = { workspace = true }
diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs
index 32a196af2..d98ec73ad 100644
--- a/crates/effects/src/object_matte.rs
+++ b/crates/effects/src/object_matte.rs
@@ -284,9 +284,13 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w:
return None;
}
let fr = fc.frames.get(&f)?;
- let a = rle::decode(&fr.a)?;
let [fw, fh] = fc.size;
- if fw == 0 || fh == 0 || a.len() != fw.checked_mul(fh)? {
+ let expected = fw.checked_mul(fh)?;
+ if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES {
+ return None;
+ }
+ let a = rle::decode_bounded(&fr.a, expected)?;
+ if a.len() != expected {
return None;
}
let af: Vec = a.iter().map(|v| *v as f32 / 255.0).collect();
diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs
index a6c80e742..4917cc231 100644
--- a/crates/effects/src/roto.rs
+++ b/crates/effects/src/roto.rs
@@ -560,10 +560,14 @@ fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc, scale: f64) -> Option<(V
&& fc.key == frozen_key(ctx.params, ctx.layer_size, ctx.fps())
&& let Some(fr) = fc.frames.get(&f)
{
- let a = rle::decode(&fr.a)?;
- let bnd = rle::decode(&fr.b)?;
let [fw, fh] = fc.size;
- if a.len() == fw * fh && bnd.len() == fw * fh {
+ let expected = fw.checked_mul(fh)?;
+ if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES {
+ return None;
+ }
+ let a = rle::decode_bounded(&fr.a, expected)?;
+ let bnd = rle::decode_bounded(&fr.b, expected)?;
+ if a.len() == expected && bnd.len() == expected {
let af: Vec = a.iter().map(|v| *v as f32 / 255.0).collect();
let (alpha, band) = if fw == w && fh == h {
(af, bnd)
diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs
index 34282c272..cb172dfc5 100644
--- a/crates/effects/src/textfx.rs
+++ b/crates/effects/src/textfx.rs
@@ -288,20 +288,39 @@ fn look_from(ctx: &EffectCtx, b: &Buf, on_original_id: &str) -> TextLook {
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const DAYS: [&str; 7] = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"];
-/// Proleptic Gregorian date from a day count since 2000-01-01 (a Saturday).
+/// `num_days_from_ce` of 2000-01-01: the day count since 2000-01-01 (Numbers' date epoch, a
+/// Saturday) rebased so that chrono's `num_days_from_ce` (0001-01-01 is day 1) can convert it.
+const CE_FROM_2000: i64 = 730_120;
+
+/// Days in one 400-year Gregorian cycle; the proleptic calendar repeats after it.
+const DAYS_PER_400_YEARS: i64 = 146_097;
+
+/// Proleptic Gregorian date (year, month, day, Saturday-indexed weekday) from a day count since
+/// 2000-01-01 (a Saturday), for any `i64` count.
+///
+/// chrono's `NaiveDate` stops near ±262143, but Numbers' `value` slider is a plain scalar with
+/// no range guard, so a keyframed count can run past that. The calendar repeats every 400 years,
+/// so chrono converts the day within one cycle and the whole cycles are added back as years (via
+/// i128 so `i64::MIN`/`MAX` can't overflow); the result matches the pre-chrono count for every
+/// year. The weekday comes from the same resolved date, so it can never disagree with it.
fn date_from_days(days: i64) -> (i64, u32, u32, usize) {
- // Days since 0000-03-01 based civil calendar conversion.
- let z = days + 730_425; // 2000-01-01 relative to 0000-03-01
- let era = z.div_euclid(146_097);
- let doe = z.rem_euclid(146_097);
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let y = yoe + era * 400;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
- let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
- let y = if m <= 2 { y + 1 } else { y };
- (y, m, d, days.rem_euclid(7) as usize)
+ use chrono::Datelike;
+ let from_ce = i128::from(days) + i128::from(CE_FROM_2000);
+ let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS)));
+ // `rest` is 0..146_097, so it is always a valid `num_days_from_ce` day inside chrono's range.
+ let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ // Unreachable fallback: every `rest` in 0..146_097 converts. 2000-01-01 keeps the epoch's date.
+ let (y, m, d, wd) = match date {
+ Some(date) => {
+ let year = i128::from(date.year()) + cycles * 400;
+ let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX });
+ // DAYS is Saturday-indexed; chrono counts from Sunday.
+ let wd = (date.weekday().num_days_from_sunday() as usize + 1) % 7;
+ (year, date.month(), date.day(), wd)
+ }
+ None => (2000, 1, 1, 0),
+ };
+ (y, m, d, wd)
}
fn timecode(frames: i64, fps: f64) -> String {
@@ -1123,6 +1142,10 @@ mod tests {
assert_eq!(txt(&[("value", num(255.0)), ("type", Value::Enum(9))], 0.0), "FF");
assert_eq!(txt(&[("value", num(0.0)), ("type", Value::Enum(6))], 0.0), "01/01/00");
assert_eq!(txt(&[("value", num(60.0)), ("type", Value::Enum(8))], 0.0), "Wednesday, March 1, 2000");
+ // A keyframed date count past chrono's ±262143 range still renders its real date and a
+ // matching weekday (275790-09-13 is a Monday), not an epoch-derived stand-in.
+ assert_eq!(txt(&[("value", num(100_000_000.0)), ("type", Value::Enum(8))], 0.0), "Monday, September 13, 275790");
+ assert_eq!(txt(&[("value", num(-100_000_000.0)), ("type", Value::Enum(7))], 0.0), "Apr 20, -271791");
assert_eq!(txt(&[("value", num(1.0)), ("currentTimeDate", Value::Bool(true)), ("decimalPlaces", num(1.0))], 2.0), "3.0");
assert_ne!(
txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 0.0),
@@ -1130,6 +1153,28 @@ mod tests {
);
}
+ #[test]
+ fn date_from_days_past_chrono_range() {
+ // chrono's NaiveDate stops near ±262143, but Numbers' `value` has no range guard, so a
+ // keyframed day count can run past it. The 400-year cycle keeps the real date (and a
+ // matching weekday) for every count; values from the pre-chrono formula, both directions,
+ // both sides of the 2000-01-01 epoch.
+ for (days, date, day) in [
+ (0_i64, (2000, 1, 1), "Saturday"),
+ (60, (2000, 3, 1), "Wednesday"),
+ (95_015_644, (262143, 12, 31), "Tuesday"), // chrono's last representable date
+ (100_000_000, (275790, 9, 13), "Monday"), // past it: used to read as the epoch
+ (-96_476_616, (-262145, 12, 31), "Monday"),
+ (-100_000_000, (-271791, 4, 20), "Thursday"),
+ ] {
+ let (y, m, d, wd) = super::date_from_days(days);
+ assert_eq!((y, m, d), date, "days={days}");
+ assert_eq!(super::DAYS[wd], day, "days={days}");
+ }
+ // Extremes don't overflow (i128 intermediates).
+ let _ = (super::date_from_days(i64::MAX), super::date_from_days(i64::MIN));
+ }
+
#[test]
fn numbers_draws_glyph_at_position() {
let img = Image::new(80, 60);
diff --git a/crates/effects/tests/gen_golden.rs b/crates/effects/tests/it/gen_golden.rs
similarity index 100%
rename from crates/effects/tests/gen_golden.rs
rename to crates/effects/tests/it/gen_golden.rs
diff --git a/crates/effects/tests/it/main.rs b/crates/effects/tests/it/main.rs
new file mode 100644
index 000000000..7b12e25c6
--- /dev/null
+++ b/crates/effects/tests/it/main.rs
@@ -0,0 +1,6 @@
+//! The crate's integration tests, one binary (each module is a former `tests/.rs`).
+
+mod gen_golden;
+mod particle_golden;
+mod sim_golden;
+mod timewarp_golden;
diff --git a/crates/effects/tests/particle_golden.rs b/crates/effects/tests/it/particle_golden.rs
similarity index 100%
rename from crates/effects/tests/particle_golden.rs
rename to crates/effects/tests/it/particle_golden.rs
diff --git a/crates/effects/tests/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs
similarity index 100%
rename from crates/effects/tests/sim_golden.rs
rename to crates/effects/tests/it/sim_golden.rs
diff --git a/crates/effects/tests/timewarp_golden.rs b/crates/effects/tests/it/timewarp_golden.rs
similarity index 100%
rename from crates/effects/tests/timewarp_golden.rs
rename to crates/effects/tests/it/timewarp_golden.rs
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 0e26cca58..7104bc1cc 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -34,6 +34,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
+chrono = { workspace = true }
rayon = { workspace = true }
image = { workspace = true }
web-time = { workspace = true }
diff --git a/crates/engine/src/commands/align_data.rs b/crates/engine/src/commands/align_data.rs
index 097bddc22..38d9e7841 100644
--- a/crates/engine/src/commands/align_data.rs
+++ b/crates/engine/src/commands/align_data.rs
@@ -33,14 +33,16 @@ pub struct Stamp {
pub dated: bool,
}
-fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
- let y = if m <= 2 { y - 1 } else { y };
- let era = y.div_euclid(400);
- let yoe = y - era * 400;
- let mp = (m + 9) % 12;
- let doy = (153 * mp + 2) / 5 + d - 1;
- let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
- era * 146_097 + doe - 719_468
+/// Days since 1970-01-01 of the proleptic-Gregorian date `(y, m, d)`. `d` is added linearly (as
+/// the previous hand-rolled `days_from_civil` did), so a day past the month's end rolls over;
+/// `None` when the year/month is out of chrono's range. `m` must be 1..=12.
+fn days_from_civil(y: i64, m: i64, d: i64) -> Option {
+ let year = i32::try_from(y).ok()?;
+ let month = u32::try_from(m).ok()?;
+ let first = chrono::NaiveDate::from_ymd_opt(year, month, 1)?;
+ // Days since the Unix epoch: `num_days_from_ce` counts from 0001-01-01; 719_162 days precede 1970-01-01.
+ use chrono::Datelike;
+ i64::from(first.num_days_from_ce()).checked_sub(719_163)?.checked_add(d.checked_sub(1)?)
}
/// `hh:mm:ss(.fff)` or timecode `hh:mm:ss:ff` / `hh:mm:ss;ff` → seconds.
@@ -71,7 +73,7 @@ pub fn parse_stamp(v: &Value, fps: f64) -> Option {
// ISO 8601: date [T| ] time [zone].
if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' {
let (y, m, d) = (s[0..4].parse::().ok()?, s[5..7].parse::().ok()?, s[8..10].parse::().ok()?);
- let mut secs = days_from_civil(y, m, d) as f64 * 86_400.0;
+ let mut secs = days_from_civil(y, m, d)? as f64 * 86_400.0;
let rest = s[10..].trim_start_matches(['T', 't', ' ']);
if !rest.is_empty() {
// Zone: Z or ±hh:mm / ±hhmm.
diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs
index b798493b1..13037c88a 100644
--- a/crates/engine/src/commands/layer.rs
+++ b/crates/engine/src/commands/layer.rs
@@ -4,8 +4,8 @@ use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
- Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
- limits,
+ Comp, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid,
+ TrackMatte, limits,
};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -307,6 +307,103 @@ fn add_item(s: &mut Session, p: &Value) -> Result {
Ok(json!({"layer": id.0}))
}
+/// A layer whose source is an item (footage, precomposition or solid), as in After Effects'
+/// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to
+/// replace.
+fn replaceable_layer(l: &Layer) -> bool {
+ matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. })
+}
+
+/// The source a Project item gives a layer, or why it can't replace one.
+fn replacement_source(item: ItemId, it: &effectcraft_project::Item) -> Result {
+ const CMD: &str = "layer.replaceSource";
+ match &it.kind {
+ ItemKind::Comp(_) => Ok(LayerSource::Comp { item }),
+ ItemKind::Footage(f) if f.kind == FootageKind::Data => {
+ Err(bad(CMD, "data files can't be layer sources; read them in expressions with footage(\"name\").sourceData"))
+ }
+ ItemKind::Footage(f) if f.kind == FootageKind::Model => Err(bad(CMD, "3D model items can't replace a 2D layer source; add them as 3D layers")),
+ ItemKind::Footage(_) => Ok(LayerSource::Footage { item }),
+ ItemKind::Solid(_) => Ok(LayerSource::Solid { item }),
+ ItemKind::Folder => Err(bad(CMD, "folders can't be layer sources")),
+ }
+}
+
+/// Whether a source brings audio properties (precompositions always can; footage when it has an
+/// audio stream).
+fn source_has_audio(project: &Project, source: &LayerSource) -> bool {
+ match source {
+ LayerSource::Comp { .. } => true,
+ LayerSource::Footage { item } => project.item(*item).is_some_and(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_audio)),
+ _ => false,
+ }
+}
+
+fn has_replace_source(s: &Session) -> std::result::Result<(), String> {
+ has_comp(s)?;
+ let c = s.active_comp().ok_or("no composition")?;
+ if !s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(replaceable_layer) {
+ return Err("select a footage, composition or solid layer".into());
+ }
+ if s.state.project_selection.is_empty() {
+ return Err("select a footage item, a composition or a solid in the Project panel".into());
+ }
+ Ok(())
+}
+
+/// Replace the source of the selected (or `layers`) AV layers with a Project item, as After
+/// Effects' Alt-drag and Ctrl+Alt+/ do. The layer keeps every property: name, timing, transforms,
+/// keyframes, expressions, effects, masks, switches, parenting and track matte. Only its source
+/// changes; precompositions bring their Essential Properties group through `sync_project`, and a
+/// source with audio adds an Audio group when the layer has none.
+fn replace_source(s: &mut Session, p: &Value) -> Result {
+ const CMD: &str = "layer.replaceSource";
+ let (cid, ids) = layers_p(s, p)?;
+ let ids = unlocked(s, cid, ids, CMD)?;
+ if ids.is_empty() {
+ return Err(bad(CMD, "select a layer first"));
+ }
+ let item = match p.get("item") {
+ Some(Value::Number(n)) => ItemId(n.as_u64().ok_or_else(|| bad(CMD, "item: a project item id"))?),
+ Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(CMD, format!("no item `{name}`")))?,
+ _ => *s.state.project_selection.first().ok_or_else(|| bad(CMD, "select a footage item, a composition or a solid in the Project panel"))?,
+ };
+ let it = s.project.item(item).ok_or_else(|| bad(CMD, format!("no item {}", item.0)))?;
+ let source = replacement_source(item, it)?;
+ let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
+ for lid in &ids {
+ let l = comp.layer(*lid).ok_or_else(|| bad(CMD, "no such layer"))?;
+ if !replaceable_layer(l) {
+ return Err(bad(CMD, format!("“{}” has no source item to replace", l.name)));
+ }
+ }
+ if let LayerSource::Comp { item: new } = &source
+ && s.project.comp_contains(*new, cid)
+ {
+ return Err(bad(CMD, "a composition can't contain itself"));
+ }
+ let has_audio = source_has_audio(&s.project, &source);
+ let source_for_edit = source.clone();
+ s.edit("Replace Layer Source", None, |proj, _| {
+ for lid in &ids {
+ let needs_audio = has_audio && proj.comp(cid).and_then(|c| c.layer(*lid)).is_some_and(|l| l.props.sub("audio").is_none());
+ let audio = if needs_audio {
+ let mut next = Ids(&mut proj.next_id);
+ Some(build::audio(&mut next))
+ } else {
+ None
+ };
+ let l = layer_mut(proj, cid, *lid)?;
+ l.source = source_for_edit.clone();
+ if let Some(audio) = audio {
+ l.props.children.push(audio.into());
+ }
+ }
+ Ok(())
+ })?;
+ Ok(json!({"layers": ids.iter().map(|l| l.0).collect::>(), "item": item.0}))
+}
+
fn select(s: &mut Session, p: &Value) -> Result {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
@@ -417,8 +514,10 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str,
let applies = |l: &Layer| match name {
"audio" => l.props.sub("audio").is_some(),
"lock" | "locked" => true,
- // Nulls have a Video switch too (it hides their outline in the viewer).
- "video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id),
+ // Every layer has a Video switch (it hides a null's outline in the viewer and turns a
+ // camera or light off), as the Timeline's eye cell shows; track mattes keep theirs off
+ // by design.
+ "video" | "eye" => !mattes.contains(&l.id),
_ => l.source.is_av() && !mattes.contains(&l.id),
};
let mut alone = true;
@@ -1628,6 +1727,15 @@ pub fn specs() -> Vec {
has_comp,
add_item
),
+ cmd!(
+ "layer.replaceSource",
+ "Replace Layer Source",
+ [],
+ Some("Cmd+Alt+/"),
+ "{comp?, layers?, layer?, item?: id|name (default the Project panel selection), fixExpressions?: bool (accepted for After Effects compatibility)} → {layers, item}",
+ has_replace_source,
+ replace_source
+ ),
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs
index 9cac80412..0a0449376 100644
--- a/crates/engine/src/commands/layer_menu.rs
+++ b/crates/engine/src/commands/layer_menu.rs
@@ -114,10 +114,10 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result {
fn hide_other_video(s: &mut Session, p: &Value) -> Result {
let (cid, ids) = layers_p(s, p)?;
s.edit("Hide Other Video", None, |proj, _| {
- for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() {
- if !ids.contains(&l.id) && (l.source.is_av() || matches!(l.source, LayerSource::Null)) {
- l.switches.video = false;
- }
+ // Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's
+ // eye cell shows): the selection stays shown.
+ for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) {
+ l.switches.video = false;
}
Ok(())
})?;
@@ -754,17 +754,33 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result {
}
fn reveal_source(s: &mut Session, p: &Value) -> Result {
- let item = source_item(s, p, "layer.revealSource")?;
- s.state.project_selection = vec![item];
- frontend(s, "window.panel", &json!({"panel": "project"}))?;
- Ok(json!({"item": item.0}))
+ let (cid, ids) = layers_p(s, p)?;
+ let mut items: Vec = Vec::new();
+ {
+ let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
+ for layer in ids.iter().filter_map(|id| comp.layer(*id)) {
+ if let Some(item) = layer.source.item()
+ && s.project.item(item).is_some()
+ && !items.contains(&item)
+ {
+ items.push(item);
+ }
+ }
+ }
+ if items.is_empty() {
+ return Err(bad("layer.revealSource", "the selected layers have no source item"));
+ }
+ s.state.project_selection = items.clone();
+ let raw: Vec = items.iter().map(|item| item.0).collect();
+ frontend(s, "project.reveal", &json!({"items": raw}))?;
+ Ok(json!({"item": items[0].0, "items": raw}))
}
fn reveal_comp(s: &mut Session, p: &Value) -> Result {
let cid = comp_id(s, p)?;
s.state.project_selection = vec![cid];
- frontend(s, "window.panel", &json!({"panel": "project"}))?;
- Ok(json!({"item": cid.0}))
+ frontend(s, "project.reveal", &json!({"items": [cid.0]}))?;
+ Ok(json!({"item": cid.0, "items": [cid.0]}))
}
impl Session {
@@ -909,7 +925,7 @@ pub fn specs() -> Vec {
cmd!("layer.trackMatte", "Track Matte", [], None, "{layers?, op: none|alpha|alphaInverted|luma|lumaInverted|above|below}", has_layers, track_matte_op),
cmd!("layer.openSource", "Open Layer Source", ["Layer"], None, "{layer?}", has_source_item, open_source),
cmd!("layer.revealInFinder", "Reveal in Finder", ["Layer"], None, "{layer?}", has_footage_layer, reveal_in_finder),
- cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layer?}", has_source_item, reveal_source),
+ cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layers?}", has_source_item, reveal_source),
cmd!("comp.revealInProject", "Reveal Composition in Project", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_comp),
cmd!("layer.revealExpressionErrors", "Reveal Expression Errors", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_expression_errors),
cmd!(
diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs
index c7416a8ce..00d6c33f1 100644
--- a/crates/engine/src/doc_path.rs
+++ b/crates/engine/src/doc_path.rs
@@ -46,13 +46,22 @@ pub fn resolve(stored: &str, project: Option<&str>) -> Result {
+ NotFollowed::NoProjectFolder
+ }
+ why => NotFollowed::Refused(why),
+ })
+}
+
+/// Whether `p` is a Windows drive or share path, which a project cannot make absolute outside Windows.
+fn is_windows_rooted(p: &Path) -> bool {
+ let s = p.to_string_lossy();
+ let b = s.as_bytes();
+ (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':') || s.starts_with(r"\\") || s.starts_with("//")
}
/// The paths of `f` (its file and every frame of an image sequence) resolved against the document
diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs
index b8dcbc190..d9d971247 100644
--- a/crates/engine/src/doc_path/tests.rs
+++ b/crates/engine/src/doc_path/tests.rs
@@ -34,9 +34,11 @@ impl Services for Recording {
}
}
-/// Probes every file as a still.
+/// Probes every file as a still. Only the Unix part of the test below imports files.
+#[cfg(unix)]
struct Stills;
+#[cfg(unix)]
impl crate::Importer for Stills {
fn probe(&self, path: &str) -> Result {
if !std::path::Path::new(path).is_file() {
@@ -147,7 +149,23 @@ fn stored_paths_resolve_against_the_project() {
let doc = doc.to_str().unwrap();
assert_eq!(super::resolve("clip.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("clip.mov")));
assert_eq!(super::resolve("clip.mov", None), Err(super::NotFollowed::NoProjectFolder));
+ assert_eq!(super::resolve("../logo.png", None), Err(super::NotFollowed::NoProjectFolder));
+ // A drive path is not a local file on Unix, with or without a project: it is not a missing project.
+ #[cfg(unix)]
+ for stored in [r"C:\x.mov", "C:x.mov"] {
+ assert_eq!(super::resolve(stored, None), Err(super::NotFollowed::Refused(super::Refused::Invalid)), "{stored}");
+ }
assert_eq!(super::resolve(r"\\192.0.2.1\share\x.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Network)));
+ assert_eq!(super::resolve("jar:clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Url)));
+ // Windows reads a colon in a name as an alternate data stream; elsewhere it is a plain character.
+ if cfg!(windows) {
+ assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid)));
+ } else {
+ assert_eq!(super::resolve("shots/take:1.mov", Some(doc)), Ok(std::env::temp_dir().join("art").join("shots").join("take:1.mov")));
+ }
+ // A Windows drive path names no file on other platforms.
+ #[cfg(unix)]
+ assert_eq!(super::resolve(r"C:\art\clip.mov", Some(doc)), Err(super::NotFollowed::Refused(super::Refused::Invalid)));
#[cfg(windows)]
assert!(super::resolve(r"\\server\share\x.mov", Some(r"\\SERVER\share\job\intro.ecproj")).is_ok());
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index cdfc7468e..96b3bf67d 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -1290,6 +1290,8 @@ mod tests_proxy;
#[cfg(test)]
mod tests_quick_offset;
#[cfg(test)]
+mod tests_replace_source;
+#[cfg(test)]
mod tests_rig3d;
#[cfg(test)]
mod tests_sequence;
diff --git a/crates/engine/src/logging.rs b/crates/engine/src/logging.rs
index 2c89466ae..ec5785f7e 100644
--- a/crates/engine/src/logging.rs
+++ b/crates/engine/src/logging.rs
@@ -93,28 +93,34 @@ fn thread_name() -> String {
return "main".to_string();
}
-/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970.
+/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970. A clock set
+/// absurdly far ahead keeps its real year (preserved) instead of reading as the epoch.
fn timestamp(ms: u64) -> String {
- let secs = ms / 1000;
- let (days, rem) = (secs / 86_400, secs % 86_400);
- let (y, m, d) = civil_from_days(days);
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000)
+ let (secs, millis) = (ms / 1000, (ms % 1000) as u32);
+ let (y, t) = utc_parts(secs, millis * 1_000_000);
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
-/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
-/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most
-/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow.
-fn civil_from_days(days: u64) -> (u64, u64, u64) {
- let z = days + 719_468;
- let era = z / 146_097;
- let doe = z % 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = yoe + era * 400 + u64::from(m <= 2);
- (y, m, d)
+/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
+/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
+/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
+/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
+/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
+fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
+ use chrono::Datelike;
+ let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
+ }
+ _ => (1970, chrono::NaiveDateTime::default()), // unreachable
+ }
}
/// One log line (without the newline): ` [] : `.
@@ -358,7 +364,12 @@ mod tests {
assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z");
- let _ = timestamp(u64::MAX);
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(timestamp(253_402_300_800_000), "10000-01-01T00:00:00.000Z");
+ // A clock absurdly far ahead keeps its real year (years far outside chrono's own range are
+ // preserved) instead of collapsing to the epoch (reviewer rule from the sibling PRs).
+ assert_eq!(timestamp(10_000_000_000_000_000), "318857-05-20T17:46:40.000Z");
+ assert_eq!(timestamp(u64::MAX), "584556019-04-03T14:25:51.615Z");
}
/// Each launch keeps the previous runs' logs, so the log of a run that crashed survives.
diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs
index d22875f17..c0e20be71 100644
--- a/crates/engine/src/media_browser.rs
+++ b/crates/engine/src/media_browser.rs
@@ -264,22 +264,33 @@ pub fn parent(dir: &str) -> Option {
std::path::Path::new(dir).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty())
}
-/// Format seconds since the epoch as an ISO 8601 UTC date-time.
+/// Format seconds since the epoch as an ISO 8601 UTC date-time. Years far outside chrono's own
+/// range are preserved (the old hand-rolled conversion kept them) rather than collapsing to the epoch.
pub fn iso_date(secs: u64) -> String {
- let days = (secs / 86_400) as i64;
- let rem = secs % 86_400;
- // Civil-from-days (Howard Hinnant's algorithm).
- let z = days + 719_468;
- let era = z.div_euclid(146_097);
- let doe = z - era * 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let y = yoe + era * 400;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = if m <= 2 { y + 1 } else { y };
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
+ let (y, t) = utc_parts(secs);
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", t.format("%m-%dT%H:%M:%SZ"))
+}
+
+/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range
+/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so
+/// month, day and time of day come from chrono and only the year is carried, which keeps every
+/// input exact instead of collapsing out-of-range years to the epoch. `secs` is `u64`, so `days`
+/// always fits an `i64`.
+fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) {
+ use chrono::Datelike;
+ let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
+ }
+ _ => (1970, chrono::NaiveDateTime::default()), // unreachable
+ }
}
/// File dates and size: (created, modified, size).
@@ -506,5 +517,10 @@ mod tests {
assert_eq!(iso_date(0), "1970-01-01T00:00:00Z");
assert_eq!(iso_date(1_700_000_000), "2023-11-14T22:13:20Z");
assert_eq!(iso_date(951_782_400), "2000-02-29T00:00:00Z");
+ // Years far outside chrono's own range are preserved (the old hand-rolled conversion kept
+ // them) rather than collapsing to the epoch.
+ assert_eq!(iso_date(253_402_300_800), "10000-01-01T00:00:00Z");
+ assert_eq!(iso_date(10_000_000_000_000), "318857-05-20T17:46:40Z");
+ assert_eq!(iso_date(u64::MAX), "584554051223-11-09T07:00:15Z");
}
}
diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs
index a2ce579ea..800cff191 100644
--- a/crates/engine/src/prefs.rs
+++ b/crates/engine/src/prefs.rs
@@ -36,6 +36,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[
("Français", "fr"),
("Deutsch", "de"),
("Čeština", "cs"),
+ ("ქართული", "ka"),
];
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
@@ -110,7 +111,8 @@ page!(General {
/// Synchronize Time of All Related Items.
sync_time_related_items: bool = true,
expression_pick_whip_compact: bool = true,
- create_split_layers_above: bool = false,
+ /// Create Split Layers Above Original Layer (After Effects' default).
+ create_split_layers_above: bool = true,
use_system_color_picker: bool = false,
/// Number of projects in File ▸ Open Recent.
recent_items: u32 = 10,
diff --git a/crates/engine/src/remote.rs b/crates/engine/src/remote.rs
index 207367b07..7d592abe2 100644
--- a/crates/engine/src/remote.rs
+++ b/crates/engine/src/remote.rs
@@ -263,6 +263,8 @@ pub struct FrameServer {
/// The disk cache's layer buffers (see the module docs) and the project's salt.
layer_store: Option>,
salt: u64,
+ /// Size and content hash for footage paths with no native metadata (the browser file table).
+ virtual_id: Option Option<(u64, u64)> + Send + Sync>>,
}
/// A frame whose readbacks are in flight.
@@ -294,9 +296,16 @@ impl FrameServer {
pending: None,
layer_store: None,
salt: 0,
+ virtual_id: None,
}
}
+ /// Identity of virtual footage files, used when native metadata cannot describe the path.
+ /// Called from [`FrameServer::load`], not from the UI thread's cache lookup.
+ pub fn set_virtual_id(&mut self, id: Option Option<(u64, u64)> + Send + Sync>>) {
+ self.virtual_id = id;
+ }
+
/// The store behind the layer cache when a request asks for `layers` (the browser: the
/// disk cache's layer buffers, fetched before the request).
pub fn set_layer_store(&mut self, store: Option>) {
@@ -338,7 +347,7 @@ impl FrameServer {
fn load(&mut self, revision: u64) -> Result<(), String> {
let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?;
- self.salt = effectcraft_render::disk_cache::footage_salt(&p);
+ self.salt = effectcraft_render::disk_cache::footage_salt_with(&p, self.virtual_id.as_deref());
self.cache.set_store_salt(self.salt);
self.project = Some(Arc::new(p));
self.revision = Some(revision);
diff --git a/crates/engine/src/tests_limits.rs b/crates/engine/src/tests_limits.rs
index 68f2c04c0..524719bbf 100644
--- a/crates/engine/src/tests_limits.rs
+++ b/crates/engine/src/tests_limits.rs
@@ -102,3 +102,38 @@ fn commands_cap_sizes_at_30000_px() {
let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap();
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
}
+
+#[test]
+fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() {
+ // Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A
+ // contains C walked the A ⇄ B cycle until the stack ran out (#1237).
+ let mut s = Session::default();
+ let mut ids = vec![];
+ for name in ["A", "B", "C"] {
+ s.execute("comp.new", json!({"name": name, "width": 32, "height": 32, "frameRate": 30, "duration": 1})).unwrap();
+ ids.push(s.active_comp_id().unwrap());
+ }
+ let (a, b, c) = (ids[0], ids[1], ids[2]);
+ {
+ let proj = std::sync::Arc::make_mut(&mut s.project);
+ for (outer, inner) in [(a, b), (b, a)] {
+ let comp = proj.comp(outer).unwrap().clone();
+ let l = effectcraft_project::build::layer(proj, &comp, "pre", effectcraft_project::LayerSource::Comp { item: inner }, (32, 32), None);
+ let ItemKind::Comp(cc) = &mut proj.item_mut(outer).unwrap().kind else { panic!("comp") };
+ std::sync::Arc::make_mut(cc).layers.push(l);
+ }
+ }
+ let path = tmp("cycle.ecproj");
+ std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap();
+ let mut t = Session::default();
+ t.execute("file.open", json!({"path": path})).unwrap();
+ t.open_comp(c);
+ // Adding A into C asks whether A contains C: it doesn't, so the layer goes in.
+ let r = t.execute("layer.addItem", json!({"comp": c.0, "item": a.0})).unwrap();
+ assert!(r["layer"].as_u64().is_some(), "{r}");
+ // Adding C into A would nest C in itself through A: refused, as before.
+ assert!(t.execute("layer.addItem", json!({"comp": a.0, "item": c.0})).is_err());
+ // C renders: inside A the renderer skips B's copy of A instead of looping.
+ let img = t.render(c, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
+ assert_eq!((img.width, img.height), (32, 32));
+}
diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs
index fe0a16936..68b897617 100644
--- a/crates/engine/src/tests_m145.rs
+++ b/crates/engine/src/tests_m145.rs
@@ -42,14 +42,14 @@ fn split_layers_go_above_or_below_the_original() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
s.execute("time.set", json!({"time": 1.0})).unwrap();
- let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
- let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
- assert_eq!(order, vec![a, below], "off (default): the new part goes below");
- s.execute("edit.undo", json!({})).unwrap();
- s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": true})).unwrap();
let above = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
- assert_eq!(order, vec![above, a]);
+ assert_eq!(order, vec![above, a], "on (default): the new part goes above");
+ s.execute("edit.undo", json!({})).unwrap();
+ s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": false})).unwrap();
+ let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
+ let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
+ assert_eq!(order, vec![a, below]);
}
#[test]
diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs
index 6da07dd40..5131bf3f4 100644
--- a/crates/engine/src/tests_menu_cmds.rs
+++ b/crates/engine/src/tests_menu_cmds.rs
@@ -111,6 +111,29 @@ fn set_switch_others() {
assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err());
}
+/// The Video switch has a cell on every layer's row, cameras and lights included, so Alt-clicking
+/// it (and Hide Other Video) takes them along with the rest.
+#[test]
+fn video_others_takes_cameras_and_lights() {
+ let mut s = comp();
+ let a = solid(&mut s, "#ff0000");
+ let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
+ let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap();
+ let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, cam, light].map(|l| f(&layer(s, l)));
+ // The camera and the light are hidden: Alt-click on the solid's eye brings them back, since
+ // they stand apart with it, and the next Alt-click hides them with every other layer.
+ s.execute("layer.setSwitch", json!({"layers": [cam, light], "switch": "video", "value": false})).unwrap();
+ s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
+ assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
+ s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
+ assert_eq!(get(&s, |l| l.switches.video), [true, false, false]);
+ // Hide Other Video takes them too.
+ s.execute("layer.showAllVideo", json!({})).unwrap();
+ assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
+ s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap();
+ assert_eq!(get(&s, |l| l.switches.video), [true, false, false]);
+}
+
/// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles,
/// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is
/// hidden (parenting ignores the eye).
@@ -725,6 +748,37 @@ fn reveal_properties() {
assert!(frontend_events(&mut s).iter().all(|(c, _)| c == "timeline.revealProps"));
}
+#[test]
+fn reveal_layer_source_selects_each_layers_source_item() {
+ let mut s = comp();
+ let a = solid(&mut s, "#ff0000");
+ let b = solid(&mut s, "#00ff00");
+ let ia = layer(&s, a).source.item().unwrap();
+ let ib = layer(&s, b).source.item().unwrap();
+ assert_ne!(ia, ib);
+ s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
+ let r = s.execute("layer.revealSource", json!({})).unwrap();
+ assert_eq!(r["item"], ia.0);
+ assert_eq!(r["items"], json!([ia.0, ib.0]));
+ assert_eq!(s.state.project_selection, vec![ia, ib]);
+ assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [ia.0, ib.0]}))]);
+ // A null has no source. Alone it is refused; in a mixed selection only the sources are revealed.
+ let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
+ s.execute("layer.select", json!({"layers": [n]})).unwrap();
+ assert!(s.execute("layer.revealSource", json!({})).is_err());
+ s.execute("layer.select", json!({"layers": [n, a]})).unwrap();
+ let r = s.execute("layer.revealSource", json!({})).unwrap();
+ assert_eq!(r["items"], json!([ia.0]));
+ assert_eq!(s.state.project_selection, vec![ia]);
+ // Reveal Composition in Project opens the same panel and folders.
+ let cid = s.active_comp_id().unwrap();
+ s.drain_events();
+ let r = s.execute("comp.revealInProject", json!({"comp": cid.0})).unwrap();
+ assert_eq!(r["item"], cid.0);
+ assert_eq!(s.state.project_selection, vec![cid]);
+ assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [cid.0]}))]);
+}
+
#[test]
fn sequence_layers() {
let mut s = comp();
diff --git a/crates/engine/src/tests_replace_source.rs b/crates/engine/src/tests_replace_source.rs
new file mode 100644
index 000000000..a5cb252ce
--- /dev/null
+++ b/crates/engine/src/tests_replace_source.rs
@@ -0,0 +1,158 @@
+//! Layer ▸ Replace Source (#991, and the `replaceSource` part of #813): Alt-drag and Ctrl+Alt+/
+//! replace an AV layer's source while every other layer property stays.
+
+use std::sync::Arc;
+
+use effectcraft_color::Label;
+use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Solid};
+use serde_json::json;
+
+use crate::Session;
+
+fn comp() -> Session {
+ let mut s = Session::default();
+ s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
+ s
+}
+
+fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
+ s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone()
+}
+
+fn solid(s: &mut Session, name: &str) -> u64 {
+ s.execute("layer.newSolid", json!({"name": name, "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()
+}
+
+fn add_footage(s: &mut Session, name: &str, kind: FootageKind, width: u32, height: u32, has_audio: bool) -> ItemId {
+ let f = Footage { path: format!("/tmp/{name}"), kind, width, height, has_video: kind != FootageKind::Data, has_audio, ..Default::default() };
+ Arc::make_mut(&mut s.project).add_item(name, Label::None, None, ItemKind::Footage(f))
+}
+
+/// The replacement changes `source` and nothing else: name, label, comment, timing, stretch,
+/// switches, blending, parenting, track matte and the whole property tree (keys, expression,
+/// effect, mask) stay.
+#[test]
+fn replace_source_keeps_every_layer_property() {
+ let mut s = comp();
+ let a = solid(&mut s, "Hero");
+ let parent = solid(&mut s, "Parent");
+ let matte = solid(&mut s, "Matte");
+ s.execute("layer.timing", json!({"layers": [a], "in": 1.0, "out": 5.0})).unwrap();
+ s.execute("layer.timeStretch", json!({"layers": [a], "percent": 50})).unwrap();
+ s.execute("layer.setSwitch", json!({"layers": [a], "switch": "preserveTransparency", "value": true})).unwrap();
+ s.execute("layer.setSwitch", json!({"layers": [a], "switch": "motionBlur", "value": true})).unwrap();
+ s.execute("layer.setBlendMode", json!({"layers": [a], "mode": "Multiply"})).unwrap();
+ s.execute("layer.setParent", json!({"layers": [a], "parent": parent, "compensate": false})).unwrap();
+ s.execute("layer.setTrackMatte", json!({"layer": a, "matte": matte, "kind": "alpha"})).unwrap();
+ s.execute("layer.setComment", json!({"layers": [a], "comment": "keep me"})).unwrap();
+ s.execute("effect.apply", json!({"layer": a, "effect": "Gaussian Blur"})).unwrap();
+ s.execute("mask.new", json!({"layer": a, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap();
+ s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 1.0, "value": 25.0})).unwrap();
+ s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 2.0, "value": 75.0})).unwrap();
+ s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "time * 90"})).unwrap();
+ let before = layer(&s, a);
+ let item = add_footage(&mut s, "new.png", FootageKind::Still, 120, 80, false);
+ s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap();
+ let after = layer(&s, a);
+ assert_eq!(after.source, LayerSource::Footage { item });
+ assert_eq!(after.name, before.name);
+ assert_eq!(after.label, before.label);
+ assert_eq!(after.comment, before.comment);
+ assert_eq!((after.start_time, after.in_point, after.out_point, after.stretch), (before.start_time, before.in_point, before.out_point, before.stretch));
+ assert_eq!((after.switches, after.blend_mode, after.preserve_transparency), (before.switches, before.blend_mode, before.preserve_transparency));
+ assert_eq!((after.parent, after.track_matte), (before.parent, before.track_matte));
+ assert_eq!(after.props, before.props);
+ assert_eq!(s.active_comp().unwrap().layers.len(), 3, "no layer is added or removed");
+ s.execute("edit.undo", json!({})).unwrap();
+ assert_eq!(layer(&s, a).source, before.source, "one undo step restores the source");
+}
+
+/// Composition, footage and solid sources are interchangeable; a precomposition adds its Audio
+/// group when the layer has none, and an existing Audio group is kept when the next source has no
+/// audio.
+#[test]
+fn replace_source_crosses_types_and_adds_audio() {
+ let mut s = comp();
+ let a = solid(&mut s, "Layer");
+ let inner = s.execute("comp.new", json!({"name": "Inner", "width": 200, "height": 100, "frameRate": 25, "duration": 3})).unwrap()["comp"].as_u64().unwrap();
+ let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id;
+ s.execute("comp.open", json!({"comp": main})).unwrap();
+ let before = layer(&s, a);
+ s.execute("layer.replaceSource", json!({"layers": [a], "item": inner})).unwrap();
+ let after = layer(&s, a);
+ assert_eq!(after.source, LayerSource::Comp { item: ItemId(inner) });
+ assert_eq!(after.name, before.name);
+ assert!(after.props.sub("audio").is_some(), "a precomposition brings Audio");
+ let footage = add_footage(&mut s, "still.png", FootageKind::Still, 64, 32, false);
+ s.execute("layer.replaceSource", json!({"layers": [a], "item": footage.0})).unwrap();
+ let after = layer(&s, a);
+ assert_eq!(after.source, LayerSource::Footage { item: footage });
+ assert!(after.props.sub("audio").is_some(), "the Audio group is preserved for the next source");
+ let new_solid = Arc::make_mut(&mut s.project).add_item(
+ "New Solid",
+ Label::None,
+ None,
+ ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 10, height: 10, pixel_aspect: 1.0 }),
+ );
+ s.execute("layer.replaceSource", json!({"layers": [a], "item": new_solid.0})).unwrap();
+ assert_eq!(layer(&s, a).source, LayerSource::Solid { item: new_solid });
+ assert_eq!(s.active_comp().unwrap().layers.len(), 1);
+ s.execute("edit.undo", json!({})).unwrap();
+ assert_eq!(layer(&s, a).source, LayerSource::Footage { item: footage });
+}
+
+/// Folders, data files, 3D models, non-source layers and composition cycles are refused before
+/// anything is edited.
+#[test]
+fn replace_source_refuses_invalid_sources_targets_and_cycles() {
+ let mut s = comp();
+ let a = solid(&mut s, "Layer");
+ let folder = Arc::make_mut(&mut s.project).add_item("Folder", Label::None, None, ItemKind::Folder);
+ let data = add_footage(&mut s, "data.txt", FootageKind::Data, 0, 0, false);
+ let model = add_footage(&mut s, "model.glb", FootageKind::Model, 10, 10, false);
+ for (item, needle) in [(folder, "folder"), (data, "data"), (model, "3D model")] {
+ let err = s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap_err().to_string();
+ assert!(err.contains(needle), "{err}");
+ }
+ let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false);
+ let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
+ let err = s.execute("layer.replaceSource", json!({"layers": [text], "item": footage.0})).unwrap_err().to_string();
+ assert!(err.contains("no source item"), "{err}");
+ let inner = s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
+ let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id;
+ s.execute("comp.open", json!({"comp": main})).unwrap();
+ let inner_layer = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
+ let err = s.execute("layer.replaceSource", json!({"layers": [inner_layer], "item": main.0})).unwrap_err().to_string();
+ assert!(err.contains("contain"), "{err}");
+}
+
+/// Ctrl+Alt+/ takes the source from the Project panel selection and replaces every selected
+/// layer; locked layers are left alone (and an all-locked selection is an error).
+#[test]
+fn replace_source_uses_the_project_selection_and_skips_locked_layers() {
+ let mut s = comp();
+ let a = solid(&mut s, "A");
+ let b = solid(&mut s, "B");
+ let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false);
+ s.state.project_selection = vec![footage];
+ let r = s.execute("layer.replaceSource", json!({"layers": [a, b]})).unwrap();
+ assert_eq!(r["item"], json!(footage.0));
+ assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage));
+ assert!(matches!(layer(&s, b).source, LayerSource::Footage { item } if item == footage));
+ s.execute("edit.undo", json!({})).unwrap();
+ s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
+ let old_b = layer(&s, b).source;
+ let r = s.execute("layer.replaceSource", json!({"layers": [a, b], "item": footage.0})).unwrap();
+ assert_eq!(r["layers"], json!([a]));
+ assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage));
+ assert_eq!(layer(&s, b).source, old_b);
+ let err = s.execute("layer.replaceSource", json!({"layers": [b], "item": footage.0})).unwrap_err().to_string();
+ assert!(err.contains("locked"), "{err}");
+}
+
+/// After Effects' Ctrl+Alt+/ default (Cmd+Alt+/ in EffectCraft's shortcut syntax).
+#[test]
+fn replace_source_is_bound_to_ctrl_alt_slash() {
+ let b = crate::shortcuts::bindables(&[]).into_iter().find(|b| b.command == "layer.replaceSource").expect("layer.replaceSource is bindable");
+ assert!(b.defaults.iter().any(|d| d == "Cmd+Alt+/"), "{:?}", b.defaults);
+}
diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs
index a4f4d89eb..c35d4f3a4 100644
--- a/crates/engine/src/tests_settings.rs
+++ b/crates/engine/src/tests_settings.rs
@@ -1064,6 +1064,12 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
cs.load_settings();
assert_eq!(cs.prefs.general.language, "cs");
assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs");
+ // Georgian too.
+ assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka")));
+ s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap();
+ let mut ka = Session { config: Some(store.clone()), ..Default::default() };
+ ka.load_settings();
+ assert_eq!(ka.prefs.general.language, "ka");
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
let mut reloaded = Session { config: Some(store), ..Default::default() };
reloaded.load_settings();
diff --git a/crates/engine/src/tests_timeline_interchange.rs b/crates/engine/src/tests_timeline_interchange.rs
index b32e19c49..45be0f7a1 100644
--- a/crates/engine/src/tests_timeline_interchange.rs
+++ b/crates/engine/src/tests_timeline_interchange.rs
@@ -1,5 +1,5 @@
//! File ▸ Import / Export ▸ Adobe Premiere Pro Project… (timeline interchange) through the command
-//! registry, without media decoding (pixels: `crates/host/tests/timeline_interop.rs`).
+//! registry, without media decoding (pixels: `crates/host/tests/it/timeline_interop.rs`).
use serde_json::json;
diff --git a/crates/engine/tests/key_time_post_snap_bounds.rs b/crates/engine/tests/it/key_time_post_snap_bounds.rs
similarity index 100%
rename from crates/engine/tests/key_time_post_snap_bounds.rs
rename to crates/engine/tests/it/key_time_post_snap_bounds.rs
diff --git a/crates/engine/tests/key_time_reply_contract.rs b/crates/engine/tests/it/key_time_reply_contract.rs
similarity index 100%
rename from crates/engine/tests/key_time_reply_contract.rs
rename to crates/engine/tests/it/key_time_reply_contract.rs
diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs
new file mode 100644
index 000000000..ac1484cfb
--- /dev/null
+++ b/crates/engine/tests/it/main.rs
@@ -0,0 +1,5 @@
+//! The crate's integration tests, one binary (each module is a former `tests/.rs`).
+
+mod key_time_post_snap_bounds;
+mod key_time_reply_contract;
+mod parenting_3d_position;
diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/it/parenting_3d_position.rs
similarity index 100%
rename from crates/engine/tests/parenting_3d_position.rs
rename to crates/engine/tests/it/parenting_3d_position.rs
diff --git a/crates/export/tests/audio_sample_positions.rs b/crates/export/tests/it/audio_sample_positions.rs
similarity index 100%
rename from crates/export/tests/audio_sample_positions.rs
rename to crates/export/tests/it/audio_sample_positions.rs
diff --git a/crates/export/tests/hevc_av1_roundtrip.rs b/crates/export/tests/it/hevc_av1_roundtrip.rs
similarity index 100%
rename from crates/export/tests/hevc_av1_roundtrip.rs
rename to crates/export/tests/it/hevc_av1_roundtrip.rs
diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs
new file mode 100644
index 000000000..0f89a641d
--- /dev/null
+++ b/crates/export/tests/it/main.rs
@@ -0,0 +1,14 @@
+//! The crate's integration tests, one binary (each module is a former `tests/.rs`).
+
+mod audio_sample_positions;
+mod hevc_av1_roundtrip;
+#[cfg(not(target_arch = "wasm32"))]
+mod mov_audio_sample_positions;
+mod ms_rounded_footage;
+#[cfg(not(target_arch = "wasm32"))]
+mod native_audio_sample_positions;
+mod opus_roundtrip;
+mod render_settings;
+mod roundtrip;
+mod uncompressed_mov;
+mod vp9_roundtrip;
diff --git a/crates/export/tests/mov_audio_sample_positions.rs b/crates/export/tests/it/mov_audio_sample_positions.rs
similarity index 99%
rename from crates/export/tests/mov_audio_sample_positions.rs
rename to crates/export/tests/it/mov_audio_sample_positions.rs
index c58d2b14d..a46a49271 100644
--- a/crates/export/tests/mov_audio_sample_positions.rs
+++ b/crates/export/tests/it/mov_audio_sample_positions.rs
@@ -1,5 +1,3 @@
-#![cfg(not(target_arch = "wasm32"))]
-
//! Tiny native WAV to ProRes PCM MOV sample-position proof.
use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings};
use effectcraft_export::{Job, JobOptions, Sink, export};
diff --git a/crates/export/tests/ms_rounded_footage.rs b/crates/export/tests/it/ms_rounded_footage.rs
similarity index 100%
rename from crates/export/tests/ms_rounded_footage.rs
rename to crates/export/tests/it/ms_rounded_footage.rs
diff --git a/crates/export/tests/native_audio_sample_positions.rs b/crates/export/tests/it/native_audio_sample_positions.rs
similarity index 99%
rename from crates/export/tests/native_audio_sample_positions.rs
rename to crates/export/tests/it/native_audio_sample_positions.rs
index 1b59e8c11..9222d0f23 100644
--- a/crates/export/tests/native_audio_sample_positions.rs
+++ b/crates/export/tests/it/native_audio_sample_positions.rs
@@ -1,5 +1,3 @@
-#![cfg(not(target_arch = "wasm32"))]
-
//! Native MediaPool decoding and native WAV export preserve selected sample positions.
use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings};
use effectcraft_export::{Job, JobOptions, Sink, export};
diff --git a/crates/export/tests/opus_roundtrip.rs b/crates/export/tests/it/opus_roundtrip.rs
similarity index 100%
rename from crates/export/tests/opus_roundtrip.rs
rename to crates/export/tests/it/opus_roundtrip.rs
diff --git a/crates/export/tests/render_settings.rs b/crates/export/tests/it/render_settings.rs
similarity index 100%
rename from crates/export/tests/render_settings.rs
rename to crates/export/tests/it/render_settings.rs
diff --git a/crates/export/tests/roundtrip.rs b/crates/export/tests/it/roundtrip.rs
similarity index 100%
rename from crates/export/tests/roundtrip.rs
rename to crates/export/tests/it/roundtrip.rs
diff --git a/crates/export/tests/uncompressed_mov.rs b/crates/export/tests/it/uncompressed_mov.rs
similarity index 100%
rename from crates/export/tests/uncompressed_mov.rs
rename to crates/export/tests/it/uncompressed_mov.rs
diff --git a/crates/export/tests/vp9_roundtrip.rs b/crates/export/tests/it/vp9_roundtrip.rs
similarity index 100%
rename from crates/export/tests/vp9_roundtrip.rs
rename to crates/export/tests/it/vp9_roundtrip.rs
diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js
index 4da7bc63b..df6ed3ff4 100644
--- a/crates/expr/src/prelude.js
+++ b/crates/expr/src/prelude.js
@@ -461,7 +461,7 @@ Comp.prototype = {
var l = this.layer(i);
if (l.__info[10] === 'Camera' && l.active) return l;
}
- __err('Composition "' + this.name + '" has no active camera');
+ return __err('Composition "' + this.name + '" has no active camera');
},
layer: function (a, rel) {
if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0));
diff --git a/crates/expr/src/rewrite.rs b/crates/expr/src/rewrite.rs
index 55e9800aa..6b0ea539f 100644
--- a/crates/expr/src/rewrite.rs
+++ b/crates/expr/src/rewrite.rs
@@ -528,8 +528,9 @@ fn rewrite_seq(items: &[Item]) -> String {
let end = assignment_end(items, j + 1);
let rhs = rewrite_seq(&items[j + 1..end]);
let h = helper(&op.text[..1]).unwrap_or("__add");
+ let target = compound_assignment_target(items, i, j);
out.push_str(&p.ws);
- out.push_str(&format!("{} ={}{h}({}, ({rhs}))", p.body, op.ws, p.body));
+ out.push_str(&format!("{target} ={}{h}({target}, ({rhs}))", op.ws));
i = end;
continue;
}
@@ -543,6 +544,34 @@ fn rewrite_seq(items: &[Item]) -> String {
out
}
+fn raw_ref(item: &Item) -> String {
+ match item {
+ Item::Tok(t) => format!("{}{}", t.ws, t.text),
+ Item::Group { open, inner, close } => {
+ let mut out = format!("{}{}", open.ws, open.text);
+ if has_assign(inner) {
+ // shortcut: nested assignments stay verbatim (rewriting them would double per level), so arithmetic beside them is not array-aware
+ for item in inner {
+ out.push_str(&raw_ref(item));
+ }
+ } else {
+ out.push_str(&rewrite_seq(inner));
+ }
+ if let Some(close) = close {
+ out.push_str(&close.text);
+ }
+ out
+ }
+ }
+}
+
+fn has_assign(items: &[Item]) -> bool {
+ items.iter().any(|it| match it {
+ Item::Tok(t) => ASSIGN.contains(&t.text.as_str()),
+ Item::Group { inner, .. } => has_assign(inner),
+ })
+}
+
fn push_piece(out: &mut String, p: Piece) {
out.push_str(&p.ws);
out.push_str(&p.body);
@@ -554,6 +583,18 @@ fn push_piece(out: &mut String, p: Piece) {
/// nested brackets, so deeper expressions are rejected with an error instead. Constructs that
/// nest without brackets count as part of a level ([`too_nested`]).
pub const MAX_NESTING: usize = 32;
+/// Hard limit on bytes emitted by the array-math source rewrite.
+pub const MAX_REWRITTEN_BYTES: usize = 4 * 1024 * 1024;
+
+fn over_rewrite_limit(text: &str) -> bool {
+ text.len() > MAX_REWRITTEN_BYTES
+}
+
+fn compound_assignment_target(items: &[Item], start: usize, end: usize) -> String {
+ // Rewriting the whole left side duplicates nested computed assignment targets. Keep the
+ // original target syntax for both reads/writes; source length is bounded above.
+ items[start..end].iter().map(raw_ref).collect()
+}
/// Stack cost of one level of brackets (also of a template substitution, a regex group and a
/// prefix operator, which cost the parser about as much or are never chained in real code).
@@ -746,12 +787,18 @@ fn too_nested_from(toks: &[Tok], base: usize) -> bool {
/// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite).
pub fn check_nesting(src: &str) -> Result<(), String> {
+ if over_rewrite_limit(src) {
+ return Err(too_large());
+ }
let (toks, tail) = tokenize(src);
if too_nested(&toks) {
return Err(too_deep());
}
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
+ if over_rewrite_limit(&out) {
+ return Err(too_large());
+ }
if too_nested(&tokenize(&out).0) {
return Err(too_deep());
}
@@ -765,12 +812,18 @@ fn too_deep() -> String {
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
/// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error.
pub fn rewrite(src: &str) -> String {
+ if over_rewrite_limit(src) {
+ return throw_too_large();
+ }
let (toks, tail) = tokenize(src);
if too_nested(&toks) {
return throw_too_deep();
}
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
+ if over_rewrite_limit(&out) {
+ return throw_too_large();
+ }
if too_nested(&tokenize(&out).0) {
return throw_too_deep();
}
@@ -781,6 +834,14 @@ fn throw_too_deep() -> String {
format!("throw new RangeError({:?})", too_deep().trim_start_matches("Error: "))
}
+fn too_large() -> String {
+ format!("Error: expression exceeds the {MAX_REWRITTEN_BYTES}-byte rewrite limit")
+}
+
+fn throw_too_large() -> String {
+ format!("throw new RangeError({:?})", too_large().trim_start_matches("Error: "))
+}
+
#[cfg(test)]
mod tests {
use super::{MAX_NESTING, check_nesting, rewrite};
@@ -821,6 +882,8 @@ mod tests {
#[test]
fn compound_assignment_and_functions() {
assert_eq!(n(&rewrite("x += [1, 0];")), n("x = __add(x, ( [1, 0]));"));
+ assert_eq!(n(&rewrite("obj[key] += 1")), n("obj[key] = __add(obj[key], (1))"));
+ assert_eq!(n(&rewrite("o[a + b] += 1")), n("o[__add(a, b)] = __add(o[__add(a, b)], (1))"));
assert_eq!(n(&rewrite("function f(a) { return a * 2 }")), n("function f(a) { return __mul(a, 2) }"));
assert_eq!(n(&rewrite("var g = (a) => a - 1")), n("var g = (a) => __sub(a, 1)"));
assert_eq!(n(&rewrite("typeof a + 'x'")), n("__add(typeof a, 'x')"));
@@ -828,6 +891,20 @@ mod tests {
assert_eq!(n(&rewrite("2 ** 3 * 2")), n("__mul(2 ** 3, 2)"));
}
+ #[test]
+ fn source_expansion_is_bounded_before_rewriting() {
+ let src = "x += 1;".repeat(super::MAX_REWRITTEN_BYTES / 6 + 1);
+ assert!(check_nesting(&src).unwrap_err().contains("rewrite limit"));
+ assert!(rewrite(&src).starts_with("throw new RangeError("));
+ }
+
+ #[test]
+ fn output_expansion_is_bounded() {
+ let src = format!("{} += 1", "a".repeat(super::MAX_REWRITTEN_BYTES / 2));
+ assert!(check_nesting(&src).unwrap_err().contains("rewrite limit"));
+ assert!(rewrite(&src).starts_with("throw new RangeError("));
+ }
+
#[test]
fn deep_nesting_is_an_error_not_a_stack_overflow() {
// Each level costs the JavaScript parser stack: ~60 nested brackets overflowed a 2 MiB
diff --git a/crates/expr/src/sample.rs b/crates/expr/src/sample.rs
index bff5a2149..28f830bb2 100644
--- a/crates/expr/src/sample.rs
+++ b/crates/expr/src/sample.rs
@@ -99,8 +99,8 @@ pub fn sample_image(ctx: EvalCtx, comp: ItemId, layer: &Layer, point: [f64; 2],
};
let mut acc = [0.0f64; 4];
let mut n = 0.0;
- for y in y0..=y1.min(y0 + 4096) {
- for x in x0..=x1.min(x0 + 4096) {
+ for y in y0..=y1.min(y0.saturating_add(4096)) {
+ for x in x0..=x1.min(x0.saturating_add(4096)) {
let p = b.img.get(x, y);
for k in 0..4 {
acc[k] += p[k] as f64;
diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs
index 18b725084..7d2790255 100644
--- a/crates/expr/src/tests.rs
+++ b/crates/expr/src/tests.rs
@@ -1166,6 +1166,13 @@ fn sample_image_reads_rendered_pixels() {
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0);
}
+/// A huge sample point saturates the pixel coordinates; it must not overflow the loop bounds.
+#[test]
+fn sample_image_huge_point_is_transparent() {
+ let f = fx();
+ assert_close!(f.num("var c = thisComp.layer(\"B\").sampleImage([1e300, 1e300]); c[3]", 0.0), 0.0);
+}
+
fn data_item(p: &mut Project, name: &str, text: &str) {
let f = effectcraft_project::Footage {
path: format!("/data/{name}"),
diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs
index 24f8c9614..79ef77b57 100644
--- a/crates/gpu/src/tests.rs
+++ b/crates/gpu/src/tests.rs
@@ -79,6 +79,35 @@ pub(crate) fn on_gl() -> bool {
gpu().is_some_and(|g| g.ctx.name.ends_with("(Gl)"))
}
+/// The test device is Mesa's software rasteriser, llvmpipe: on OpenGL, or as lavapipe on Vulkan
+/// (the Linux CI gate).
+pub(crate) fn on_llvmpipe() -> bool {
+ gpu().is_some_and(|g| g.ctx.name.starts_with("llvmpipe"))
+}
+
+thread_local! {
+ static LLVMPIPE_FLIPS: std::cell::Cell = const { std::cell::Cell::new(false) };
+}
+
+/// Until the guard drops, comparisons on lavapipe get [`GL_BOUNDARY_FLIPS`] too. Only the tests
+/// that measured such flips there opt in (CC HexTile cell edges, CC Threshold RGB thresholds);
+/// every other comparison on the Linux gate stays exact.
+pub(crate) fn allow_llvmpipe_boundary_flips() -> impl Drop {
+ struct Reset;
+ impl Drop for Reset {
+ fn drop(&mut self) {
+ LLVMPIPE_FLIPS.with(|f| f.set(false));
+ }
+ }
+ LLVMPIPE_FLIPS.with(|f| f.set(true));
+ Reset
+}
+
+/// The boundary-flip allowance applies: OpenGL, or llvmpipe in a test that opted in.
+fn boundary_flips_allowed() -> bool {
+ on_gl() || (LLVMPIPE_FLIPS.with(std::cell::Cell::get) && on_llvmpipe())
+}
+
/// On GL, the fraction of pixels allowed past the tolerance: pixels on a decision boundary (a
/// hex cell's edge, a threshold, a corner pin's coverage edge, a median's rank tie) may land on
/// the other side, since GL drivers may divide through a reciprocal and GLSL's `fma` need not be
@@ -255,7 +284,7 @@ pub(crate) fn check(label: &str, d: Option, allow: f64) {
let frac = d.over as f64 / d.total as f64;
// Quantised depths: rounding-boundary flips (see the module docs).
let (allow, cap) = if d.quantized && allow == 0.0 { (0.003, 4.0 / 255.0 + 1e-6) } else { (allow, f32::INFINITY) };
- let (allow, cap) = if on_gl() { (allow.max(GL_BOUNDARY_FLIPS), f32::INFINITY) } else { (allow, cap) };
+ let (allow, cap) = if boundary_flips_allowed() { (allow.max(GL_BOUNDARY_FLIPS), f32::INFINITY) } else { (allow, cap) };
assert!(frac <= allow && d.max <= cap, "{label}: {} of {} pixels over tolerance (max {:.6}); worst {:?}", d.over, d.total, d.max, d.worst);
if d.over > 0 {
eprintln!("{label}: {} of {} pixels over tolerance (max {:.6})", d.over, d.total, d.max);
@@ -454,7 +483,7 @@ pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool)
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry");
let d = diff(&cpu.img, &out.img, 1e-3);
- let allow = if on_gl() { (d.total as f64 * GL_BOUNDARY_FLIPS) as usize } else { 0 };
+ let allow = if boundary_flips_allowed() { (d.total as f64 * GL_BOUNDARY_FLIPS) as usize } else { 0 };
assert!(
d.over <= allow,
"{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}",
diff --git a/crates/gpu/src/tests_fx_pixel2.rs b/crates/gpu/src/tests_fx_pixel2.rs
index 899955b85..4a7886afe 100644
--- a/crates/gpu/src/tests_fx_pixel2.rs
+++ b/crates/gpu/src/tests_fx_pixel2.rs
@@ -12,7 +12,7 @@ use effectcraft_project::{BitDepth, MaskMode};
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
-use crate::tests::{Scene, c, check, compare_at, diff, effect_case, gpu, n, opts, pattern, set};
+use crate::tests::{Scene, allow_llvmpipe_boundary_flips, c, check, compare_at, diff, effect_case, gpu, n, opts, pattern, set};
pub(crate) fn e(v: u32) -> Value {
Value::Enum(v)
@@ -193,6 +193,7 @@ fn composites_and_colour() {
#[test]
fn stylize() {
+ let _flips = allow_llvmpipe_boundary_flips();
for (done, flags) in
[(30.0, [true, true, true, false]), (55.0, [false, true, false, true]), (70.0, [true, false, false, false]), (10.0, [false, false, true, true])]
{
diff --git a/crates/gpu/src/tests_fx_stylize.rs b/crates/gpu/src/tests_fx_stylize.rs
index 44e156aec..20d76efa6 100644
--- a/crates/gpu/src/tests_fx_stylize.rs
+++ b/crates/gpu/src/tests_fx_stylize.rs
@@ -6,7 +6,7 @@ use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
-use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set};
+use crate::tests::{Scene, allow_llvmpipe_boundary_flips, c, check, compare_at, effect_case, n, opts, set};
fn e(v: u32) -> Value {
Value::Enum(v)
@@ -22,6 +22,7 @@ fn pt(x: f64, y: f64) -> Value {
#[test]
fn posterize_thresholds_strobe_vignette() {
+ let _flips = allow_llvmpipe_boundary_flips();
effect_case("ec.stylize.posterize", &[("level", n(4.0))]);
effect_case("ec.stylize.posterize", &[("level", n(9.0))]);
effect_case("ec.stylize.threshold", &[("level", n(100.0))]);
diff --git a/crates/gpu/src/tests_fx_warp.rs b/crates/gpu/src/tests_fx_warp.rs
index 39bcba13c..1482c268b 100644
--- a/crates/gpu/src/tests_fx_warp.rs
+++ b/crates/gpu/src/tests_fx_warp.rs
@@ -2,6 +2,8 @@
//! Emboss, Cartoon) vs the CPU effects (the oracle): direct on a buffer at full and half
//! resolution, as adjustment, and composited at 8 and 32 bpc.
+use std::io::Write;
+
use effectcraft_effects::MaskShape;
use effectcraft_keyframe::{ShapePath, Value};
use effectcraft_project::build::{self, Ids};
@@ -9,7 +11,7 @@ use effectcraft_project::{BitDepth, MaskMode};
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
-use crate::tests::{Scene, c, check, compare_at, diff, effect_case, gpu, n, opts, pattern};
+use crate::tests::{Scene, c, check, compare_at, diff, effect_case, gpu, n, on_llvmpipe, opts, pattern};
fn e(v: u32) -> Value {
Value::Enum(v)
@@ -182,6 +184,12 @@ fn smear_and_reshape() {
],
];
for vals in reshape {
+ if on_llvmpipe() && vals.iter().any(|(k, _)| *k == "boundaryMask") {
+ // Straight to stderr: libtest captures `eprintln!` of passing tests, and the skip
+ // should show in the log.
+ let _ = writeln!(std::io::stderr(), "tests_fx_warp::smear_and_reshape: skipping ec.distort.reshape {vals:?} on llvmpipe (crashes lavapipe, #2238)");
+ continue;
+ }
direct_with_masks("ec.distort.reshape", vals);
composite_with_masks("ec.distort.reshape", vals);
}
diff --git a/crates/host/tests/background_presets.rs b/crates/host/tests/it/background_presets.rs
similarity index 100%
rename from crates/host/tests/background_presets.rs
rename to crates/host/tests/it/background_presets.rs
diff --git a/crates/host/tests/determinism.rs b/crates/host/tests/it/determinism.rs
similarity index 100%
rename from crates/host/tests/determinism.rs
rename to crates/host/tests/it/determinism.rs
diff --git a/crates/host/tests/exr_layers.rs b/crates/host/tests/it/exr_layers.rs
similarity index 100%
rename from crates/host/tests/exr_layers.rs
rename to crates/host/tests/it/exr_layers.rs
diff --git a/crates/host/tests/footage_reload.rs b/crates/host/tests/it/footage_reload.rs
similarity index 100%
rename from crates/host/tests/footage_reload.rs
rename to crates/host/tests/it/footage_reload.rs
diff --git a/crates/host/tests/gif_animates.rs b/crates/host/tests/it/gif_animates.rs
similarity index 100%
rename from crates/host/tests/gif_animates.rs
rename to crates/host/tests/it/gif_animates.rs
diff --git a/crates/host/tests/it/main.rs b/crates/host/tests/it/main.rs
new file mode 100644
index 000000000..575ae946a
--- /dev/null
+++ b/crates/host/tests/it/main.rs
@@ -0,0 +1,13 @@
+//! The crate's integration tests, one binary (each module is a former `tests/.rs`).
+
+mod background_presets;
+mod determinism;
+mod exr_layers;
+mod footage_reload;
+mod gif_animates;
+mod plugins;
+mod relink;
+mod tga_import;
+mod timeline_interop;
+mod vector_import;
+mod webm_metadata;
diff --git a/crates/host/tests/plugins.rs b/crates/host/tests/it/plugins.rs
similarity index 100%
rename from crates/host/tests/plugins.rs
rename to crates/host/tests/it/plugins.rs
diff --git a/crates/host/tests/relink.rs b/crates/host/tests/it/relink.rs
similarity index 100%
rename from crates/host/tests/relink.rs
rename to crates/host/tests/it/relink.rs
diff --git a/crates/host/tests/tga_import.rs b/crates/host/tests/it/tga_import.rs
similarity index 100%
rename from crates/host/tests/tga_import.rs
rename to crates/host/tests/it/tga_import.rs
diff --git a/crates/host/tests/timeline_interop.rs b/crates/host/tests/it/timeline_interop.rs
similarity index 100%
rename from crates/host/tests/timeline_interop.rs
rename to crates/host/tests/it/timeline_interop.rs
diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/it/vector_import.rs
similarity index 99%
rename from crates/host/tests/vector_import.rs
rename to crates/host/tests/it/vector_import.rs
index 49f8d950c..36d83dd1a 100644
--- a/crates/host/tests/vector_import.rs
+++ b/crates/host/tests/it/vector_import.rs
@@ -121,8 +121,8 @@ fn psd_16bit_cmyk_composition_imports() {
assert!(d < 2.0 / 255.0, "mean diff {d}");
}
-const SVG: &[u8] = include_bytes!("../../svg/tests/fixtures/basic-shapes.svg");
-const SVG2: &[u8] = include_bytes!("../../svg/tests/fixtures/paths-gradients.svg");
+const SVG: &[u8] = include_bytes!("../../../svg/tests/fixtures/basic-shapes.svg");
+const SVG2: &[u8] = include_bytes!("../../../svg/tests/fixtures/paths-gradients.svg");
#[test]
fn svg_footage_and_shapes_from_vector_layer() {
diff --git a/crates/host/tests/webm_metadata.rs b/crates/host/tests/it/webm_metadata.rs
similarity index 100%
rename from crates/host/tests/webm_metadata.rs
rename to crates/host/tests/it/webm_metadata.rs
diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs
index 1dff4477f..6609aed68 100644
--- a/crates/keyframe/src/value.rs
+++ b/crates/keyframe/src/value.rs
@@ -82,7 +82,18 @@ impl ShapePath {
if i == 0 && f.segment == 0 && !self.closed {
return false;
}
- if i > 0 && f.segment == i - 1 {
+ if i == 0 && self.closed && segs > 0 {
+ // The old closing segment and segment 0 merge into the new closing one.
+ if f.segment == segs {
+ f.segment = segs - 1;
+ f.t *= 0.5;
+ } else if f.segment == 0 {
+ f.segment = segs - 1;
+ f.t = 0.5 + f.t * 0.5;
+ } else {
+ f.segment -= 1;
+ }
+ } else if i > 0 && f.segment == i - 1 {
f.t *= 0.5;
} else if f.segment == i && i > 0 {
f.segment = i - 1;
diff --git a/crates/keyframe/tests/feather_remove.rs b/crates/keyframe/tests/feather_remove.rs
new file mode 100644
index 000000000..06836bbf6
--- /dev/null
+++ b/crates/keyframe/tests/feather_remove.rs
@@ -0,0 +1,14 @@
+use effectcraft_keyframe::{FeatherPoint, ShapePath};
+
+#[test]
+fn removing_first_vertex_of_closed_path_merges_closing_segment() {
+ let mut p = ShapePath::rect([0.0, 0.0], 10.0, 10.0);
+ let fp = |segment| FeatherPoint { segment, t: 0.5, radius: 1.0, tension: 0.0 };
+ p.feather = vec![fp(3), fp(0)];
+ p.vertices.remove(0);
+ p.in_tangents.remove(0);
+ p.out_tangents.remove(0);
+ p.feather_after_remove(0);
+ let got: Vec<(usize, f64)> = p.feather.iter().map(|f| (f.segment, f.t)).collect();
+ assert_eq!(got, vec![(2, 0.25), (2, 0.75)]);
+}
diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs
index 7730017d8..b2eeece49 100644
--- a/crates/lottie/src/import.rs
+++ b/crates/lottie/src/import.rs
@@ -96,9 +96,11 @@ fn external_image(stored: &str, base_dir: &str) -> Result {
let doc = std::path::Path::new(base_dir).join("lottie.json");
std::path::absolute(&doc).unwrap_or(doc)
});
- match craft_docpath::resolve(stored, doc.as_deref()) {
- Ok(path) if path.is_absolute() => Ok(path.to_string_lossy().into_owned()),
- Ok(_) => Err(format!("{stored} is relative and the Lottie file's folder is not known")),
+ match craft_docpath::resolve_absolute(stored, doc.as_deref()) {
+ Ok(path) => Ok(path.to_string_lossy().into_owned()),
+ Err(craft_docpath::Refused::Invalid) if doc.is_none() && craft_docpath::resolve(stored, None).is_ok() => {
+ Err(format!("{stored} is relative and the Lottie file's folder is not known"))
+ }
Err(why) => Err(format!("{stored}: {why}")),
}
}
diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs
index 9a8ec5c28..ee1d80cd3 100644
--- a/crates/media/src/pool.rs
+++ b/crates/media/src/pool.rs
@@ -45,6 +45,9 @@ struct Key {
matte: [u32; 3],
/// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded).
size: (u32, u32),
+ /// A still at a reduced `size`: the footage's nominal size, which decides whether it is
+ /// resampled (see `Inner::still_at_size`); (0, 0) otherwise.
+ nominal: (u32, u32),
}
struct Entry {
@@ -559,7 +562,7 @@ impl Inner {
let loaded = self.read(path).map_err(|e| e.to_string()).and_then(|bytes| {
// Files the model names are read only when local or on the model's own network share.
let resolve = |uri: &str| -> Option> {
- let p = craft_docpath::resolve(uri, Some(std::path::Path::new(path))).ok().filter(|p| p.is_absolute())?;
+ let p = craft_docpath::resolve_absolute(uri, Some(std::path::Path::new(path))).ok()?;
self.read(&p.to_string_lossy()).ok().map(|b| b.to_vec())
};
effectcraft_model::load(path, &bytes, &resolve).map_err(|e| e.to_string())
@@ -678,7 +681,7 @@ impl Inner {
FloatCurve::Gamma24 => 2,
} << 2);
let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] };
- let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0) };
+ let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0) };
match footage.kind {
FootageKind::Sequence if !footage.sequence.is_empty() => {
let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count);
@@ -708,16 +711,20 @@ impl Inner {
}
}
- /// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that
- /// size directly (see [`FootageSource::frame_at_size`]).
+ /// The frame of `footage` at `t`, resampled to `size` (see [`FootageSource::frame_at_size`]):
+ /// a movie's made at that size directly, a still's resampled once and kept at that size.
fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result> {
if !footage.has_video {
return Err(MediaError::Unsupported(format!("{}: no video", footage.path)));
}
let mut loc = Self::locate(footage, t, float);
let movie = loc.media_t.is_some();
- if movie && let Some(size) = size {
+ // A still asked for at its own size is the unsized frame (one decode, one entry).
+ if let Some(size) = size.filter(|s| movie || *s != (footage.width, footage.height)) {
loc.key.size = size;
+ if !movie {
+ loc.key.nominal = (footage.width, footage.height);
+ }
}
let mut c = lock(&self.cache);
// sequential playback of a movie: read the next frame ahead
@@ -773,34 +780,62 @@ impl Inner {
#[cfg(target_arch = "wasm32")]
fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {}
+ /// A still (image file or sequence frame) at `loc.key.size` when one is set: resampled as the
+ /// renderer would ([`FootageSource::frame_at_size`]), so the pool keeps the reduced frame and a
+ /// reduced-resolution render neither decodes nor resamples a large still again for every frame
+ /// (#1812: 21120-px plates at Quarter). Like the renderer's `footage_buf`, an image decoded
+ /// larger than the footage's nominal size (a smart object's pixels) is resampled too, and one
+ /// decoded smaller is not (the renderer scales the layer instead).
+ fn still_at_size(img: Image, loc: &Loc, footage: &Footage) -> Image {
+ let (w, h) = loc.key.size;
+ let smaller = img.width == 0 || footage.width as f64 / img.width as f64 > 1.0 + 1e-9;
+ if w == 0 || h == 0 || smaller || (w, h) == (img.width, img.height) {
+ return img;
+ }
+ effectcraft_raster::resample(&img, w, h)
+ }
+
fn decode(&self, loc: &Loc, footage: &Footage) -> Result {
+ match loc.media_t {
+ None => self.decode_still(loc, footage).map(|img| Self::still_at_size(img, loc, footage)),
+ Some(_) => self.decode_media(loc, footage),
+ }
+ }
+
+ fn decode_still(&self, loc: &Loc, footage: &Footage) -> Result {
+ let op = AlphaOp::of(footage, loc.float);
+ let path = &loc.key.path;
+ if &**path == PLACEHOLDER {
+ return Ok(colour_bars(footage.width, footage.height));
+ }
+ let bytes = self.read(path)?;
+ if let Some(img) = crate::dpx::decode(path, &bytes)? {
+ return Ok(dynamic_to_image(&img, op));
+ }
+ if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? {
+ return Ok(img);
+ }
+ // (an OpenEXR compression the decoder lacks came out transparent or as a missing
+ // channel error)
+ if path.to_ascii_lowercase().ends_with(".exr")
+ && let Some(why) = crate::exr_channels::unsupported_compression(&bytes)
+ {
+ return Err(MediaError::Decode(format!("{path}: {why}")));
+ }
+ let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes));
+ let img = match loaded {
+ Ok(img) => img,
+ // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
+ Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?,
+ };
+ Ok(dynamic_to_image(&img, op))
+ }
+
+ fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result {
let op = AlphaOp::of(footage, loc.float);
let path = &loc.key.path;
match loc.media_t {
- None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)),
- None => {
- let bytes = self.read(path)?;
- if let Some(img) = crate::dpx::decode(path, &bytes)? {
- return Ok(dynamic_to_image(&img, op));
- }
- if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? {
- return Ok(img);
- }
- // (an OpenEXR compression the decoder lacks came out transparent or as a missing
- // channel error)
- if path.to_ascii_lowercase().ends_with(".exr")
- && let Some(why) = crate::exr_channels::unsupported_compression(&bytes)
- {
- return Err(MediaError::Decode(format!("{path}: {why}")));
- }
- let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes));
- let img = match loaded {
- Ok(img) => img,
- // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
- Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?,
- };
- Ok(dynamic_to_image(&img, op))
- }
+ None => self.decode_still(loc, footage),
Some(mt) if gif_anim::is_animated(footage) => {
let anim = self.gif(path)?;
let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?;
@@ -1045,7 +1080,7 @@ mod tests {
fn lru_respects_budget_and_recycles() {
let mut c = Lru::default();
let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead
- let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0) };
+ let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0) };
let budget = 3 * (4096 + 64);
for i in 0..10 {
c.insert(k(i), img(), budget);
diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs
index 2793be251..6bba682b3 100644
--- a/crates/media/src/probe.rs
+++ b/crates/media/src/probe.rs
@@ -27,12 +27,21 @@ pub fn probe(path: impl AsRef) -> Result {
}
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) {
- let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
- return probe_model(&path.to_string_lossy(), &bytes, &|uri| std::fs::read(dir.join(uri)).ok());
+ return probe_model(&path.to_string_lossy(), &bytes, &side_file_reader(path));
}
probe_bytes(&path.to_string_lossy(), bytes.into())
}
+/// Reads the files a model file names (buffers, textures, MTL files). Like the pool, it follows only
+/// local files and files on the model's own network share: a URI is a path stored in the model.
+fn side_file_reader(model: &Path) -> impl Fn(&str) -> Option> + use<> {
+ let model = std::path::absolute(model).unwrap_or_else(|_| model.to_path_buf());
+ move |uri| {
+ let p = craft_docpath::resolve_absolute(uri, Some(&model)).ok()?;
+ std::fs::read(p).ok()
+ }
+}
+
/// Probe a 3D model (glTF 2.0 / OBJ / STL / FBX): a [`FootageKind::Model`] item whose duration is its
/// longest animation. `resolve` reads sibling resources.
pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result {
@@ -316,6 +325,20 @@ mod tests {
(0..n).map(|k| (at(k + 1) - at(k)) as u32).collect()
}
+ /// A model's side files are read from beside it; a URI to another computer is not followed.
+ #[test]
+ fn model_side_files_are_not_read_from_other_computers() {
+ let dir = std::env::temp_dir().join(format!("ec-probe-side-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("buf.bin"), b"abc").unwrap();
+ let read = side_file_reader(&dir.join("model.gltf"));
+ assert_eq!(read("buf.bin"), Some(b"abc".to_vec()));
+ assert_eq!(read(r"\\192.0.2.1\share\buf.bin"), None);
+ assert_eq!(read("file://192.0.2.1/share/buf.bin"), None);
+ assert_eq!(read(r"C:\buf.bin"), None);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn millisecond_rounded_frame_times_average_to_their_rate() {
assert_eq!(rounded(FrameRate::FPS_30, 1000, 6), [33, 34, 33, 33, 34, 33]);
diff --git a/crates/media/tests/downmix.rs b/crates/media/tests/it/downmix.rs
similarity index 100%
rename from crates/media/tests/downmix.rs
rename to crates/media/tests/it/downmix.rs
diff --git a/crates/media/tests/exif_orientation.rs b/crates/media/tests/it/exif_orientation.rs
similarity index 100%
rename from crates/media/tests/exif_orientation.rs
rename to crates/media/tests/it/exif_orientation.rs
diff --git a/crates/media/tests/footage.rs b/crates/media/tests/it/footage.rs
similarity index 94%
rename from crates/media/tests/footage.rs
rename to crates/media/tests/it/footage.rs
index 13ca605e2..a2849dc1d 100644
--- a/crates/media/tests/footage.rs
+++ b/crates/media/tests/it/footage.rs
@@ -40,7 +40,11 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option {
let tmp = out.with_file_name(format!("tmp-{}-{:?}.{ext}", std::process::id(), std::thread::current().id()).replace(['(', ')'], ""));
let st = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).arg(&tmp).status().expect("run ffmpeg");
assert!(st.success(), "ffmpeg failed for {name}");
- std::fs::rename(&tmp, &out).expect("rename");
+ if std::fs::rename(&tmp, &out).is_err() {
+ // Another test made it first; on Windows the rename fails while that test has it open.
+ let _ = std::fs::remove_file(&tmp);
+ assert!(out.exists(), "rename {} to {name} failed", tmp.display());
+ }
Some(out)
}
@@ -580,6 +584,46 @@ fn sequence_gaps_show_placeholders() {
let _ = std::fs::remove_dir_all(&dir);
}
+/// #1812: a still asked for at a reduced size comes back as the renderer would resample it, and
+/// is kept at that size: later frames at that size neither decode nor resample it again (a
+/// parallax project's 21120-px plates were decoded and resampled for every frame at Quarter).
+#[test]
+fn stills_at_reduced_sizes_are_resampled_once() {
+ let dir = std::env::temp_dir().join(format!("ec-still-size-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).expect("temp dir");
+ let p = dir.join("plate.png");
+ let px = |x: u32, y: u32| {
+ image::Rgba([(x * 7 % 256) as u8, (y * 13 % 256) as u8, ((x ^ y) % 256) as u8, if (x + y).is_multiple_of(5) { 0 } else { 255 - (x % 128) as u8 }])
+ };
+ image::RgbaImage::from_fn(613, 97, px).save(&p).expect("png");
+ let f = probe(&p).expect("probe");
+ let pool = MediaPool::new();
+ let full = pool.frame(ItemId(1), &f, Tick::ZERO).expect("frame");
+ // At its own size it is the unsized frame: no second decode, no second full-size copy.
+ let misses = pool.stats().misses;
+ let native = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, f.width, f.height).expect("frame");
+ assert!(Arc::ptr_eq(&full, &native), "the native size reuses the unsized frame");
+ assert_eq!(pool.stats().misses, misses);
+ for (w, h) in [(306, 48), (153, 24), (77, 12)] {
+ let sized = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, w, h).expect("frame");
+ assert_eq!(*sized, effectcraft_raster::resample(&full, w, h), "{w}×{h}: the renderer's resample");
+ let misses = pool.stats().misses;
+ let again = pool.frame_at_size(ItemId(1), &f, Tick(3 * TICKS_PER_SECOND), w, h).expect("frame");
+ assert!(Arc::ptr_eq(&sized, &again), "{w}×{h}: kept at that size");
+ assert_eq!(pool.stats().misses, misses, "{w}×{h}: not decoded again");
+ }
+ // Pixels larger than the nominal size (a smart object's) are resampled as the renderer does;
+ // smaller ones are left as they are (the renderer scales the layer up instead).
+ // One pool, one file, one requested size: the two decisions don't share a cache entry.
+ let larger = Footage { width: 306, height: 48, ..f.clone() };
+ let smaller = Footage { width: 1226, height: 194, ..f.clone() };
+ let sized = pool.frame_at_size(ItemId(2), &larger, Tick::ZERO, 153, 24).expect("frame");
+ assert_eq!(*sized, effectcraft_raster::resample(&full, 153, 24));
+ let kept = pool.frame_at_size(ItemId(3), &smaller, Tick::ZERO, 153, 24).expect("frame");
+ assert_eq!(*kept, *full);
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
#[test]
fn png_with_alpha() {
let Some(p) = alpha_png() else { return };
@@ -833,7 +877,7 @@ fn flac_audio_of_any_shape_reads_in_device_blocks() {
}
// ffmpeg's sine source has an amplitude of 1/8 (mono is duplicated). The fixture carries
// channels 0 and 1 at full level and the others at half, so a multichannel file sounds as
- // its stereo downmix (tests/downmix.rs checks that matrix channel by channel).
+ // its stereo downmix (tests/it/downmix.rs checks that matrix channel by channel).
let gains = effectcraft_media::downmix::stereo_gains(channels as usize, false);
let side = |s: usize| gains.iter().enumerate().map(|(c, g)| f64::from(g[s]) * if c < 2 { 1.0 } else { 0.5 }).sum::();
let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt();
diff --git a/crates/media/tests/it/main.rs b/crates/media/tests/it/main.rs
new file mode 100644
index 000000000..5f7fb8037
--- /dev/null
+++ b/crates/media/tests/it/main.rs
@@ -0,0 +1,7 @@
+//! The crate's integration tests, one binary (each module is a former `tests/.rs`).
+
+mod downmix;
+mod exif_orientation;
+mod footage;
+mod open_in_place;
+mod tga;
diff --git a/crates/media/tests/open_in_place.rs b/crates/media/tests/it/open_in_place.rs
similarity index 100%
rename from crates/media/tests/open_in_place.rs
rename to crates/media/tests/it/open_in_place.rs
diff --git a/crates/media/tests/tga.rs b/crates/media/tests/it/tga.rs
similarity index 100%
rename from crates/media/tests/tga.rs
rename to crates/media/tests/it/tga.rs
diff --git a/crates/opusenc/README.md b/crates/opusenc/README.md
index 999d8a7e9..1ebf6fedb 100644
--- a/crates/opusenc/README.md
+++ b/crates/opusenc/README.md
@@ -130,7 +130,7 @@ weights) and the decoder-side normative arithmetic the encoder must reproduce bi
allocation; SILK NLSF dequantisation and stabilisation, NLSF → LPC conversion, `log2lin`, the
fixed-point division/inverse helpers and the LPC analysis filter) are mirrored from FilmCraft's
first-party, spec-derived `filmcraft-opus` decoder (MIT OR Apache-2.0), which is also the
-round-trip oracle in `tests/` and `crates/export/tests/opus_roundtrip.rs` (ffmpeg is a second,
+round-trip oracle in `tests/` and `crates/export/tests/it/opus_roundtrip.rs` (ffmpeg is a second,
external oracle when installed). All encoder-side analysis and search (pitch, LPC, NLSF and
LTP search, noise feedback, rate control, stereo prediction, the decimator) is our own design.
No third-party encoder or decoder source (libopus or otherwise) was consulted.
diff --git a/crates/opusenc/tests/common/mod.rs b/crates/opusenc/tests/it/common/mod.rs
similarity index 100%
rename from crates/opusenc/tests/common/mod.rs
rename to crates/opusenc/tests/it/common/mod.rs
diff --git a/crates/opusenc/tests/ffmpeg.rs b/crates/opusenc/tests/it/ffmpeg.rs
similarity index 99%
rename from crates/opusenc/tests/ffmpeg.rs
rename to crates/opusenc/tests/it/ffmpeg.rs
index fcbb8c8a7..1294ed348 100644
--- a/crates/opusenc/tests/ffmpeg.rs
+++ b/crates/opusenc/tests/it/ffmpeg.rs
@@ -2,7 +2,7 @@
//! without errors with both ffmpeg's native `opus` decoder and `libopus`, honour the pre-skip
//! and end granule, and reproduce the input. Skipped when ffmpeg is not installed.
-mod common;
+use crate::common;
use std::process::Command;
diff --git a/crates/opusenc/tests/it/main.rs b/crates/opusenc/tests/it/main.rs
new file mode 100644
index 000000000..ba6a7895d
--- /dev/null
+++ b/crates/opusenc/tests/it/main.rs
@@ -0,0 +1,5 @@
+//! The crate's integration tests, one binary (each module is a former `tests/.rs`).
+
+mod common;
+mod ffmpeg;
+mod modes;
diff --git a/crates/opusenc/tests/modes.rs b/crates/opusenc/tests/it/modes.rs
similarity index 99%
rename from crates/opusenc/tests/modes.rs
rename to crates/opusenc/tests/it/modes.rs
index 843cf3d9f..c01f64e69 100644
--- a/crates/opusenc/tests/modes.rs
+++ b/crates/opusenc/tests/it/modes.rs
@@ -2,7 +2,7 @@
//! mode/bandwidth decodes, waveform quality on speech-like and music-like signals, bitrate
//! tracking, stereo, the application tables and delay alignment.
-mod common;
+use crate::common;
use common::*;
use effectcraft_opusenc::{Application, Bandwidth, Mode, OpusEncoder};
diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs
index 249eb9fdd..5069cb83e 100644
--- a/crates/path/src/lib.rs
+++ b/crates/path/src/lib.rs
@@ -133,7 +133,9 @@ pub fn from_kurbo(path: &BezPath) -> Vec {
/// Rectangle path centred at `pos` with corner `roundness` (AE draws clockwise from the top-right).
pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath {
let r = kurbo::Rect::from_center_size(Point::new(pos[0], pos[1]), (size[0].abs(), size[1].abs()));
- let rad = roundness.clamp(0.0, size[0].abs().min(size[1].abs()) / 2.0);
+ let max_rad = size[0].abs().min(size[1].abs()) / 2.0;
+ // `f64::clamp` panics on a NaN bound (both size components NaN): fall back to no roundness.
+ let rad = if max_rad.is_nan() { 0.0 } else { roundness.clamp(0.0, max_rad) };
if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) }
}
@@ -354,6 +356,11 @@ pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec {
mod tests {
use super::*;
+ #[test]
+ fn rect_with_nan_size_does_not_panic() {
+ let _ = rect([f64::NAN, f64::NAN], [0.0, 0.0], 5.0);
+ }
+
#[test]
fn roundtrip_vertex_model() {
let sp = ShapePath::ellipse([10.0, 20.0], 40.0, 30.0);
diff --git a/crates/pdf/src/image.rs b/crates/pdf/src/image.rs
index 4d55fef8e..7006979c2 100644
--- a/crates/pdf/src/image.rs
+++ b/crates/pdf/src/image.rs
@@ -16,6 +16,7 @@ pub(crate) struct Decoded {
/// The longest side of a decoded image (larger images are refused rather than exhausting
/// memory).
const MAX_SIDE: usize = 16384;
+const MAX_PIXELS: usize = 64 << 20;
fn get<'a>(file: &'a File, d: &'a Dict, long: &str, short: &str) -> Option<&'a Obj> {
file.get(d, long).or_else(|| file.get(d, short))
@@ -114,6 +115,11 @@ fn jpeg_decode(data: &[u8]) -> Result<(Vec, usize), String> {
use zune_jpeg::zune_core::options::DecoderOptions;
let mut probe = JpegDecoder::new(std::io::Cursor::new(data));
probe.decode_headers().map_err(|_| "image (JPEG)".to_string())?;
+ // Bound the header-declared size before decode() allocates the output.
+ let (hw, hh) = probe.dimensions().ok_or_else(|| "image (JPEG)".to_string())?;
+ if hw.checked_mul(hh).is_none_or(|p| p > MAX_PIXELS) {
+ return Err("image (JPEG)".to_string());
+ }
let n = probe.input_colorspace().map(|c| c.num_components()).unwrap_or(3);
let out = match n {
1 => ColorSpace::Luma,
@@ -134,8 +140,17 @@ fn jpeg_decode(data: &[u8]) -> Result<(Vec, usize), String> {
/// Unpack `w`×`h` samples of `n` components at `bpc` bits into 0..=max integers.
fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec {
- let row_bytes = (w * n * bpc).div_ceil(8);
- let mut out = Vec::with_capacity(w * h * n);
+ let Some(samples) = w.checked_mul(h).and_then(|p| p.checked_mul(n)).filter(|count| *count <= MAX_PIXELS.saturating_mul(4)) else { return Vec::new() };
+ let Some(row_bits) = w.checked_mul(n).and_then(|v| v.checked_mul(bpc)) else { return Vec::new() };
+ let row_bytes = row_bits.div_ceil(8);
+ let Some(row_len) = row_bytes.checked_mul(h) else { return Vec::new() };
+ if row_len > data.len() {
+ return Vec::new();
+ }
+ let mut out = Vec::new();
+ if out.try_reserve_exact(samples).is_err() {
+ return Vec::new();
+ }
for y in 0..h {
let row = data.get(y * row_bytes..((y + 1) * row_bytes).min(data.len())).unwrap_or(&[]);
for i in 0..w * n {
@@ -157,10 +172,22 @@ fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec {
/// Nearest-neighbour resample of a single-channel 8-bit mask to `w`×`h`.
fn resample(m: &[u8], mw: usize, mh: usize, w: usize, h: usize) -> Vec {
+ let Some(pixels) = w.checked_mul(h).filter(|p| *p <= MAX_PIXELS) else { return Vec::new() };
+ if mw.checked_mul(mh).is_none_or(|n| n > m.len()) {
+ return Vec::new();
+ }
if mw == w && mh == h {
- return m.to_vec();
+ let mut out = Vec::new();
+ if out.try_reserve_exact(pixels).is_err() {
+ return Vec::new();
+ }
+ out.extend_from_slice(&m[..pixels]);
+ return out;
+ }
+ let mut out = Vec::new();
+ if out.try_reserve_exact(pixels).is_err() {
+ return Vec::new();
}
- let mut out = Vec::with_capacity(w * h);
for y in 0..h {
let sy = (y * mh / h.max(1)).min(mh.saturating_sub(1));
for x in 0..w {
@@ -176,12 +203,16 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize
let Obj::Stream(d, raw) = file.resolve(o) else { return None };
let w = file.get_num(d, "Width")? as usize;
let h = file.get_num(d, "Height")? as usize;
- if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE {
+ if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w.checked_mul(h).is_none_or(|p| p > MAX_PIXELS) {
return None;
}
let (data, jpeg_n) = samples(file, d, raw).ok()?;
+ let pixels = w.checked_mul(h)?;
if jpeg_n.is_some() {
- return Some((data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(w * h).collect(), w, h));
+ let mut out = Vec::new();
+ out.try_reserve_exact(pixels).ok()?;
+ out.extend(data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(pixels));
+ return Some((out, w, h));
}
let bpc = if soft { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize } else { 1 };
// Like `decode()`: only 1/2/4/8/16-bit depths are supported. Anything else (notably
@@ -192,19 +223,21 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize
let max = ((1u32 << bpc) - 1).max(1) as f64;
let dec = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= 2);
let v = unpack(&data, w, h, 1, bpc);
- let out = v
- .into_iter()
- .map(|s| {
- let t = s as f64 / max;
- let t = match &dec {
- Some(dd) => dd[0] + t * (dd[1] - dd[0]),
- None => t,
- };
- // Explicit (stencil) masks: sample 1 masks out with the default Decode.
- let a = if soft { t } else { 1.0 - t };
- (a.clamp(0.0, 1.0) * 255.0).round() as u8
- })
- .collect();
+ if v.len() != pixels {
+ return None;
+ }
+ let mut out = Vec::new();
+ out.try_reserve_exact(pixels).ok()?;
+ out.extend(v.into_iter().map(|s| {
+ let t = s as f64 / max;
+ let t = match &dec {
+ Some(dd) => dd[0] + t * (dd[1] - dd[0]),
+ None => t,
+ };
+ // Explicit (stencil) masks: sample 1 masks out with the default Decode.
+ let a = if soft { t } else { 1.0 - t };
+ (a.clamp(0.0, 1.0) * 255.0).round() as u8
+ }));
Some((out, w, h))
}
@@ -212,15 +245,24 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize
pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill: [f64; 3]) -> Result {
let w = file.get_num(d, "Width").unwrap_or(0.0) as usize;
let h = file.get_num(d, "Height").unwrap_or(0.0) as usize;
- if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w * h > 64 << 20 {
+ let Some(pixels) = w.checked_mul(h).filter(|p| *p <= MAX_PIXELS) else {
+ return Err("image (size)".into());
+ };
+ if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE {
return Err("image (size)".into());
}
let stencil = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true)));
let (data, jpeg_n) = samples(file, d, raw)?;
let decode_arr = file.get(d, "Decode").map(|x| file.nums(x)).unwrap_or_default();
- let mut rgba = vec![0u8; w * h * 4];
+ let rgba_len = pixels.checked_mul(4).ok_or_else(|| "image (size)".to_string())?;
+ let mut rgba = Vec::new();
+ rgba.try_reserve_exact(rgba_len).map_err(|_| "image (allocation)".to_string())?;
+ rgba.resize(rgba_len, 0);
if stencil {
let v = unpack(&data, w, h, 1, 1);
+ if v.len() != pixels {
+ return Err("image (sample allocation)".into());
+ }
let paint_on = if decode_arr.first().copied().unwrap_or(0.0) > 0.5 { 1 } else { 0 };
let c = fill.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8);
for (i, s) in v.into_iter().enumerate() {
@@ -243,8 +285,14 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
return Err(format!("image ({bpc} bits per component)"));
}
+ let Some(sample_count) = pixels.checked_mul(n).filter(|n| *n <= MAX_PIXELS.saturating_mul(4)) else {
+ return Err("image (size)".into());
+ };
let max = ((1u32 << bpc) - 1) as f64;
let raw_samples = unpack(&data, w, h, n, bpc);
+ if raw_samples.len() != sample_count {
+ return Err("image (sample allocation)".into());
+ }
// Decode ranges per component.
let indexed = matches!(cs, Cs::Indexed { .. });
let range = |k: usize| -> (f64, f64) {
@@ -262,7 +310,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
Cs::Indexed { hival, .. } if n == 1 => Some((0..=*hival).map(|i| cs.to_rgb(&[i as f64])).collect()),
_ => None,
};
- for i in 0..w * h {
+ for i in 0..pixels {
if let Some(pal) = &palette {
let s = raw_samples[i] as f64 / max;
let (a, b) = ranges[0];
@@ -288,7 +336,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
Some(Obj::Array(a)) => {
let keys: Vec = a.iter().filter_map(|x| file.resolve(x).num()).collect();
if keys.len() >= 2 * n {
- for i in 0..w * h {
+ for i in 0..pixels {
if (0..n).all(|k| {
let v = raw_samples[i * n + k] as f64;
v >= keys[2 * k] && v <= keys[2 * k + 1]
@@ -300,7 +348,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
}
Some(m @ Obj::Stream(..)) => {
if let Some((m, mw, mh)) = mask_image(file, m, false) {
- for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() {
+ for (i, a) in resample(&m, mw, mh, w, h).into_iter().take(pixels).enumerate() {
rgba[i * 4 + 3] = a;
}
}
@@ -310,7 +358,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
if let Some(sm) = file.get(d, "SMask")
&& let Some((m, mw, mh)) = mask_image(file, sm, true)
{
- for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() {
+ for (i, a) in resample(&m, mw, mh, w, h).into_iter().take(pixels).enumerate() {
rgba[i * 4 + 3] = ((rgba[i * 4 + 3] as u32 * a as u32) / 255) as u8;
}
}
diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs
index a4bffbe0c..ccc6be252 100644
--- a/crates/project/src/lib.rs
+++ b/crates/project/src/lib.rs
@@ -1478,11 +1478,32 @@ impl Project {
}
/// Whether comp `inner` is (transitively) nested inside comp `outer` (to prevent cycles).
pub fn comp_contains(&self, outer: ItemId, inner: ItemId) -> bool {
+ // A walk with a stack and a visited set rather than recursion (#1237): a damaged project
+ // may hold a precomp cycle that doesn't pass `inner` (recursion never ended), a long
+ // chain must not use up the call stack, and comps shared by many paths are visited once
+ // (recursion re-walked them for every path).
if outer == inner {
return true;
}
- let Some(c) = self.comp(outer) else { return false };
- c.layers.iter().any(|l| matches!(l.source, LayerSource::Comp { item } if self.comp_contains(item, inner)))
+ let mut seen = std::collections::HashSet::new();
+ let mut stack = vec![outer];
+ while let Some(id) = stack.pop() {
+ if !seen.insert(id) {
+ continue;
+ }
+ let Some(c) = self.comp(id) else { continue };
+ for l in &c.layers {
+ if let LayerSource::Comp { item } = l.source {
+ if item == inner {
+ return true;
+ }
+ if !seen.contains(&item) {
+ stack.push(item);
+ }
+ }
+ }
+ }
+ false
}
/// Make sure `next_id` exceeds every id in the project (after loading or merging).
pub fn fix_next_id(&mut self) {
diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs
index 8cd52092f..bc69a2c24 100644
--- a/crates/project/src/tests.rs
+++ b/crates/project/src/tests.rs
@@ -212,3 +212,58 @@ fn item_users_count_layers_in_every_comp() {
assert!(p.item_users(unused).is_empty());
assert!(p.item_users(main).is_empty());
}
+
+/// A project of `n` empty comps; `links` adds a precomp layer of comp `b` inside comp `a`
+/// (indexes into the returned ids).
+fn comp_graph(n: usize, links: &[(usize, usize)]) -> (Project, Vec) {
+ let mut p = Project::default();
+ let ids: Vec = (0..n)
+ .map(|i| {
+ p.add_item(
+ &format!("C{i}"),
+ effectcraft_color::Label::None,
+ None,
+ ItemKind::Comp(Comp::new(8, 8, FrameRate::FPS_25, Tick::from_seconds_f64(1.0)).into()),
+ )
+ })
+ .collect();
+ for &(a, b) in links {
+ let outer = p.comp(ids[a]).unwrap().clone();
+ let l = build::layer(&mut p, &outer, "pre", LayerSource::Comp { item: ids[b] }, (8, 8), None);
+ let ItemKind::Comp(c) = &mut p.item_mut(ids[a]).unwrap().kind else { panic!("comp") };
+ std::sync::Arc::make_mut(c).layers.push(l);
+ }
+ (p, ids)
+}
+
+#[test]
+fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() {
+ // A ⇄ B cycle, C unrelated: the walk used to recurse until the stack ran out (#1237).
+ let (p, c) = comp_graph(3, &[(0, 1), (1, 0)]);
+ assert!(!p.comp_contains(c[0], c[2]));
+ assert!(p.comp_contains(c[0], c[1]) && p.comp_contains(c[1], c[0]));
+ // A comp nesting itself.
+ let (p, c) = comp_graph(2, &[(0, 0)]);
+ assert!(!p.comp_contains(c[0], c[1]));
+ assert!(p.comp_contains(c[0], c[0]));
+ // A target reached through a cycle.
+ let (p, c) = comp_graph(3, &[(0, 1), (1, 0), (1, 2)]);
+ assert!(p.comp_contains(c[0], c[2]));
+ assert!(!p.comp_contains(c[2], c[0]));
+ // 40 levels of diamonds: 2^40 paths, each comp visited once.
+ let mut links = vec![];
+ for i in 0..40 {
+ let (top, l, r, bottom) = (i * 3, i * 3 + 1, i * 3 + 2, i * 3 + 3);
+ links.extend([(top, l), (top, r), (l, bottom), (r, bottom)]);
+ }
+ let (p, c) = comp_graph(121, &links);
+ let t0 = std::time::Instant::now();
+ assert!(p.comp_contains(c[0], c[120]));
+ assert!(!p.comp_contains(c[120], c[0]));
+ assert!(t0.elapsed() < std::time::Duration::from_secs(1), "{:?}", t0.elapsed());
+ // A chain 20 000 comps deep (a deep recursion's stack frames).
+ let n = 20_000;
+ let (p, c) = comp_graph(n, &(0..n - 1).map(|i| (i, i + 1)).collect::>());
+ assert!(p.comp_contains(c[0], c[n - 1]));
+ assert!(!p.comp_contains(c[n - 1], c[0]));
+}
diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs
index bae12d1e3..89e9fd1c9 100644
--- a/crates/render/src/cache.rs
+++ b/crates/render/src/cache.rs
@@ -22,7 +22,7 @@ use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use effectcraft_effects::Buf;
-use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup};
+use effectcraft_project::{FootageKind, ItemKind, Layer, LayerSource, Node, PropGroup};
use crate::eval::EvalCtx;
@@ -168,15 +168,23 @@ impl LayerCache {
// Disk cache: a hit comes back into memory.
let (dc, dk) = self.disk_key(key)?;
let buf = dc.get_layer(dk)?;
- self.insert_mem(key, buf.clone());
- Some(buf)
+ Some(self.insert_mem(key, buf))
}
}
}
- pub fn insert(&self, key: u64, buf: Arc) {
+ /// Keep `buf` under `key`, and return the buffer the cache holds for it: the one kept first
+ /// when another render of the same layer got there before (#1812: preview workers starting
+ /// together each rendered every layer; sharing the first buffer, the GPU compositor uploads
+ /// and packs it once instead of once per worker, which ran it out of memory). Renders don't
+ /// wait for each other: on the frame workers' shared thread pool a wait can end up under
+ /// the very work it waits for.
+ pub fn insert(&self, key: u64, buf: Arc) -> Arc {
if self.gated() {
- return;
+ return buf;
+ }
+ if let Some(kept) = self.inner.lock().ok().and_then(|g| g.map.get(&key).map(|e| e.buf.clone())) {
+ return kept;
}
let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key));
if let Some((dc, dk)) = self.disk_key(key) {
@@ -185,20 +193,24 @@ impl LayerCache {
dc.put_layer(dk, &buf);
}
}
- self.insert_mem(key, buf);
+ self.insert_mem(key, buf)
}
- fn insert_mem(&self, key: u64, buf: Arc) {
+ /// See [`LayerCache::insert`]: the buffer kept under `key` (`buf`, unless one was kept first
+ /// or `buf` is too large to keep).
+ fn insert_mem(&self, key: u64, buf: Arc) -> Arc {
let bytes = buf.img.data.len() * std::mem::size_of::();
- let Ok(mut g) = self.inner.lock() else { return };
+ let Ok(mut g) = self.inner.lock() else { return buf };
if bytes > g.budget / 4 {
- return;
+ return buf;
}
g.clock += 1;
let now = g.clock;
- if let Some(old) = g.map.insert(key, Entry { buf, bytes, last_use: now }) {
- g.bytes -= old.bytes;
+ if let Some(e) = g.map.get_mut(&key) {
+ e.last_use = now;
+ return e.buf.clone();
}
+ g.map.insert(key, Entry { buf: buf.clone(), bytes, last_use: now });
g.bytes += bytes;
if g.bytes > g.budget {
// Evict least-recently used entries down to 3/4 of the budget.
@@ -214,6 +226,7 @@ impl LayerCache {
}
}
}
+ buf
}
/// The memory budget in bytes.
@@ -481,7 +494,7 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
}
/// Cache key for the processed (source → masks → effects) buffer of `layer` at the context
-/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment
+/// time, or `None` when the layer must not be cached (movie footage, precomps, cameras, adjustment
/// layers…).
pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option {
// Time effects see property values at other times. Keyframes are part of the hashed
@@ -514,6 +527,34 @@ fn reads_other_times(layer: &Layer) -> bool {
})
}
+/// A still image (no proxy): its layer is cached like a text or shape layer, so the GPU
+/// compositor uploads it once, not every frame (#1812: four 21120 px plates re-uploaded by every
+/// preview worker ran the GPU out of memory). Movies and sequences stay in the media pool only.
+fn still_footage(ctx: &EvalCtx, item: effectcraft_project::ItemId) -> bool {
+ ctx.project.item(item).is_some_and(|it| it.proxy.is_none() && matches!(&it.kind, ItemKind::Footage(f) if f.kind == FootageKind::Still))
+}
+
+/// Does the layer have an animated mask that motion blur may draw at other times (Layer ▸ Mask ▸
+/// Motion Blur On, or Same As Layer with the layer blurred)?
+fn mask_motion_blur(ctx: &EvalCtx, layer: &Layer, blur: bool) -> bool {
+ use effectcraft_project::{GroupKind, MaskMotionBlur};
+ fn animated(g: &PropGroup) -> bool {
+ g.children.iter().any(|c| match c {
+ Node::Prop(p) => !p.keys.is_empty() || p.has_expression(),
+ Node::Group(sub) => animated(sub),
+ })
+ }
+ ctx.comp.enable_motion_blur
+ && layer.masks().is_some_and(|m| {
+ m.groups().any(|g| match g.kind {
+ GroupKind::Mask { motion_blur, .. } => {
+ (motion_blur == MaskMotionBlur::On || (motion_blur == MaskMotionBlur::SameAsLayer && blur)) && animated(g)
+ }
+ _ => false,
+ })
+ })
+}
+
fn has_expression(g: &PropGroup) -> bool {
g.children.iter().any(|c| match c {
Node::Prop(p) => p.has_expression(),
@@ -552,14 +593,17 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
hash_debug(&mut h, s);
}
LayerSource::Text | LayerSource::Shape => {}
- LayerSource::Footage { item } if footage => {
+ LayerSource::Footage { item } if footage || still_footage(ctx, *item) => {
let it = ctx.project.item(*item)?;
let ItemKind::Footage(f) = &it.kind else { return None };
hash_debug(&mut h, f);
// A proxy (and its Use Proxy switch) changes the pixels.
hash_debug(&mut h, &it.proxy);
item.hash(&mut h);
- ctx.source_time(layer).0.hash(&mut h);
+ // A still is the same at every time (unless a proxy, which may be a movie, stands in).
+ if f.kind != FootageKind::Still || it.proxy.is_some() {
+ ctx.source_time(layer).0.hash(&mut h);
+ }
// Frame blending mixes neighbouring source frames.
ctx.comp.enable_frame_blending.hash(&mut h);
}
@@ -625,6 +669,11 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
effectcraft_effects::paint::cache_key(g, lt).0.hash(&mut h);
}
}
+ // Mask motion blur draws animated masks at the shutter's other times too: their values at
+ // this time don't tell two frames apart (a hold before a move).
+ if mask_motion_blur(ctx, layer, blur) {
+ layer.layer_time(ctx.time).0.hash(&mut h);
+ }
if time_dependent(layer, effects != Some(0)) {
layer.layer_time(ctx.time).0.hash(&mut h);
// Effects also see the layer's time mapping.
@@ -668,3 +717,23 @@ pub fn derive(key: u64, i: u64) -> u64 {
i.hash(&mut h);
h.finish()
}
+
+#[cfg(test)]
+mod shared_insert_tests {
+ use std::sync::Arc;
+
+ use super::LayerCache;
+ use effectcraft_effects::Buf;
+ use effectcraft_raster::Image;
+
+ /// Two renders of one layer kept under one key: both use the buffer kept first.
+ #[test]
+ fn the_buffer_kept_first_is_shared() {
+ let cache = LayerCache::default();
+ let buf = || Arc::new(Buf { img: Image::new(4, 4), offset: [0.0; 2], scale: 1.0 });
+ let (a, b) = (buf(), buf());
+ assert!(Arc::ptr_eq(&cache.insert(3, a.clone()), &a));
+ assert!(Arc::ptr_eq(&cache.insert(3, b), &a), "the later render gets the first buffer");
+ assert!(Arc::ptr_eq(&cache.get(3).unwrap(), &a));
+ }
+}
diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs
index 4140b7923..f867a614a 100644
--- a/crates/render/src/disk_cache.rs
+++ b/crates/render/src/disk_cache.rs
@@ -779,8 +779,12 @@ fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) {
h.write(&[0xff]);
}
-/// A file's size and modification time (so edited footage gets new keys).
-fn file_stamp(h: &mut Hash128, path: &str) {
+/// Size and content hash of a path that has no native metadata (the browser file table).
+pub type VirtualFileId<'a> = &'a (dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync);
+
+/// A file's size and modification time (so edited footage gets new keys). When the path has no
+/// native metadata, `virtual_id` supplies a durable size and content hash instead.
+fn file_stamp(h: &mut Hash128, path: &str, virtual_id: Option>) {
if path.is_empty() {
return;
}
@@ -791,26 +795,36 @@ fn file_stamp(h: &mut Hash128, path: &str) {
{
h.write_u64(d.as_nanos() as u64);
}
+ return;
+ }
+ if let Some((len, hash)) = virtual_id.and_then(|id| id(path)) {
+ h.write_u64(len);
+ h.write_u64(hash);
}
}
-fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage) {
- file_stamp(h, &f.path);
+fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage, virtual_id: Option>) {
+ file_stamp(h, &f.path, virtual_id);
for p in &f.sequence {
- file_stamp(h, p);
+ file_stamp(h, p, virtual_id);
}
}
/// Salt for layer cache keys on disk: the renderer version and every footage file's identity
/// (layer keys name footage items, not file contents).
pub fn footage_salt(project: &effectcraft_project::Project) -> u64 {
+ footage_salt_with(project, None)
+}
+
+/// [`footage_salt`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe.
+pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Option>) -> u64 {
let mut h = Hash128::default();
h.write(RENDER_VERSION.as_bytes());
for (id, it) in &project.items {
if let effectcraft_project::ItemKind::Footage(f) = &it.kind {
h.write_u64(id.0);
hash_debug(&mut h, f);
- footage_stamp(&mut h, f);
+ footage_stamp(&mut h, f, virtual_id);
}
}
let v = h.finish();
@@ -822,6 +836,11 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 {
/// files' size and time). With expressions in play (which can read any comp) the whole project
/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count.
pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 {
+ comp_content_key_with(project, comp, None)
+}
+
+/// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe.
+pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option>) -> u128 {
use effectcraft_project::{ItemKind, LayerSource};
let mut h = Hash128::default();
h.write(RENDER_VERSION.as_bytes());
@@ -855,11 +874,11 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf
k => hash_debug(&mut h, k),
}
if let ItemKind::Footage(f) = &it.kind {
- footage_stamp(&mut h, f);
+ footage_stamp(&mut h, f, virtual_id);
}
hash_debug(&mut h, &it.proxy);
if let Some(px) = &it.proxy {
- footage_stamp(&mut h, &px.footage);
+ footage_stamp(&mut h, &px.footage, virtual_id);
}
}
h.write_u64(comp.0);
@@ -1082,4 +1101,29 @@ mod tests {
assert_eq!(b.get_layer(k).unwrap().img, buf(k as u32, 16, 16).img);
}
}
+
+ /// A path with no native metadata takes its key from the caller's size and content hash (#1253).
+ #[test]
+ fn virtual_file_bytes_change_content_keys() {
+ use effectcraft_project::build;
+ use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project};
+ use effectcraft_time::{FrameRate, Tick};
+
+ let mut p = Project::default();
+ let f = Footage { path: "/virtual.mov".into(), width: 16, height: 9, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() };
+ let fid = p.add_item("virtual.mov", effectcraft_color::Label::None, None, ItemKind::Footage(f));
+ let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
+ comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)];
+ let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
+
+ let id = |mark: u64| move |path: &str| (path == "/virtual.mov").then_some((8, mark));
+ let (one, two) = (id(1), id(2));
+ let a = footage_salt_with(&p, Some(&one));
+ let b = footage_salt_with(&p, Some(&two));
+ let ka = comp_content_key_with(&p, cid, Some(&one));
+ let kb = comp_content_key_with(&p, cid, Some(&two));
+ assert_ne!(a, b, "replaced virtual media must not reuse the salt");
+ assert_ne!(ka, kb, "replaced virtual media must not reuse the frame key");
+ assert_eq!(comp_content_key(&p, cid), comp_content_key_with(&p, cid, None));
+ }
}
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
index eccaed2f4..73b86e6b6 100644
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -1478,10 +1478,11 @@ impl<'a> Renderer<'a> {
let mut nb = (*b).clone();
color::convert(&mut nb.img, &c);
let nb = Arc::new(nb);
- if let (Some(cache), Some(k)) = (self.cache_to_keep(), key) {
- cache.insert(k, nb.clone());
+ // The buffer the cache keeps (another worker's, if it kept one first).
+ match (self.cache_to_keep(), key) {
+ (Some(cache), Some(k)) => cache.insert(k, nb),
+ _ => nb,
}
- nb
}
fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option)> {
@@ -1496,9 +1497,11 @@ impl<'a> Renderer<'a> {
return Some((b, key));
}
let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten());
- if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
- c.insert(k, flat.clone());
- }
+ // The buffer the cache keeps (another worker's, if it kept one first).
+ let flat = match (self.cache_to_keep(), key) {
+ (Some(c), Some(k)) => c.insert(k, flat),
+ _ => flat,
+ };
Some((flat, key))
}
@@ -1527,9 +1530,11 @@ impl<'a> Renderer<'a> {
}
let buf = self.masked_source(ctx, layer)?;
let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects)));
- if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
- c.insert(k, buf.clone());
- }
+ // The buffer the cache keeps (another worker's, if it kept one first).
+ let buf = match (self.cache_to_keep(), key) {
+ (Some(c), Some(k)) => c.insert(k, buf),
+ _ => buf,
+ };
Some((buf, key))
}
@@ -1552,9 +1557,11 @@ impl<'a> Renderer<'a> {
buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin);
}
let buf = Arc::new(buf);
- if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
- c.insert(k, buf.clone());
- }
+ // The buffer the cache keeps (another worker's, if it kept one first).
+ let buf = match (self.cache_to_keep(), key) {
+ (Some(c), Some(k)) => c.insert(k, buf),
+ _ => buf,
+ };
Some(buf)
}
@@ -1582,12 +1589,13 @@ impl<'a> Renderer<'a> {
t.cached = false;
t.effects.push(("layerStyles".into(), t0.elapsed().as_secs_f64() * 1e3));
}
- let body = Arc::new(st.body);
- let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect();
+ let mut body = Arc::new(st.body);
+ let mut passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect();
+ // The buffers the cache keeps (another worker's, if it kept them first).
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
- c.insert(k, body.clone());
- for (i, (b, _)) in passes.iter().enumerate() {
- c.insert(cache::derive(k, i as u64 + 1), b.clone());
+ body = c.insert(k, body);
+ for (i, (b, _)) in passes.iter_mut().enumerate() {
+ *b = c.insert(cache::derive(k, i as u64 + 1), b.clone());
}
}
Some(StyledLayer { passes, body, content, key, ckey, plain: false })
@@ -2215,9 +2223,11 @@ impl<'a> Renderer<'a> {
let mut foot = self.source(ctx, layer)?;
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
let foot = Arc::new(foot);
- if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
- c.insert(k, foot.clone());
- }
+ // The buffer the cache keeps (another worker's, if it kept one first).
+ let foot = match (self.cache_to_keep(), key) {
+ (Some(c), Some(k)) => c.insert(k, foot),
+ _ => foot,
+ };
Some(foot)
}
diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs
index 72dd26507..f290e31ca 100644
--- a/crates/render/src/tests.rs
+++ b/crates/render/src/tests.rs
@@ -1010,3 +1010,32 @@ fn a_selected_layer_reading_the_comp_scene_invalidates_its_owner() {
let (a, b) = (particles_x(&here), particles_x(&panned));
assert!(b < a - 20.0, "the blended particles follow the camera: {a} -> {b}");
}
+
+/// #1812: preview workers that render a layer at once end up with one buffer (the one kept
+/// first), so the GPU compositor uploads and packs it once, not once per worker.
+#[test]
+fn workers_rendering_a_layer_at_once_share_one_buffer() {
+ let (mut p, cid, comp) = setup();
+ let mut l = solid(&mut p, &comp, [0.2, 0.5, 0.8], 1600, 1200);
+ add_effect(&mut p, &mut l, "ec.blur.gaussian", [1600.0, 1200.0], &[("blurriness", Value::Scalar(60.0))]);
+ p.comp_mut(cid).unwrap().layers.push(l);
+ let cache = crate::LayerCache::default();
+ let start = std::sync::Barrier::new(8);
+ let bufs: Vec<_> = std::thread::scope(|s| {
+ let hs: Vec<_> = (0..8)
+ .map(|_| {
+ s.spawn(|| {
+ let mut r = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default());
+ r.cache = Some(&cache);
+ let c = p.comp(cid).unwrap();
+ let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
+ start.wait();
+ r.layer_buf(&ctx, &c.layers[0]).unwrap()
+ })
+ })
+ .collect();
+ hs.into_iter().map(|h| h.join().unwrap()).collect()
+ });
+ assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer");
+ assert_eq!(cache.stats().entries, 1);
+}
diff --git a/crates/render/src/tests_footage_size.rs b/crates/render/src/tests_footage_size.rs
index 88df726e9..065941d8a 100644
--- a/crates/render/src/tests_footage_size.rs
+++ b/crates/render/src/tests_footage_size.rs
@@ -81,3 +81,28 @@ fn reduced_renders_take_footage_resampled_by_the_source() {
Renderer::new(&p, &sized, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO);
assert!(sized.sizes.unwrap().into_inner().unwrap().is_empty());
}
+
+/// #1812: a still's layer is kept in the layer cache across times (its texture then uploads to
+/// the GPU once, not every frame); a movie's is fetched again at every time.
+#[test]
+fn still_layers_are_cached_across_times_and_movies_are_not() {
+ for (kind, fetches) in [(FootageKind::Still, 1), (FootageKind::Video, 3)] {
+ let (mut p, cid) = project(BitDepth::Bpc8, false);
+ let Some(LayerSource::Footage { item }) = p.comp(cid).map(|c| c.layers[0].source.clone()) else { panic!("footage layer") };
+ if let Some(ItemKind::Footage(f)) = p.item_mut(item).map(|it| &mut it.kind) {
+ f.kind = kind;
+ }
+ let cache = crate::LayerCache::default();
+ let src = Clip { sizes: Some(Mutex::default()) };
+ let opts = RenderOpts { scale: 0.5, ..Default::default() };
+ for t in [0.0, 0.25, 0.5] {
+ let t = Tick::from_seconds_f64(t);
+ let mut r = Renderer::new(&p, &src, opts);
+ r.cache = Some(&cache);
+ let frame = r.comp_frame(cid, t);
+ assert_eq!(frame, Renderer::new(&p, &src, opts).comp_frame(cid, t), "{kind:?} at {t:?}");
+ }
+ // (each uncached render above fetches once more)
+ assert_eq!(src.sizes.unwrap().into_inner().unwrap().len(), fetches + 3, "{kind:?}");
+ }
+}
diff --git a/crates/render/src/tests_mask_blur.rs b/crates/render/src/tests_mask_blur.rs
index 12b88bbba..07bc1e95f 100644
--- a/crates/render/src/tests_mask_blur.rs
+++ b/crates/render/src/tests_mask_blur.rs
@@ -41,6 +41,31 @@ fn alpha_at_25(p: &Project, cid: ItemId, motion_blur: bool) -> f32 {
Renderer::new(p, &NoFootage, RenderOpts { motion_blur, ..Default::default() }).comp_frame(cid, half).get(25, 50)[3]
}
+/// A mask that holds from frame 0 to 2, then moves: frames 1 and 2 have the same mask value, but at
+/// frame 2 the shutter already sees it move. The layer cache must not serve frame 1's layer there.
+#[test]
+fn mask_motion_blur_keys_the_layer_cache_by_time() {
+ let (mut p, cid) = project(true);
+ let rect = |x: f64| Value::Path(ShapePath::rect([x, 50.0], 20.0, 100.0));
+ let l = &mut p.comp_mut(cid).unwrap().layers[0];
+ let Some(Node::Group(m)) = l.props.sub_mut("masks").unwrap().children.first_mut() else { panic!("mask") };
+ m.get_mut("path").unwrap().keys = vec![
+ Keyframe::new(Tick::ZERO, rect(20.0)),
+ Keyframe::new(FrameRate::FPS_30.tick_of(2), rect(20.0)),
+ Keyframe::new(FrameRate::FPS_30.tick_of(3), rect(60.0)),
+ ];
+ let cache = crate::LayerCache::default();
+ let render = |t: Tick, cached: bool| {
+ let mut r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: true, ..Default::default() });
+ r.cache = cached.then_some(&cache);
+ r.comp_frame(cid, t)
+ };
+ let (f1, f2) = (FrameRate::FPS_30.tick_of(1), FrameRate::FPS_30.tick_of(2));
+ assert_ne!(render(f1, false), render(f2, false), "the shutter at frame 2 sees the move");
+ render(f1, true);
+ assert_eq!(render(f2, true), render(f2, false));
+}
+
#[test]
fn mask_motion_blur_follows_the_render_and_the_comp() {
let (p, cid) = project(true);
diff --git a/crates/render/src/tests_styles.rs b/crates/render/src/tests_styles.rs
index 147ff1590..b59f009de 100644
--- a/crates/render/src/tests_styles.rs
+++ b/crates/render/src/tests_styles.rs
@@ -475,3 +475,35 @@ fn gradient_glow_uses_gradient() {
let px = img.straight(121, 50);
assert!(px[0] > px[2], "near the edge the glow takes the gradient's start colour {px:?}");
}
+
+/// #1812 (review of #1928): a styled layer rendered by several workers at once shares its body and
+/// style passes, as unstyled layers share their buffer.
+#[test]
+fn workers_styling_a_layer_at_once_share_its_buffers() {
+ let (p, cid) = scene([1.0; 3], None, |p, c, l| {
+ add(p, c, l, "dropShadow");
+ add(p, c, l, "stroke");
+ });
+ let cache = crate::LayerCache::default();
+ let start = std::sync::Barrier::new(8);
+ let styled: Vec<_> = std::thread::scope(|s| {
+ let hs: Vec<_> = (0..8)
+ .map(|_| {
+ s.spawn(|| {
+ let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default());
+ r.cache = Some(&cache);
+ let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
+ start.wait();
+ r.styled_layer(&ctx, &p.comp(cid).unwrap().layers[0]).unwrap()
+ })
+ })
+ .collect();
+ hs.into_iter().map(|h| h.join().unwrap()).collect()
+ });
+ let first = &styled[0];
+ assert!(!first.passes.is_empty(), "the drop shadow is a pass");
+ for st in &styled {
+ assert!(std::sync::Arc::ptr_eq(&st.body, &first.body), "one body");
+ assert!(st.passes.iter().zip(&first.passes).all(|(a, b)| std::sync::Arc::ptr_eq(&a.0, &b.0)), "one set of passes");
+ }
+}
diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs
index fbb0feb24..496bc3a60 100644
--- a/crates/render/src/text.rs
+++ b/crates/render/src/text.rs
@@ -12,7 +12,7 @@ use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3};
use effectcraft_keyframe::{Justify, TextDoc, Value};
use effectcraft_path::{BezPath, FillRule, StrokeStyle};
use effectcraft_project::{Layer, PropGroup};
-use effectcraft_raster::Image;
+use effectcraft_raster::{Image, Mask};
use effectcraft_text::path_text::{self, PathMeasure};
use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly};
use effectcraft_text::{CharGlyph, TextLayout, char_glyph_style, layout_doc};
@@ -187,6 +187,14 @@ pub fn range_value_of(f: f64, offset: f64, units_index: bool, n: usize) -> f64 {
if units_index { (v * n as f64).round() } else { v * 100.0 }
}
+/// Whether property `uid` of `layer` is a Range Selector's Start, End or Offset while its Units
+/// are Index: the value is then a count of characters (words, lines), not a percentage.
+pub fn in_index_units(layer: &Layer, uid: effectcraft_project::Uid) -> bool {
+ let Some(sel) = layer.props.parent_of(uid).filter(|g| g.match_id == "rangeSelector") else { return false };
+ let is_range_value = sel.props().any(|p| p.uid == uid && matches!(p.match_id.as_str(), "start" | "end" | "offset"));
+ is_range_value && sel.prop("advanced/units").is_some_and(|u| u.value == Value::Enum(1))
+}
+
/// Selection of every glyph by an animator's selectors, per dimension.
pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> {
let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default();
@@ -479,6 +487,9 @@ pub struct PlacedGlyph {
pub fill: [f32; 4],
pub stroke: [f32; 4],
pub stroke_width: f64,
+ /// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows
+ /// on every side, in character space (0 when not bold).
+ pub bold: f64,
/// The character's style fills it (Character panel fill on).
pub apply_fill: bool,
/// Index of the character in the text.
@@ -498,8 +509,30 @@ impl PlacedGlyph {
pub fn path(&self) -> BezPath {
effectcraft_path::transform(std::slice::from_ref(&self.local), &self.m2()).remove(0)
}
+ /// [`Self::bold`] in layer space.
+ fn bold_in_layer(&self) -> f64 {
+ self.bold * self.m2().mean_scale()
+ }
+ /// Whether [`Self::bold`] grows the outline as evenly in layer space as in character space:
+ /// no bold, or a matrix that only rotates and scales uniformly.
+ fn bold_is_even(&self) -> bool {
+ let [[a, b, _], [c, d, _], _] = self.m2().0;
+ let n = a * a + b * b + c * c + d * d;
+ self.bold == 0.0 || ((a * a + c * c - b * b - d * d).abs() <= 1e-9 * n && (a * b + c * d).abs() <= 1e-9 * n)
+ }
}
+/// How far a circle of radius `r` reaches along x and along y once mapped by `m`.
+fn axis_reach(m: &Mat3, r: f64) -> [f64; 2] {
+ let [[a, b, _], [c, d, _], _] = m.0;
+ [r * a.hypot(b), r * c.hypot(d)]
+}
+
+/// How far Faux Bold grows a glyph's outline on every side, as a fraction of its size: Inter
+/// Regular grown by this has about the stems of Inter Bold, so synthetic bold stands in for a
+/// missing bold face.
+const FAUX_BOLD_RADIUS: f64 = 0.03;
+
/// Laid-out, animated text of a layer at the context time.
pub struct TextGeom {
pub doc: TextDoc,
@@ -777,6 +810,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option {
fill,
stroke,
stroke_width: (base_stroke_w + x.stroke_width).max(0.0),
+ bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 },
apply_fill: st.apply_fill,
char_index: g.char_index,
rest,
@@ -826,17 +860,41 @@ fn passes(geom: &TextGeom) -> Vec {
v
}
+/// Coverage of the outline grown by `d` on every side (shrunk when `d` is negative): the fill
+/// merged with, or cut by, a round stroke `2|d|` wide, which keeps the edges anti-aliased.
+fn grown_coverage(paths: &[BezPath], mm: &Mat3, w: u32, h: u32, d: f64) -> Mask {
+ let mut cov = effectcraft_path::fill_coverage(paths, mm, w, h, FillRule::NonZero);
+ if d.abs() < 1e-9 {
+ return cov;
+ }
+ let st = StrokeStyle { width: 2.0 * d.abs(), join: effectcraft_path::Join::Round, ..Default::default() };
+ let ring = effectcraft_path::stroke_coverage(paths, &st, mm, w, h);
+ for (c, r) in cov.data.iter_mut().zip(&ring.data) {
+ *c = if d > 0.0 { c.max(*r) } else { c.min(1.0 - r) };
+ }
+ cov
+}
+
/// Rasterize one pass of a glyph — or of a run of glyphs painted alike, as one outline — (paths
/// mapped into target pixels by `m`) into a premultiplied patch over the target rect, blurred
-/// when the character has Blur.
-fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image {
+/// when the character has Blur. `bold` is the glyph's Faux Bold in the paths' space.
+fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2], bold: f64) -> Image {
let (w, h) = ((rect[2] - rect[0]) as u32, (rect[3] - rect[1]) as u32);
let mm = Mat3::translate(vec2(-rect[0] as f64, -rect[1] as f64)) * *m;
- let cov = if stroke {
+ let cov = if stroke && bold > 0.0 {
+ // The stroke of the bold outline: the band between it grown and shrunk by half the width.
+ let half = g.stroke_width / 2.0;
+ let mut outer = grown_coverage(paths, &mm, w, h, bold + half);
+ let inner = grown_coverage(paths, &mm, w, h, bold - half);
+ for (o, i) in outer.data.iter_mut().zip(&inner.data) {
+ *o = o.min(1.0 - i);
+ }
+ outer
+ } else if stroke {
let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
effectcraft_path::stroke_coverage(paths, &st, &mm, w, h)
} else {
- effectcraft_path::fill_coverage(paths, &mm, w, h, FillRule::NonZero)
+ grown_coverage(paths, &mm, w, h, bold)
};
let col = if stroke { g.stroke } else { g.fill };
let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32;
@@ -892,10 +950,16 @@ fn runs(geom: &TextGeom) -> Vec<(Vec, bool)> {
if g.xf.opacity <= 0.0 {
continue;
}
+ // A run grows its outline in layer space by one radius, which matches each glyph grown in
+ // its own space only when the bold stays round there.
if geom.blend == BlendMode::Normal
&& solid_fill(g, stroke)
+ && g.bold_is_even()
&& let Some((run, false)) = out.last_mut()
- && run.last().is_some_and(|&p| solid_fill(&geom.glyphs[p], false) && geom.glyphs[p].fill == g.fill)
+ && run.last().is_some_and(|&p| {
+ let q = &geom.glyphs[p];
+ solid_fill(q, false) && q.fill == g.fill && q.bold_is_even() && (q.bold_in_layer() - g.bold_in_layer()).abs() < 1e-6
+ })
{
run.push(gi);
continue;
@@ -917,28 +981,31 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt
// One glyph is drawn from its own outline; a run from its glyphs' outlines in layer
// space, all wound the same way so overlaps add up instead of cancelling (NonZero).
let local = Cow::Borrowed(std::slice::from_ref(&g.local));
- let (paths, m): (Cow<[BezPath]>, Mat3) = match (only, run.as_slice()) {
- (Some(_), _) => (local, *to_px),
- (None, [_]) => (local, *to_px * g.m2()),
+ let (paths, m, bold): (Cow<[BezPath]>, Mat3, f64) = match (only, run.as_slice()) {
+ (Some(_), _) => (local, *to_px, g.bold),
+ (None, [_]) => (local, *to_px * g.m2(), g.bold),
(None, _) => {
let wound = |p: BezPath| if kurbo::Shape::area(&p) < 0.0 { p.reverse_subpaths() } else { p };
- (Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px)
+ (Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px, g.bold_in_layer())
}
};
let Some(b) = effectcraft_path::bounds(&paths) else { continue };
let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s];
- let pad = g.stroke_width * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5;
+ // Stroke and bold grow the outline in the paths' space: under a non-uniform `m` they reach
+ // further along one axis than the other.
+ let [px, py] = axis_reach(&m, g.stroke_width + 2.0 * bold);
+ let pad = 2.0 + blur[0].max(blur[1]) * 1.5;
let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1));
let rect = [
- ((r.x0 - pad).floor() as i64).max(0),
- ((r.y0 - pad).floor() as i64).max(0),
- ((r.x1 + pad).ceil() as i64).min(w),
- ((r.y1 + pad).ceil() as i64).min(h),
+ ((r.x0 - px - pad).floor() as i64).max(0),
+ ((r.y0 - py - pad).floor() as i64).max(0),
+ ((r.x1 + px + pad).ceil() as i64).min(w),
+ ((r.y1 + py + pad).ceil() as i64).min(h),
];
if rect[2] <= rect[0] || rect[3] <= rect[1] {
continue;
}
- let patch = raster_pass(g, stroke, &paths, &m, rect, blur);
+ let patch = raster_pass(g, stroke, &paths, &m, rect, blur, bold);
blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend });
}
}
@@ -950,8 +1017,9 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
let mut bounds: Option = None;
for g in &geom.glyphs {
if let Some(b) = effectcraft_path::bounds(&[g.path()]) {
+ let [bx, by] = axis_reach(&g.m2(), 2.0 * g.bold);
let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
- let b = b.inflate(pad, pad);
+ let b = b.inflate(pad + bx, pad + by);
bounds = Some(bounds.map_or(b, |a| a.union(b)));
}
}
@@ -976,7 +1044,7 @@ pub fn per_char_planes(ctx: &EvalCtx, layer: &Layer, s: f64) -> Vec<(Buf, Mat4)>
continue;
}
let Some(b) = effectcraft_path::bounds(std::slice::from_ref(&g.local)) else { continue };
- let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
+ let pad = g.stroke_width + 2.0 * g.bold + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
let b = b.inflate(pad, pad);
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 4096);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 4096);
diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs
index 020e08517..ea89e37b0 100644
--- a/crates/render/src/text/tests.rs
+++ b/crates/render/src/text/tests.rs
@@ -99,6 +99,24 @@ fn range_based_on_words_and_lines() {
assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}");
}
+/// With Units = Index a Range Selector's Start, End and Offset are character counts, not
+/// percentages (#1814); the selector's other values, and other selectors, are unaffected.
+#[test]
+fn index_units_mark_start_end_and_offset() {
+ let (mut p, _cid, comp) = setup(400, 200);
+ let mut l = text_layer(&mut p, &comp, doc("ABCDEFG", 30.0));
+ animator(&mut p, &mut l, &["opacity"], &["range", "wiggly"]);
+ let uid = |l: &Layer, path: &str| l.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid;
+ let base = "text/animators/#1/selectors/#1";
+ let range_values = ["start", "end", "offset"].map(|k| uid(&l, &format!("{base}/{k}")));
+ assert!(range_values.iter().all(|u| !in_index_units(&l, *u)), "Percentage, the default");
+ set(&mut l, &format!("{base}/advanced/units"), Value::Enum(1));
+ assert!(range_values.iter().all(|u| in_index_units(&l, *u)));
+ assert!(!in_index_units(&l, uid(&l, &format!("{base}/advanced/amount"))), "Amount stays a percentage");
+ assert!(!in_index_units(&l, uid(&l, "text/animators/#1/properties/opacity")));
+ assert!(!in_index_units(&l, uid(&l, "transform/opacity")));
+}
+
#[test]
fn range_start_end_and_offset_are_capped() {
let (mut p, cid, comp) = setup(400, 200);
@@ -524,6 +542,7 @@ fn touching_glyphs_join_without_a_seam() {
fill,
stroke: [0.0; 4],
stroke_width: 0.0,
+ bold: 0.0,
apply_fill: true,
char_index: 0,
rest: [0.0; 2],
@@ -551,6 +570,64 @@ fn touching_glyphs_join_without_a_seam() {
assert!(multiply[3] < 0.9, "{multiply:?}");
}
+/// #1676: Faux Bold, and the synthetic bold of a style the family has no face for, are drawn.
+#[test]
+fn faux_bold_and_synthetic_bold_thicken_the_glyphs() {
+ // Total alpha and the alpha bounding box of a rendered word.
+ let ink = |d: TextDoc| {
+ let (mut p, cid, comp) = setup(400, 120);
+ let mut l = text_layer(&mut p, &comp, TextDoc { text: "Hamburg".into(), size: 60.0, ..d });
+ set(&mut l, "transform/position", Value::Vec3([40.0, 85.0, 0.0]));
+ p.comp_mut(cid).unwrap().layers.push(l);
+ let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
+ (img.data.iter().map(|px| px[3]).sum::(), alpha_bbox(&img).unwrap())
+ };
+ let (plain, pb) = ink(TextDoc::default());
+ let (bold, bb) = ink(TextDoc { faux_bold: true, ..Default::default() });
+ assert!(bold > plain * 1.15, "faux bold adds ink: {plain} → {bold}");
+ // 0.03 em = 1.8 px on every side.
+ let grow = (bb[2] - bb[0]) - (pb[2] - pb[0]);
+ assert!((3..=6).contains(&grow), "and widens the word by about 3.6 px: {pb:?} → {bb:?}");
+ // Bundled Inter has no Bold Italic face: the italic face is drawn with synthetic bold.
+ let (italic, _) = ink(TextDoc { style: "Italic".into(), ..Default::default() });
+ let (bold_italic, _) = ink(TextDoc { style: "Bold Italic".into(), ..Default::default() });
+ assert!(bold_italic > italic * 1.15, "Bold Italic is bolder than Italic: {italic} → {bold_italic}");
+ // A stroke follows the bold outline: as wide as before, moved out with it.
+ let stroked = TextDoc { apply_fill: false, apply_stroke: true, stroke: [1.0; 4], stroke_width: 4.0, ..Default::default() };
+ let (ring, rb) = ink(stroked.clone());
+ let (bold_ring, brb) = ink(TextDoc { faux_bold: true, ..stroked });
+ assert!(bold_ring > ring * 0.95 && bold_ring < ring * 1.3, "the stroke keeps its width: {ring} → {bold_ring}");
+ assert!(brb[0] < rb[0] && brb[2] > rb[2], "{rb:?} → {brb:?}");
+}
+
+/// #1676: under a non-uniform character Scale the bold is grown in the character's space, so it
+/// reaches further along the stretched axis; it is neither clipped nor grown evenly in a run.
+#[test]
+fn faux_bold_under_a_non_uniform_scale() {
+ use kurbo::Shape;
+ let glyph = |x0: f64| PlacedGlyph {
+ local: kurbo::Rect::new(x0, 4.0, x0 + 4.0, 16.0).to_path(0.1),
+ m: Mat4::scale(vec3(9.0, 1.0, 1.0)),
+ xf: CharXf::default(),
+ fill: [1.0; 4],
+ stroke: [0.0; 4],
+ stroke_width: 0.0,
+ bold: 2.0,
+ apply_fill: true,
+ char_index: 0,
+ rest: [0.0; 2],
+ };
+ let geom = |glyphs| TextGeom { doc: TextDoc::default(), glyphs, fill_stroke: 0, blend: BlendMode::Normal };
+ assert_eq!(runs(&geom(vec![glyph(2.0), glyph(6.0)])).len(), 2, "bold glyphs stretched unevenly are drawn one by one");
+ // x 6..10 → 54..90, grown by 2 × 9 = 18 → 36..108; y 4..16 grown by 2 → 2..18.
+ let mut img = crate::Image::new(120, 24);
+ draw_glyphs(&geom(vec![glyph(6.0)]), &mut img, &Mat3::IDENTITY, 1.0, None);
+ for (x, y) in [(37, 10), (106, 10), (70, 3)] {
+ assert!(img.get(x, y)[3] > 0.99, "bold ink at ({x}, {y}): {:?}", img.get(x, y));
+ }
+ assert!(img.get(34, 10)[3] < 0.01 && img.get(110, 10)[3] < 0.01 && img.get(70, 0)[3] < 0.01);
+}
+
/// #1011: what dragging a Range Selector bar to fraction `f` of the text writes.
#[test]
fn range_bar_drag_values() {
diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js
index 2eeca86bf..c7f071183 100644
--- a/crates/script/src/prelude.js
+++ b/crates/script/src/prelude.js
@@ -869,8 +869,11 @@ Property.prototype.property = function () { return null; };
}
if (info.dims > 1) {
if (!(v instanceof Array)) throw __err("value must be an array of " + info.dims + " numbers");
+ // After Effects rejects an array of the wrong length instead of truncating or broadcasting it
+ // (#1936). Two values for a 3-D property stay legal, as they are in After Effects: z is 0.
+ if (v.length !== info.dims && !(info.dims === 3 && v.length === 2)) throw __err("Array is not of the correct dimension");
var o = [];
- for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : (d === 2 ? 0 : __num(v[v.length - 1])));
+ for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : 0);
return o;
}
if (v instanceof Array) throw __err("value must be a number, not an array");
@@ -1301,7 +1304,14 @@ AVLayer.prototype.constructor = AVLayer;
}
P.sourcePointToComp = function (p) { return pointIn(this, p, true); };
P.compPointToSource = function (p) { return pointIn(this, p, false); };
- P.replaceSource = function () { throw __err("replaceSource is not supported yet"); };
+ // Replace the layer's source item, keeping every layer property (After Effects'
+ // AVLayer.replaceSource). `fixExpressions` is accepted for compatibility: only AV sources are
+ // replaced, so the property tree and the layer's name stay and expressions keep resolving.
+ P.replaceSource = function (newSource, fixExpressions) {
+ if (!newSource || newSource.__id === undefined) throw __err("replaceSource needs a project item");
+ __call("layer.replaceSource", { comp: this.__comp, layers: [this.__id], item: newSource.__id, fixExpressions: !!fixExpressions });
+ return null;
+ };
P.openInViewer = function () { __call("layer.openLayer", this.__p1()); return null; };
})();
diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js
index af1e14b5e..52c65efb6 100644
--- a/crates/script/src/scriptui.js
+++ b/crates/script/src/scriptui.js
@@ -840,7 +840,7 @@ function __uiJson(c, inherited) {
o.value = Number(c.value) || 0;
o.min = Number(c.minvalue) || 0;
o.max = Number(c.maxvalue);
- if (!(o.max === o.max)) o.max = 100;
+ if (Number.isNaN(o.max)) o.max = 100;
}
if (c.items) {
o.items = c.items.map(function (it) { return String(it.text); });
diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs
index 6f87f7572..9777a0f43 100644
--- a/crates/script/src/tests.rs
+++ b/crates/script/src/tests.rs
@@ -963,6 +963,37 @@ fn footage_proxies_sequences_and_conform_rate() {
let _ = std::fs::remove_dir_all(&dir);
}
+/// #813: `AVLayer.replaceSource` changes the source item and keeps the layer's name, timing and
+/// properties; a folder is not a source.
+#[test]
+fn av_layer_replace_source_keeps_the_layer() {
+ let mut s = session();
+ let o = ok(
+ &mut s,
+ r#"
+ var main = app.project.items.addComp("Main", 100, 100, 1, 5, 25);
+ var layer = main.layers.addSolid([1, 0, 0], "Old", 100, 100, 1);
+ layer.transform.position.setValue([10, 20]);
+ layer.startTime = 1;
+ var nested = app.project.items.addComp("Nested", 200, 100, 1, 3, 25);
+ layer.replaceSource(nested, false);
+ [layer.source.name, layer.source.width, layer.source.height, layer.source instanceof CompItem,
+ layer.name, layer.transform.position.value[0], layer.startTime, main.numLayers]
+ "#,
+ );
+ assert_eq!(o.result, json!(["Nested", 200, 100, true, "Old", 10, 1, 1]));
+ let bad = run_code(
+ &mut s,
+ r#"
+ var main = app.project.items.addComp("Other", 100, 100, 1, 2, 25);
+ var layer = main.layers.addSolid([1, 0, 0], "Layer", 100, 100, 1);
+ layer.replaceSource(app.project.items.addFolder("F"), false);
+ "#,
+ "replace-source.jsx",
+ );
+ assert!(bad.error.as_ref().is_some_and(|e| e.message.contains("folder")), "{bad:?}");
+}
+
/// A flat green footage source, so `sampleImage` on a footage layer has known pixels.
struct Flat;
impl effectcraft_engine::render::FootageSource for Flat {
@@ -1585,3 +1616,41 @@ fn layer_property_resolves_transform_and_source_text_shortcuts() {
let (_, c) = comp_named(&s, "x");
assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0));
}
+
+/// #1936: an array whose length doesn't match the property's dimension is an error in After
+/// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal.
+#[test]
+fn set_value_rejects_an_array_of_the_wrong_dimension() {
+ let mut s = session();
+ let o = ok(
+ &mut s,
+ r#"
+ var c = app.project.items.addComp("Dims", 640, 360, 1, 2, 30);
+ var l = c.layers.addSolid([1, 0, 0], "s", 640, 360, 1);
+ var ramp = l.property("ADBE Effect Parade").addProperty("ADBE Ramp").property("ADBE Ramp-0001");
+ function attempt(f) { try { f(); return "no error"; } catch (e) { return e.message; } }
+ var before = "" + ramp.value;
+ var tooMany = attempt(function () { ramp.setValue([1, 2, 3]); });
+ var tooFew = attempt(function () { ramp.setValue([1]); });
+ var untouched = "" + ramp.value;
+ ramp.setValue([7, 8]);
+ var keyed = attempt(function () { ramp.setValueAtTime(0, [1, 2, 3, 4]); });
+ // A 3-D property still takes two values, as it does in After Effects: z stays 0.
+ l.property("Position").setValue([120, 34]);
+ [tooMany, tooFew, untouched === before, "" + ramp.value, keyed, "" + l.property("Position").value]
+ "#,
+ );
+ assert_eq!(
+ o.result,
+ json!([
+ "Array is not of the correct dimension",
+ "Array is not of the correct dimension",
+ true,
+ "7,8",
+ "Array is not of the correct dimension",
+ "120,34,0"
+ ])
+ );
+ let (_, c) = comp_named(&s, "Dims");
+ assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([120.0, 34.0, 0.0]));
+}
diff --git a/crates/segment/src/pt.rs b/crates/segment/src/pt.rs
index d85ff80ca..35ed57297 100644
--- a/crates/segment/src/pt.rs
+++ b/crates/segment/src/pt.rs
@@ -6,6 +6,15 @@
use std::collections::HashMap;
+const MAX_ARCHIVE_BYTES: usize = 1 << 30;
+const MAX_PICKLE_OPS: usize = 4_000_000;
+const MAX_TENSORS: usize = 1_000_000;
+const MAX_TENSOR_ELEMENTS: usize = 1 << 28;
+const MAX_ENTRY_BYTES: usize = 1 << 30;
+const MAX_PICKLE_NODES: usize = 4_000_000;
+const MAX_TOTAL_ELEMENTS: usize = 1 << 29;
+const MAX_TENSOR_DIMS: usize = 64;
+
/// A tensor read from a checkpoint: its shape and its values as f32 (row-major, contiguous).
#[derive(Clone, Debug, PartialEq)]
pub struct Tensor {
@@ -38,6 +47,9 @@ struct Entry {
/// The entries of a zip archive by name.
fn zip_entries(b: &[u8]) -> Result> {
+ if b.len() > MAX_ARCHIVE_BYTES {
+ return Err("checkpoint exceeds the 1024 MiB archive limit".into());
+ }
// End of central directory: the last `PK\x05\x06` (a comment may follow it).
let eocd =
(0..b.len().saturating_sub(21)).rev().find(|&i| b.get(i..i + 4) == Some(b"PK\x05\x06")).ok_or("not a zip archive (no end of central directory)")?;
@@ -96,6 +108,9 @@ fn read_entry<'a>(b: &'a [u8], e: &Entry) -> Result>
match e.method {
0 => Ok(std::borrow::Cow::Borrowed(raw)),
8 => {
+ if e.size > MAX_ENTRY_BYTES {
+ return Err("zip entry exceeds the size limit".into());
+ }
miniz_oxide::inflate::decompress_to_vec_with_limit(raw, e.size.max(1)).map(std::borrow::Cow::Owned).map_err(|e| format!("bad deflate data: {e:?}"))
}
m => Err(format!("unsupported zip compression {m}")),
@@ -142,7 +157,58 @@ fn int_list(o: &Obj) -> Option> {
}
}
+/// Nodes in `o`, stopping once the count passes `budget`.
+fn nodes(o: &Obj, budget: usize) -> usize {
+ let kids: Vec<&Obj> = match o {
+ Obj::Tuple(v) | Obj::List(v) => v.iter().collect(),
+ Obj::Dict(v) => v.iter().flat_map(|(k, x)| [k, x]).collect(),
+ _ => return 1,
+ };
+ let mut n = 1;
+ for k in kids {
+ if n > budget {
+ break;
+ }
+ n += nodes(k, budget - n);
+ }
+ n
+}
+
+fn tensor_element_count(name: &str, offset: usize, size: &[usize], stride: &[usize], values_len: usize) -> Result {
+ if size.len() != stride.len() || size.len() > MAX_TENSOR_DIMS {
+ return Err(format!("{name}: invalid tensor dimensionality"));
+ }
+ let n = size
+ .iter()
+ .try_fold(1usize, |n, &dim| n.checked_mul(dim))
+ .filter(|n| *n <= MAX_TENSOR_ELEMENTS)
+ .ok_or_else(|| format!("{name}: tensor exceeds the element limit"))?;
+ if n == 0 {
+ return Ok(0);
+ }
+ let mut max_at = offset;
+ for (&dim, &step) in size.iter().zip(stride) {
+ max_at = max_at
+ .checked_add((dim - 1).checked_mul(step).ok_or_else(|| format!("{name}: tensor stride overflow"))?)
+ .ok_or_else(|| format!("{name}: tensor offset overflow"))?;
+ }
+ if max_at >= values_len {
+ return Err(format!("{name}: data out of range"));
+ }
+ Ok(n)
+}
+
/// Run the pickle program and return its result.
+/// A memoized object, charged against the clone budget (memo reuse can otherwise grow trees exponentially).
+fn memo_get(memo: &HashMap, k: u64, cloned: &mut usize) -> Result {
+ let o = memo.get(&k).ok_or("pickle: bad memo")?;
+ *cloned = cloned.saturating_add(nodes(o, MAX_PICKLE_NODES));
+ if *cloned > MAX_PICKLE_NODES {
+ return Err("pickle exceeds the object limit".into());
+ }
+ Ok(o.clone())
+}
+
fn unpickle(p: &[u8]) -> Result {
let mut stack: Vec = Vec::new();
let mut memo: HashMap = HashMap::new();
@@ -166,7 +232,10 @@ fn unpickle(p: &[u8]) -> Result {
*i += n + 1;
Ok(s)
};
+ let mut op_count = 0usize;
+ let mut cloned = 0usize;
loop {
+ op_count = op_count.checked_add(1).filter(|n| *n <= MAX_PICKLE_OPS).ok_or("pickle exceeds the opcode limit")?;
let op = *p.get(i).ok_or("truncated pickle")?;
i += 1;
match op {
@@ -253,11 +322,11 @@ fn unpickle(p: &[u8]) -> Result {
}
b'h' => {
let k = take(&mut i, 1)?[0] as u64;
- stack.push(memo.get(&k).cloned().ok_or("pickle: bad memo")?);
+ stack.push(memo_get(&memo, k, &mut cloned)?);
}
b'j' => {
let s = take(&mut i, 4)?;
- stack.push(memo.get(&(u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64)).cloned().ok_or("pickle: bad memo")?);
+ stack.push(memo_get(&memo, u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64, &mut cloned)?);
}
b't' => {
let items = pop_mark(&mut stack)?;
@@ -290,9 +359,10 @@ fn unpickle(p: &[u8]) -> Result {
stack.push(match f {
Obj::Global(m, n) if m == "torch._utils" && n == "_rebuild_tensor_v2" => {
match (args.first(), args.get(1), args.get(2).and_then(int_list), args.get(3).and_then(int_list)) {
- (Some(Obj::Storage(dtype, key)), Some(Obj::Int(off)), Some(size), Some(stride)) => {
- Obj::Tensor { dtype: dtype.clone(), key: key.clone(), offset: usize::try_from(*off).unwrap_or(0), size, stride }
- }
+ (Some(Obj::Storage(dtype, key)), Some(Obj::Int(off)), Some(size), Some(stride)) => match usize::try_from(*off) {
+ Ok(offset) => Obj::Tensor { dtype: dtype.clone(), key: key.clone(), offset, size, stride },
+ Err(_) => Obj::Other,
+ },
_ => Obj::Other,
}
}
@@ -337,16 +407,24 @@ fn unpickle(p: &[u8]) -> Result {
}
}
-/// Element bytes and conversion of a storage dtype.
-fn to_f32(dtype: &str, bytes: &[u8]) -> Option> {
+/// Bytes per element of a float storage dtype.
+fn elem_size(dtype: &str) -> Option {
match dtype {
- "FloatStorage" => Some(bytes.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()),
- "HalfStorage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| half_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect()),
- "BFloat16Storage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)).collect()),
+ "FloatStorage" => Some(4),
+ "HalfStorage" | "BFloat16Storage" => Some(2),
_ => None,
}
}
+/// Element `i` of a storage (`i` must be below `bytes.len() / elem_size`).
+fn elem_f32(dtype: &str, bytes: &[u8], i: usize) -> f32 {
+ match dtype {
+ "FloatStorage" => f32::from_le_bytes([bytes[4 * i], bytes[4 * i + 1], bytes[4 * i + 2], bytes[4 * i + 3]]),
+ "HalfStorage" => half_to_f32(u16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]])),
+ _ => f32::from_bits((u16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]) as u32) << 16),
+ }
+}
+
pub(crate) fn half_to_f32(h: u16) -> f32 {
let (s, e, m) = ((h >> 15) as u32, ((h >> 10) & 0x1f) as u32, (h & 0x3ff) as u32);
let bits = match (e, m) {
@@ -382,22 +460,42 @@ pub fn read(bytes: &[u8]) -> Result {
Some((_, Obj::Dict(inner))) => inner.clone(),
_ => dict,
};
+ if dict.len() > MAX_TENSORS {
+ return Err("checkpoint exceeds the tensor count limit".into());
+ }
let mut storages: HashMap> = HashMap::new();
let mut out = Weights::new();
+ let mut total = 0usize;
+ let mut inflated = 0usize;
for (k, v) in dict {
let (Obj::Str(name), Obj::Tensor { dtype, key, offset, size, stride }) = (k, v) else { continue };
if !storages.contains_key(&key) {
let e = entries.get(&format!("{prefix}data/{key}")).ok_or_else(|| format!("missing storage {key}"))?;
+ if e.method == 8 {
+ inflated = inflated.saturating_add(e.size);
+ if inflated > MAX_ENTRY_BYTES {
+ return Err("checkpoint exceeds the decompressed storage limit".into());
+ }
+ }
storages.insert(key.clone(), read_entry(bytes, e)?);
}
- let Some(values) = storages.get(&key).and_then(|b| to_f32(&dtype, b)) else { continue };
- let n: usize = size.iter().product();
- // Gather (any strides) into a contiguous row-major tensor.
- let mut data = Vec::with_capacity(n);
+ let (Some(esz), Some(values)) = (elem_size(&dtype), storages.get(&key)) else { continue };
+ // Check hostile stride math before allocating or indexing the backing storage.
+ let n = tensor_element_count(&name, offset, &size, &stride, values.len() / esz)?;
+ total = total.saturating_add(n);
+ if total > MAX_TOTAL_ELEMENTS {
+ return Err("checkpoint exceeds the total element limit".into());
+ }
+ let mut data = Vec::new();
+ data.try_reserve_exact(n).map_err(|_| format!("{name}: tensor allocation failed"))?;
let mut idx = vec![0usize; size.len()];
for _ in 0..n {
- let at = offset + idx.iter().zip(&stride).map(|(i, s)| i * s).sum::();
- data.push(*values.get(at).ok_or_else(|| format!("{name}: data out of range"))?);
+ let at = idx
+ .iter()
+ .zip(&stride)
+ .try_fold(offset, |at, (i, s)| i.checked_mul(*s).and_then(|v| at.checked_add(v)))
+ .ok_or_else(|| format!("{name}: tensor offset overflow"))?;
+ data.push(elem_f32(&dtype, values, at));
for d in (0..idx.len()).rev() {
idx[d] += 1;
if idx[d] < size[d] {
@@ -490,6 +588,15 @@ mod tests {
assert!(!w.contains_key("n"), "integer tensors are skipped");
}
+ #[test]
+ fn rejects_tensor_shape_overflow_and_invalid_strides() {
+ assert!(tensor_element_count("bad", 0, &[usize::MAX, 2], &[1, 1], 16).is_err());
+ assert!(tensor_element_count("bad", 0, &[2, 2], &[usize::MAX, 1], 16).is_err());
+ assert!(tensor_element_count("bad", 0, &[2, 2], &[1], 16).is_err());
+ assert!(tensor_element_count("bad", usize::MAX, &[2], &[1], 16).is_err());
+ assert_eq!(tensor_element_count("empty", 0, &[0, usize::MAX], &[0, 0], 0), Ok(0));
+ }
+
#[test]
fn damaged_files_are_errors() {
assert!(read(b"not a zip").is_err());
diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs
index af59e33db..8ccfa58c7 100644
--- a/crates/svg/src/raster.rs
+++ b/crates/svg/src/raster.rs
@@ -20,12 +20,47 @@ pub fn rasterize(doc: &Doc, w: u32, h: u32, scale: f64) -> Image {
/// Rasterise `doc` with `m` mapping document pixels to image pixels.
pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image {
- let mut img = Image::new(w, h);
- draw_group(&doc.root, m, 1.0, &mut img);
+ let mut budget = SurfaceBudget { live_bytes: 0, limit_bytes: MAX_LIVE_SURFACE_BYTES };
+ let Some(mut img) = budget.image(w, h, None) else { return Image::default() };
+ draw_group(&doc.root, m, 1.0, &mut img, &mut budget);
img
}
-fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
+/// Bound the simultaneously live offscreen image surfaces created by nested groups.
+const MAX_LIVE_SURFACE_BYTES: usize = 512 * 1024 * 1024;
+
+struct SurfaceBudget {
+ live_bytes: usize,
+ limit_bytes: usize,
+}
+
+impl SurfaceBudget {
+ fn image(&mut self, width: u32, height: u32, copy: Option<&Image>) -> Option {
+ let pixels = (width as usize).checked_mul(height as usize)?;
+ let bytes = pixels.checked_mul(std::mem::size_of::<[f32; 4]>())?;
+ let new_live = self.live_bytes.checked_add(bytes)?;
+ if new_live > self.limit_bytes {
+ return None;
+ }
+ let mut data = Vec::new();
+ data.try_reserve_exact(pixels).ok()?;
+ data.resize(pixels, [0.0; 4]);
+ if let Some(source) = copy {
+ if source.width != width || source.height != height || source.data.len() != pixels {
+ return None;
+ }
+ data.copy_from_slice(&source.data);
+ }
+ self.live_bytes = new_live;
+ Some(Image { width, height, data })
+ }
+
+ fn release(&mut self, image: &Image) {
+ self.live_bytes = self.live_bytes.saturating_sub(image.data.len().saturating_mul(std::mem::size_of::<[f32; 4]>()));
+ }
+}
+
+fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) {
let m = m * g.transform;
let o = opacity * g.opacity;
if o <= 0.0 {
@@ -38,7 +73,7 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
|| g.knockout;
if !offscreen {
for c in &g.children {
- draw_node(c, m, o, dst);
+ draw_node(c, m, o, dst, budget);
}
return;
}
@@ -46,12 +81,24 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
// Draw the children on their own (isolated) or over a copy of the backdrop, keep what
// every clip path covers and the soft mask lets through, then composite with the group's
// opacity and blend mode.
- let backdrop = if g.isolated { None } else { Some(dst.clone()) };
+ let backdrop = if g.isolated { None } else { budget.image(dst.width, dst.height, Some(dst)) };
+ if !g.isolated && backdrop.is_none() {
+ return;
+ }
let mut tmp = match &backdrop {
- Some(b) => b.clone(),
- None => Image::new(dst.width, dst.height),
+ Some(b) => match budget.image(dst.width, dst.height, Some(b)) {
+ Some(image) => image,
+ None => {
+ budget.release(b);
+ return;
+ }
+ },
+ None => match budget.image(dst.width, dst.height, None) {
+ Some(image) => image,
+ None => return,
+ },
};
- draw_children(g, m, &mut tmp, backdrop.as_ref());
+ draw_children(g, m, &mut tmp, backdrop.as_ref(), budget);
if let Some(b) = &backdrop {
if only_clip {
// A non-isolated clipping group: the backdrop where the clip excludes the children.
@@ -61,12 +108,24 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
d[i] = d[i] * (1.0 - c) + t[i] * c;
}
});
+ budget.release(&tmp);
+ drop(tmp);
+ if let Some(b) = &backdrop {
+ budget.release(b);
+ }
return;
}
// Remove the backdrop's contribution (ISO 32000-1 §11.4.8): with the group's own
// alpha αg (its children alone), C = Cn + (Cn − C0)·(α0/αg − α0).
- let mut solo = Image::new(dst.width, dst.height);
- draw_children(g, m, &mut solo, None);
+ let Some(mut solo) = budget.image(dst.width, dst.height, None) else {
+ budget.release(&tmp);
+ drop(tmp);
+ if let Some(b) = &backdrop {
+ budget.release(b);
+ }
+ return;
+ };
+ draw_children(g, m, &mut solo, None, budget);
tmp.data.par_iter_mut().zip(b.data.par_iter()).zip(solo.data.par_iter()).for_each(|((t, b0), s)| {
let ag = s[3];
if ag <= 0.0 {
@@ -83,6 +142,8 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
out[3] = ag;
*t = out;
});
+ budget.release(&solo);
+ drop(solo);
}
if !g.clip.is_empty() {
let cov = clip_coverage(g, m, dst.width, dst.height);
@@ -93,8 +154,15 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
});
}
if let Some(sm) = &g.mask {
- let mut mk = Image::new(dst.width, dst.height);
- draw_group(&sm.content, m, 1.0, &mut mk);
+ let Some(mut mk) = budget.image(dst.width, dst.height, None) else {
+ budget.release(&tmp);
+ drop(tmp);
+ if let Some(b) = &backdrop {
+ budget.release(b);
+ }
+ return;
+ };
+ draw_group(&sm.content, m, 1.0, &mut mk, budget);
let bc = sm.backdrop.map(|v| v as f32);
let lum = sm.luminosity;
tmp.data.par_iter_mut().zip(mk.data.par_iter()).for_each(|(p, q)| {
@@ -110,6 +178,8 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
*v *= k;
}
});
+ budget.release(&mk);
+ drop(mk);
}
if g.blend == BlendMode::Normal {
over(dst, &tmp, o as f32);
@@ -123,6 +193,11 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
}
});
}
+ budget.release(&tmp);
+ drop(tmp);
+ if let Some(b) = &backdrop {
+ budget.release(b);
+ }
}
/// The product of a group's clip-path coverages.
@@ -142,19 +217,22 @@ fn clip_coverage(g: &Group, m: Affine, w: u32, h: u32) -> Vec {
/// Draw a group's children into `dst`. Knockout groups composite each child with the
/// group's initial backdrop (`backdrop`, or transparent) instead of with the children below:
/// where the child has shape `f`, `dst = dst·(1 − f) + child + backdrop·(f − αchild)`.
-fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>) {
+fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>, budget: &mut SurfaceBudget) {
if !g.knockout {
for c in &g.children {
- draw_node(c, m, 1.0, dst);
+ draw_node(c, m, 1.0, dst, budget);
}
return;
}
for c in &g.children {
- let mut ci = Image::new(dst.width, dst.height);
- draw_node(c, m, 1.0, &mut ci);
+ let Some(mut ci) = budget.image(dst.width, dst.height, None) else { return };
+ draw_node(c, m, 1.0, &mut ci, budget);
// Shape: the child drawn fully opaque (constant opacities to 1).
- let mut si = Image::new(dst.width, dst.height);
- draw_node(&opaque(c), m, 1.0, &mut si);
+ let Some(mut si) = budget.image(dst.width, dst.height, None) else {
+ budget.release(&ci);
+ return;
+ };
+ draw_node(&opaque(c), m, 1.0, &mut si, budget);
let zero = [0.0f32; 4];
dst.data.par_iter_mut().enumerate().for_each(|(i, d)| {
let (cp, f) = (ci.data[i], si.data[i][3].max(ci.data[i][3]));
@@ -166,6 +244,10 @@ fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>
d[k] = d[k] * (1.0 - f) + cp[k] + b[k] * (f - cp[3]);
}
});
+ budget.release(&ci);
+ budget.release(&si);
+ drop(ci);
+ drop(si);
}
}
@@ -299,14 +381,15 @@ fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
c.map(|v| (v - mn) * s / (mx - mn))
}
-fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image) {
+fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) {
match n {
- Node::Group(g) => draw_group(g, m, opacity, dst),
+ Node::Group(g) => draw_group(g, m, opacity, dst, budget),
Node::Shape(s) => {
if s.opacity < 1.0 && s.fill.is_some() && s.stroke.is_some() {
- let mut tmp = Image::new(dst.width, dst.height);
+ let Some(mut tmp) = budget.image(dst.width, dst.height, None) else { return };
draw_shape(s, m, 1.0, &mut tmp);
over(dst, &tmp, (opacity * s.opacity) as f32);
+ budget.release(&tmp);
} else {
draw_shape(s, m, opacity * s.opacity, dst);
}
@@ -514,12 +597,12 @@ pub(crate) fn eval_stops(stops: &[(f64, [f64; 4])], t: f64) -> [f32; 4] {
if t <= first.0 {
return first.1.map(|v| v as f32);
}
- for w in stops.windows(2) {
- let (a, b) = (w[0], w[1]);
- if t <= b.0 {
- let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
- return [0, 1, 2, 3].map(|i| (a.1[i] + (b.1[i] - a.1[i]) * k) as f32);
- }
- }
- stops.last().map_or([0.0; 4], |s| s.1.map(|v| v as f32))
+ // Find the first upper stop (not the last duplicate): equal-offset segments retain the
+ // reference algorithm's exact-boundary behavior, while nearby values are interpolated from
+ // the preceding stop as before.
+ let upper = stops.partition_point(|stop| stop.0 < t);
+ let Some(b) = stops.get(upper) else { return stops.last().map_or([0.0; 4], |s| s.1.map(|v| v as f32)) };
+ let Some(a) = stops.get(upper.saturating_sub(1)) else { return first.1.map(|v| v as f32) };
+ let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
+ [0, 1, 2, 3].map(|i| (a.1[i] + (b.1[i] - a.1[i]) * k) as f32)
}
diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs
index 93db715c1..ee13a79f6 100644
--- a/crates/svg/src/tests.rs
+++ b/crates/svg/src/tests.rs
@@ -11,6 +11,25 @@ fn near(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
(0..4).all(|i| (a[i] - b[i]).abs() <= tol)
}
+#[test]
+fn gradient_stop_lookup_handles_duplicate_offsets() {
+ use crate::raster::eval_stops;
+ let stops = [(0.0, [0.0, 0.0, 0.0, 1.0]), (0.5, [1.0, 0.0, 0.0, 1.0]), (0.5, [0.0, 1.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 1.0, 1.0])];
+ assert_eq!(eval_stops(&stops, 0.5), [1.0, 0.0, 0.0, 1.0]);
+ assert!(near(eval_stops(&stops, 0.5001), [0.0, 1.0, 0.0, 1.0], 3e-4));
+ assert_eq!(eval_stops(&stops, 0.75), [0.0, 0.5, 0.5, 1.0]);
+}
+
+#[test]
+fn raster_surface_budget_rejects_oversized_destination_before_allocation() {
+ let doc = parse(br#""#).unwrap();
+ // 16,384² RGBA-f32 pixels exceed the bounded destination-surface budget; no giant
+ // allocation should be attempted and the caller receives an empty image.
+ let img = rasterize(&doc, 16_384, 16_384, 1.0);
+ assert!(img.data.is_empty());
+ assert_eq!((img.width, img.height), (0, 0));
+}
+
#[test]
fn basic_shapes_structure() {
let d = parse(BASIC).unwrap();
diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs
index aeabf1872..724fc4aaf 100644
--- a/crates/text/src/fonts.rs
+++ b/crates/text/src/fonts.rs
@@ -633,6 +633,8 @@ enum FallbackScript {
/// Sinhala (one family covers them all on Windows; elsewhere each has its own).
Indic,
Thai,
+ /// Georgian: Mkhedruli, Asomtavruli and Nuskhuri, and Mtavruli capitals.
+ Georgian,
Other,
}
@@ -661,6 +663,7 @@ fn script_of(c: char) -> FallbackScript {
0x0590..=0x05FF | 0xFB1D..=0xFB4F => FallbackScript::Hebrew,
0x0900..=0x0DFF | 0xA8E0..=0xA8FF => FallbackScript::Indic,
0x0E00..=0x0E7F => FallbackScript::Thai,
+ 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F => FallbackScript::Georgian,
_ => FallbackScript::Other,
}
}
@@ -726,6 +729,7 @@ const INDIC_FAMILIES: &[&str] = &[
"Noto Sans Sinhala",
];
const THAI_FAMILIES: &[&str] = &["Leelawadee UI", "Tahoma", "Thonburi", "Noto Sans Thai", "Noto Sans Thai UI", "Loma", "Garuda"];
+const GEORGIAN_FAMILIES: &[&str] = &["Sylfaen", ".SF Georgian", "SF Georgian", "Noto Sans Georgian", "DejaVu Sans"];
/// The CJK locale the host asked fallbacks to prefer, when it has one (see [`set_cjk_locale`]).
static REQUESTED_CJK_LOCALE: RwLock