mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 19:47:12 -06:00
Merge main into fix/issue-2096-deselect-during-text-edit
main moved crates/ui-egui/tests/ui_text_edit.rs to tests/it/ (one test binary per crate); this branch's test changes follow the move.
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -11,7 +11,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# 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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+14
@@ -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"
|
||||
@@ -1233,6 +1242,7 @@ dependencies = [
|
||||
name = "effectcraft-effects"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1250,6 +1260,7 @@ dependencies = [
|
||||
name = "effectcraft-engine"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1651,6 +1662,7 @@ dependencies = [
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-ui",
|
||||
"effectcraft-color",
|
||||
"effectcraft-engine",
|
||||
@@ -6627,7 +6639,9 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
name = "xtask"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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]
|
||||
|
||||
+2
-1
@@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%) and Russian (menus only, 16%). No language has had
|
||||
Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had
|
||||
native-speaker review.
|
||||
|
||||
## Upcoming
|
||||
@@ -135,6 +135,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.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod cli;
|
||||
mod qa_template;
|
||||
@@ -50,6 +50,7 @@ web-sys = { version = "0.3", features = [
|
||||
"HtmlElement",
|
||||
"HtmlInputElement",
|
||||
"Location",
|
||||
"Navigator",
|
||||
"Url",
|
||||
"UrlSearchParams",
|
||||
"Window",
|
||||
|
||||
@@ -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::<Vec<_>>());
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u64>,
|
||||
}
|
||||
|
||||
/// 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<Mirror>,
|
||||
/// Called after every change that needs flushing (the web host schedules a flush).
|
||||
pub on_change: OnceLock<fn()>,
|
||||
/// Clock in milliseconds since the Unix epoch (tests pin it).
|
||||
pub clock: OnceLock<fn() -> f64>,
|
||||
/// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash.
|
||||
media_epoch: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
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<AtomicU64> {
|
||||
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<Arc<[u8]>> {
|
||||
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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod bench;
|
||||
mod common;
|
||||
mod ffmpeg_oracle;
|
||||
mod roundtrip;
|
||||
@@ -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};
|
||||
@@ -21,4 +21,5 @@ rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
|
||||
@@ -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<f32> = a.iter().map(|v| *v as f32 / 255.0).collect();
|
||||
|
||||
@@ -560,10 +560,14 @@ fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc<Image>, 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<f32> = a.iter().map(|v| *v as f32 / 255.0).collect();
|
||||
let (alpha, band) = if fw == w && fh == h {
|
||||
(af, bnd)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod gen_golden;
|
||||
mod particle_golden;
|
||||
mod sim_golden;
|
||||
mod timewarp_golden;
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<i64> {
|
||||
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<Stamp> {
|
||||
// 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::<i64>().ok()?, s[5..7].parse::<i64>().ok()?, s[8..10].parse::<i64>().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.
|
||||
|
||||
@@ -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<Value> {
|
||||
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<LayerSource> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>(), "item": item.0}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<CommandSpec> {
|
||||
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),
|
||||
|
||||
@@ -114,10 +114,10 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn hide_other_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Hide Other Video", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() {
|
||||
if !ids.contains(&l.id) && (l.source.is_av() || 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<Value> {
|
||||
}
|
||||
|
||||
fn reveal_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<ItemId> = 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<u64> = 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<Value> {
|
||||
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<CommandSpec> {
|
||||
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!(
|
||||
|
||||
@@ -46,13 +46,22 @@ pub fn resolve(stored: &str, project: Option<&str>) -> Result<PathBuf, NotFollow
|
||||
let p = Path::new(p);
|
||||
std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
});
|
||||
let path = craft_docpath::resolve(stored, project.as_deref()).map_err(NotFollowed::Refused)?;
|
||||
// A path that is still relative would be taken from the working directory, which the
|
||||
// project does not choose.
|
||||
if path.is_relative() {
|
||||
return Err(NotFollowed::NoProjectFolder);
|
||||
}
|
||||
Ok(path)
|
||||
craft_docpath::resolve_absolute(stored, project.as_deref()).map_err(|why| match why {
|
||||
// Resolved, but still relative: it would be taken from the working directory, which the
|
||||
// project does not choose. A Windows drive or share path (`C:\x`) stays invalid outside
|
||||
// Windows even with a project.
|
||||
Refused::Invalid if project.is_none() && craft_docpath::resolve(stored, None).is_ok_and(|p| cfg!(windows) || !is_windows_rooted(&p)) => {
|
||||
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
|
||||
|
||||
@@ -149,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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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): `<timestamp> <LEVEL> [<thread>] <target>: <message>`.
|
||||
@@ -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.
|
||||
|
||||
@@ -264,22 +264,33 @@ pub fn parent(dir: &str) -> Option<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Arc<PrefetchStore>>,
|
||||
salt: u64,
|
||||
/// Size and content hash for footage paths with no native metadata (the browser file table).
|
||||
virtual_id: Option<Arc<dyn Fn(&str) -> 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<Arc<dyn Fn(&str) -> 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<Arc<PrefetchStore>>) {
|
||||
@@ -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);
|
||||
|
||||
@@ -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<u64> = 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<u64> = 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<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
|
||||
assert_eq!(order, vec![a, below]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod key_time_post_snap_bounds;
|
||||
mod key_time_reply_contract;
|
||||
mod parenting_3d_position;
|
||||
@@ -0,0 +1,14 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.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;
|
||||
-2
@@ -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};
|
||||
-2
@@ -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};
|
||||
@@ -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
|
||||
|
||||
+31
-2
@@ -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<bool> = 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<Diff>, 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 {:?}",
|
||||
|
||||
@@ -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])]
|
||||
{
|
||||
|
||||
@@ -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))]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.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;
|
||||
@@ -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() {
|
||||
@@ -96,9 +96,11 @@ fn external_image(stored: &str, base_dir: &str) -> Result<String, String> {
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
|
||||
+65
-30
@@ -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<Vec<u8>> {
|
||||
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<Self>, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result<Arc<Image>> {
|
||||
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<Self>, _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<Image> {
|
||||
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<Image> {
|
||||
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<Image> {
|
||||
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);
|
||||
|
||||
@@ -27,12 +27,21 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
|
||||
}
|
||||
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<Vec<u8>> + 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<Vec<u8>>) -> Result<Footage> {
|
||||
@@ -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]);
|
||||
|
||||
@@ -584,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 };
|
||||
@@ -837,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::<f64>();
|
||||
let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt();
|
||||
@@ -0,0 +1,7 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod downmix;
|
||||
mod exif_orientation;
|
||||
mod footage;
|
||||
mod open_in_place;
|
||||
mod tga;
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod common;
|
||||
mod ffmpeg;
|
||||
mod modes;
|
||||
@@ -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};
|
||||
+73
-25
@@ -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<u8>, 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<u8>, 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<u16> {
|
||||
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<u16> {
|
||||
|
||||
/// 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<u8> {
|
||||
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<u8>, 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<u8>, 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<u8>, usize, usize
|
||||
pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill: [f64; 3]) -> Result<Decoded, String> {
|
||||
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<f64> = 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;
|
||||
}
|
||||
}
|
||||
|
||||
+83
-14
@@ -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<Buf>) {
|
||||
/// 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<Buf>) -> Arc<Buf> {
|
||||
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<Buf>) {
|
||||
/// 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<Buf>) -> Arc<Buf> {
|
||||
let bytes = buf.img.data.len() * std::mem::size_of::<effectcraft_raster::Px>();
|
||||
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<u64> {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<VirtualFileId<'_>>) {
|
||||
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<VirtualFileId<'_>>) {
|
||||
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<VirtualFileId<'_>>) -> 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<VirtualFileId<'_>>) -> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+30
-20
@@ -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<Buf>, Option<u64>)> {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user