mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 06:42:31 +00:00
Merge upstream/main into reversed-layer-audio-3239
This commit is contained in:
commit
cea96922fc
165 files changed
+11008
-1427
No files matched your search
@@ -15,7 +15,14 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
@@ -25,6 +32,7 @@ jobs:
|
||||
app-name: effectcraft
|
||||
app-title: EffectCraft
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
Generated
+3
@@ -1468,6 +1468,7 @@ dependencies = [
|
||||
"effectcraft-script",
|
||||
"image",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -1741,6 +1742,7 @@ version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
"effectcraft-project",
|
||||
@@ -1877,6 +1879,7 @@ dependencies = [
|
||||
"lz4_flex",
|
||||
"rayon",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
|
||||
@@ -329,7 +329,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)));
|
||||
app.frames.set_virtual_id(Arc::new(|path| files::STORE.file_identity(path)));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
|
||||
@@ -4,23 +4,28 @@
|
||||
//!
|
||||
//! - **Writes** happen in the frame workers: a render request carries the frame's disk-cache key
|
||||
//! (`effectcraft_engine::remote::FrameMsg::Render`), the worker posts the frame, then writes
|
||||
//! the sealed entry (`disk_cache::frame_entry`: LZ4, checksum) with a synchronous access
|
||||
//! handle into a temporary file moved into place (`js/host.js` `cacheWrite`), and reports
|
||||
//! `FrameReply::Stored`.
|
||||
//! the sealed entry (`disk_cache::frame_entry`: the source files the frame read, LZ4,
|
||||
//! checksum) with a synchronous access handle into a temporary file moved into place
|
||||
//! (`js/host.js` `cacheWrite`), and reports `FrameReply::Stored`. A frame whose files can't
|
||||
//! all be named is not stored.
|
||||
//! - **The index** lives here, on the page ([`DiskIndex`], shared with the desktop's cache):
|
||||
//! rebuilt at startup from the folder listing (oldest files first), updated by the workers'
|
||||
//! `Stored` replies and by reads; eviction deletes the least recently used files.
|
||||
//! - **Reads** are asynchronous: a frame whose key is indexed is read and decoded here instead
|
||||
//! of rendering it (`crate::frames`); a damaged or missing file is a miss and is forgotten.
|
||||
//! of rendering it (`crate::frames`), if the files its entry lists still have the size and
|
||||
//! content hash it was made of (the file table's identities); a damaged or missing entry, or
|
||||
//! one whose files changed, is a miss and is deleted.
|
||||
//!
|
||||
//! - **Layer buffers** too (`layers/<32 hex>.ecc`): a layer lookup happens in the middle of a
|
||||
//! synchronous render, where the browser's file system can't be read, so the frame workers
|
||||
//! back their layer caches with a `PrefetchStore`. They report each frame's layer-cache
|
||||
//! misses; before the next request the page names the recent misses that are on disk
|
||||
//! ([`layer_prefetch`], `disk_cache::LayerPrefetch`), and the worker reads those entries
|
||||
//! into memory before rendering, so the lookups find them. Slow buffers are written by the
|
||||
//! worker after its frame and indexed here like frames (one least-recently-used order for
|
||||
//! both kinds).
|
||||
//! into memory before rendering, so the lookups find them; an entry is used only while the
|
||||
//! files it lists (the file table's size and content hash) are unchanged, and a buffer whose
|
||||
//! files can't all be named is not written (#3184). Slow buffers are written by the worker
|
||||
//! after its frame and indexed here like frames (one least-recently-used order for both
|
||||
//! kinds).
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
@@ -187,11 +192,12 @@ pub fn note_miss() {
|
||||
}
|
||||
|
||||
/// Read and decode a cached frame; `None` (forgotten and deleted) when the file is missing or
|
||||
/// damaged.
|
||||
/// damaged, or the source files it was made of changed since.
|
||||
pub async fn read(key: u128) -> Option<disk_cache::Frame8> {
|
||||
let name = disk_cache::entry_name(key);
|
||||
let bytes = JsFuture::from(cache_read(&name)).await.ok().and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok()).map(|b| b.to_vec());
|
||||
let frame = bytes.as_deref().and_then(disk_cache::read_frame_entry);
|
||||
let identity = |path: &str| crate::files::STORE.file_identity(path);
|
||||
let frame = bytes.as_deref().and_then(|b| disk_cache::read_frame_entry(b, Some(&identity)));
|
||||
ST.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
if frame.is_some() {
|
||||
|
||||
@@ -473,10 +473,13 @@ pub async fn worker_frame(json: String) {
|
||||
g.settled().await;
|
||||
replies = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| s.resume()).unwrap_or_default());
|
||||
}
|
||||
// The source files the frame read, kept with its entry (checked by the page before use); a
|
||||
// frame whose files can't all be named is not stored (#3184).
|
||||
let (files, virtual_id) = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| (s.take_files(), s.virtual_id().cloned())).unwrap_or_default());
|
||||
for (r, px) in replies {
|
||||
post_reply(&r, px.as_deref());
|
||||
if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key)) = (&r, &px, &disk) {
|
||||
let entry = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px);
|
||||
if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key), Some(files)) = (&r, &px, &disk, &files) {
|
||||
let Some(entry) = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px, files, virtual_id.as_deref()) else { continue };
|
||||
let name = format!("{key}.ecc");
|
||||
match JsFuture::from(crate::diskcache::cache_write(&name, js_sys::Uint8Array::from(&entry[..]))).await {
|
||||
Ok(n) => post_reply(&FrameReply::Stored { key: key.clone(), bytes: n.as_f64().unwrap_or(entry.len() as f64) as u64, layer: false }, None),
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
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};
|
||||
@@ -187,13 +186,12 @@ pub struct Store {
|
||||
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 {
|
||||
/// An empty store with no mirror contents, change callback or clock.
|
||||
fn default() -> Self {
|
||||
Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) }
|
||||
Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,22 +216,12 @@ impl Store {
|
||||
pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) {
|
||||
let now = self.now();
|
||||
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)> {
|
||||
@@ -541,11 +529,9 @@ 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).
|
||||
|
||||
@@ -276,9 +276,6 @@ fn main() -> eframe::Result {
|
||||
// written on background threads (M13.14).
|
||||
session.check_footage_on_open = true;
|
||||
session.autosave.background = true;
|
||||
// The disk cache's footage stamps too: an imported or opened project with thousands of
|
||||
// sequence frames on a network share took half a minute to stamp.
|
||||
session.disk_salt_background = true;
|
||||
// Settings, shortcut presets and the crash-recovery sentinel live in the platform
|
||||
// config directory. Agent-driven runs (`--control`) skip crash recovery.
|
||||
if let Some(dir) = effectcraft_host::config_dir() {
|
||||
|
||||
@@ -37,7 +37,9 @@ mod version;
|
||||
mod write;
|
||||
|
||||
pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits};
|
||||
pub use arbitrary::{CURVE_IDENTITY, CryptomatteData, CurvesData, HueSaturationData, LevelsData, MeshData, curve_lut};
|
||||
pub use arbitrary::{
|
||||
CURVE_IDENTITY, CryptomatteData, CurvesData, ExtractorChannels, HueSaturationData, LevelsData, MeshData, curve_lut, identifier_channel_type,
|
||||
};
|
||||
pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM};
|
||||
pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk};
|
||||
pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue};
|
||||
|
||||
@@ -308,6 +308,82 @@ impl MeshData {
|
||||
}
|
||||
}
|
||||
|
||||
/// EXtractoR's Channel Info data (`EXtractoR-0013`, 688 bytes, the plug-in's own little-endian
|
||||
/// layout; read from the instances of the importer block of 2026-11-05: the defaults and a preset
|
||||
/// of our own that reads `Z` into Red, Green and Blue): a `u32` 1 and 28 bytes, then four records
|
||||
/// of 164 bytes (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by
|
||||
/// name, 2 the input's own channel: `(copy)`; bytes 1..4 are not always zero, `0x32` at 3 in one
|
||||
/// definition's default, so only byte 0 is read) and the channel name from byte 35,
|
||||
/// NUL-terminated. The other bytes are not read (they differ between instances with the same
|
||||
/// channels; the `u32` at 4 of a chosen channel looks like its position in the footage's sorted
|
||||
/// channel list, 13 of 14 for `Z`, where the defaults hold 0). Only the name is read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExtractorChannels {
|
||||
/// Per output channel (red, green, blue, alpha): the kind and the name.
|
||||
pub channels: [(u32, String); 4],
|
||||
}
|
||||
|
||||
impl ExtractorChannels {
|
||||
/// The data's length.
|
||||
pub const LEN: usize = 688;
|
||||
const RECORD: usize = 164;
|
||||
|
||||
/// `None` when the data does not have this layout.
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
if data.len() != Self::LEN || data.get(..4)? != 1u32.to_le_bytes() {
|
||||
return None;
|
||||
}
|
||||
let mut channels: [(u32, String); 4] = Default::default();
|
||||
for (k, slot) in channels.iter_mut().enumerate() {
|
||||
let at = 32usize.checked_add(k.checked_mul(Self::RECORD)?)?;
|
||||
let rec = data.get(at..at.checked_add(Self::RECORD)?)?;
|
||||
let kind = u32::from(*rec.first()?);
|
||||
let name = rec.get(35..)?;
|
||||
let end = name.iter().position(|b| *b == 0)?;
|
||||
let name = std::str::from_utf8(name.get(..end)?).ok()?;
|
||||
*slot = (kind, name.to_string());
|
||||
}
|
||||
Some(Self { channels })
|
||||
}
|
||||
|
||||
/// The channels of a fresh EXtractoR: Red, Green and Blue from the channels of those names,
|
||||
/// Alpha copied from the input.
|
||||
pub fn is_default(&self) -> bool {
|
||||
let want = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")];
|
||||
self.channels.iter().zip(want).all(|((k, n), (wk, wn))| *k == wk && n == wn)
|
||||
}
|
||||
|
||||
/// Per output channel (red, green, blue, alpha) the channel it reads, `Err` with the
|
||||
/// position of the first record of another kind or without a name: a named channel by its
|
||||
/// name, `(copy)` as EffectCraft's [`EXTRACTOR_COPY`](effectcraft_effects::EXTRACTOR_COPY),
|
||||
/// the effect input's own channel of the same position. It is not `R`, `G`, `B` or `A`:
|
||||
/// EffectCraft's EXtractoR reads those from an OpenEXR footage's channels of that name, the
|
||||
/// file's own, which loses what an earlier effect did to the channel.
|
||||
pub fn sources(&self) -> Result<[String; 4], usize> {
|
||||
let mut out: [String; 4] = Default::default();
|
||||
for (k, ((kind, name), slot)) in self.channels.iter().zip(out.iter_mut()).enumerate() {
|
||||
*slot = match (kind, name.trim()) {
|
||||
(1, n) if !n.is_empty() => n.to_string(),
|
||||
(2, _) => effectcraft_effects::EXTRACTOR_COPY.to_string(),
|
||||
_ => return Err(k),
|
||||
};
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// IDentifier's Channel Info data (`IDentifier-0005`, 148 bytes): the chosen channel's name,
|
||||
/// NUL-terminated from byte 19, or `Object ID` when none is chosen (importer blocks 1300 and 1310
|
||||
/// of 2026-11-05: `objectId` in a preset of our own that chose that UINT channel, `Object ID` in
|
||||
/// every other instance). The bytes around it differ between instances with the same settings
|
||||
/// and are not read (a chosen channel has 15 at byte 4, which looks like its position in the
|
||||
/// EXR's sorted channel list, as for EXtractoR).
|
||||
pub fn identifier_channel_type(data: &[u8]) -> Option<&str> {
|
||||
let s = data.get(19..)?;
|
||||
let end = s.iter().position(|b| *b == 0)?;
|
||||
std::str::from_utf8(s.get(..end)?).ok()
|
||||
}
|
||||
|
||||
/// The Cryptomatte plug-in's Settings data (`Cryptomatte-0001`, the plug-in's own layout,
|
||||
/// little-endian; read from 88 instances of one project and from our own sample 1400, fresh
|
||||
/// instances and two presets that pick a layer and an object, docs/aep-format.md 3.7): the magic
|
||||
|
||||
@@ -141,6 +141,13 @@ pub enum Extra {
|
||||
Curves,
|
||||
/// Mesh Warp's Distortion Mesh (965, 985, `MeshData`).
|
||||
MeshWarp,
|
||||
/// Lumetri Color: every parameter goes through its own table, keyed by match name
|
||||
/// (`import/lumetri.rs`, 1200).
|
||||
Lumetri,
|
||||
/// EXtractoR's Channel Info (`ExtractorChannels`): the channels by name.
|
||||
Extractor,
|
||||
/// IDentifier's Channel Info (`identifier_channel_type`): the chosen channel by name.
|
||||
Identifier,
|
||||
/// The Cryptomatte plug-in: the layer and selection in its Settings data
|
||||
/// (`CryptomatteData`), Output split over EffectCraft's Display and Matte Only; every
|
||||
/// parameter is handled by the importer's Cryptomatte step, none by the table.
|
||||
@@ -361,6 +368,8 @@ pub const SHUTTER_CONTROL: &[&str] = &["Manual", "Automatic"];
|
||||
pub const ECHO_OPERATOR: &[&str] = &["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"];
|
||||
/// Color Stabilizer's Stabilize (`colstab-02`, `colstab-03`; the fresh default is Levels).
|
||||
pub const STABILIZE: &[&str] = &["Brightness", "Levels", "Curves"];
|
||||
/// IDentifier's Display (`identifier-04` ... `identifier-06`, `1300-thirdparty.aep`).
|
||||
pub const IDENTIFIER_DISPLAY: &[&str] = &["Colors", "Luma Matte", "Alpha Matte", "Raw"];
|
||||
|
||||
/// The effects the importer converts.
|
||||
pub const EFFECTS: &[EffectMap] = &[
|
||||
@@ -532,6 +541,9 @@ pub const EFFECTS: &[EffectMap] = &[
|
||||
],
|
||||
extra: Extra::MeshWarp,
|
||||
},
|
||||
// Lumetri Color (1200): the parameters are mapped by match name, not by `parT` index
|
||||
// (`import/lumetri.rs`).
|
||||
EffectMap { match_name: "ADBE Lumetri", effect: "ec.color.lumetri", params: &[], extra: Extra::Lumetri },
|
||||
// The Cryptomatte plug-in (docs/aep-format.md 3.7): 1 Settings data, 2 Output, 3 Selection
|
||||
// Mode, imported by the Cryptomatte step (`Extra::Cryptomatte`).
|
||||
EffectMap { match_name: "Cryptomatte", effect: "ec.3d.cryptomatte", params: &[], extra: Extra::Cryptomatte },
|
||||
@@ -1076,6 +1088,40 @@ pub const EFFECTS: &[EffectMap] = &[
|
||||
pm(5, K::FixedSlider, "sampleSize", Conv::Plain { valid_max: 50.0 }),
|
||||
],
|
||||
),
|
||||
// CC Color Neutralizer (importer block 2026-11-05, `ccneutral-*`): Pinning and Blend w.
|
||||
// Original are float sliders in percent (valid 0..100). After Effects' correction is the Red /
|
||||
// Green / Blue slider of each range (3-5, 9-11, 15-17; the Unbalance colours are pickers that
|
||||
// set them and keep no record), then View (22) and Black / White Point (23, 24):
|
||||
// EffectCraft's CC Color Neutralizer has none of them, so they are reported when stored.
|
||||
fx(
|
||||
"CS Color Neutralizer",
|
||||
"ec.color.cccolorneutralizer",
|
||||
&[pm(19, K::FloatSlider, "pinning", Conv::Plain { valid_max: 100.0 }), pm(20, K::FloatSlider, "blendWOriginal", Conv::Plain { valid_max: 100.0 })],
|
||||
),
|
||||
// EXtractoR (`extractor-*`): 1 Channel Info (arbitrary data, `Extra::Extractor`), 2 / 6 the
|
||||
// Process group, Black and White Point float sliders (valid ±3.4e38, the float32 range),
|
||||
// UnMult. EffectCraft's Clip has no counterpart and stays off.
|
||||
EffectMap {
|
||||
match_name: "EXtractoR",
|
||||
effect: "ec.3d.extractor",
|
||||
params: &[
|
||||
pm(3, K::FloatSlider, "blackPoint", Conv::Plain { valid_max: F32_MAX }),
|
||||
pm(4, K::FloatSlider, "whitePoint", Conv::Plain { valid_max: F32_MAX }),
|
||||
pm(5, K::Checkbox, "unMult", Conv::Checkbox),
|
||||
],
|
||||
extra: Extra::Extractor,
|
||||
},
|
||||
// IDentifier (`identifier-*`): 1 Channel Info (arbitrary data, `Extra::Identifier`), 2 Display,
|
||||
// 3 ID an integer slider (valid 0..2 147 483 647).
|
||||
EffectMap {
|
||||
match_name: "IDentifier",
|
||||
effect: "ec.3d.identifier",
|
||||
params: &[
|
||||
pm(2, K::Popup, "display", Conv::Popup { default: 1, items: IDENTIFIER_DISPLAY }),
|
||||
pm(3, K::IntSlider, "id", Conv::Plain { valid_max: 2_147_483_647.0 }),
|
||||
],
|
||||
extra: Extra::Identifier,
|
||||
},
|
||||
// Layer Control (076, 220, 320, 321): the default is the owning layer, None is unset.
|
||||
fx("ADBE Layer Control", "ec.control.layer", &[pm(1, K::Layer, "layer", Conv::LayerRef { own: true })]),
|
||||
fx("ADBE Slider Control", "ec.control.slider", &[pm(1, K::FloatSlider, "slider", Conv::Plain { valid_max: 1_000_000.0 })]),
|
||||
@@ -1089,6 +1135,8 @@ pub const EFFECTS: &[EffectMap] = &[
|
||||
pub const EXPOSURE: Conv = Conv::Plain { valid_max: 100.0 };
|
||||
pub const OFFSET: Conv = Conv::Plain { valid_max: 2.0 };
|
||||
pub const GAMMA: Conv = Conv::Plain { valid_max: 10.0 };
|
||||
/// The largest float32, the valid maximum of EXtractoR's Black and White Point.
|
||||
pub const F32_MAX: f64 = f32::MAX as f64;
|
||||
/// The valid maximum of Glow's A & B Midpoint (16.16 of 0.99, 427).
|
||||
pub const GLOW_MIDPOINT_MAX: f64 = 0.990_005_493_164_062_5;
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
//! project-level `LIST EfdG` (one `EfDf` per effect type: a `tdmn` with the match name, then a copy
|
||||
//! of one instance's `sspc`), see [`Project::effect_params`]. That copy also carries the values of
|
||||
//! the instance it was copied from, so values are only ever read from the instance's own `tdgp`.
|
||||
//!
|
||||
//! Some files have no `EfdG` at all (seen in 17 projects, all from inside After Effects-made
|
||||
//! Motion Graphics templates). In those, one instance per effect type kept its full `parT` (which
|
||||
//! one is not known) and it is the only place the definitions are written, so the other instances
|
||||
//! of that type borrow them from it (#3170).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -138,7 +143,11 @@ pub enum DefinitionSource {
|
||||
/// The project-level `EfdG/EfDf` copy for the effect's match name (the instance's own `parT`
|
||||
/// is empty, 383).
|
||||
Project,
|
||||
/// Neither has any: the effect has no readable parameter definitions.
|
||||
/// The own `parT` of another instance of the same match name in the file: the instance's
|
||||
/// own `parT` is empty and the file has no project-level copy of the type (a project without
|
||||
/// `EfdG`, #3170).
|
||||
OtherInstance,
|
||||
/// None of these has any: the effect has no readable parameter definitions.
|
||||
Missing,
|
||||
}
|
||||
|
||||
@@ -155,7 +164,8 @@ pub struct EffectParam<'f, 'a> {
|
||||
pub name: String,
|
||||
/// The popup items (`pdnm`, split at `|`; for a button its label, e.g. `Swap Colors`).
|
||||
pub items: Option<Vec<String>>,
|
||||
/// True when the definition is the project-level copy (see [`DefinitionSource::Project`]).
|
||||
/// True when the definition is borrowed: the project-level copy ([`DefinitionSource::Project`])
|
||||
/// or another instance's ([`DefinitionSource::OtherInstance`]).
|
||||
pub borrowed_definition: bool,
|
||||
pard: &'f [u8],
|
||||
stored: Option<Property<'f, 'a>>,
|
||||
@@ -214,6 +224,22 @@ impl<'f, 'a> Effect<'f, 'a> {
|
||||
self.chunk.child_list(b"parT").is_some_and(has_pard)
|
||||
}
|
||||
|
||||
/// The match names of the value records in the instance's own `tdgp` (properties only, not
|
||||
/// groups), in file order, each once.
|
||||
pub fn value_record_names(&self) -> Vec<String> {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
self.chunk
|
||||
.child_list(b"tdgp")
|
||||
.and_then(PropGroup::from_chunk)
|
||||
.map(|g| g.entries())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|e| matches!(e.node, PropNode::Property(_)))
|
||||
.map(|e| e.match_name)
|
||||
.filter(|n| seen.insert(n.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The parameters in the order of the instance's own `parT`. Empty for an instance whose
|
||||
/// `parT` is empty; [`Project::effect_params`] falls back to the project's definitions.
|
||||
pub fn params(&self) -> Vec<EffectParam<'f, 'a>> {
|
||||
@@ -299,8 +325,25 @@ impl<'f, 'a> Project<'f, 'a> {
|
||||
|
||||
/// [`Self::effect_definitions`] indexed by match name, for looking up many effects: build it
|
||||
/// once per file, not once per effect (a file can hold many definitions and many instances).
|
||||
/// For a type without a project-level copy it also keeps the own `parT` of the first instance
|
||||
/// in the file (items, layers and effects in file order) that has one.
|
||||
pub fn effect_definition_index(&self) -> EffectDefinitions<'f, 'a> {
|
||||
EffectDefinitions { by_name: self.effect_definitions().into_iter().collect() }
|
||||
let by_name: HashMap<String, &'f Chunk<'a>> = self.effect_definitions().into_iter().collect();
|
||||
let mut instances: HashMap<String, &'f Chunk<'a>> = HashMap::new();
|
||||
for item in self.all_items() {
|
||||
let Some(comp) = item.comp() else { continue };
|
||||
for layer in comp.layers() {
|
||||
for fx in layer.effects() {
|
||||
if by_name.contains_key(&fx.match_name) || instances.contains_key(&fx.match_name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(part) = fx.chunk.child_list(b"parT").filter(|p| has_pard(p)) {
|
||||
instances.insert(fx.match_name, part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EffectDefinitions { by_name, instances }
|
||||
}
|
||||
|
||||
/// Where the parameter definitions of `effect` come from. Builds the index each call: use
|
||||
@@ -316,10 +359,13 @@ impl<'f, 'a> Project<'f, 'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The project-level effect definitions by match name ([`Project::effect_definition_index`]).
|
||||
/// The borrowable effect definitions by match name ([`Project::effect_definition_index`]).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EffectDefinitions<'f, 'a> {
|
||||
/// The project-level copies (`EfdG`).
|
||||
by_name: HashMap<String, &'f Chunk<'a>>,
|
||||
/// For a type without a project-level copy: the own `parT` of the first instance that has one.
|
||||
instances: HashMap<String, &'f Chunk<'a>>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> EffectDefinitions<'f, 'a> {
|
||||
@@ -329,20 +375,23 @@ impl<'f, 'a> EffectDefinitions<'f, 'a> {
|
||||
DefinitionSource::Instance
|
||||
} else if self.by_name.contains_key(effect.match_name()) {
|
||||
DefinitionSource::Project
|
||||
} else if self.instances.contains_key(effect.match_name()) {
|
||||
DefinitionSource::OtherInstance
|
||||
} else {
|
||||
DefinitionSource::Missing
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters of `effect`: from its own `parT`, or, when that is empty, from the
|
||||
/// project-level definition of its match name ([`EffectParam::borrowed_definition`]). Values
|
||||
/// always come from the instance's own records (absent = the `pard` default); the project
|
||||
/// copy's values belong to another instance and are never read.
|
||||
/// project-level definition of its match name, else from another instance of it that has
|
||||
/// its own ([`EffectParam::borrowed_definition`]). Values always come from the instance's own
|
||||
/// records (absent = the `pard` default); the values next to borrowed definitions belong to
|
||||
/// another instance and are never read.
|
||||
pub fn params(&self, effect: &Effect<'f, 'a>) -> Vec<EffectParam<'f, 'a>> {
|
||||
if effect.has_own_definitions() {
|
||||
return effect.params();
|
||||
}
|
||||
match self.by_name.get(effect.match_name()) {
|
||||
match self.by_name.get(effect.match_name()).or_else(|| self.instances.get(effect.match_name())) {
|
||||
Some(part) => effect.params_from(part, true),
|
||||
None => Vec::new(),
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! of a layer's source.
|
||||
|
||||
use crate::container::bytes::{be_f32, be_f64, be_u32, cstr, le_u32, text};
|
||||
use crate::container::chunk::Chunk;
|
||||
use crate::container::header::Rational;
|
||||
use crate::container::typed::{Item, ItemKind, Layer, Project};
|
||||
|
||||
@@ -159,19 +160,25 @@ fn json_string_at(json: &[u8], mut i: usize) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The numbering of an image sequence item (353, 603).
|
||||
/// The numbering of an image sequence item (353, 603), or its file list when it was imported with
|
||||
/// Force alphabetical order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SequenceInfo {
|
||||
/// The file name before the frame number (`seq_`, `shot_`): the first `Utf8` after the alias.
|
||||
/// An empty prefix is not stored (`0001.png`): the one `Utf8` is then the suffix.
|
||||
pub prefix: String,
|
||||
/// The file name after the frame number (`.png`, `.exr`): the second `Utf8` after the alias.
|
||||
pub suffix: String,
|
||||
/// The first and last frame number (`sspc` `u32` at 172 and 176: 1 and 3 in 353, 101 and 103
|
||||
/// in 603).
|
||||
/// in 603; both `0xFFFFFFFF` for a sequence with a file list).
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
/// The digits of the frame number (`sspc` `u32` at 180: 3 for `seq_001`, 4 for `shot_0101`).
|
||||
pub digits: u32,
|
||||
/// The file names in order for a sequence imported with Force alphabetical order (`LIST StVc`
|
||||
/// after the alias: `StVS` with the count as a little-endian `u32`, then one `Utf8` per file);
|
||||
/// empty for a numbered sequence. When set, it replaces the numbering.
|
||||
pub files: Vec<String>,
|
||||
}
|
||||
|
||||
impl SequenceInfo {
|
||||
@@ -180,6 +187,62 @@ impl SequenceInfo {
|
||||
let width = usize::try_from(self.digits.min(16)).unwrap_or(0);
|
||||
format!("{}{n:0width$}{}", self.prefix, self.suffix)
|
||||
}
|
||||
|
||||
/// The number of files: the listed files, or the frame numbers from start to end (0 when the
|
||||
/// end lies before the start).
|
||||
pub fn frame_count(&self) -> u32 {
|
||||
if self.files.is_empty() {
|
||||
self.end.checked_sub(self.start).map_or(0, |d| d.saturating_add(1))
|
||||
} else {
|
||||
u32::try_from(self.files.len()).unwrap_or(u32::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the `i`-th file (from 0): from the file list, or numbered from `start`; `None`
|
||||
/// past the last file.
|
||||
pub fn nth_file_name(&self, i: u32) -> Option<String> {
|
||||
if self.files.is_empty() {
|
||||
let n = self.start.checked_add(i).filter(|n| *n <= self.end)?;
|
||||
Some(self.file_name(n))
|
||||
} else {
|
||||
self.files.get(usize::try_from(i).ok()?).cloned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file names of a `LIST StVc` (Force alphabetical order): the `Utf8` children in order, at most
|
||||
/// as many as the count in `StVS` (little-endian `u32`) says. Empty names count toward that number
|
||||
/// and are then skipped. Without a readable count (no `StVS`, or one shorter than 4 bytes) the list
|
||||
/// is unreadable (empty). A name that is not a plain file name (holding `/`, `\`, `:` or a control
|
||||
/// character such as NUL, or being `.` or `..`) makes the whole list unreadable (empty): the names
|
||||
/// are joined to the folder.
|
||||
fn sequence_file_list(list: &Chunk<'_>) -> Vec<String> {
|
||||
// After Effects writes the count first (the one sample); a list without a readable count is
|
||||
// not read rather than guessed.
|
||||
let Some(count) = list.child(b"StVS").and_then(|c| c.bytes()).and_then(|b| le_u32(b, 0)).and_then(|n| usize::try_from(n).ok()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let names: Vec<String> =
|
||||
list.children().iter().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).take(count).map(text).filter(|n| !n.is_empty()).collect();
|
||||
if names.iter().any(|n| n.contains(|c: char| matches!(c, '/' | '\\' | ':') || c.is_control()) || n == "." || n == "..") {
|
||||
return Vec::new();
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// The prefix and suffix of a sequence whose naming holds one `Utf8` (`part`): After Effects leaves
|
||||
/// out an empty prefix (`0001.png` stores only `.png`). The stored first file name (`opti`) decides:
|
||||
/// a name starting with `part` has it as the prefix, one ending with it as the suffix. Without that
|
||||
/// evidence, a `part` starting with `.` is the suffix (an extension); anything else is not read.
|
||||
fn single_name_part(part: &str, first_file: Option<&str>) -> Option<(String, String)> {
|
||||
if let Some(first) = first_file.filter(|_| !part.is_empty()) {
|
||||
match (first.starts_with(part), first.ends_with(part)) {
|
||||
(true, false) => return Some((part.to_string(), String::new())),
|
||||
(false, true) => return Some((String::new(), part.to_string())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
part.starts_with('.').then(|| (String::new(), part.to_string()))
|
||||
}
|
||||
|
||||
impl<'f, 'a> Item<'f, 'a> {
|
||||
@@ -194,10 +257,24 @@ impl<'f, 'a> Item<'f, 'a> {
|
||||
let sspc = pin.child(b"sspc")?.bytes()?;
|
||||
let kids = pin.children();
|
||||
let after = kids.iter().position(|c| c.is_list(b"Als2"))?;
|
||||
let mut names = kids.iter().skip(after + 1).take_while(|c| !c.is(b"opti")).filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text);
|
||||
let prefix = names.next()?;
|
||||
let suffix = names.next()?;
|
||||
Some(SequenceInfo { prefix, suffix, start: be_u32(sspc, 172)?, end: be_u32(sspc, 176)?, digits: be_u32(sspc, 180)? })
|
||||
let naming = || kids.iter().skip(after.saturating_add(1)).take_while(|c| !c.is(b"opti"));
|
||||
let (start, end, digits) = (be_u32(sspc, 172)?, be_u32(sspc, 176)?, be_u32(sspc, 180)?);
|
||||
// Force alphabetical order: the file list replaces the numbering.
|
||||
if let Some(list) = naming().find(|c| c.is_list(b"StVc")) {
|
||||
let files = sequence_file_list(list);
|
||||
if files.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(SequenceInfo { prefix: String::new(), suffix: String::new(), start, end, digits, files });
|
||||
}
|
||||
let names: Vec<String> = naming().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text).take(2).collect();
|
||||
let (prefix, suffix) = match names.as_slice() {
|
||||
[prefix, suffix] => (prefix.clone(), suffix.clone()),
|
||||
// An empty prefix is not stored (`0001.png` has only `.png`).
|
||||
[one] => single_name_part(one, f.name.as_deref())?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(SequenceInfo { prefix, suffix, start, end, digits, files: Vec::new() })
|
||||
}
|
||||
|
||||
/// The rate a clip is conformed to (Interpret Footage ▸ Conform to frame rate; `sspc` `u32` at
|
||||
|
||||
@@ -461,7 +461,7 @@ fn plan_layer_param(layer: &Layer, g: &PropGroup, target: &str, own: bool, prop:
|
||||
fn plan_extra(spec: &EffectMap, g: &PropGroup, pdefs: &HashMap<u16, ParDef>, here: &str, col: &mut Collector) -> Option<(u16, Vec<u8>)> {
|
||||
let ids: &[&str] = match spec.extra {
|
||||
// (the Cryptomatte plug-in's settings are its own binary layout: not written, reported below)
|
||||
Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Cryptomatte => return None,
|
||||
Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Lumetri | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => return None,
|
||||
Extra::HueSaturation => &HUE_SATURATION_MASTER,
|
||||
Extra::Levels => &LEVELS_RGB,
|
||||
};
|
||||
@@ -643,7 +643,9 @@ fn report_unmapped(g: &PropGroup, (effect, spec): (&str, &EffectMap), size: (f64
|
||||
// effect was added at is not known here.
|
||||
let (fresh, other) = (fresh([size.0, size.1], &mut next), fresh([size.0 * 2.0 + 7.0, size.1 * 3.0 + 5.0], &mut next));
|
||||
let extra: &[&str] = match spec.extra {
|
||||
Extra::None | Extra::Cryptomatte => &[],
|
||||
// Lumetri Color, EXtractoR and IDentifier have no prototype to write from, so they never
|
||||
// get this far (they are reported).
|
||||
Extra::None | Extra::Lumetri | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => &[],
|
||||
Extra::HueSaturation => &HUE_SATURATION_MASTER,
|
||||
Extra::Levels => &LEVELS_RGB,
|
||||
Extra::Curves => &["channel", "rgb", "red", "green", "blue", "alpha"],
|
||||
|
||||
@@ -1047,9 +1047,10 @@ fn plan_layer(cx: &PlanCx<'_>, at: CompAt<'_>, cp: &CompPlan, layer: &Layer, col
|
||||
let props = plan_transform(layer, &src, comp, source_size, cp, comp_name, col);
|
||||
let remap = remap_prop.filter(|_| remapped).map(|p| plan_remap(p, comp, cp, &layer.name, comp_name, col));
|
||||
let mut name = cut_name(&layer.name, 255, &what("layer"), col);
|
||||
// A text layer named after its text stores no name (110, 113: After Effects shows the text).
|
||||
// A text layer named after its text stores no name (110, 113: After Effects shows the text):
|
||||
// the layer's own rule decides (#3233).
|
||||
if let LayerSrc::Text(tp) = &src
|
||||
&& tp.text.split('\r').next().is_some_and(|first| first.chars().take(64).collect::<String>() == name)
|
||||
&& layer.name_follows_text(&effectcraft_project::text_layer_name(&tp.text))
|
||||
{
|
||||
name.clear();
|
||||
}
|
||||
|
||||
@@ -27,9 +27,11 @@
|
||||
mod build;
|
||||
mod camera;
|
||||
mod effects;
|
||||
mod lumetri;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod props;
|
||||
mod puppet;
|
||||
mod report;
|
||||
mod settings;
|
||||
mod shapes;
|
||||
|
||||
@@ -206,6 +206,8 @@ fn new_comp(h: &CompHeader, renderer: Option<AepRenderer>, map: &TimeMap, name:
|
||||
comp
|
||||
}
|
||||
|
||||
/// Adds one footage item to the project under the given parent.
|
||||
/// Unreadable footage is recorded as a loss, solids and null placeholders are handled separately, and file and sequence footage is linked to its files.
|
||||
fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_>, name: String, label: Label, parent: ItemId, null_only: &HashSet<u32>) {
|
||||
let Some(f) = item.footage() else {
|
||||
cx.col.lose(LossCategory::DamagedData, format!("footage '{name}': data unreadable, not imported"));
|
||||
@@ -240,31 +242,42 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
|
||||
}
|
||||
let mut path = f.path.clone().unwrap_or_default();
|
||||
// An image sequence's alias is its folder; its files are named by the stored numbering
|
||||
// (353, 603). The first file stands for the sequence, as in EffectCraft.
|
||||
// (353, 603), or listed one by one when imported with Force alphabetical order. The first
|
||||
// file stands for the sequence, as in EffectCraft.
|
||||
let mut sequence = Vec::new();
|
||||
if let Some(seq) = item.sequence_info().filter(|s| !path.is_empty() && s.end >= s.start) {
|
||||
let frames = (seq.end - seq.start).saturating_add(1);
|
||||
if let Some(seq) = item.sequence_info().filter(|s| !path.is_empty() && s.frame_count() > 0) {
|
||||
let frames = seq.frame_count();
|
||||
let sep = if path.contains('\\') { '\\' } else { '/' };
|
||||
let folder = path.trim_end_matches(['\\', '/']).to_string();
|
||||
// One name is the folder, a separator and the file name; the number is as wide as the stored
|
||||
// digits or as the largest number, whichever is longer (an upper bound for every frame).
|
||||
let name_bytes = (folder.len() + 1 + seq.prefix.len() + seq.suffix.len() + usize::try_from(seq.digits.min(16)).unwrap_or(16).max(10)) as u64;
|
||||
let bytes = name_bytes.saturating_mul(u64::from(frames));
|
||||
// One name is the folder, a separator and the file name. A numbered name is as wide as the
|
||||
// stored digits or as the largest number, whichever is longer (an upper bound for every
|
||||
// frame); listed names are counted as they are.
|
||||
let len = |n: usize| u64::try_from(n).unwrap_or(u64::MAX);
|
||||
let file_bytes = if seq.files.is_empty() {
|
||||
len(seq.prefix.len().saturating_add(seq.suffix.len()).saturating_add(usize::try_from(seq.digits.min(16)).unwrap_or(16).max(10)))
|
||||
.saturating_mul(u64::from(frames))
|
||||
} else {
|
||||
seq.files.iter().fold(0u64, |sum, n| sum.saturating_add(len(n.len())))
|
||||
};
|
||||
let bytes = len(folder.len().saturating_add(1)).saturating_mul(u64::from(frames)).saturating_add(file_bytes);
|
||||
if frames > MAX_SEQUENCE_FRAMES || frames > cx.sequence_budget || bytes > cx.sequence_bytes {
|
||||
// Not expanded: the item keeps its first file (a still standing for the sequence).
|
||||
cx.col.lose(
|
||||
LossCategory::DamagedData,
|
||||
format!("footage '{name}': a sequence of {frames} frames is longer than the import expands; only its first file is linked"),
|
||||
);
|
||||
path = format!("{folder}{sep}{}", seq.file_name(seq.start));
|
||||
path = format!("{folder}{sep}{}", seq.nth_file_name(0).unwrap_or_default());
|
||||
} else {
|
||||
cx.sequence_budget -= frames;
|
||||
cx.sequence_bytes -= bytes;
|
||||
sequence = (seq.start..=seq.end).map(|n| format!("{folder}{sep}{}", seq.file_name(n))).collect();
|
||||
sequence = (0..frames).filter_map(|i| seq.nth_file_name(i)).map(|n| format!("{folder}{sep}{n}")).collect();
|
||||
}
|
||||
if let Some(first) = sequence.first() {
|
||||
path = first.clone();
|
||||
}
|
||||
} else if f.is_sequence && !path.is_empty() {
|
||||
// The alias is a folder, but the file names could not be read: the item links the folder.
|
||||
cx.col.lose(LossCategory::DamagedData, format!("footage '{name}': image sequence naming not read, linked to its folder"));
|
||||
}
|
||||
let present = !path.is_empty() && (cx.options.exists)(&path);
|
||||
if !present {
|
||||
@@ -318,6 +331,11 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
|
||||
/// sequence the name with the frame range (`shot_[0101-0103].exr`).
|
||||
fn default_footage_name(item: &AepItem<'_, '_>, f: &crate::Footage) -> Option<String> {
|
||||
if let Some(s) = item.sequence_info() {
|
||||
// A listed sequence (Force alphabetical order) has no numbering: its first file names it.
|
||||
// Assumption: what After Effects shows for such an item without a stored name was not sampled.
|
||||
if let Some(first) = s.files.first() {
|
||||
return Some(first.clone());
|
||||
}
|
||||
let w = usize::try_from(s.digits.min(16)).unwrap_or(0);
|
||||
return Some(format!("{}[{:0w$}-{:0w$}]{}", s.prefix, s.start, s.end, s.suffix));
|
||||
}
|
||||
@@ -479,7 +497,12 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
|
||||
.map(|d| crate::import::text::first_paragraph(&d.raw_text))
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
};
|
||||
let name = aep_name.clone().or_else(|| src.as_ref().map(|s| s.name.clone())).or_else(text_name).unwrap_or_else(|| "Layer".to_string());
|
||||
let name = aep_name
|
||||
.clone()
|
||||
.or_else(|| src.as_ref().map(|s| s.name.clone()))
|
||||
.or_else(text_name)
|
||||
// An empty text layer without a name is named as a new one is (#3233).
|
||||
.unwrap_or_else(|| if h.layer_type == LayerType::Text { "Text" } else { "Layer" }.to_string());
|
||||
let skip = |cx: &mut Ctx<'_>, skipped: &mut HashMap<u32, String>, category: LossCategory, what: &str| {
|
||||
cx.col.lose(category, format!("{comp_name}: {what} '{name}'"));
|
||||
skipped.insert(h.id, what.to_string());
|
||||
@@ -537,6 +560,10 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
|
||||
};
|
||||
let mut l: Layer = build::layer(project, &comp, &name, source.clone(), size, None);
|
||||
l.name = name.clone();
|
||||
// A text layer without a stored name follows its text; a stored name is the user's (#3233).
|
||||
if is_text {
|
||||
l.name_follows_text = Some(aep_name.is_none());
|
||||
}
|
||||
l.label = Label::ALL.get(usize::from(h.label)).copied().unwrap_or(Label::None);
|
||||
apply_timing(&mut l, &h, &tm, &name, &mut cx.col);
|
||||
apply_switches(&mut l, &h);
|
||||
@@ -681,7 +708,14 @@ fn apply_timing(l: &mut Layer, h: &LayerHeader, tm: &TimeMap, name: &str, col: &
|
||||
col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch is zero or unreadable, 100 % is used"));
|
||||
Rational { num: 1, den: 1 }
|
||||
});
|
||||
l.stretch = stretch.to_f64().map_or(100.0, |r| r * 100.0);
|
||||
let stored = stretch.to_f64().map_or(100.0, |r| r * 100.0);
|
||||
// A ratio such as 1/2^31 (5e-8 %) or 2^31/1 is outside what After Effects allows (±1 % to
|
||||
// ±9900 %); taken as stored, the layer and audio time would divide by ~0 (#3247). The in and
|
||||
// out points keep the comp times the file stores.
|
||||
l.stretch = effectcraft_project::limits::stretch(stored);
|
||||
if l.stretch.to_bits() != stored.to_bits() {
|
||||
col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch of {stored:e} % is outside ±1 % to ±9900 %, {} % is used", l.stretch));
|
||||
}
|
||||
// A stored time with a zero denominator (or a sum that overflows) has no value; it is read as 0
|
||||
// and reported, so the layer's timing never changes silently.
|
||||
let unreadable: Vec<&str> = [("start", h.start), ("in point", h.in_relative), ("out point", h.out_relative)]
|
||||
|
||||
@@ -34,10 +34,10 @@
|
||||
//! import. Hue/Saturation's master values and Levels' values are arbitrary data, decoded by the
|
||||
//! reader (444-446, 512-516).
|
||||
|
||||
use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, popup_key};
|
||||
use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_key};
|
||||
use crate::{
|
||||
CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, HueSaturationData, Layer as AepLayer, LayerRef,
|
||||
LevelsData, MeshData, ParamKind, ParamValue,
|
||||
CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, ExtractorChannels, HueSaturationData,
|
||||
Layer as AepLayer, LayerRef, LevelsData, MeshData, ParamKind, ParamValue,
|
||||
};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::Ids;
|
||||
@@ -76,6 +76,11 @@ pub(crate) fn import_effects<'f, 'a>(
|
||||
col: &mut Collector,
|
||||
) {
|
||||
for fx in layer.effects() {
|
||||
// Puppet keeps its meshes and pins in nested groups, not in `parT` (its own step).
|
||||
if fx.match_name() == crate::import::puppet::MATCH_NAME {
|
||||
crate::import::puppet::import_puppet(defs, &fx, cx, ids, target, col);
|
||||
continue;
|
||||
}
|
||||
let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string());
|
||||
let Some(map) = EFFECTS.iter().find(|m| m.match_name == fx.match_name()) else {
|
||||
col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()));
|
||||
@@ -99,7 +104,25 @@ pub(crate) fn import_effects<'f, 'a>(
|
||||
None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)),
|
||||
}
|
||||
let params = defs.params(&fx);
|
||||
if map.extra == Extra::Cryptomatte {
|
||||
// Borrowed definitions (another instance's or the project copy) may not name every value
|
||||
// record this instance has: such a record has nothing to import into, so it is reported.
|
||||
if params.first().is_some_and(|p| p.borrowed_definition) {
|
||||
let defined: std::collections::HashSet<&str> = params.iter().map(|p| p.match_name.as_str()).collect();
|
||||
for name in fx.value_record_names().iter().filter(|n| !defined.contains(n.as_str())) {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!(
|
||||
"{}: '{}' effect '{shown}' ({}): the value record {name} has no parameter definition, not imported",
|
||||
cx.comp,
|
||||
cx.layer,
|
||||
fx.match_name()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if map.extra == Extra::Lumetri {
|
||||
crate::import::lumetri::import_lumetri(&fx, ¶ms, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col });
|
||||
} else if map.extra == Extra::Cryptomatte {
|
||||
import_cryptomatte(&fx, ¶ms, cx, &shown, &mut group, col);
|
||||
} else {
|
||||
for p in ¶ms {
|
||||
@@ -112,7 +135,7 @@ pub(crate) fn import_effects<'f, 'a>(
|
||||
}
|
||||
|
||||
/// Parameters that never carry a value to import.
|
||||
fn carries_nothing(p: &EffectParam<'_, '_>) -> bool {
|
||||
pub(crate) fn carries_nothing(p: &EffectParam<'_, '_>) -> bool {
|
||||
matches!(p.kind, ParamKind::Button | ParamKind::NoData | ParamKind::GroupStart | ParamKind::GroupEnd) || (p.index == 0 && p.kind == ParamKind::Layer)
|
||||
}
|
||||
|
||||
@@ -131,10 +154,15 @@ pub(crate) struct PendingLayerRef {
|
||||
|
||||
/// Where a parameter is imported to: the effect's group, the layer parameters still to resolve
|
||||
/// and the report.
|
||||
struct ParamOut<'a> {
|
||||
group: &'a mut PropGroup,
|
||||
refs: &'a mut Vec<PendingLayerRef>,
|
||||
col: &'a mut Collector,
|
||||
pub(crate) struct ParamOut<'a> {
|
||||
pub group: &'a mut PropGroup,
|
||||
pub refs: &'a mut Vec<PendingLayerRef>,
|
||||
pub col: &'a mut Collector,
|
||||
}
|
||||
|
||||
/// How a parameter is named in the report: its name and match name.
|
||||
pub(crate) fn param_label(p: &EffectParam<'_, '_>) -> String {
|
||||
if p.name.trim().is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name.trim(), p.match_name) }
|
||||
}
|
||||
|
||||
/// Imports one parameter of `fx` into its EffectCraft property: its keyframes, the value in its
|
||||
@@ -144,7 +172,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
if carries_nothing(p) {
|
||||
return;
|
||||
}
|
||||
let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) };
|
||||
let label = param_label(p);
|
||||
let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name());
|
||||
if p.kind == ParamKind::ArbitraryData && map.extra != Extra::None {
|
||||
import_extra(fx, map.extra, p, &here, cx.size, group, col);
|
||||
@@ -158,6 +186,14 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
}
|
||||
return;
|
||||
};
|
||||
import_mapped(p, m, cx, (&here, &label), ParamOut { group, refs, col });
|
||||
}
|
||||
|
||||
/// Imports parameter `p` into the property `m` names, converted by `m.conv`. `names` is
|
||||
/// `(here, label)`, the effect and the parameter for the report.
|
||||
pub(crate) fn import_mapped(p: &EffectParam<'_, '_>, m: &ParamMap, cx: &LayerCtx<'_>, names: (&str, &str), out: ParamOut<'_>) {
|
||||
let (here, label) = names;
|
||||
let ParamOut { group, refs, col } = out;
|
||||
if p.kind != m.kind {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, m.kind));
|
||||
return;
|
||||
@@ -165,7 +201,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
match m.conv {
|
||||
Conv::Skip => return,
|
||||
Conv::LayerRef { own } => {
|
||||
import_layer_ref(p, m.target, own, cx, (&here, &label), ParamOut { group, refs, col });
|
||||
import_layer_ref(p, m.target, own, cx, (here, label), ParamOut { group, refs, col });
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
@@ -270,9 +306,9 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} has no readable keyframe, default used"));
|
||||
}
|
||||
if let Some(map) = &popup_map {
|
||||
remap_popup_keys(prop, map, &here, &label, col);
|
||||
remap_popup_keys(prop, map, here, label, col);
|
||||
}
|
||||
report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), &here, &label, col);
|
||||
report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), here, label, col);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -308,7 +344,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
};
|
||||
match converted {
|
||||
Some(v) => {
|
||||
report_beyond_limit(m.limit, std::iter::once(&v), &here, &label, col);
|
||||
report_beyond_limit(m.limit, std::iter::once(&v), here, label, col);
|
||||
prop.value = v;
|
||||
}
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?}), default used")),
|
||||
@@ -422,7 +458,15 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h
|
||||
col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {} are not imported", p.name));
|
||||
}
|
||||
let Some(data) = fx.arbitrary_data(&p.match_name) else {
|
||||
// No data: every value is at its default.
|
||||
// No record: every value is at its default. A Channel Info value record without its
|
||||
// aRbs/aRbp payload (every sampled record has it) is damaged: the payload may have chosen
|
||||
// other channels (`Z`), so the defaults are not what After Effects renders.
|
||||
if p.stored().is_some() && matches!(extra, Extra::Extractor | Extra::Identifier) {
|
||||
col.lose(
|
||||
LossCategory::DamagedData,
|
||||
format!("{here}: the Channel Info data is missing (a value record without its data), the default channels used"),
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let mut set = |id: &str, v: f64| {
|
||||
@@ -473,7 +517,61 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h
|
||||
Some(m) => import_mesh(&m, here, size, group, col),
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: the Distortion Mesh is unreadable, defaults used")),
|
||||
},
|
||||
Extra::None | Extra::Cryptomatte => {}
|
||||
Extra::Extractor => match ExtractorChannels::parse(data).map(|c| (c.sources(), c)) {
|
||||
// Each output channel reads the footage's channel of the stored name, which
|
||||
// EffectCraft's EXtractoR finds by the same name (`Z`); After Effects' `(copy)` is the
|
||||
// input's own channel, EffectCraft's `(copy)` (not `A`, which EffectCraft reads from an
|
||||
// OpenEXR footage's alpha plane, losing what earlier effects did to it).
|
||||
Some((Ok(names), _)) => {
|
||||
for (id, name) in ["red", "green", "blue", "alpha"].into_iter().zip(names) {
|
||||
if let Some(prop) = group.prop_mut(id) {
|
||||
prop.value = Value::Str(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((Err(k), c)) => {
|
||||
let names: Vec<&str> = c.channels.iter().map(|(_, n)| n.as_str()).collect();
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!(
|
||||
"{here}: the channels {} are not imported (record {} is of an unknown kind or names no channel), R, G, B, A used",
|
||||
names.join(", "),
|
||||
k.saturating_add(1)
|
||||
),
|
||||
);
|
||||
}
|
||||
None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, R, G, B, A used")),
|
||||
},
|
||||
Extra::Identifier => match crate::identifier_channel_type(data) {
|
||||
// No channel chosen (the type's label): After Effects lists only the footage's integer
|
||||
// (UINT) channels and, with none chosen, leaves the layer unchanged, where EffectCraft
|
||||
// draws the footage's object ID channel.
|
||||
Some("Object ID") => {
|
||||
if let Some(prop) = group.prop_mut("channelType") {
|
||||
prop.value = Value::Enum(0);
|
||||
}
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: no ID channel is chosen (Channel Info), so After Effects leaves the layer unchanged; EffectCraft draws the footage's object ID channel"),
|
||||
);
|
||||
}
|
||||
// A chosen channel, by name. EffectCraft's IDentifier has no channel of its own: it
|
||||
// reads the footage's first `ObjectID` (`MaterialID`) channel, names compared without
|
||||
// regard to case, so a channel of that name is the one After Effects reads.
|
||||
Some(name) => match IDENTIFIER_OWN.iter().position(|own| own.eq_ignore_ascii_case(name.trim())) {
|
||||
Some(kind) => {
|
||||
if let Some(prop) = group.prop_mut("channelType") {
|
||||
prop.value = Value::Enum(u32::try_from(kind).unwrap_or(0));
|
||||
}
|
||||
}
|
||||
None => col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: the ID channel '{name}' (Channel Info) is not imported; EffectCraft reads the footage's object ID channel (ObjectID, object_id, objectId, id)"),
|
||||
),
|
||||
},
|
||||
None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, the object ID channel used")),
|
||||
},
|
||||
Extra::None | Extra::Lumetri | Extra::Cryptomatte => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,6 +712,10 @@ fn crypto_output(p: &EffectParam<'_, '_>, here: &str, label: &str, col: &mut Col
|
||||
}
|
||||
}
|
||||
|
||||
/// The channels EffectCraft's IDentifier reads first for its Channel Type Object ID and Material
|
||||
/// ID (`AuxChannels::object_id`, `material_id`).
|
||||
const IDENTIFIER_OWN: [&str; 2] = ["ObjectID", "MaterialID"];
|
||||
|
||||
/// Curves' EffectCraft parameters, in the stored order (RGB, Red, Green, Blue, Alpha).
|
||||
const CURVE_IDS: [&str; 5] = ["rgb", "red", "green", "blue", "alpha"];
|
||||
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
//! Lumetri Color (`ADBE Lumetri`) to EffectCraft's Lumetri Color (`ec.color.lumetri`).
|
||||
//!
|
||||
//! The parameters are found by their match name (`ADBE Lumetri-0011` is Exposure), not by their
|
||||
//! place in `parT`: After Effects has added parameters in the middle of the list over the
|
||||
//! versions (`-0127` ... `-0130` sit between older ones), and several sliders share a valid range
|
||||
//! (Contrast, Highlights, Shadows, Whites and Blacks are all -150..150), so a shifted index
|
||||
//! would map silently to the wrong control. Evidence: the 1200 samples (After Effects 2026,
|
||||
//! `docs/aep-format.md` 3.7).
|
||||
//!
|
||||
//! - **Values in any record state.** Lumetri writes the values of its hidden controls (High
|
||||
//! Dynamic Range, HDR Specular, the second Look popup, ...) in records in the default state
|
||||
//! (`tdsb` 3); After Effects reads them back as set, so every record counts here
|
||||
//! ([`EffectParam::recorded_value`]).
|
||||
//! - **Section switches.** Each section (Basic Correction, Creative, Curves, RGB Curves, the five
|
||||
//! hue / saturation curves, Color Wheels, HSL Secondary, Vignette) has an on / off checkbox in
|
||||
//! After Effects; EffectCraft's Lumetri has none. A section that is off does nothing in After
|
||||
//! Effects (1200 `lumetri-03-sec-basic-correction`, `-17-sec-vignette` render as `-00-defaults`),
|
||||
//! so its values are not imported (the effect looks the same) and are listed in the report.
|
||||
//! - **Arbitrary data** (`ParamKind::ArbitraryData`, every instance stores it). Curves are a
|
||||
//! curve type, a point count and points as `f64` x, y ([`curve_points`]).
|
||||
//! - The RGB curve (`-0039`) holds four curves; the first is the master (white) curve (1200
|
||||
//! `lumetri-60-preset-curves-rgb`), imported. The other three are reported when moved: no
|
||||
//! sample tells which is which channel.
|
||||
//! - Hue vs Sat (`-0042`): y is an offset from neutral, 1 doubling the saturation (`-61`), so
|
||||
//! EffectCraft's y is `0.5 + y / 2`. The other four hue / saturation curves have no sample
|
||||
//! of their scale and are reported when moved.
|
||||
//! - Color Wheels (`-0047`): Shadows, Midtones, Highlights and a fourth wheel, each hue,
|
||||
//! brightness slider, distance ([`import_wheels`], `-62`).
|
||||
//! - The HSL Secondary key ([`HslKey`]) is imported; Split Toning and the correction wheels are
|
||||
//! reported when they are not the default (not sampled).
|
||||
//! - **LUTs and looks.** The Input LUT and the Look are files After Effects names (the Look's name
|
||||
//! is UTF-16 text in `-0096`, the popups only index a list of the installed ones); they are not
|
||||
//! in the project, so a set one is reported by name and EffectCraft's popup stays None.
|
||||
//! - **Eyedropper colours** (White Balance, the hue curve selectors, the key's Set / Add /
|
||||
//! Remove colours) only set other controls in After Effects; they are reported when set.
|
||||
//! - The `Blob` (`-0001`, an XML copy of the grade After Effects writes when a Look changes and does
|
||||
//! not keep current: 1200 `-01-set-all` holds the defaults of controls set after the Look) and
|
||||
//! the Auto Tone analysis (`-0121`, `-0122`) are derived data and not read.
|
||||
|
||||
use crate::effect_map::{Conv, ParamMap};
|
||||
use crate::{Effect as AepEffect, EffectParam, ParamKind, ParamValue};
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
use crate::import::effects::{LayerCtx, ParamOut, carries_nothing, import_mapped, param_label};
|
||||
use crate::import::report::LossCategory;
|
||||
use ParamKind as K;
|
||||
|
||||
/// After Effects' Show Mask styles (`-0075`, `pdnm`), EffectCraft's in the same order.
|
||||
pub const MASK_STYLE: &[&str] = &["Color/Gray", "Color/Black", "White/Black"];
|
||||
|
||||
/// A part of the effect with its own on / off switch.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Section {
|
||||
/// No switch (High Dynamic Range and the controls outside the sections).
|
||||
Always,
|
||||
BasicCorrection,
|
||||
Creative,
|
||||
RgbCurves,
|
||||
/// The five hue / saturation curves (0 Hue vs Sat ... 4 Sat vs Sat).
|
||||
HueSat(u8),
|
||||
ColorWheels,
|
||||
HslSecondary,
|
||||
Vignette,
|
||||
SpeedGradeCustom,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Section::Always => "",
|
||||
Section::BasicCorrection => "Basic Correction",
|
||||
Section::Creative => "Creative",
|
||||
Section::RgbCurves => "RGB Curves",
|
||||
Section::HueSat(0) => "Hue vs Sat",
|
||||
Section::HueSat(1) => "Hue vs Hue",
|
||||
Section::HueSat(2) => "Hue vs Luma",
|
||||
Section::HueSat(3) => "Luma vs Sat",
|
||||
Section::HueSat(_) => "Sat vs Sat",
|
||||
Section::ColorWheels => "Color Wheels",
|
||||
Section::HslSecondary => "HSL Secondary",
|
||||
Section::Vignette => "Vignette",
|
||||
Section::SpeedGradeCustom => "SpeedGrade Custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arbitrary data the importer reads.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Data {
|
||||
/// The four RGB curves (4 x 520 bytes).
|
||||
RgbCurves,
|
||||
/// One hue / saturation curve (520 bytes); the EffectCraft parameter when the scale of its y
|
||||
/// is known (Hue vs Sat).
|
||||
HueSatCurve(Option<&'static str>),
|
||||
/// Color Wheels (4 x 3 `f64`, each wheel 0, 0.5, 0 by default).
|
||||
Wheels,
|
||||
/// HSL Secondary's correction wheels (8 x 3 `f64`, the same default).
|
||||
CorrectionWheels,
|
||||
/// Split Toning (32 zero bytes by default).
|
||||
SplitToning,
|
||||
/// HSL Secondary's key ([`HslKey`]).
|
||||
HslKey,
|
||||
/// Data without a counterpart, empty by default (`fe fe` or zero bytes).
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What a Lumetri parameter is.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Role {
|
||||
/// An EffectCraft parameter and the conversion.
|
||||
To(&'static str, Conv),
|
||||
/// The on / off switch of a section.
|
||||
Switch,
|
||||
/// No counterpart: reported when not at the default.
|
||||
Missing,
|
||||
/// A checkbox without counterpart whose `pard` default is not reliable: reported when a
|
||||
/// record says on. Legacy Tone And Saturation (`-0097`) is off in a fresh instance, but its
|
||||
/// `pard` default reads off in 1200 as saved by the script and on after the project was
|
||||
/// opened and saved again (`versions/1200-lumetri-26`, `-25x`, `-24x`).
|
||||
MissingWhenOn,
|
||||
/// An eyedropper colour: reported when set.
|
||||
Eyedropper,
|
||||
/// The Input LUT (`lut` true) or the Look: a popup, a name or an asset.
|
||||
Lut {
|
||||
lut: bool,
|
||||
},
|
||||
/// The Look's colour space (its popup and the space's id): goes with the Look.
|
||||
LookSpace,
|
||||
Data(Data),
|
||||
/// Derived data, not read.
|
||||
Derived,
|
||||
}
|
||||
|
||||
struct Row {
|
||||
suffix: u16,
|
||||
section: Section,
|
||||
kind: Option<ParamKind>,
|
||||
role: Role,
|
||||
}
|
||||
|
||||
const fn row(suffix: u16, section: Section, kind: Option<ParamKind>, role: Role) -> Row {
|
||||
Row { suffix, section, kind, role }
|
||||
}
|
||||
|
||||
const fn to(suffix: u16, section: Section, kind: ParamKind, target: &'static str, conv: Conv) -> Row {
|
||||
row(suffix, section, Some(kind), Role::To(target, conv))
|
||||
}
|
||||
|
||||
const fn plain(valid_max: f64) -> Conv {
|
||||
Conv::Plain { valid_max }
|
||||
}
|
||||
|
||||
use Section as S;
|
||||
|
||||
/// Every Lumetri parameter by match-name suffix (1200: `dump.props` and the `pard` of
|
||||
/// `lumetri-00-defaults`). The valid maxima are After Effects 2026's; the stored numbers are the
|
||||
/// values After Effects shows (read back in every variant).
|
||||
const ROWS: &[Row] = &[
|
||||
to(62, S::Always, K::Checkbox, "highDynamicRange", Conv::Checkbox),
|
||||
// Basic Correction: 3 the switch; 4 / 95 the Input LUT's name data, 5 / 127 its popups.
|
||||
row(3, S::BasicCorrection, Some(K::Checkbox), Role::Switch),
|
||||
row(4, S::BasicCorrection, Some(K::ArbitraryData), Role::Lut { lut: true }),
|
||||
row(95, S::BasicCorrection, Some(K::ArbitraryData), Role::Lut { lut: true }),
|
||||
row(5, S::BasicCorrection, Some(K::Popup), Role::Lut { lut: true }),
|
||||
row(127, S::BasicCorrection, Some(K::Popup), Role::Lut { lut: true }),
|
||||
to(63, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/hdrWhite", plain(1000.0)),
|
||||
// The Auto tone's Intensity (next to the Auto and Reset buttons 120, 119).
|
||||
row(123, S::BasicCorrection, Some(K::FloatSlider), Role::Missing),
|
||||
row(94, S::BasicCorrection, Some(K::Color), Role::Eyedropper),
|
||||
to(7, S::BasicCorrection, K::FloatSlider, "basicCorrection/whiteBalance/temperature", plain(300.0)),
|
||||
to(8, S::BasicCorrection, K::FloatSlider, "basicCorrection/whiteBalance/tint", plain(300.0)),
|
||||
to(20, S::BasicCorrection, K::FloatSlider, "basicCorrection/saturation", plain(300.0)),
|
||||
to(11, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/exposure", plain(7.0)),
|
||||
to(12, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/contrast", plain(150.0)),
|
||||
to(13, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/highlights", plain(150.0)),
|
||||
to(14, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/shadows", plain(150.0)),
|
||||
to(15, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/whites", plain(150.0)),
|
||||
to(16, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/blacks", plain(150.0)),
|
||||
to(64, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/hdrSpecular", plain(150.0)),
|
||||
// Creative: 23 the switch; 24 / 96 the Look's name, 25 / 128 its popups, 129 / 130 its colour
|
||||
// space.
|
||||
row(23, S::Creative, Some(K::Checkbox), Role::Switch),
|
||||
row(24, S::Creative, Some(K::ArbitraryData), Role::Lut { lut: false }),
|
||||
row(96, S::Creative, Some(K::ArbitraryData), Role::Lut { lut: false }),
|
||||
row(25, S::Creative, Some(K::Popup), Role::Lut { lut: false }),
|
||||
row(128, S::Creative, Some(K::Popup), Role::Lut { lut: false }),
|
||||
row(129, S::Creative, Some(K::ArbitraryData), Role::LookSpace),
|
||||
row(130, S::Creative, Some(K::Popup), Role::LookSpace),
|
||||
to(26, S::Creative, K::FloatSlider, "creative/lookIntensity", plain(200.0)),
|
||||
to(28, S::Creative, K::FloatSlider, "creative/adjustments/fadedFilm", plain(150.0)),
|
||||
to(29, S::Creative, K::FloatSlider, "creative/adjustments/sharpen", plain(100.0)),
|
||||
to(30, S::Creative, K::FloatSlider, "creative/adjustments/vibrance", plain(100.0)),
|
||||
to(31, S::Creative, K::FloatSlider, "creative/adjustments/creativeSaturation", plain(300.0)),
|
||||
row(32, S::Creative, Some(K::ArbitraryData), Role::Data(Data::SplitToning)),
|
||||
to(33, S::Creative, K::FloatSlider, "creative/adjustments/tintBalance", plain(150.0)),
|
||||
// Curves: 37 switches all curves, 118 the RGB curves, 104 ... 111 each hue / saturation curve.
|
||||
row(37, S::RgbCurves, Some(K::Checkbox), Role::Switch),
|
||||
row(118, S::RgbCurves, Some(K::Checkbox), Role::Switch),
|
||||
row(65, S::RgbCurves, Some(K::FloatSlider), Role::Missing),
|
||||
row(39, S::RgbCurves, Some(K::ArbitraryData), Role::Data(Data::RgbCurves)),
|
||||
row(104, S::HueSat(0), Some(K::Checkbox), Role::Switch),
|
||||
row(113, S::HueSat(0), Some(K::Color), Role::Eyedropper),
|
||||
row(42, S::HueSat(0), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(Some("curves/hueSaturationCurves/hueVsSat")))),
|
||||
row(105, S::HueSat(1), Some(K::Checkbox), Role::Switch),
|
||||
row(114, S::HueSat(1), Some(K::Color), Role::Eyedropper),
|
||||
row(106, S::HueSat(1), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
|
||||
row(107, S::HueSat(2), Some(K::Checkbox), Role::Switch),
|
||||
row(115, S::HueSat(2), Some(K::Color), Role::Eyedropper),
|
||||
row(108, S::HueSat(2), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
|
||||
row(109, S::HueSat(3), Some(K::Checkbox), Role::Switch),
|
||||
row(116, S::HueSat(3), Some(K::Color), Role::Eyedropper),
|
||||
row(110, S::HueSat(3), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
|
||||
row(111, S::HueSat(4), Some(K::Checkbox), Role::Switch),
|
||||
row(117, S::HueSat(4), Some(K::Color), Role::Eyedropper),
|
||||
row(112, S::HueSat(4), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
|
||||
// Color Wheels: 46 the switch, 102 Face Detection (on by default), 66 HDR White, 47 the wheels.
|
||||
row(46, S::ColorWheels, Some(K::Checkbox), Role::Switch),
|
||||
row(102, S::ColorWheels, Some(K::Checkbox), Role::Missing),
|
||||
row(66, S::ColorWheels, Some(K::FloatSlider), Role::Missing),
|
||||
row(47, S::ColorWheels, Some(K::ArbitraryData), Role::Data(Data::Wheels)),
|
||||
// HSL Secondary: 68 the switch; the key (70-76), Refine (81, 82), Correction (85-90).
|
||||
row(68, S::HslSecondary, Some(K::Checkbox), Role::Switch),
|
||||
row(70, S::HslSecondary, Some(K::Color), Role::Eyedropper),
|
||||
row(71, S::HslSecondary, Some(K::Color), Role::Eyedropper),
|
||||
row(72, S::HslSecondary, Some(K::Color), Role::Eyedropper),
|
||||
row(73, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::HslKey)),
|
||||
to(74, S::HslSecondary, K::Checkbox, "hslSecondary/key/showMask", Conv::Checkbox),
|
||||
to(75, S::HslSecondary, K::Popup, "hslSecondary/key/maskStyle", Conv::Popup { default: 1, items: MASK_STYLE }),
|
||||
to(76, S::HslSecondary, K::Checkbox, "hslSecondary/key/invertMask", Conv::Checkbox),
|
||||
to(81, S::HslSecondary, K::FloatSlider, "hslSecondary/refine/denoise", plain(100.0)),
|
||||
to(82, S::HslSecondary, K::FloatSlider, "hslSecondary/refine/blur", plain(1000.0)),
|
||||
row(85, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::CorrectionWheels)),
|
||||
// The correction wheel's mode (4 zero bytes by default).
|
||||
row(93, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
to(86, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/temperature", plain(300.0)),
|
||||
to(87, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/tint", plain(300.0)),
|
||||
to(88, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/contrast", plain(150.0)),
|
||||
to(89, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/sharpen", plain(100.0)),
|
||||
to(90, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/saturation", plain(300.0)),
|
||||
// Vignette: 50 the switch.
|
||||
row(50, S::Vignette, Some(K::Checkbox), Role::Switch),
|
||||
to(51, S::Vignette, K::FloatSlider, "vignette/vignetteAmount", plain(5.0)),
|
||||
to(52, S::Vignette, K::FloatSlider, "vignette/vignetteMidpoint", plain(100.0)),
|
||||
to(53, S::Vignette, K::FloatSlider, "vignette/vignetteRoundness", plain(100.0)),
|
||||
to(54, S::Vignette, K::FloatSlider, "vignette/vignetteFeather", plain(100.0)),
|
||||
// SpeedGrade Custom: 57 Custom Layer (on by default), 60 its data.
|
||||
row(57, S::SpeedGradeCustom, Some(K::Checkbox), Role::Switch),
|
||||
row(60, S::SpeedGradeCustom, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
// Outside the sections: Legacy Tone And Saturation, the embedded LUTs, Auto Tone, Auto Color,
|
||||
// the LUT and Look assets.
|
||||
row(97, S::Always, Some(K::Checkbox), Role::MissingWhenOn),
|
||||
row(98, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
row(99, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
row(100, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
row(124, S::Always, Some(K::Checkbox), Role::Missing),
|
||||
row(125, S::Always, Some(K::ArbitraryData), Role::Lut { lut: true }),
|
||||
row(126, S::Always, Some(K::ArbitraryData), Role::Lut { lut: false }),
|
||||
row(1, S::Always, Some(K::ArbitraryData), Role::Derived),
|
||||
row(121, S::Always, Some(K::FloatSlider), Role::Derived),
|
||||
row(122, S::Always, Some(K::ArbitraryData), Role::Derived),
|
||||
];
|
||||
|
||||
/// The names After Effects shows for the parameters whose `pard` name is blank (`dump.props`).
|
||||
fn blank_name(suffix: u16) -> Option<&'static str> {
|
||||
Some(match suffix {
|
||||
62 => "High Dynamic Range",
|
||||
3 | 23 | 37 | 118 | 104 | 105 | 107 | 109 | 111 | 46 | 68 | 50 => "Active",
|
||||
4 | 95 => "Input LUT data",
|
||||
24 | 96 => "Look name",
|
||||
102 => "Face Detection",
|
||||
74 => "Show Mask",
|
||||
75 => "Mask style",
|
||||
76 => "Invert Mask",
|
||||
97 => "Legacy Tone And Saturation",
|
||||
124 => "Auto Color Active",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The parameter's name for the report: its own, else the one After Effects shows.
|
||||
fn name_of(p: &EffectParam<'_, '_>) -> String {
|
||||
match (p.name.trim(), suffix(&p.match_name).and_then(blank_name)) {
|
||||
("", Some(n)) => n.to_string(),
|
||||
("", None) => p.match_name.clone(),
|
||||
(n, _) => n.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The match-name suffix of a Lumetri parameter (`ADBE Lumetri-0011` is 11).
|
||||
fn suffix(match_name: &str) -> Option<u16> {
|
||||
match_name.strip_prefix("ADBE Lumetri-")?.parse().ok()
|
||||
}
|
||||
|
||||
/// The parameter's value: the recorded one, else the `pard` default.
|
||||
fn value_of(p: &EffectParam<'_, '_>) -> Option<ParamValue> {
|
||||
p.recorded_value().or_else(|| p.pard_default())
|
||||
}
|
||||
|
||||
/// True when the parameter has keyframes, an expression, or a recorded value other than its
|
||||
/// default (a record without a readable default counts as set).
|
||||
fn is_set(p: &EffectParam<'_, '_>) -> bool {
|
||||
if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) {
|
||||
return true;
|
||||
}
|
||||
match (p.recorded_value(), p.pard_default()) {
|
||||
(Some(v), Some(d)) => !same(v, d),
|
||||
(Some(_), None) => true,
|
||||
(None, _) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal within the float noise of a stored `f64` / `f32`.
|
||||
fn same(a: ParamValue, b: ParamValue) -> bool {
|
||||
let close = |x: f64, y: f64| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0);
|
||||
match (a, b) {
|
||||
(ParamValue::Number(x), ParamValue::Number(y)) => close(x, y),
|
||||
(ParamValue::Argb(x), ParamValue::Argb(y)) => x.iter().zip(y).skip(1).all(|(x, y)| close(*x, y)),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// A value as the report shows it.
|
||||
fn shown_value(v: Option<ParamValue>) -> String {
|
||||
match v {
|
||||
Some(ParamValue::Number(n)) => {
|
||||
let s = format!("{n:.3}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
Some(ParamValue::Bool(b)) => if b { "on" } else { "off" }.to_string(),
|
||||
Some(ParamValue::Index(i)) => format!("item {i}"),
|
||||
Some(ParamValue::Argb([_, r, g, b])) => format!("RGB {:.3} {:.3} {:.3}", r, g, b),
|
||||
Some(other) => format!("{other:?}"),
|
||||
None => "set".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty data: absent, `fe fe` (no text, no asset) or only zero bytes.
|
||||
fn is_empty_data(d: Option<&[u8]>) -> bool {
|
||||
match d {
|
||||
None => true,
|
||||
Some(d) => d == [0xfe, 0xfe] || d.iter().all(|b| *b == 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn le_u32(d: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?))
|
||||
}
|
||||
|
||||
fn le_f32(d: &[u8], at: usize) -> Option<f32> {
|
||||
Some(f32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?))
|
||||
}
|
||||
|
||||
fn le_f64(d: &[u8], at: usize) -> Option<f64> {
|
||||
Some(f64::from_le_bytes(*d.get(at..at.checked_add(8)?)?.first_chunk::<8>()?))
|
||||
}
|
||||
|
||||
/// A UTF-16LE text up to its NUL (the Look's name, `-0096`: `SL BLUE ICE` in 1200 `-01-set-all`);
|
||||
/// `None` for empty data.
|
||||
fn utf16_text(d: &[u8]) -> Option<String> {
|
||||
if is_empty_data(Some(d)) {
|
||||
return None;
|
||||
}
|
||||
let units: Vec<u16> = d.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|u| *u != 0).collect();
|
||||
let s = String::from_utf16_lossy(&units);
|
||||
let s = s.trim();
|
||||
(!s.is_empty()).then(|| s.to_string())
|
||||
}
|
||||
|
||||
/// A Lumetri curve (520 bytes): `i32` curve type at 0 (3 for the hue curves, which wrap; 2 for
|
||||
/// Luma vs Sat and Sat vs Sat; 0 for an RGB curve), the `u32` point count at 4, then the points
|
||||
/// as little-endian `f64` pairs x, y from byte 8 (at most 32), sorted by x. Bytes after the
|
||||
/// counted points are stale (a fresh hue curve has count 0 and old points behind it). An RGB
|
||||
/// curve's x and y are 0..1 (2 points 0,0 and 1,1 by default); a hue / saturation curve's x is
|
||||
/// the hue (0 red, 1/3 green) or the luma / saturation, 0..1, and y its offset from neutral.
|
||||
/// `None` when the count does not fit.
|
||||
fn curve_points(d: &[u8]) -> Option<Vec<(f64, f64)>> {
|
||||
let n = usize::try_from(le_u32(d, 4)?).ok()?;
|
||||
if n > d.len().saturating_sub(8) / 16 {
|
||||
return None;
|
||||
}
|
||||
(0..n).map(|i| Some((le_f64(d, 8 + 16 * i)?, le_f64(d, 16 + 16 * i)?))).collect()
|
||||
}
|
||||
|
||||
/// HSL Secondary's key (`-0073`, 40 bytes): a flag byte (1 once a key colour was picked, 0 for a
|
||||
/// fresh effect), then hue, saturation and lightness, each three little-endian `f32` (centre,
|
||||
/// range, tolerance; fractions of the full hue circle / of 0..1) and an enabled byte. A fresh effect
|
||||
/// stores flag 0 and centre 0.5, range 0, tolerance 0, enabled for each (1200 `-00-defaults`);
|
||||
/// After Effects' own copy in `-0001` names the same fields (`middleHLS`, `rangeHLS`,
|
||||
/// `toleranceHLS`, `enableHLS`, hue in degrees: centre 180). Picking Set color RGB 0.02 0.55 0.85
|
||||
/// gives hue centre 0.5605 (that colour's hue, 201.8 degrees) and lightness centre 0.435 (its
|
||||
/// HSL lightness) with range 0.05 and tolerance 0.1 (`-01-set-all`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct HslKey {
|
||||
pub picked: bool,
|
||||
/// Hue, saturation, lightness: centre, range, tolerance, enabled.
|
||||
pub channels: [(f32, f32, f32, bool); 3],
|
||||
}
|
||||
|
||||
impl HslKey {
|
||||
pub fn parse(d: &[u8]) -> Option<HslKey> {
|
||||
if d.len() != 40 {
|
||||
return None;
|
||||
}
|
||||
let picked = match d.first()? {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return None,
|
||||
};
|
||||
let ch = |at: usize| -> Option<(f32, f32, f32, bool)> {
|
||||
let (c, r, t) = (le_f32(d, at)?, le_f32(d, at + 4)?, le_f32(d, at + 8)?);
|
||||
let on = match d.get(at + 12)? {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return None,
|
||||
};
|
||||
(c.is_finite() && r.is_finite() && t.is_finite()).then_some((c, r, t, on))
|
||||
};
|
||||
Some(HslKey { picked, channels: [ch(1)?, ch(14)?, ch(27)?] })
|
||||
}
|
||||
|
||||
fn is_default(&self) -> bool {
|
||||
!self.picked && self.channels.iter().all(|c| *c == (0.5, 0.0, 0.0, true))
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour wheel data: triples of `f64`, each wheel `0, 0.5, 0` by default.
|
||||
fn wheels_default(d: &[u8], wheels: usize) -> bool {
|
||||
d.len() == wheels * 24 && (0..wheels).all(|w| le_f64(d, w * 24) == Some(0.0) && le_f64(d, w * 24 + 8) == Some(0.5) && le_f64(d, w * 24 + 16) == Some(0.0))
|
||||
}
|
||||
|
||||
/// Is the data of `data` at After Effects' default (as far as it can be told)?
|
||||
fn data_default(data: Data, d: Option<&[u8]>) -> bool {
|
||||
let Some(d) = d else { return true };
|
||||
match data {
|
||||
Data::RgbCurves => {
|
||||
d.len() == 4 * 520 && d.as_chunks::<520>().0.iter().all(|c| curve_points(c).is_some_and(|pts| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9)))
|
||||
}
|
||||
Data::HueSatCurve(_) => curve_points(d).is_some_and(|pts| pts.iter().all(|(_, y)| *y == 0.0)),
|
||||
Data::Wheels => wheels_default(d, 4),
|
||||
Data::CorrectionWheels => wheels_default(d, 8),
|
||||
Data::SplitToning | Data::Other => is_empty_data(Some(d)),
|
||||
Data::HslKey => HslKey::parse(d).is_some_and(|k| k.is_default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the Input LUT or the Look holds: names, a popup item, an asset.
|
||||
#[derive(Default)]
|
||||
struct LutState {
|
||||
names: Vec<String>,
|
||||
item: Option<String>,
|
||||
asset: bool,
|
||||
}
|
||||
|
||||
/// How a set parameter of a section that does nothing is listed in the report; `None` when it is
|
||||
/// at its default.
|
||||
fn off_text(p: &EffectParam<'_, '_>, role: Role, data: Option<&[u8]>) -> Option<String> {
|
||||
match role {
|
||||
Role::Data(d) => (!data_default(d, data)).then(|| name_of(p)),
|
||||
// A LUT by its name (the popups index the same choice).
|
||||
Role::Lut { lut } if p.kind == K::ArbitraryData => {
|
||||
let what = if lut { "Input LUT" } else { "Look" };
|
||||
data.and_then(utf16_text).map(|n| format!("{what} '{n}'")).or_else(|| (!is_empty_data(data)).then(|| what.to_string()))
|
||||
}
|
||||
// The Look's colour space goes with the Look.
|
||||
Role::Lut { .. } | Role::LookSpace | Role::Derived => None,
|
||||
_ => is_set(p).then(|| format!("{} {}", name_of(p), shown_value(p.recorded_value()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes what an Input LUT or Look parameter holds: a popup item, an asset or a name.
|
||||
fn note_lut(p: &EffectParam<'_, '_>, suffix: u16, data: Option<&[u8]>, st: &mut LutState) {
|
||||
if p.kind == K::Popup {
|
||||
// The popup indexes the LUTs installed with After Effects; item 1 is None.
|
||||
if let Some(ParamValue::Index(i)) = p.recorded_value()
|
||||
&& i > 1
|
||||
{
|
||||
st.item = usize::try_from(i).ok().and_then(|i| p.items.as_ref()?.get(i - 1).cloned()).or(Some(format!("item {i}")));
|
||||
}
|
||||
} else if suffix == 125 || suffix == 126 {
|
||||
st.asset |= !is_empty_data(data);
|
||||
} else if let Some(name) = data.and_then(utf16_text)
|
||||
&& !st.names.contains(&name)
|
||||
{
|
||||
st.names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a Lumetri Color instance's parameters into `out.group` and reports what does not arrive.
|
||||
pub(crate) fn import_lumetri(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, '_>], cx: &LayerCtx<'_>, shown: &str, out: ParamOut<'_>) {
|
||||
let ParamOut { group, refs, col } = out;
|
||||
let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name());
|
||||
let find = |s: u16| params.iter().find(|p| suffix(&p.match_name) == Some(s));
|
||||
// The switches: on unless a record says off. Keys or an expression on a switch are not
|
||||
// imported (the static value counts).
|
||||
let switch_on = |s: u16| -> bool {
|
||||
match find(s) {
|
||||
Some(p) => !matches!(value_of(p), Some(ParamValue::Bool(false))),
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
// HSL Secondary without a picked key colour selects nothing: After Effects applies none of
|
||||
// its correction (1200 `-16-sec-correction`, `-15-sec-refine` render as `-00-defaults`);
|
||||
// EffectCraft's default key is the whole image, so the values are left out too.
|
||||
// Unreadable or missing key data does not count as unpicked (it is reported or older).
|
||||
let key_picked = find(73).and_then(|p| fx.arbitrary_data(&p.match_name)).and_then(HslKey::parse).is_none_or(|k| k.picked);
|
||||
let section_on = |sec: Section| -> bool {
|
||||
match sec {
|
||||
Section::Always => true,
|
||||
Section::BasicCorrection => switch_on(3),
|
||||
Section::Creative => switch_on(23),
|
||||
Section::RgbCurves => switch_on(37) && switch_on(118),
|
||||
Section::HueSat(i) => switch_on(37) && switch_on([104, 105, 107, 109, 111].get(usize::from(i)).copied().unwrap_or(104)),
|
||||
Section::ColorWheels => switch_on(46),
|
||||
Section::HslSecondary => switch_on(68) && key_picked,
|
||||
Section::Vignette => switch_on(50),
|
||||
// Custom Layer off is itself reported (a switch without counterpart).
|
||||
Section::SpeedGradeCustom => true,
|
||||
}
|
||||
};
|
||||
let mut off: Vec<(Section, Vec<String>)> = Vec::new();
|
||||
let mut eyedroppers: Vec<String> = Vec::new();
|
||||
let mut luts = [LutState::default(), LutState::default()];
|
||||
for p in params {
|
||||
if carries_nothing(p) || p.match_name == "ADBE Effect Built In Params" {
|
||||
continue;
|
||||
}
|
||||
let label = if p.name.trim().is_empty() { format!("{} ({})", name_of(p), p.match_name) } else { param_label(p) };
|
||||
let Some(row) = suffix(&p.match_name).and_then(|s| ROWS.iter().find(|r| r.suffix == s)) else {
|
||||
// A parameter this table does not know (a newer version): lost when set.
|
||||
if p.stored().is_some()
|
||||
&& (p.kind != K::ArbitraryData && is_set(p) || p.kind == K::ArbitraryData && !is_empty_data(fx.arbitrary_data(&p.match_name)))
|
||||
{
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported"));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if row.kind.is_some_and(|k| k != p.kind) {
|
||||
if p.stored().is_some() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, row.kind));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let data = || fx.arbitrary_data(&p.match_name);
|
||||
// A switched-off section: its set values are listed, not imported.
|
||||
if !matches!(row.role, Role::Switch) && !section_on(row.section) {
|
||||
let text = off_text(p, row.role, data());
|
||||
if let Some(text) = text {
|
||||
match off.iter_mut().find(|(s, _)| *s == row.section) {
|
||||
Some((_, list)) => {
|
||||
if !list.contains(&text) {
|
||||
list.push(text);
|
||||
}
|
||||
}
|
||||
None => off.push((row.section, vec![text])),
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match row.role {
|
||||
Role::To(target, conv) => {
|
||||
let m = ParamMap { index: p.index, kind: p.kind, target, conv, limit: None };
|
||||
import_mapped(p, &m, cx, (&here, &label), ParamOut { group: &mut *group, refs: &mut *refs, col: &mut *col });
|
||||
}
|
||||
Role::Switch => {
|
||||
if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) {
|
||||
col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on the switch {label} are not imported"));
|
||||
}
|
||||
if row.section == Section::SpeedGradeCustom && !switch_on(row.suffix) {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} is off and has no counterpart"));
|
||||
}
|
||||
}
|
||||
Role::MissingWhenOn => {
|
||||
if p.is_animated() || matches!(p.recorded_value(), Some(ParamValue::Bool(true))) {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} is on and has no counterpart, not imported"));
|
||||
}
|
||||
}
|
||||
Role::Missing => {
|
||||
if is_set(p) {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: parameter {label} is {} and has no counterpart, not imported", shown_value(p.recorded_value())),
|
||||
);
|
||||
}
|
||||
}
|
||||
Role::Eyedropper => {
|
||||
if is_set(p) {
|
||||
eyedroppers.push(name_of(p));
|
||||
}
|
||||
}
|
||||
Role::Lut { lut } => note_lut(p, row.suffix, data(), &mut luts[usize::from(!lut)]),
|
||||
// The Look's colour space only applies to a Look, which is reported.
|
||||
Role::LookSpace => {}
|
||||
Role::Data(_) if p.stored().is_some() && data().is_none() => {
|
||||
// The value record is there but its aRbs/aRbp payload is not: damaged, not the
|
||||
// default (a parameter without a record is the default).
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is missing, not imported (the default used)"));
|
||||
}
|
||||
Role::Data(Data::HslKey) => import_hsl_key(data(), &here, &mut *group, &mut *col),
|
||||
Role::Data(d) => {
|
||||
if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) {
|
||||
col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {label} are not imported"));
|
||||
}
|
||||
if !data_default(d, data()) {
|
||||
let why = match d {
|
||||
Data::RgbCurves => {
|
||||
import_rgb_curves(data(), (&here, &label), &mut *group, &mut *col);
|
||||
continue;
|
||||
}
|
||||
Data::HueSatCurve(Some(target)) => {
|
||||
import_hue_sat_curve(data(), target, (&here, &label), &mut *group, &mut *col);
|
||||
continue;
|
||||
}
|
||||
Data::Wheels => {
|
||||
import_wheels(data(), (&here, &label), &mut *group, &mut *col);
|
||||
continue;
|
||||
}
|
||||
Data::HueSatCurve(None) => "the curve's points are moved; no sample tells their scale",
|
||||
Data::CorrectionWheels | Data::SplitToning => "the data is not decoded (not sampled)",
|
||||
Data::HslKey | Data::Other => "no counterpart",
|
||||
};
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} is not imported ({why})"));
|
||||
}
|
||||
}
|
||||
Role::Derived => {}
|
||||
}
|
||||
}
|
||||
for (sec, list) in off {
|
||||
let why = if sec == Section::HslSecondary && switch_on(68) {
|
||||
"has no key colour picked (After Effects applies it to nothing)"
|
||||
} else {
|
||||
"is switched off (EffectCraft's Lumetri has no section switch)"
|
||||
};
|
||||
col.lose(LossCategory::Effects, format!("{here}: section {} {why}, its values are not imported: {}", sec.name(), list.join(", ")));
|
||||
}
|
||||
if !eyedroppers.is_empty() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: the eyedropper colours {} are not imported (they only set other controls)", eyedroppers.join(", ")));
|
||||
}
|
||||
for (st, what) in luts.iter().zip(["Input LUT", "Look"]) {
|
||||
let name = st.names.first().cloned().or_else(|| st.item.clone());
|
||||
if name.is_some() || st.asset {
|
||||
let name = name.map(|n| format!(" '{n}'")).unwrap_or_default();
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: the {what}{name} is not imported (the LUT file is not in the project; EffectCraft's {what} stays None)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HSL Secondary's key as EffectCraft's hue centre / range / softness (degrees) and saturation and
|
||||
/// lightness bands (percent): range is taken as the half width, tolerance as the softness. A key
|
||||
/// that was never picked keeps EffectCraft's default (the whole image).
|
||||
fn import_hsl_key(d: Option<&[u8]>, here: &str, group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
|
||||
let Some(d) = d else { return };
|
||||
let Some(key) = HslKey::parse(d) else {
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the HSL Secondary key data is unreadable, the default key used"));
|
||||
return;
|
||||
};
|
||||
if !key.picked {
|
||||
if !key.is_default() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: the HSL Secondary key holds values without a picked colour, not imported"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut set = |id: &str, v: Value| {
|
||||
if let Some(prop) = group.prop_mut(&format!("hslSecondary/key/{id}")) {
|
||||
prop.value = v;
|
||||
}
|
||||
};
|
||||
let [h, s, l] = key.channels.map(|(c, r, t, on)| (f64::from(c), f64::from(r), f64::from(t), on));
|
||||
set("hueEnabled", Value::Bool(h.3));
|
||||
set("hueCenter", Value::Scalar((h.0 * 360.0).rem_euclid(360.0)));
|
||||
set("hueRange", Value::Scalar((h.1 * 360.0).clamp(0.0, 180.0)));
|
||||
set("hueSoftness", Value::Scalar((h.2 * 360.0).clamp(0.0, 180.0)));
|
||||
for ((c, r, t, on), id) in [(s.0, s.1, s.2, s.3), (l.0, l.1, l.2, l.3)].into_iter().zip(["saturation", "lightness"]) {
|
||||
set(&format!("{id}Enabled"), Value::Bool(on));
|
||||
set(&format!("{id}Low"), Value::Scalar(((c - r) * 100.0).clamp(0.0, 100.0)));
|
||||
set(&format!("{id}High"), Value::Scalar(((c + r) * 100.0).clamp(0.0, 100.0)));
|
||||
set(&format!("{id}Softness"), Value::Scalar((t * 100.0).clamp(0.0, 100.0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A parameter of `group` set to `v` (ids the spec does not have are skipped).
|
||||
fn set_prop(group: &mut effectcraft_project::PropGroup, id: &str, v: Value) {
|
||||
if let Some(prop) = group.prop_mut(id) {
|
||||
prop.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Curve points as EffectCraft's `x,y x,y ...` text, x and y clamped to 0..1; `None` when a value
|
||||
/// is not finite.
|
||||
fn points_text(pts: impl IntoIterator<Item = (f64, f64)>) -> Option<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (x, y) in pts {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
out.push(format!("{},{}", x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
|
||||
}
|
||||
Some(out.join(" "))
|
||||
}
|
||||
|
||||
/// The RGB curve (`-0039`, four curves of 520 bytes). The first is the master curve (1200
|
||||
/// `lumetri-60-preset-curves-rgb`: its midpoint raised to 0.787 lifts R, G and B alike) and
|
||||
/// becomes `curves/rgbCurves/master`. The other three are the channel curves, but no sample moves
|
||||
/// one, so which is red, green or blue is not known: a moved one is reported.
|
||||
fn import_rgb_curves(d: Option<&[u8]>, (here, label): (&str, &str), group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
|
||||
let Some(d) = d else { return };
|
||||
let curves: Option<Vec<Vec<(f64, f64)>>> = if d.len() == 4 * 520 { d.as_chunks::<520>().0.iter().map(|c| curve_points(c)).collect() } else { None };
|
||||
let Some(curves) = curves else {
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the curves are not imported"));
|
||||
return;
|
||||
};
|
||||
let identity = |pts: &[(f64, f64)]| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9);
|
||||
if let Some(master) = curves.first().filter(|c| !identity(c)) {
|
||||
match points_text(master.iter().copied()) {
|
||||
Some(text) => set_prop(group, "curves/rgbCurves/master", Value::Str(text)),
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: the master curve of {label} has unreadable points, not imported")),
|
||||
}
|
||||
}
|
||||
let moved = curves.iter().skip(1).filter(|c| !identity(c)).count();
|
||||
if moved > 0 {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: {moved} channel curve(s) of {label} are not imported (no sample tells which curve is red, green or blue)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hue vs Sat (`-0042`): After Effects' y is an offset from neutral where 1 doubles the
|
||||
/// saturation (1200 `lumetri-61-preset-curves-huesat`: a point at hue 0.0155, y 0.97 scales pure
|
||||
/// red's saturation by about 1.96 in linear light; pins at -0.03 and -0.02 scale green, blue and
|
||||
/// cyan by 0.97). EffectCraft's y = 1 doubles it and 0.5 is neutral, so y becomes `0.5 + y / 2`.
|
||||
/// x is the hue in both (0 red, 1/3 green).
|
||||
fn import_hue_sat_curve(
|
||||
d: Option<&[u8]>,
|
||||
target: &str,
|
||||
(here, label): (&str, &str),
|
||||
group: &mut effectcraft_project::PropGroup,
|
||||
col: &mut crate::import::report::Collector,
|
||||
) {
|
||||
let Some(d) = d else { return };
|
||||
match curve_points(d).and_then(|pts| points_text(pts.into_iter().map(|(x, y)| (x, 0.5 + y / 2.0)))) {
|
||||
Some(text) => set_prop(group, target, Value::Str(text)),
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the curve is not imported")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour EffectCraft's wheel takes for After Effects' hue (degrees, 0 red, 120 green) and
|
||||
/// distance from the centre (0..1): the HSL colour of that hue with saturation = distance and
|
||||
/// lightness 0.5 (grey 0.5 at distance 0). EffectCraft's wheel adds that colour's offset from its
|
||||
/// grey, so the direction is After Effects' and the strength grows with the distance.
|
||||
fn wheel_colour(hue: f64, distance: f64) -> [f64; 4] {
|
||||
let c = distance.clamp(0.0, 1.0);
|
||||
let h = hue.rem_euclid(360.0) / 60.0;
|
||||
let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs());
|
||||
let (r, g, b) = match h {
|
||||
h if h < 1.0 => (c, x, 0.0),
|
||||
h if h < 2.0 => (x, c, 0.0),
|
||||
h if h < 3.0 => (0.0, c, x),
|
||||
h if h < 4.0 => (0.0, x, c),
|
||||
h if h < 5.0 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = 0.5 - c / 2.0;
|
||||
[r + m, g + m, b + m, 1.0]
|
||||
}
|
||||
|
||||
/// Color Wheels (`-0047`, 96 bytes): four wheels of three little-endian `f64`, the hue in degrees,
|
||||
/// the brightness slider (0..1, 0.5 neutral) and the distance from the centre (0..1); `0, 0.5, 0`
|
||||
/// each by default. The first three are Shadows, Midtones and Highlights (1200
|
||||
/// `lumetri-62-preset-wheels`: Shadows 229 degrees (blue), slider 0.37, distance 0.55; Midtones
|
||||
/// 0.1 degrees (red), 0.5, 0.39; Highlights 93 degrees (yellow-green), 0.73, 0.32); the fourth is
|
||||
/// not known. Hue and distance become EffectCraft's wheel colour ([`wheel_colour`]); the brightness
|
||||
/// sliders and a set fourth wheel have no counterpart and are reported.
|
||||
fn import_wheels(d: Option<&[u8]>, (here, label): (&str, &str), group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
|
||||
let Some(d) = d else { return };
|
||||
let wheels: Option<Vec<[f64; 3]>> = if d.len() == 4 * 24 {
|
||||
(0..4).map(|w| Some([le_f64(d, w * 24)?, le_f64(d, w * 24 + 8)?, le_f64(d, w * 24 + 16)?]).filter(|v| v.iter().all(|x| x.is_finite()))).collect()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(wheels) = wheels else {
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the colour wheels are not imported"));
|
||||
return;
|
||||
};
|
||||
let mut lost: Vec<String> = Vec::new();
|
||||
for (w, (id, name)) in wheels.iter().zip([("shadowsWheel", "Shadows"), ("midtonesWheel", "Midtones"), ("highlightsWheel", "Highlights")]) {
|
||||
let [hue, brightness, distance] = *w;
|
||||
if distance != 0.0 {
|
||||
set_prop(group, &format!("colorWheels/{id}"), Value::Color(wheel_colour(hue, distance)));
|
||||
}
|
||||
if (brightness - 0.5).abs() > 1e-9 {
|
||||
lost.push(format!("{name} brightness {brightness:.3} (0.5 is neutral)"));
|
||||
}
|
||||
}
|
||||
if wheels.get(3).is_some_and(|w| *w != [0.0, 0.5, 0.0]) {
|
||||
lost.push("the fourth wheel (its role is not known)".to_string());
|
||||
}
|
||||
if !lost.is_empty() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: {label}: {} not imported (no counterpart)", lost.join(", ")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! After Effects' Puppet effect (`ADBE FreePin3`) arrives as EffectCraft's Puppet
|
||||
//! (`ec.distort.puppet`) in its place in the stack, with its name and fx switch, but **without
|
||||
//! its meshes and pins**: how After Effects stores a pin's rest place (a vertex index and offset
|
||||
//! into the hidden mesh data) and its Position (pixels or a fraction of the layer) is not decoded
|
||||
//! yet, and a guessed rest place would deform the layer wrongly without a word. What the instance
|
||||
//! holds is reported instead, mesh by mesh: Triangles, Expansion, and per pin container (Deform,
|
||||
//! Stiffness, Overlap) the number of pins, how many are keyed or carry an expression, and the
|
||||
//! match names of their atoms (Advanced and Bend pins show up there). Any entry this reader does
|
||||
//! not know is named verbatim.
|
||||
//!
|
||||
//! The tree read here (docs/aep-format.md 3.7, Puppet) comes from an external scripting listing
|
||||
//! of the effect's property tree, not from the corpus (no corpus file has a Puppet): **?** until a
|
||||
//! byte sample exists. It is read with the same verified group reader as masks, shapes and layer
|
||||
//! styles; a file whose tree differs is reported, never guessed at.
|
||||
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{Node, PropGroup};
|
||||
|
||||
use crate::import::effects::{LayerCtx, carries_nothing};
|
||||
use crate::import::report::{Collector, LossCategory};
|
||||
use crate::{Effect as AepEffect, EffectDefinitions, ParamValue, PropGroup as AepGroup, PropNode, Property as AepProperty};
|
||||
|
||||
/// After Effects' Puppet effect.
|
||||
pub(crate) const MATCH_NAME: &str = "ADBE FreePin3";
|
||||
/// The hidden group that holds the meshes (`arap`).
|
||||
const ARAP: &str = "ADBE FreePin3 ARAP Group";
|
||||
/// Auto-traced Shapes, beside the meshes.
|
||||
const OUTLINES: &str = "ADBE FreePin3 Outlines";
|
||||
/// The hidden group of the meshes.
|
||||
const MESH_GROUP: &str = "ADBE FreePin3 Mesh Group";
|
||||
/// One mesh (`Mesh 1`).
|
||||
const MESH_ATOM: &str = "ADBE FreePin3 Mesh Atom";
|
||||
/// A mesh's hidden triangulation.
|
||||
const MESH_DATA: &str = "ADBE FreePin3 Mesh";
|
||||
const TRIANGLES: &str = "ADBE FreePin3 Mesh Tri Count";
|
||||
const EXPANSION: &str = "ADBE FreePin3 Mesh Expansion";
|
||||
/// At most this many meshes, pin atom names or unrecognised names are listed in a report line;
|
||||
/// the rest are counted (`and 3 more`).
|
||||
const LIST_CAP: usize = 10;
|
||||
/// The pin containers of a mesh: match name and the name After Effects shows.
|
||||
const PIN_GROUPS: [(&str, &str); 3] = [("ADBE FreePin3 PosPins", "Deform"), ("ADBE FreePin3 HghtPins", "Overlap"), ("ADBE FreePin3 StarchPins", "Stiffness")];
|
||||
|
||||
/// Imports a Puppet instance (the caller checked the match name) into `target`.
|
||||
pub(crate) fn import_puppet<'f, 'a>(
|
||||
defs: &EffectDefinitions<'f, 'a>,
|
||||
fx: &AepEffect<'f, 'a>,
|
||||
cx: &LayerCtx<'_>,
|
||||
ids: &mut Ids<'_>,
|
||||
target: &mut PropGroup,
|
||||
col: &mut Collector,
|
||||
) {
|
||||
let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string());
|
||||
let here = format!("{}: '{}' effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer);
|
||||
let Some(spec) = effectcraft_effects::find(effectcraft_effects::puppet::ID) else {
|
||||
col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer));
|
||||
return;
|
||||
};
|
||||
let size = [f64::from(cx.size.0), f64::from(cx.size.1)];
|
||||
let mut group = effectcraft_effects::instantiate(spec, ids, &shown, size);
|
||||
match fx.enabled() {
|
||||
Some(on) => group.enabled = on,
|
||||
None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)),
|
||||
}
|
||||
// The ordinary parameters (`parT`): none is mapped yet, so a stored one is reported.
|
||||
let params = defs.params(fx);
|
||||
for p in ¶ms {
|
||||
if carries_nothing(p) {
|
||||
continue;
|
||||
}
|
||||
if p.stored().is_some() && !matches!(p.stored_value(), Some(ParamValue::Layer(0))) {
|
||||
let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) };
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported"));
|
||||
}
|
||||
}
|
||||
report_meshes(fx, ¶ms.iter().map(|p| p.match_name.as_str()).collect::<Vec<_>>(), &here, col);
|
||||
// The effect itself arrives (an EffectCraft Puppet the user can pin again), so it counts as
|
||||
// imported; what it held is in the losses above.
|
||||
col.imported.effects = col.imported.effects.saturating_add(1);
|
||||
target.children.push(Node::Group(group));
|
||||
}
|
||||
|
||||
/// Reports the meshes and pins of `fx` and every entry of its value group that is neither a
|
||||
/// `parT` parameter (`param_names`) nor the mesh tree.
|
||||
fn report_meshes(fx: &AepEffect<'_, '_>, param_names: &[&str], here: &str, col: &mut Collector) {
|
||||
let mut unknown: Vec<String> = Vec::new();
|
||||
let values = fx.chunk().child_list(b"tdgp").and_then(AepGroup::from_chunk);
|
||||
let mut arap = None;
|
||||
for e in values.map(|g| g.entries()).unwrap_or_default() {
|
||||
if e.match_name == ARAP {
|
||||
if let PropNode::Group(g) = e.node {
|
||||
if arap.is_none() {
|
||||
arap = Some(g);
|
||||
}
|
||||
} else {
|
||||
push_unique(&mut unknown, e.match_name);
|
||||
}
|
||||
} else if !param_names.contains(&e.match_name.as_str()) {
|
||||
match e.node {
|
||||
// A value outside `parT` (On Transparent, ...): reported with what it holds.
|
||||
PropNode::Property(p) => col.lose(LossCategory::Effects, format!("{here}: property {} ({}) not imported", e.match_name, describe(&p))),
|
||||
PropNode::Group(_) | PropNode::Other(_) => push_unique(&mut unknown, e.match_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut meshes: Vec<String> = Vec::new();
|
||||
let mut found = false;
|
||||
if let Some(arap) = arap {
|
||||
for e in arap.entries() {
|
||||
match (e.match_name.as_str(), e.node) {
|
||||
(MESH_GROUP, PropNode::Group(g)) if !found => {
|
||||
found = true;
|
||||
for m in g.entries() {
|
||||
match (m.match_name.as_str(), m.node) {
|
||||
(MESH_ATOM, PropNode::Group(mesh)) => {
|
||||
let n = meshes.len().saturating_add(1);
|
||||
meshes.push(mesh_summary(&mesh, n, &mut unknown));
|
||||
}
|
||||
_ => push_unique(&mut unknown, m.match_name.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Auto-traced Shapes are made from the layer again (EffectCraft traces the alpha).
|
||||
(OUTLINES, _) => {}
|
||||
_ => push_unique(&mut unknown, e.match_name.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
let extra = if unknown.is_empty() { String::new() } else { format!("; not recognised: {}", capped(&unknown, ", ")) };
|
||||
if !found {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: imported without meshes or pins: its mesh group ({ARAP} / {MESH_GROUP}) was not found or is not a group{extra}"),
|
||||
);
|
||||
} else if !meshes.is_empty() {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!(
|
||||
"{here}: imported without its meshes and pins (After Effects' pin rest places and positions are not decoded yet): {}{extra}",
|
||||
capped(&meshes, "; ")
|
||||
),
|
||||
);
|
||||
} else if !unknown.is_empty() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: no mesh{extra}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mesh n (Triangles t, Expansion e): Deform 3 pins (1 keyed) [atom names], Overlap no pins, ...`
|
||||
fn mesh_summary(mesh: &AepGroup<'_, '_>, n: usize, unknown: &mut Vec<String>) -> String {
|
||||
let name = mesh.user_name().unwrap_or_else(|| format!("Mesh {n}"));
|
||||
let mut settings: Vec<String> = Vec::new();
|
||||
let mut containers: Vec<String> = Vec::new();
|
||||
for e in mesh.entries() {
|
||||
let label = match e.match_name.as_str() {
|
||||
TRIANGLES => Some("Triangles"),
|
||||
EXPANSION => Some("Expansion"),
|
||||
_ => None,
|
||||
};
|
||||
match (label, e.node) {
|
||||
(Some(label), PropNode::Property(p)) => settings.push(format!("{label} {}", describe(&p))),
|
||||
// The triangulation After Effects keeps; EffectCraft builds its own.
|
||||
_ if e.match_name == MESH_DATA => {}
|
||||
_ => match (PIN_GROUPS.iter().find(|(m, _)| *m == e.match_name), e.node) {
|
||||
(Some((_, shown)), PropNode::Group(g)) => containers.push(format!("{shown} {}", pins_summary(&g, unknown))),
|
||||
_ => push_unique(unknown, e.match_name),
|
||||
},
|
||||
}
|
||||
}
|
||||
let settings = if settings.is_empty() { String::new() } else { format!(" ({})", settings.join(", ")) };
|
||||
let containers = if containers.is_empty() { "no pin groups".to_string() } else { containers.join(", ") };
|
||||
format!("{name}{settings}: {containers}")
|
||||
}
|
||||
|
||||
/// `3 pins (1 keyed, 1 with an expression) [ADBE FreePin3 PosPin Atom]`, or `no pins`. An entry
|
||||
/// of the container that is not a group (a pin is one) goes to `unknown`.
|
||||
fn pins_summary(container: &AepGroup<'_, '_>, unknown: &mut Vec<String>) -> String {
|
||||
let mut count = 0usize;
|
||||
let mut keyed = 0usize;
|
||||
let mut expressions = 0usize;
|
||||
let mut atoms: Vec<String> = Vec::new();
|
||||
for e in container.entries() {
|
||||
let PropNode::Group(pin) = e.node else {
|
||||
push_unique(unknown, e.match_name);
|
||||
continue;
|
||||
};
|
||||
count = count.saturating_add(1);
|
||||
push_unique(&mut atoms, e.match_name);
|
||||
let props: Vec<AepProperty<'_, '_>> =
|
||||
pin.entries().into_iter().filter_map(|p| if let PropNode::Property(p) = p.node { Some(p) } else { None }).collect();
|
||||
if props.iter().any(AepProperty::is_animated) {
|
||||
keyed = keyed.saturating_add(1);
|
||||
}
|
||||
if props.iter().any(|p| p.expression().is_some()) {
|
||||
expressions = expressions.saturating_add(1);
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return "no pins".to_string();
|
||||
}
|
||||
let mut what = vec![format!("{keyed} keyed")];
|
||||
if expressions > 0 {
|
||||
what.push(format!("{expressions} with an expression"));
|
||||
}
|
||||
format!("{count} pin{} ({}) [{}]", if count == 1 { "" } else { "s" }, what.join(", "), capped(&atoms, ", "))
|
||||
}
|
||||
|
||||
/// A stored property's value for the report: `keyed`, the static value, or `unreadable`.
|
||||
fn describe(p: &AepProperty<'_, '_>) -> String {
|
||||
if p.is_animated() {
|
||||
return "keyed".to_string();
|
||||
}
|
||||
match p.static_value() {
|
||||
Some(v) if v.len() == 1 => v.first().map(|x| format!("{x}")).unwrap_or_default(),
|
||||
Some(v) => format!("[{}]", v.iter().map(|x| format!("{x}")).collect::<Vec<_>>().join(", ")),
|
||||
None => "unreadable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `name` to `names` unless it is there already.
|
||||
fn push_unique(names: &mut Vec<String>, name: String) {
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first [`LIST_CAP`] of `items` joined by `sep`, and how many more there are.
|
||||
fn capped(items: &[String], sep: &str) -> String {
|
||||
let shown: Vec<&str> = items.iter().take(LIST_CAP).map(String::as_str).collect();
|
||||
let mut out = shown.join(sep);
|
||||
let more = items.len().saturating_sub(LIST_CAP);
|
||||
if more > 0 {
|
||||
out.push_str(&format!("{sep}and {more} more"));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -26,10 +26,11 @@ pub(crate) const MAX_TEXT_CHARS: usize = 1 << 20;
|
||||
/// U+0003 (END OF TEXT): After Effects' other paragraph break besides `\r`.
|
||||
const ETX: char = '\u{3}';
|
||||
|
||||
/// The first paragraph of a stored text, at most 64 characters: the name After Effects shows for
|
||||
/// a text layer without a name of its own (U+0003 ends a paragraph like `\r`).
|
||||
/// The name of a text layer without a name of its own: its first paragraph, cut as EffectCraft
|
||||
/// cuts a following name ([`effectcraft_project::text_layer_name`]), so a following name that
|
||||
/// export writes as no name comes back unchanged (#3233). U+0003 ends a paragraph like `\r`.
|
||||
pub(crate) fn first_paragraph(raw: &str) -> String {
|
||||
raw.split(['\r', ETX]).next().unwrap_or_default().chars().take(64).collect()
|
||||
effectcraft_project::text_layer_name(raw.split(ETX).next().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The Source Text value of `doc`, with what cannot be mapped reported for the layer `what`.
|
||||
|
||||
@@ -16,11 +16,12 @@ pub mod import;
|
||||
|
||||
pub use container::{
|
||||
AeVersion, AepError, AepFile, AutoKern, BlendMode, Body, CURVE_IDENTITY, CameraPosition, Caps, Chunk, Comp, CompHeader, CryptomatteData, CueType,
|
||||
CurvesData, DEFAULT_CAMERA_ZOOM, DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, FontBaseline, Footage, GradientType,
|
||||
HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer, LayerHeader,
|
||||
LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS, Orientation,
|
||||
OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode, Property, Quality,
|
||||
Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun, TextBox,
|
||||
TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace, curve_lut,
|
||||
effect_map, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, write, write_to,
|
||||
CurvesData, DEFAULT_CAMERA_ZOOM, DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, ExtractorChannels, FontBaseline, Footage,
|
||||
GradientType, HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer,
|
||||
LayerHeader, LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS,
|
||||
Orientation, OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode,
|
||||
Property, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun,
|
||||
TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace,
|
||||
curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map,
|
||||
write, write_to,
|
||||
};
|
||||
@@ -237,9 +237,11 @@ fn exercise_layer(project: &Project<'_, '_>, comp_size: (u32, u32), layer: &Laye
|
||||
if let Some(root) = layer.properties() {
|
||||
exercise_group(&root, 0);
|
||||
}
|
||||
// One index for the layer's effects: building it walks every layer of the file.
|
||||
let defs = project.effect_definition_index();
|
||||
for e in layer.effects() {
|
||||
let _ = (e.match_name().len(), e.name(), e.declared_param_count(), e.has_own_definitions(), project.definition_source(&e));
|
||||
for p in e.params().into_iter().chain(project.effect_params(&e)) {
|
||||
let _ = (e.match_name().len(), e.name(), e.declared_param_count(), e.has_own_definitions(), defs.source(&e));
|
||||
for p in e.params().into_iter().chain(defs.params(&e)) {
|
||||
let _ = (p.index, p.kind, p.name.len(), p.items.as_ref().map(Vec::len), p.pard().len(), p.value(), p.stored_value(), p.pard_default());
|
||||
let _ = (p.is_default(), p.popup_item_count(), p.popup_selected_index(), p.popup_selected_item(), p.valid_max(), p.is_animated());
|
||||
let _ = (p.valid_min(), p.slider_range());
|
||||
|
||||
@@ -364,6 +364,42 @@ fn what_cannot_be_written_is_reported() {
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
|
||||
/// Lumetri Color is imported but not written (no prototype to write from): the export reports it
|
||||
/// as a loss and still produces a valid file.
|
||||
#[test]
|
||||
fn lumetri_color_is_reported_not_written() {
|
||||
let mut p = skip!(tint(|_| {}));
|
||||
let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) {
|
||||
g.kind = effectcraft_project::GroupKind::Effect { effect: "ec.color.lumetri".into() };
|
||||
}
|
||||
});
|
||||
let r = export_all(&p);
|
||||
assert_eq!(r.report.count(LossCategory::Effects), 1, "{:?}", r.report.losses);
|
||||
assert_eq!(r.report.exported.effects, 0);
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
|
||||
/// CC Color Neutralizer, EXtractoR and IDentifier are in the import table but have no prototype
|
||||
/// to write from: each is reported, not written.
|
||||
#[test]
|
||||
fn imported_plug_in_effects_are_reported_not_written() {
|
||||
for effect in ["ec.color.cccolorneutralizer", "ec.3d.extractor", "ec.3d.identifier"] {
|
||||
let mut p = skip!(tint(|_| {}));
|
||||
let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) {
|
||||
g.kind = effectcraft_project::GroupKind::Effect { effect: effect.into() };
|
||||
}
|
||||
});
|
||||
let r = export_all(&p);
|
||||
assert_eq!(r.report.count(LossCategory::Effects), 1, "{effect}: {:?}", r.report.losses);
|
||||
assert_eq!(r.report.exported.effects, 0, "{effect}");
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_effect_values_never_panic() {
|
||||
for v in [Value::Scalar(f64::NAN), Value::Scalar(1e300), Value::Color([f32::NAN as f64, 2.0, -1.0, 1.0]), Value::Bool(true), Value::Text(Box::default())] {
|
||||
|
||||
@@ -196,6 +196,69 @@ fn documents_built_in_code_are_written_as_their_fields() {
|
||||
assert_eq!(layer.header().map(|h| h.layer_type), Some(effectcraft_aep::LayerType::Text));
|
||||
}
|
||||
|
||||
/// #3233: whether a text layer's name follows its text survives export and import. A followed
|
||||
/// name is written as no name (After Effects shows the text) and comes back following; a name the
|
||||
/// user gave is written, even when it equals the text, and comes back as the user's. Without a
|
||||
/// recorded state (older projects) a name equal to the text follows.
|
||||
#[test]
|
||||
fn a_text_layer_name_follows_its_text_through_export_and_import() {
|
||||
for (name, follows, stored) in [
|
||||
("Hello", Some(false), Some("Hello")),
|
||||
("Hello 2", Some(true), None),
|
||||
("Hello", None, None),
|
||||
("Text 2", None, None),
|
||||
("Text Title", None, Some("Text Title")),
|
||||
("Text Title", Some(false), Some("Text Title")),
|
||||
] {
|
||||
let case = format!("{name:?} {follows:?}");
|
||||
let (mut p, c) = text_project(vec![TextDoc::plain("Hello")]);
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
l.name = name.into();
|
||||
l.name_follows_text = follows;
|
||||
});
|
||||
let r = export_all(&p);
|
||||
let file = parse(&r.bytes).expect("parse");
|
||||
let view = effectcraft_aep::Project::new(&file).expect("project");
|
||||
let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer");
|
||||
assert_eq!(layer.name().as_deref(), stored, "stored name ({case})");
|
||||
let back = reimport(&r.bytes);
|
||||
let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer");
|
||||
assert_eq!(l.name, stored.unwrap_or("Hello"), "imported name ({case})");
|
||||
assert_eq!(l.name_follows_text, Some(stored.is_none()), "imported state ({case})");
|
||||
}
|
||||
// An empty text layer that follows its text is written without a name, as After Effects
|
||||
// writes it, and comes back named "Text", still following.
|
||||
let (mut p, c) = text_project(vec![TextDoc::plain("")]);
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
l.name = "Text".into();
|
||||
l.name_follows_text = Some(true);
|
||||
});
|
||||
let back = reimport(&export_all(&p).bytes);
|
||||
let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer");
|
||||
assert_eq!((l.name.as_str(), l.name_follows_text), ("Text", Some(true)), "an empty text layer");
|
||||
}
|
||||
|
||||
/// #3233: a following name cut from a first line longer than 40 characters is written as no name
|
||||
/// and comes back as the same cut name, still following; a long name the user gave stays whole.
|
||||
#[test]
|
||||
fn a_long_first_line_names_a_text_layer_the_same_after_export_and_import() {
|
||||
let line = "A headline that runs past forty characters, to 64";
|
||||
assert!((41..=64).contains(&line.chars().count()));
|
||||
let text = format!("{line}\rSecond");
|
||||
let cut = effectcraft_project::text_layer_name(&text);
|
||||
assert_eq!(cut.chars().count(), 40);
|
||||
for (name, follows, back_name) in [(cut.as_str(), Some(true), cut.as_str()), (line, Some(false), line)] {
|
||||
let (mut p, c) = text_project(vec![TextDoc::plain(&text)]);
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
l.name = name.into();
|
||||
l.name_follows_text = follows;
|
||||
});
|
||||
let back = reimport(&export_all(&p).bytes);
|
||||
let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer");
|
||||
assert_eq!((l.name.as_str(), l.name_follows_text), (back_name, follows), "{name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_text_document_cannot_hold_is_reported() {
|
||||
let mut d = TextDoc::plain("x");
|
||||
|
||||
@@ -15,10 +15,13 @@ mod effects_batch5;
|
||||
mod effects_quickwins;
|
||||
mod effects_round2;
|
||||
mod effects_round3;
|
||||
mod effects_thirdparty;
|
||||
mod fx_inject;
|
||||
mod lumetri;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod mattes;
|
||||
mod puppet;
|
||||
mod resaved_defaults;
|
||||
mod robustness;
|
||||
mod shapes;
|
||||
|
||||
@@ -72,7 +72,7 @@ fn the_table_names_tint_gradient_ramp_and_exposure() {
|
||||
] {
|
||||
assert!(t.contains(&want), "{want:?}");
|
||||
}
|
||||
assert_eq!(t.len(), 63);
|
||||
assert_eq!(t.len(), 67);
|
||||
for (_, id) in t {
|
||||
assert!(effectcraft_effects::find(id).is_some(), "{id}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! CC Color Neutralizer, EXtractoR and IDentifier (#3112) arrive as EffectCraft's effects of the
|
||||
//! same name. Each is built into `synthetic.aep` (`fx_inject`) with the definitions After Effects
|
||||
//! 2026 wrote for it in the importer block of 2026-11-05 (`1300-thirdparty.aep`) and the values
|
||||
//! of that block's compositions; the Channel Info data is laid out as those instances store it.
|
||||
|
||||
use crate::import::fx_inject::{Default, Fx, losses, near, other, popup, run, slider, v};
|
||||
use effectcraft_aep::import::LossCategory;
|
||||
use effectcraft_aep::{ExtractorChannels, ParamKind as K, identifier_channel_type};
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
fn neutralizer(values: Vec<(usize, f64)>) -> Fx {
|
||||
let range = |name| slider(K::FloatSlider, name, 0.0, (-100.0, 100.0));
|
||||
let group = |name| other(K::GroupStart, name, Default::None);
|
||||
let end = || other(K::GroupEnd, "", Default::None);
|
||||
Fx {
|
||||
match_name: "CS Color Neutralizer",
|
||||
name: "CC Color Neutralizer",
|
||||
defs: vec![
|
||||
other(K::Color, "Shadows Unbalance", Default::Color([0, 0, 0, 0])),
|
||||
group("Shadows"),
|
||||
range("Red - Shadows"),
|
||||
range("Green - Shadows"),
|
||||
range("Blue - Shadows"),
|
||||
end(),
|
||||
other(K::Color, "Midtones Unbalance", Default::Color([255, 127, 127, 127])),
|
||||
group("Midtones"),
|
||||
range("Red - Midtones"),
|
||||
range("Green - Midtones"),
|
||||
range("Blue - Midtones"),
|
||||
end(),
|
||||
other(K::Color, "Highlights Unbalance", Default::Color([255, 255, 255, 255])),
|
||||
group("Highlights"),
|
||||
range("Red - Highlights"),
|
||||
range("Green - Highlights"),
|
||||
range("Blue - Highlights"),
|
||||
end(),
|
||||
slider(K::FloatSlider, "Pinning", 0.0, (0.0, 100.0)),
|
||||
slider(K::FloatSlider, "Blend w. Original", 0.0, (0.0, 100.0)),
|
||||
group("Special"),
|
||||
popup("View", 1, &["Final Result", "Correction B to W", "Correction Stair"]),
|
||||
slider(K::FloatSlider, "Black Point", 0.0, (0.0, 49.9)),
|
||||
slider(K::FloatSlider, "White Point", 100.0, (50.1, 100.0)),
|
||||
end(),
|
||||
],
|
||||
values,
|
||||
data: vec![],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_color_neutralizer_pinning_and_blend() {
|
||||
// `ccneutral-00-defaults` and the Pinning / Blend of `ccneutral-01-set-all`.
|
||||
let (g, r) = run(&neutralizer(vec![(19, 16.0), (20, 19.0)]), "ec.color.cccolorneutralizer");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "CS Color Neutralizer"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
near(&g, "pinning", 16.0);
|
||||
near(&g, "blendWOriginal", 19.0);
|
||||
// EffectCraft's own (identity) balance stays.
|
||||
assert_eq!(v(&g, "midtonesUnbalance"), v(&g, "midtonesBalance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_color_neutralizer_corrections_without_a_counterpart_are_reported() {
|
||||
// `ccneutral-03-sec-shadows`, `ccneutral-09-pop22-2`, `ccneutral-06-sec-special`.
|
||||
let (g, r) = run(&neutralizer(vec![(3, 13.0), (4, 16.0), (5, 19.0), (22, 2.0), (23, 10.0), (24, 87.0)]), "ec.color.cccolorneutralizer");
|
||||
let found = losses(&r, LossCategory::Effects, "CS Color Neutralizer");
|
||||
for name in ["Red - Shadows", "Green - Shadows", "Blue - Shadows", "View", "Black Point", "White Point"] {
|
||||
assert!(found.iter().any(|d| d.contains(&format!("parameter {name} (")) && d.contains("not imported")), "{name}: {found:?}");
|
||||
}
|
||||
assert_eq!(found.len(), 6, "{found:?}");
|
||||
assert!(g.is_some(), "the effect itself is imported");
|
||||
}
|
||||
|
||||
/// EXtractoR's Channel Info data with these record kinds and names.
|
||||
fn channels(recs: [(u32, &str); 4]) -> Vec<u8> {
|
||||
let mut d = vec![0u8; ExtractorChannels::LEN];
|
||||
d[..4].copy_from_slice(&1u32.to_le_bytes());
|
||||
for (k, (kind, name)) in recs.iter().enumerate() {
|
||||
let at = 32 + k * 164;
|
||||
d[at..at + 4].copy_from_slice(&kind.to_le_bytes());
|
||||
d[at + 35..at + 35 + name.len()].copy_from_slice(name.as_bytes());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
const DEFAULT_CHANNELS: [(u32, &str); 4] = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")];
|
||||
|
||||
fn extractor(values: Vec<(usize, f64)>, data: Vec<u8>) -> Fx {
|
||||
let mut values = values;
|
||||
values.insert(0, (1, 0.0));
|
||||
Fx {
|
||||
match_name: "EXtractoR",
|
||||
name: "EXtractoR",
|
||||
defs: vec![
|
||||
other(K::ArbitraryData, "Channel Info", Default::None),
|
||||
other(K::GroupStart, "Process", Default::None),
|
||||
slider(K::FloatSlider, "Black Point", 0.0, (-f64::from(f32::MAX), f64::from(f32::MAX))),
|
||||
slider(K::FloatSlider, "White Point", 1.0, (-f64::from(f32::MAX), f64::from(f32::MAX))),
|
||||
other(K::Checkbox, "", Default::Number(0.0)),
|
||||
other(K::GroupEnd, "", Default::None),
|
||||
],
|
||||
values,
|
||||
data: vec![(1, data)],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_channel_info_layout() {
|
||||
let c = ExtractorChannels::parse(&channels(DEFAULT_CHANNELS)).expect("the default layout");
|
||||
assert!(c.is_default());
|
||||
let c = ExtractorChannels::parse(&channels([(1, "Z"), (1, "G"), (1, "B"), (2, "(copy)")])).expect("parses");
|
||||
assert!(!c.is_default());
|
||||
assert_eq!(c.channels[0], (1, "Z".to_string()));
|
||||
assert_eq!(ExtractorChannels::parse(&[0u8; 10]), None);
|
||||
// The kind is byte 0 of a record: the definition default of `extractor-00-defaults` has
|
||||
// `02 00 00 32` on Alpha, still the input's own channel.
|
||||
let c = ExtractorChannels::parse(&channels([(1, "R"), (1, "G"), (1, "B"), (0x3200_0002, "(copy)")])).expect("parses");
|
||||
assert!(c.is_default());
|
||||
assert_eq!(c.sources(), Ok(["R", "G", "B", "(copy)"].map(String::from)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_points_unmult_and_default_channels() {
|
||||
// `extractor-01-set-all`: Black Point 10, White Point 14, UnMult on.
|
||||
let (g, r) = run(&extractor(vec![(3, 10.0), (4, 14.0), (5, 1.0)], channels(DEFAULT_CHANNELS)), "ec.3d.extractor");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
near(&g, "blackPoint", 10.0);
|
||||
near(&g, "whitePoint", 14.0);
|
||||
assert_eq!((v(&g, "unMult"), v(&g, "clip")), (Value::Bool(true), Value::Bool(false)));
|
||||
// After Effects' `(copy)` Alpha is the input's alpha: EffectCraft's `(copy)`, not `A` (an
|
||||
// OpenEXR's own alpha plane).
|
||||
for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "(copy)")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `extractor-07-preset-channel-z` (a preset of our own): `Z` into Red, Green and Blue, Alpha
|
||||
/// `(copy)`. EffectCraft's EXtractoR reads the footage's channel by the same name.
|
||||
#[test]
|
||||
fn extractor_channels_are_imported_by_name() {
|
||||
let (g, r) = run(&extractor(vec![], channels([(1, "Z"), (1, "Z"), (1, "Z"), (2, "(copy)")])), "ec.3d.extractor");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "Z"), ("green", "Z"), ("blue", "Z"), ("alpha", "(copy)")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
// `(copy)` on another channel is that channel of the input (EffectCraft's `(copy)` there).
|
||||
let (g, _) = run(&extractor(vec![], channels([(2, "(copy)"), (1, "CryptoObject00.R"), (2, "(copy)"), (1, "Z")])), "ec.3d.extractor");
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "(copy)"), ("green", "CryptoObject00.R"), ("blue", "(copy)"), ("alpha", "Z")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_channels_of_an_unknown_kind_are_reported() {
|
||||
for recs in [[(1, "Z"), (3, "Z"), (1, "Z"), (2, "(copy)")], [(1, "Z"), (1, ""), (1, "Z"), (2, "(copy)")]] {
|
||||
let (g, r) = run(&extractor(vec![], channels(recs)), "ec.3d.extractor");
|
||||
let found = losses(&r, LossCategory::Effects, "EXtractoR");
|
||||
assert!(found.len() == 1 && found[0].contains("are not imported (record 2 is of an unknown kind or names no channel)"), "{recs:?}: {found:?}");
|
||||
assert_eq!(v(&g.expect("imported"), "red"), Value::Str("R".into()), "{recs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// IDentifier's Channel Info: the channel type's name at 19 among bytes that are not read.
|
||||
fn id_info(name: &str) -> Vec<u8> {
|
||||
let mut d: Vec<u8> = (0..148u32).map(|i| (i * 37 % 251) as u8 | 1).collect();
|
||||
d[19..19 + name.len()].copy_from_slice(name.as_bytes());
|
||||
d[19 + name.len()] = 0;
|
||||
d
|
||||
}
|
||||
|
||||
fn identifier(values: Vec<(usize, f64)>, data: Vec<u8>) -> Fx {
|
||||
let mut values = values;
|
||||
values.insert(0, (1, 0.0));
|
||||
Fx {
|
||||
match_name: "IDentifier",
|
||||
name: "IDentifier",
|
||||
defs: vec![
|
||||
other(K::ArbitraryData, "Channel Info", Default::None),
|
||||
popup("Display", 1, &["Colors", "Luma Matte", "Alpha Matte", "Raw"]),
|
||||
slider(K::IntSlider, "ID", 0.0, (0.0, 2_147_483_647.0)),
|
||||
],
|
||||
values,
|
||||
data: vec![(1, data)],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_display_id_and_channel_type() {
|
||||
assert_eq!(identifier_channel_type(&id_info("Object ID")), Some("Object ID"));
|
||||
// `identifier-01-set-all` (ID 10) and `identifier-05-pop2-3` (Alpha Matte): no channel chosen
|
||||
// (After Effects found none or none was picked), so After Effects leaves the layer unchanged.
|
||||
let (g, r) = run(&identifier(vec![(2, 3.0), (3, 10.0)], id_info("Object ID")), "ec.3d.identifier");
|
||||
let found = losses(&r, LossCategory::Effects, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains("no ID channel is chosen"), "{found:?}");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!((v(&g, "display"), v(&g, "channelType")), (Value::Enum(2), Value::Enum(0)));
|
||||
near(&g, "id", 10.0);
|
||||
}
|
||||
|
||||
/// `identifier-09-preset-channel-objectid-id2` (1310, a preset of our own): the UINT channel
|
||||
/// `objectId` chosen and ID 2. EffectCraft's IDentifier reads that channel itself (`ObjectID`
|
||||
/// without regard to case), so nothing is lost; another name is reported.
|
||||
#[test]
|
||||
fn identifier_chosen_channels_by_name() {
|
||||
for (name, kind) in [("objectId", 0), ("ObjectID", 0), ("materialid", 1)] {
|
||||
let (g, r) = run(&identifier(vec![(3, 2.0)], id_info(name)), "ec.3d.identifier");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::<String>::new(), "{name}");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!(v(&g, "channelType"), Value::Enum(kind), "{name}");
|
||||
near(&g, "id", 2.0);
|
||||
}
|
||||
for name in ["IndexOB.X", "ID", "Material ID"] {
|
||||
let (_, r) = run(&identifier(vec![], id_info(name)), "ec.3d.identifier");
|
||||
let found = losses(&r, LossCategory::Effects, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains(&format!("the ID channel '{name}' (Channel Info) is not imported")), "{name}: {found:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// EXtractoR's data with the name of record `k` from byte 35 replaced by `name` (no NUL added; the
|
||||
/// rest of the record's name field is left as `fill`).
|
||||
fn channels_with_raw_name(k: usize, name: &[u8], fill: u8) -> Vec<u8> {
|
||||
let mut d = channels(DEFAULT_CHANNELS);
|
||||
let at = 32 + k * 164 + 35;
|
||||
d[at..at + 129].fill(fill);
|
||||
d[at..at + name.len()].copy_from_slice(name);
|
||||
d
|
||||
}
|
||||
|
||||
/// A channel name that never ends (no NUL up to the end of its record) or is not UTF-8 is
|
||||
/// refused by the decoder and reported at the importer; the defaults R, G, B, A stay.
|
||||
#[test]
|
||||
fn extractor_names_without_a_terminator_or_in_invalid_utf8_are_refused() {
|
||||
let unterminated = channels_with_raw_name(2, b"Z", b'Z');
|
||||
let invalid = channels_with_raw_name(1, &[0x5a, 0xff, 0xfe, 0x00], 0);
|
||||
for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] {
|
||||
assert_eq!(ExtractorChannels::parse(&data), None, "{what}");
|
||||
let (g, r) = run(&extractor(vec![], data), "ec.3d.extractor");
|
||||
let found = losses(&r, LossCategory::Effects, "EXtractoR");
|
||||
assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}");
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{what}: {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same for IDentifier: a channel name without NUL up to the end of the data, or in invalid
|
||||
/// UTF-8, is refused and reported; the Object ID channel stays.
|
||||
#[test]
|
||||
fn identifier_names_without_a_terminator_or_in_invalid_utf8_are_refused() {
|
||||
let mut unterminated = id_info("objectId");
|
||||
unterminated[19..].fill(b'x');
|
||||
let mut invalid = id_info("objectId");
|
||||
invalid[19..22].copy_from_slice(&[b'o', 0xff, 0]);
|
||||
for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] {
|
||||
assert_eq!(identifier_channel_type(&data), None, "{what}");
|
||||
let (g, r) = run(&identifier(vec![], data), "ec.3d.identifier");
|
||||
let found = losses(&r, LossCategory::Effects, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}");
|
||||
assert_eq!(v(&g.expect("imported"), "channelType"), Value::Enum(0), "{what}");
|
||||
}
|
||||
// Data too short to reach the name.
|
||||
assert_eq!(identifier_channel_type(&[1; 10]), None);
|
||||
}
|
||||
|
||||
/// A Channel Info value record without its arbitrary data (no `aRbs`): the effect still imports,
|
||||
/// with the defaults, nothing panics, and the missing data is reported as damaged (it may have
|
||||
/// chosen other channels, so the defaults are not what After Effects renders).
|
||||
#[test]
|
||||
fn a_missing_channel_info_payload_is_reported_and_leaves_the_defaults() {
|
||||
let mut fx = extractor(vec![], vec![]);
|
||||
fx.data.clear();
|
||||
let (g, r) = run(&fx, "ec.3d.extractor");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::<String>::new());
|
||||
let found = losses(&r, LossCategory::DamagedData, "EXtractoR");
|
||||
assert!(found.len() == 1 && found[0].contains("Channel Info data is missing"), "{found:?}");
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
let mut fx = identifier(vec![(3, 5.0)], vec![]);
|
||||
fx.data.clear();
|
||||
let (g, r) = run(&fx, "ec.3d.identifier");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::<String>::new());
|
||||
let found = losses(&r, LossCategory::DamagedData, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains("Channel Info data is missing"), "{found:?}");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!(v(&g, "channelType"), Value::Enum(0));
|
||||
near(&g, "id", 5.0);
|
||||
}
|
||||
@@ -103,18 +103,18 @@ pub fn tdmn(name: &str) -> Chunk<'static> {
|
||||
}
|
||||
|
||||
/// A UTF-8 string chunk (`Utf8`).
|
||||
fn utf8(s: &str) -> Chunk<'static> {
|
||||
pub fn utf8(s: &str) -> Chunk<'static> {
|
||||
Chunk::data(*b"Utf8", s.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// A chunk whose body is chunks without being a `LIST` (`pdnm`, `fnam`: `NESTED_IDS`).
|
||||
fn nested(id: [u8; 4], children: Vec<Chunk<'static>>) -> Chunk<'static> {
|
||||
pub fn nested(id: [u8; 4], children: Vec<Chunk<'static>>) -> Chunk<'static> {
|
||||
Chunk { id, body: Body::Chunks(children), pad: None }
|
||||
}
|
||||
|
||||
/// A static value record holding `v`, after the one After Effects wrote for Glow's A & B Midpoint
|
||||
/// (its header; without the expression).
|
||||
fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
|
||||
pub fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
|
||||
let mut kids = Vec::new();
|
||||
for c in template.children() {
|
||||
match &c.body {
|
||||
@@ -131,7 +131,7 @@ fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
|
||||
}
|
||||
|
||||
/// A keyframed value record: `record`'s header with a key list of linear one-component keys.
|
||||
fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> {
|
||||
pub fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> {
|
||||
let Body::List { children, .. } = record(template, keys.first().map_or(0.0, |k| k.1)).body else { unreachable!("record builds a LIST") };
|
||||
let mut children = children;
|
||||
let mut lhd3 = vec![0u8; 52];
|
||||
@@ -229,39 +229,51 @@ fn find_glow_template(chunks: &[Chunk<'_>]) -> Option<Chunk<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with `fx`; the number replaced.
|
||||
fn replace_glow(chunks: &mut [Chunk<'_>], fx: &Fx, template: &Chunk<'_>) -> usize {
|
||||
/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with the effect `match_name`
|
||||
/// and its `sspc`; the number replaced.
|
||||
fn replace_glow(chunks: &mut [Chunk<'_>], match_name: &str, sspc: &Chunk<'static>) -> usize {
|
||||
let mut n = 0;
|
||||
for ch in chunks {
|
||||
let kids = ch.children_mut();
|
||||
if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0")))
|
||||
&& kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc"))
|
||||
{
|
||||
kids[at] = tdmn(fx.match_name);
|
||||
kids[at + 1] = sspc(fx, template);
|
||||
kids[at] = tdmn(match_name);
|
||||
kids[at + 1] = sspc.clone();
|
||||
n += 1;
|
||||
}
|
||||
n += replace_glow(kids, fx, template);
|
||||
n += replace_glow(kids, match_name, sspc);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `synthetic.aep` with `fx` in place of the Glow effect.
|
||||
pub fn bytes_with(fx: &Fx) -> Vec<u8> {
|
||||
/// `synthetic.aep` with the effect `match_name` in place of the Glow effect; `make` builds its
|
||||
/// `sspc` from the Glow's value record (the template of every record).
|
||||
pub fn bytes_with_effect(match_name: &str, make: impl Fn(&Chunk<'_>) -> Chunk<'static>) -> Vec<u8> {
|
||||
let file = parse(SYNTHETIC).unwrap();
|
||||
let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record");
|
||||
let sspc = make(&template);
|
||||
let mut file = parse(SYNTHETIC).unwrap();
|
||||
assert_eq!(replace_glow(&mut file.chunks, fx, &template), 2, "the Glow effect and its shared definitions");
|
||||
assert_eq!(replace_glow(&mut file.chunks, match_name, &sspc), 2, "the Glow effect and its shared definitions");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// `synthetic.aep` with `fx` in place of the Glow effect.
|
||||
pub fn bytes_with(fx: &Fx) -> Vec<u8> {
|
||||
bytes_with_effect(fx.match_name, |t| sspc(fx, t))
|
||||
}
|
||||
|
||||
/// Imports `synthetic.aep` with `fx` in place of its Glow: the EffectCraft effect `effect` (if
|
||||
/// imported) and the result.
|
||||
pub fn run(fx: &Fx, effect: &str) -> (Option<PropGroup>, ImportResult) {
|
||||
let bytes = bytes_with(fx);
|
||||
import_bytes(&bytes_with(fx), effect)
|
||||
}
|
||||
|
||||
/// Imports `bytes`: the first EffectCraft effect `effect` (if imported) and the result.
|
||||
pub fn import_bytes(bytes: &[u8], effect: &str) -> (Option<PropGroup>, ImportResult) {
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
let r = import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
let g = project
|
||||
.comps()
|
||||
.flat_map(|(_, c)| c.layers.iter())
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
//! Lumetri Color (`ADBE Lumetri`) arrives as EffectCraft's Lumetri Color (`ec.color.lumetri`),
|
||||
//! its parameters found by match name. The effect is built into the committed `synthetic.aep` in
|
||||
//! place of its Glow, in the layout of the 1200 samples (After Effects 2026, docs/aep-format.md
|
||||
//! 3.7): the definitions are a subset in an order of our own, so a mapping by `parT` index fails.
|
||||
|
||||
use effectcraft_aep::import::{ImportOptions, ImportResult, LossCategory, import};
|
||||
use effectcraft_aep::{Body, Chunk, parse, write};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{Project, PropGroup};
|
||||
|
||||
const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep");
|
||||
|
||||
fn tdmn(name: &str) -> Chunk<'static> {
|
||||
let mut b = name.as_bytes().to_vec();
|
||||
b.resize(40, 0);
|
||||
Chunk::data(*b"tdmn", b)
|
||||
}
|
||||
|
||||
/// A chunk holding chunks (`pdnm`, `fnam`: `NESTED_IDS`) with one `Utf8`.
|
||||
fn nested(id: [u8; 4], text: &[u8]) -> Chunk<'static> {
|
||||
Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", text.to_vec())]), pad: None }
|
||||
}
|
||||
|
||||
fn mn(suffix: u16) -> String {
|
||||
format!("ADBE Lumetri-{suffix:04}")
|
||||
}
|
||||
|
||||
/// A parameter definition (`pard`, 148 bytes): the type at byte 15, the name at 16, the default at
|
||||
/// 56 (a float slider's `f64`, a checkbox's or popup's `u32`), a popup's item count at 60, a float
|
||||
/// slider's valid range at 104 / 108 and slider range at 112 / 116 (`f32`).
|
||||
#[derive(Clone)]
|
||||
enum Def {
|
||||
Float { name: &'static str, default: f64, min: f32, max: f32 },
|
||||
Check { default: bool },
|
||||
Popup { name: &'static str, items: &'static [&'static str] },
|
||||
Data { name: &'static str },
|
||||
Group { name: &'static str, end: bool },
|
||||
}
|
||||
|
||||
fn pard(d: &Def) -> Vec<Chunk<'static>> {
|
||||
let mut b = vec![0u8; 148];
|
||||
let name = |b: &mut Vec<u8>, n: &str| b[16..16 + n.len()].copy_from_slice(n.as_bytes());
|
||||
let mut extra = Vec::new();
|
||||
match d {
|
||||
Def::Float { name: n, default, min, max } => {
|
||||
b[15] = 10;
|
||||
name(&mut b, n);
|
||||
b[56..64].copy_from_slice(&default.to_be_bytes());
|
||||
b[104..108].copy_from_slice(&min.to_be_bytes());
|
||||
b[108..112].copy_from_slice(&max.to_be_bytes());
|
||||
b[112..116].copy_from_slice(&min.to_be_bytes());
|
||||
b[116..120].copy_from_slice(&max.to_be_bytes());
|
||||
}
|
||||
Def::Check { default } => {
|
||||
b[15] = 4;
|
||||
name(&mut b, " ");
|
||||
b[56..60].copy_from_slice(&u32::from(*default).to_be_bytes());
|
||||
}
|
||||
Def::Popup { name: n, items } => {
|
||||
b[15] = 7;
|
||||
name(&mut b, n);
|
||||
b[56..60].copy_from_slice(&1u32.to_be_bytes());
|
||||
b[60..62].copy_from_slice(&u16::try_from(items.len()).unwrap().to_be_bytes());
|
||||
extra.push(nested(*b"pdnm", items.join("|").as_bytes()));
|
||||
}
|
||||
Def::Data { name: n } => {
|
||||
b[15] = 11;
|
||||
name(&mut b, n);
|
||||
}
|
||||
Def::Group { name: n, end } => {
|
||||
b[15] = if *end { 14 } else { 13 };
|
||||
name(&mut b, n);
|
||||
}
|
||||
}
|
||||
let mut v = vec![Chunk::data(*b"pard", b)];
|
||||
v.extend(extra);
|
||||
v
|
||||
}
|
||||
|
||||
/// A stored value.
|
||||
#[derive(Clone)]
|
||||
enum Rec {
|
||||
/// A number in a record of state `tdsb` (1 written, 3 the default state).
|
||||
Num(f64, u32),
|
||||
/// Arbitrary data.
|
||||
Data(Vec<u8>),
|
||||
/// A value record whose arbitrary data (`aRbs`) is missing.
|
||||
NoData,
|
||||
}
|
||||
|
||||
/// A static value record after the one After Effects wrote for Glow's A & B Midpoint (its header,
|
||||
/// one component).
|
||||
fn record(template: &Chunk<'_>, tdsb: u32, v: f64) -> Chunk<'static> {
|
||||
let mut kids = Vec::new();
|
||||
for c in template.children() {
|
||||
match &c.body {
|
||||
Body::Data(_) if c.id == *b"tdsb" => kids.push(Chunk::data(*b"tdsb", tdsb.to_be_bytes().to_vec())),
|
||||
Body::Data(d) if c.id == *b"tdb4" => kids.push(Chunk::data(c.id, d.to_vec())),
|
||||
Body::Data(_) if c.id == *b"cdat" => {
|
||||
let mut d = vec![0u8; 40];
|
||||
d[..8].copy_from_slice(&v.to_be_bytes());
|
||||
kids.push(Chunk::data(*b"cdat", d));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Chunk::list(*b"tdbs", kids)
|
||||
}
|
||||
|
||||
/// The defaults of a fresh instance's data (1200 `lumetri-00-defaults`).
|
||||
fn empty() -> Vec<u8> {
|
||||
vec![0xfe, 0xfe]
|
||||
}
|
||||
|
||||
/// A Lumetri curve: type, point count, points (`f64` x, y), padded to 520 bytes.
|
||||
fn curve(kind: i32, pts: &[(f64, f64)]) -> Vec<u8> {
|
||||
let mut d = kind.to_le_bytes().to_vec();
|
||||
d.extend(u32::try_from(pts.len()).unwrap().to_le_bytes());
|
||||
for (x, y) in pts {
|
||||
d.extend(x.to_le_bytes());
|
||||
d.extend(y.to_le_bytes());
|
||||
}
|
||||
d.resize(520, 0);
|
||||
d
|
||||
}
|
||||
|
||||
fn rgb_curves() -> Vec<u8> {
|
||||
(0..4).flat_map(|_| curve(0, &[(0.0, 0.0), (1.0, 1.0)])).collect()
|
||||
}
|
||||
|
||||
fn wheels(n: usize) -> Vec<u8> {
|
||||
(0..n).flat_map(|_| [0.0f64, 0.5, 0.0].into_iter().flat_map(f64::to_le_bytes)).collect()
|
||||
}
|
||||
|
||||
fn utf16(s: &str) -> Vec<u8> {
|
||||
s.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
/// HSL Secondary's key data as 1200 `lumetri-01-set-all` stores it (Set color RGB 0.02 0.55 0.85).
|
||||
const HSL_PICKED: &str = "01327f0f3fcdcc4c3dcdcccc3d01d519683f2e57c93dcade173e01dfdede3ecdcc4c3dcdcccc3d01";
|
||||
/// A fresh instance's key data (`-00-defaults`).
|
||||
const HSL_FRESH: &str = "000000003f0000000000000000010000003f0000000000000000010000003f000000000000000001";
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
const MASK_STYLE: &[&str] = &["Color/Gray", "Color/Black", "White/Black"];
|
||||
const LOOKS: &[&str] = &["None", "[Custom]", "Browse...", "(-", "SL BIG", "SL BLUE ICE"];
|
||||
|
||||
/// The definitions, an order of our own (not After Effects' `parT` order) and without the ones
|
||||
/// After Effects 2026 added late (-0127 ... -0130).
|
||||
fn defs() -> Vec<(u16, Def)> {
|
||||
let f = |name, default, min, max| Def::Float { name, default, min, max };
|
||||
vec![
|
||||
(1, Def::Data { name: "Blob" }),
|
||||
(62, Def::Check { default: false }),
|
||||
(2, Def::Group { name: "Basic Correction", end: false }),
|
||||
(3, Def::Check { default: true }),
|
||||
(95, Def::Data { name: " " }),
|
||||
(64, f("HDR Specular", 0.0, -150.0, 150.0)),
|
||||
(16, f("Blacks", 0.0, -150.0, 150.0)),
|
||||
(11, f("Exposure", 0.0, -7.0, 7.0)),
|
||||
(12, f("Contrast", 0.0, -150.0, 150.0)),
|
||||
(7, f("Temperature", 0.0, -300.0, 300.0)),
|
||||
(21, Def::Group { name: "", end: true }),
|
||||
(22, Def::Group { name: "Creative", end: false }),
|
||||
(23, Def::Check { default: true }),
|
||||
(96, Def::Data { name: " " }),
|
||||
(25, Def::Popup { name: "Look", items: LOOKS }),
|
||||
(26, f("Intensity", 100.0, 0.0, 200.0)),
|
||||
(32, Def::Data { name: "Split Toning" }),
|
||||
(35, Def::Group { name: "", end: true }),
|
||||
(39, Def::Data { name: "RGB Curve" }),
|
||||
(104, Def::Check { default: true }),
|
||||
(42, Def::Data { name: "Hue vs Sat" }),
|
||||
(106, Def::Data { name: "Hue vs Hue" }),
|
||||
(47, Def::Data { name: "Wheels" }),
|
||||
(68, Def::Check { default: true }),
|
||||
(73, Def::Data { name: "HSL Sliders" }),
|
||||
(74, Def::Check { default: false }),
|
||||
(75, Def::Popup { name: " ", items: MASK_STYLE }),
|
||||
(85, Def::Data { name: "Color Wheels" }),
|
||||
(86, f("Temperature", 0.0, -300.0, 300.0)),
|
||||
(50, Def::Check { default: true }),
|
||||
(51, f("Amount", 0.0, -5.0, 5.0)),
|
||||
(54, f("Feather", 50.0, 0.0, 100.0)),
|
||||
(97, Def::Check { default: false }),
|
||||
]
|
||||
}
|
||||
|
||||
/// The data every instance stores, at the defaults.
|
||||
fn default_data() -> Vec<(u16, Rec)> {
|
||||
vec![
|
||||
(1, Rec::Data(empty())),
|
||||
(95, Rec::Data(empty())),
|
||||
(96, Rec::Data(empty())),
|
||||
(32, Rec::Data(vec![0; 32])),
|
||||
(39, Rec::Data(rgb_curves())),
|
||||
(42, Rec::Data(curve(3, &[]))),
|
||||
(106, Rec::Data(curve(3, &[]))),
|
||||
(47, Rec::Data(wheels(4))),
|
||||
(73, Rec::Data(hex(HSL_FRESH))),
|
||||
(85, Rec::Data(wheels(8))),
|
||||
]
|
||||
}
|
||||
|
||||
/// The Lumetri instance: the definitions, then the stored values `recs` (data replaces the
|
||||
/// default data of the same parameter).
|
||||
fn sspc(defs: &[(u16, Def)], recs: &[(u16, Rec)], template: &Chunk<'_>) -> Chunk<'static> {
|
||||
let mut part = vec![Chunk::data(*b"parn", u32::try_from(defs.len() + 2).unwrap().to_be_bytes().to_vec()), tdmn(&mn(0))];
|
||||
let mut layer = vec![0u8; 148];
|
||||
layer[15] = 0;
|
||||
part.push(Chunk::data(*b"pard", layer));
|
||||
for (s, d) in defs {
|
||||
part.push(tdmn(&mn(*s)));
|
||||
part.extend(pard(d));
|
||||
}
|
||||
part.push(tdmn("ADBE Effect Built In Params"));
|
||||
let mut end = vec![0u8; 148];
|
||||
end[15] = 9;
|
||||
part.push(Chunk::data(*b"pard", end));
|
||||
let mut all: Vec<(u16, Rec)> = default_data().into_iter().filter(|(s, _)| !recs.iter().any(|(r, _)| r == s)).collect();
|
||||
all.extend(recs.iter().cloned());
|
||||
let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec())];
|
||||
for (s, r) in all {
|
||||
values.push(tdmn(&mn(s)));
|
||||
match r {
|
||||
Rec::Num(v, tdsb) => values.push(record(template, tdsb, v)),
|
||||
Rec::Data(d) => {
|
||||
values.push(record(template, 1, 0.0));
|
||||
values.push(Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", d)]));
|
||||
}
|
||||
Rec::NoData => values.push(record(template, 1, 0.0)),
|
||||
}
|
||||
}
|
||||
Chunk::list(*b"sspc", vec![nested(*b"fnam", b"Lumetri Color"), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)])
|
||||
}
|
||||
|
||||
fn find_glow_template<'a>(chunks: &'a [Chunk<'a>]) -> Option<Chunk<'a>> {
|
||||
for c in chunks {
|
||||
let kids = c.children();
|
||||
if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2-0011\0")))
|
||||
&& let Some(record) = kids.get(at + 1).filter(|k| k.is_list(b"tdbs"))
|
||||
{
|
||||
return Some(record.clone());
|
||||
}
|
||||
if let Some(t) = find_glow_template(kids) {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Puts the instance in place of the Glow effect (in the effect parade and the project's shared
|
||||
/// definitions); the number of places.
|
||||
fn replace_glow(chunks: &mut [Chunk<'_>], instance: &Chunk<'static>) -> usize {
|
||||
let mut n = 0;
|
||||
for ch in chunks {
|
||||
let kids = ch.children_mut();
|
||||
if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0")))
|
||||
&& kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc"))
|
||||
{
|
||||
kids[at] = tdmn("ADBE Lumetri");
|
||||
kids[at + 1] = instance.clone();
|
||||
n += 1;
|
||||
}
|
||||
n += replace_glow(kids, instance);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn run_with(defs: &[(u16, Def)], recs: &[(u16, Rec)]) -> (PropGroup, ImportResult) {
|
||||
let file = parse(SYNTHETIC).unwrap();
|
||||
let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record");
|
||||
let instance = sspc(defs, recs, &template);
|
||||
let mut file = parse(SYNTHETIC).unwrap();
|
||||
assert_eq!(replace_glow(&mut file.chunks, &instance), 2, "the Glow effect and its shared definitions");
|
||||
let bytes = write(&file).unwrap();
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
let g = project
|
||||
.comps()
|
||||
.flat_map(|(_, c)| c.layers.iter())
|
||||
.find_map(|l| l.props.sub("effects").and_then(|fx| fx.children.iter().filter_map(|n| n.as_group()).find(|g| g.match_id == "ec.color.lumetri").cloned()))
|
||||
.expect("an EffectCraft Lumetri Color");
|
||||
(g, r)
|
||||
}
|
||||
|
||||
fn run(recs: &[(u16, Rec)]) -> (PropGroup, ImportResult) {
|
||||
run_with(&defs(), recs)
|
||||
}
|
||||
|
||||
fn losses(r: &ImportResult) -> Vec<String> {
|
||||
r.report
|
||||
.losses
|
||||
.iter()
|
||||
.filter(|l| matches!(l.category, LossCategory::Effects | LossCategory::DamagedData))
|
||||
.flat_map(|l| l.details.clone())
|
||||
.filter(|d| d.contains("Lumetri"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn num(g: &PropGroup, id: &str) -> f64 {
|
||||
g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.as_f64()
|
||||
}
|
||||
|
||||
fn val(g: &PropGroup, id: &str) -> Value {
|
||||
g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_lumetri_arrives_without_losses() {
|
||||
let (g, r) = run(&[]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
assert_eq!((num(&g, "basicCorrection/tone/exposure"), num(&g, "creative/lookIntensity"), num(&g, "vignette/vignetteFeather")), (0.0, 100.0, 50.0));
|
||||
assert_eq!(val(&g, "creative/look"), Value::Enum(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_values_arrive_by_match_name_also_from_default_state_records() {
|
||||
// 1200 `lumetri-01-set-all` / `-06-sec-light`: High Dynamic Range and HDR Specular are
|
||||
// recorded in the default state (`tdsb` 3) and read back as set.
|
||||
let (g, r) = run(&[
|
||||
(62, Rec::Num(1.0, 3)),
|
||||
(64, Rec::Num(22.0, 3)),
|
||||
(11, Rec::Num(2.589_999_914_169_311_5, 1)),
|
||||
(12, Rec::Num(37.0, 1)),
|
||||
(16, Rec::Num(-150.0, 1)),
|
||||
(7, Rec::Num(300.0, 1)),
|
||||
(26, Rec::Num(134.0, 1)),
|
||||
(51, Rec::Num(1.85, 1)),
|
||||
(74, Rec::Num(1.0, 1)),
|
||||
(75, Rec::Num(3.0, 1)),
|
||||
(73, Rec::Data(hex(HSL_PICKED))),
|
||||
]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
assert_eq!(val(&g, "highDynamicRange"), Value::Bool(true));
|
||||
for (id, want) in [
|
||||
("basicCorrection/tone/hdrSpecular", 22.0),
|
||||
("basicCorrection/tone/exposure", 2.589_999_914_169_311_5),
|
||||
("basicCorrection/tone/contrast", 37.0),
|
||||
("basicCorrection/tone/blacks", -150.0),
|
||||
// Past EffectCraft's old slider range: kept (the range is After Effects' now).
|
||||
("basicCorrection/whiteBalance/temperature", 300.0),
|
||||
("creative/lookIntensity", 134.0),
|
||||
("vignette/vignetteAmount", 1.85),
|
||||
] {
|
||||
assert_eq!(num(&g, id), want, "{id}");
|
||||
}
|
||||
assert_eq!((val(&g, "hslSecondary/key/showMask"), val(&g, "hslSecondary/key/maskStyle")), (Value::Bool(true), Value::Enum(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_switched_off_section_is_reported_and_not_applied() {
|
||||
// 1200 `-03-sec-basic-correction`, `-17-sec-vignette`: a section that is off does nothing.
|
||||
let (g, r) = run(&[(3, Rec::Num(0.0, 1)), (11, Rec::Num(2.0, 1)), (50, Rec::Num(0.0, 1)), (51, Rec::Num(-2.0, 1)), (12, Rec::Num(37.0, 1))]);
|
||||
assert_eq!((num(&g, "basicCorrection/tone/exposure"), num(&g, "basicCorrection/tone/contrast"), num(&g, "vignette/vignetteAmount")), (0.0, 0.0, 0.0));
|
||||
let l = losses(&r);
|
||||
assert_eq!(l.len(), 2, "{l:?}");
|
||||
assert!(l[0].contains("section Basic Correction is switched off") && l[0].contains("Exposure 2, Contrast 37"), "{l:?}");
|
||||
assert!(l[1].contains("section Vignette is switched off") && l[1].contains("Amount -2"), "{l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_look_is_reported_by_name() {
|
||||
// 1200 `-01-set-all`: the Look's name in -0096, the popup's record in the default state.
|
||||
let (g, r) = run(&[(96, Rec::Data(utf16("SL BLUE ICE"))), (25, Rec::Num(6.0, 3))]);
|
||||
assert_eq!(val(&g, "creative/look"), Value::Enum(0));
|
||||
let l = losses(&r);
|
||||
assert_eq!(l.len(), 1, "{l:?}");
|
||||
assert!(l[0].contains("the Look 'SL BLUE ICE' is not imported"), "{l:?}");
|
||||
// Only the popup: its item.
|
||||
let (_, r) = run(&[(25, Rec::Num(5.0, 1))]);
|
||||
assert!(losses(&r).iter().any(|d| d.contains("the Look 'SL BIG' is not imported")), "{:?}", losses(&r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_vs_sat_arrives_with_its_y_scaled_other_moved_curves_are_reported() {
|
||||
// 1200 `-11-sec-hue-saturation-curves`: the selector put three neutral points on the curve.
|
||||
let neutral = curve(3, &[(0.742_483, 0.0), (0.798_038, 0.0), (0.853_594, 0.0)]);
|
||||
let (g, r) = run(&[(42, Rec::Data(neutral))]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new()));
|
||||
// 1200 `-61-preset-curves-huesat`: red raised to 0.97 (After Effects nearly doubles its
|
||||
// saturation), the neighbours pinned near 0. EffectCraft's y: 0.5 + y / 2.
|
||||
let preset = curve(3, &[(0.015_463_917_525_773_196, 0.97), (0.262_886_597_938_144_34, -0.03), (0.764_604_810_996_563_5, -0.02)]);
|
||||
let (g, r) = run(&[(42, Rec::Data(preset))]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
let Value::Str(text) = val(&g, "curves/hueSaturationCurves/hueVsSat") else { panic!("a point list") };
|
||||
let pts: Vec<(f64, f64)> = text
|
||||
.split(' ')
|
||||
.map(|p| {
|
||||
let (x, y) = p.split_once(',').unwrap();
|
||||
(x.parse().unwrap(), y.parse().unwrap())
|
||||
})
|
||||
.collect();
|
||||
let want = [(0.015_464, 0.985), (0.262_887, 0.485), (0.764_605, 0.49)];
|
||||
assert_eq!(pts.len(), 3, "{text}");
|
||||
for ((x, y), (wx, wy)) in pts.iter().zip(want) {
|
||||
assert!((x - wx).abs() < 1e-5 && (y - wy).abs() < 1e-9, "{text}");
|
||||
}
|
||||
// Hue vs Hue moved: no sample tells its scale, reported and left neutral.
|
||||
let (g, r) = run(&[(106, Rec::Data(curve(3, &[(0.5, 0.25)])))]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("Hue vs Hue (ADBE Lumetri-0106) is not imported"), "{l:?}");
|
||||
assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsHue"), Value::Str(String::new()));
|
||||
// A point count past the data: not a default, reported as damaged, no panic.
|
||||
let mut bad = curve(3, &[]);
|
||||
bad[4..8].copy_from_slice(&1000u32.to_le_bytes());
|
||||
let (g, r) = run(&[(42, Rec::Data(bad))]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("Hue vs Sat (ADBE Lumetri-0042) is unreadable"), "{l:?}");
|
||||
assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_master_rgb_curve_arrives_moved_channel_curves_are_reported() {
|
||||
// 1200 `-60-preset-curves-rgb`: the first of the four curves, its midpoint raised.
|
||||
let master = curve(0, &[(0.0, 0.0), (0.491_408_934_707_903_76, 0.786_885_245_901_639_3), (1.0, 1.0)]);
|
||||
let identity = curve(0, &[(0.0, 0.0), (1.0, 1.0)]);
|
||||
let mut d = master.clone();
|
||||
(0..3).for_each(|_| d.extend(&identity));
|
||||
let (g, r) = run(&[(39, Rec::Data(d))]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 0.49140893470790376,0.7868852459016393 1,1".into()));
|
||||
assert_eq!(val(&g, "curves/rgbCurves/red"), Value::Str("0,0 1,1".into()));
|
||||
// A moved channel curve: which channel it is is not known, reported.
|
||||
let mut d = identity.clone();
|
||||
d.extend(&master);
|
||||
(0..2).for_each(|_| d.extend(&identity));
|
||||
let (g, r) = run(&[(39, Rec::Data(d))]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("1 channel curve(s) of RGB Curve (ADBE Lumetri-0039) are not imported"), "{l:?}");
|
||||
assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 1,1".into()));
|
||||
// Damaged: the wrong length.
|
||||
let (_, r) = run(&[(39, Rec::Data(master))]);
|
||||
assert!(losses(&r).iter().any(|x| x.contains("RGB Curve (ADBE Lumetri-0039) is unreadable")), "{:?}", losses(&r));
|
||||
}
|
||||
|
||||
/// A value record without its arbitrary data is damaged, not the default: reported for the RGB
|
||||
/// curve and the HSL key, the neighbours stay as they were.
|
||||
#[test]
|
||||
fn a_value_record_without_its_data_is_reported_as_damaged() {
|
||||
for (suffix, name) in [(39, "RGB Curve (ADBE Lumetri-0039)"), (73, "HSL Sliders (ADBE Lumetri-0073)")] {
|
||||
let (g, r) = run(&[(suffix, Rec::NoData)]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains(name) && l[0].contains("is missing"), "{suffix}: {l:?}");
|
||||
assert!(r.report.losses.iter().any(|x| x.category == LossCategory::DamagedData), "{suffix}");
|
||||
// The grade stays at the default.
|
||||
assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 1,1".into()));
|
||||
assert_eq!(num(&g, "hslSecondary/key/hueRange"), 180.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_wheels_arrive_as_colours_their_brightness_is_reported() {
|
||||
// 1200 `-62-preset-wheels`: Shadows towards blue with the slider down, Midtones towards red,
|
||||
// Highlights towards yellow-green with the slider up; the fourth wheel at its default.
|
||||
let w: Vec<u8> = [
|
||||
229.119_895_976_548_18,
|
||||
0.369_892_467_494_294_2,
|
||||
0.547_209_348_004_638,
|
||||
0.104_745_366_878_020_72,
|
||||
0.5,
|
||||
0.394_961_840_132_529_44,
|
||||
93.374_076_744_668_57,
|
||||
0.726_881_718_523_399_9,
|
||||
0.318_976_840_675_999_35,
|
||||
0.0,
|
||||
0.5,
|
||||
0.0,
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(f64::to_le_bytes)
|
||||
.collect();
|
||||
let (g, r) = run(&[(47, Rec::Data(w.clone()))]);
|
||||
let colour = |id: &str| match val(&g, id) {
|
||||
Value::Color(c) => c,
|
||||
other => panic!("{id}: {other:?}"),
|
||||
};
|
||||
// The hue's direction: blue strongest in Shadows, red in Midtones, green in Highlights.
|
||||
let s = colour("colorWheels/shadowsWheel");
|
||||
assert!(s[2] > 0.77 && s[0] < 0.23 && (s[0] + s[2] - 1.0).abs() < 1e-9, "{s:?}");
|
||||
let m = colour("colorWheels/midtonesWheel");
|
||||
assert!((m[0] - 0.697_480_920_066_264_7).abs() < 1e-9 && m[1] > m[2], "{m:?}");
|
||||
let h = colour("colorWheels/highlightsWheel");
|
||||
assert!(h[1] > h[0] && h[0] > h[2], "{h:?}");
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("Shadows brightness 0.370") && l[0].contains("Highlights brightness 0.727"), "{l:?}");
|
||||
assert!(!l[0].contains("Midtones") && !l[0].contains("fourth"), "{l:?}");
|
||||
// The fourth wheel set: reported; damaged data: reported, the wheels stay grey.
|
||||
let mut w4 = w;
|
||||
w4[88..96].copy_from_slice(&0.3f64.to_le_bytes());
|
||||
let (_, r) = run(&[(47, Rec::Data(w4))]);
|
||||
assert!(losses(&r).iter().any(|x| x.contains("the fourth wheel")), "{:?}", losses(&r));
|
||||
let (g, r) = run(&[(47, Rec::Data(vec![1; 95]))]);
|
||||
assert!(losses(&r).iter().any(|x| x.contains("Wheels (ADBE Lumetri-0047) is unreadable")), "{:?}", losses(&r));
|
||||
assert_eq!(val(&g, "colorWheels/shadowsWheel"), Value::Color([0.5, 0.5, 0.5, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_picked_hsl_key_becomes_hue_saturation_and_lightness_ranges() {
|
||||
let (g, r) = run(&[(73, Rec::Data(hex(HSL_PICKED)))]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
let close = |id: &str, want: f64| assert!((num(&g, id) - want).abs() < 0.01, "{id}: {} != {want}", num(&g, id));
|
||||
// Hue centre 0.5605 of the circle, range 0.05, tolerance 0.1.
|
||||
close("hslSecondary/key/hueCenter", 201.79);
|
||||
close("hslSecondary/key/hueRange", 18.0);
|
||||
close("hslSecondary/key/hueSoftness", 36.0);
|
||||
// Lightness centre 0.435 +- 0.05, tolerance 0.1, as percent.
|
||||
close("hslSecondary/key/lightnessLow", 38.53);
|
||||
close("hslSecondary/key/lightnessHigh", 48.53);
|
||||
close("hslSecondary/key/lightnessSoftness", 10.0);
|
||||
assert_eq!(val(&g, "hslSecondary/key/hueEnabled"), Value::Bool(true));
|
||||
// With the key picked the correction applies; without, After Effects applies it to nothing
|
||||
// (1200 `-16-sec-correction` renders as `-00-defaults`): left out and reported.
|
||||
let (g, _) = run(&[(73, Rec::Data(hex(HSL_PICKED))), (86, Rec::Num(19.0, 1))]);
|
||||
assert_eq!(num(&g, "hslSecondary/correction/temperature"), 19.0);
|
||||
let (g, r) = run(&[(86, Rec::Num(19.0, 1))]);
|
||||
assert_eq!(num(&g, "hslSecondary/correction/temperature"), 0.0);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("HSL Secondary has no key colour picked") && l[0].contains("Temperature 19"), "{l:?}");
|
||||
// Damaged key data: reported, the default key kept.
|
||||
let (g, r) = run(&[(73, Rec::Data(vec![7; 40]))]);
|
||||
assert!(losses(&r).iter().any(|d| d.contains("HSL Secondary key data is unreadable")), "{:?}", losses(&r));
|
||||
assert_eq!(num(&g, "hslSecondary/key/hueRange"), 180.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecoded_data_and_controls_without_counterpart_are_reported_when_set() {
|
||||
let mut split = vec![0u8; 32];
|
||||
split[0] = 1;
|
||||
let (_, r) = run(&[(32, Rec::Data(split)), (97, Rec::Num(1.0, 3)), (95, Rec::Data(utf16("my.cube")))]);
|
||||
let l = losses(&r);
|
||||
assert_eq!(l.len(), 3, "{l:?}");
|
||||
assert!(l.iter().any(|d| d.contains("Split Toning (ADBE Lumetri-0032) is not imported")), "{l:?}");
|
||||
assert!(l.iter().any(|d| d.contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on")), "{l:?}");
|
||||
assert!(l.iter().any(|d| d.contains("the Input LUT 'my.cube' is not imported")), "{l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_tone_on_is_reported_whatever_the_pard_default_says() {
|
||||
// After the project is opened and saved again, the `pard` default of Legacy Tone And
|
||||
// Saturation reads on (versions/1200-lumetri-26 / -25x / -24x; off in 1200 as the script
|
||||
// saved it): a record that says on is still reported, a missing record is not.
|
||||
let mut d = defs();
|
||||
if let Some((_, Def::Check { default })) = d.iter_mut().find(|(s, _)| *s == 97) {
|
||||
*default = true;
|
||||
}
|
||||
let (_, r) = run_with(&d, &[(97, Rec::Num(1.0, 3))]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on"), "{l:?}");
|
||||
let (_, r) = run_with(&d, &[]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_scale_or_parameter_is_reported_not_guessed() {
|
||||
let mut d = defs();
|
||||
// Exposure with another valid range (an older or newer version): not guessed at.
|
||||
if let Some((_, Def::Float { min, max, .. })) = d.iter_mut().find(|(s, _)| *s == 11) {
|
||||
(*min, *max) = (-100.0, 100.0);
|
||||
}
|
||||
// A parameter this importer does not know, set.
|
||||
d.push((200, Def::Float { name: "Future", default: 0.0, min: -1.0, max: 1.0 }));
|
||||
let (g, r) = run_with(&d, &[(11, Rec::Num(3.0, 1)), (200, Rec::Num(0.5, 1))]);
|
||||
assert_eq!(num(&g, "basicCorrection/tone/exposure"), 0.0);
|
||||
let l = losses(&r);
|
||||
assert!(l.iter().any(|x| x.contains("Exposure (ADBE Lumetri-0011) has the valid maximum Some(100.0), expected 7")), "{l:?}");
|
||||
assert!(l.iter().any(|x| x.contains("Future (ADBE Lumetri-0200) not imported")), "{l:?}");
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! After Effects' Puppet (`ADBE FreePin3`, #3093) arrives as EffectCraft's Puppet
|
||||
//! (`ec.distort.puppet`) in its place in the stack, and what its meshes and pins hold is reported
|
||||
//! mesh by mesh instead of the one line "has the effect 'Puppet'". The effect is built into the
|
||||
//! committed `synthetic.aep` in place of its Glow (`fx_inject`, in memory), in the tree
|
||||
//! docs/aep-format.md 3.7 gives for Puppet. That tree comes from an external scripting listing,
|
||||
//! not a byte sample: these tests prove the reader's walk and its report, not the format.
|
||||
|
||||
use effectcraft_aep::Chunk;
|
||||
use effectcraft_aep::import::{ImportOptions, LossCategory, import};
|
||||
use effectcraft_project::Project;
|
||||
|
||||
use crate::import::fx_inject::{bytes_with_effect, import_bytes, keyed_record, losses, nested, record, tdmn, utf8};
|
||||
|
||||
const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep");
|
||||
const PUPPET: &str = "ADBE FreePin3";
|
||||
const POS_ATOM: &str = "ADBE FreePin3 PosPin Atom";
|
||||
|
||||
fn tdsb(v: u32) -> Chunk<'static> {
|
||||
Chunk::data(*b"tdsb", v.to_be_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// A property group of `entries` (match name, node).
|
||||
fn group(entries: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> {
|
||||
let mut kids = vec![tdsb(1)];
|
||||
for (name, node) in entries {
|
||||
kids.push(tdmn(name));
|
||||
kids.push(node);
|
||||
}
|
||||
Chunk::list(*b"tdgp", kids)
|
||||
}
|
||||
|
||||
/// A pin atom with its hidden vertex records and Position (keyed when `keys`). The values are
|
||||
/// one-component records: the reader only asks whether a pin is keyed.
|
||||
fn pin(t: &Chunk<'_>, atom: &'static str, keys: bool) -> (&'static str, Chunk<'static>) {
|
||||
let position = if keys { keyed_record(t, &[(0, 1.0), (25_600, 2.0)]) } else { record(t, 1.0) };
|
||||
(
|
||||
atom,
|
||||
group(vec![
|
||||
("ADBE FreePin3 PosPin Vtx Offset", record(t, 0.0)),
|
||||
("ADBE FreePin3 PosPin Vtx Index", record(t, 7.0)),
|
||||
("ADBE FreePin3 PosPin Position", position),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
/// `Mesh 1`: Triangles 500, Expansion 5, four Deform pins (one keyed, one with an atom name
|
||||
/// nobody knows) and a property among them, no Overlap pin, one Stiffness pin, and a group
|
||||
/// nobody knows.
|
||||
fn full_mesh(t: &Chunk<'_>) -> Chunk<'static> {
|
||||
let mesh_data =
|
||||
Chunk::list(*b"tdbs", vec![tdsb(1), Chunk::data(*b"cdat", vec![0u8; 4]), Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", vec![1, 2, 3, 4])])]);
|
||||
let deform = group(vec![
|
||||
pin(t, POS_ATOM, false),
|
||||
pin(t, POS_ATOM, true),
|
||||
pin(t, POS_ATOM, false),
|
||||
pin(t, "ADBE FreePin3 Test Atom", false),
|
||||
("ADBE FreePin3 Test Prop", record(t, 3.0)),
|
||||
]);
|
||||
let starch = group(vec![("ADBE FreePin3 StarchPin Atom", group(vec![("ADBE FreePin3 StarchPin Amount", record(t, 0.5))]))]);
|
||||
group(vec![
|
||||
("ADBE FreePin3 Mesh", mesh_data),
|
||||
("ADBE FreePin3 Mesh Tri Count", record(t, 500.0)),
|
||||
("ADBE FreePin3 Mesh Expansion", record(t, 5.0)),
|
||||
("ADBE FreePin3 PosPins", deform),
|
||||
("ADBE FreePin3 HghtPins", group(vec![])),
|
||||
("ADBE FreePin3 StarchPins", starch),
|
||||
("ADBE FreePin3 Test Group", group(vec![])),
|
||||
])
|
||||
}
|
||||
|
||||
/// The Puppet instance: `fx_switch` is the value group's `tdsb`, `arap` its mesh tree (`None`:
|
||||
/// none), `extra` more entries of its value group.
|
||||
fn sspc(fx_switch: u32, arap: Option<Chunk<'static>>, extra: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> {
|
||||
let pard = |kind: u8| {
|
||||
let mut b = vec![0u8; 148];
|
||||
b[15] = kind;
|
||||
Chunk::data(*b"pard", b)
|
||||
};
|
||||
let part = vec![Chunk::data(*b"parn", 2u32.to_be_bytes().to_vec()), tdmn("ADBE FreePin3-0000"), pard(0), tdmn("ADBE Effect Built In Params"), pard(9)];
|
||||
let mut values = vec![tdsb(fx_switch)];
|
||||
if let Some(arap) = arap {
|
||||
values.push(tdmn("ADBE FreePin3 ARAP Group"));
|
||||
values.push(arap);
|
||||
}
|
||||
for (name, node) in extra {
|
||||
values.push(tdmn(name));
|
||||
values.push(node);
|
||||
}
|
||||
Chunk::list(*b"sspc", vec![nested(*b"fnam", vec![utf8("Puppet")]), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)])
|
||||
}
|
||||
|
||||
/// The layer that holds `effect`, the effect's place in that layer's stack and the stack's size.
|
||||
fn stack_place(bytes: &[u8], effect: &str) -> Option<(String, usize, usize)> {
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
project.comps().flat_map(|(_, c)| c.layers.iter()).find_map(|l| {
|
||||
let fx: Vec<_> = l.props.sub("effects")?.children.iter().filter_map(|n| n.as_group()).collect();
|
||||
let at = fx.iter().position(|g| g.match_id == effect)?;
|
||||
Some((l.name.clone(), at, fx.len()))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puppet_arrives_and_its_pins_are_reported_mesh_by_mesh() {
|
||||
let bytes = bytes_with_effect(PUPPET, |t| {
|
||||
let arap = group(vec![("ADBE FreePin3 Outlines", group(vec![])), ("ADBE FreePin3 Mesh Group", group(vec![("ADBE FreePin3 Mesh Atom", full_mesh(t))]))]);
|
||||
sspc(1, Some(arap), vec![("ADBE FreePin3 On Transparent", record(t, 1.0))])
|
||||
});
|
||||
let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
|
||||
let g = g.expect("an EffectCraft Puppet in the Glow's place");
|
||||
assert!(g.enabled);
|
||||
assert_eq!(g.name, "Puppet");
|
||||
// No pin is made up: the rest places are not decoded.
|
||||
assert_eq!(effectcraft_effects::puppet::pin_count(&g), 0);
|
||||
// In the Glow's place in its layer's stack (the second of two effects there).
|
||||
assert!(stack_place(SYNTHETIC, "ec.stylize.glow").is_some_and(|(_, at, n)| at > 0 && n > 1));
|
||||
assert_eq!(stack_place(&bytes, effectcraft_effects::puppet::ID), stack_place(SYNTHETIC, "ec.stylize.glow"));
|
||||
let l = losses(&r, LossCategory::Effects, PUPPET);
|
||||
assert_eq!(l.len(), 2, "{l:#?}");
|
||||
assert!(!l.iter().any(|d| d.contains("has the effect")), "not the old one-line report: {l:#?}");
|
||||
assert!(l.iter().any(|d| d.ends_with("property ADBE FreePin3 On Transparent (1) not imported")), "{l:#?}");
|
||||
let mesh = l.iter().find(|d| d.contains("imported without its meshes and pins")).expect("the mesh report");
|
||||
assert!(
|
||||
mesh.ends_with(
|
||||
": Mesh 1 (Triangles 500, Expansion 5): Deform 4 pins (1 keyed) [ADBE FreePin3 PosPin Atom, ADBE FreePin3 Test Atom], \
|
||||
Overlap no pins, Stiffness 1 pin (0 keyed) [ADBE FreePin3 StarchPin Atom]; not recognised: ADBE FreePin3 Test Prop, ADBE FreePin3 Test Group"
|
||||
),
|
||||
"{mesh}"
|
||||
);
|
||||
assert_eq!(losses(&r, LossCategory::DamagedData, PUPPET), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_meshes_are_listed_up_to_ten_and_names_once() {
|
||||
let bytes = bytes_with_effect(PUPPET, |_| {
|
||||
let mesh = || group(vec![("ADBE FreePin3 Test Group", group(vec![]))]);
|
||||
let meshes = (0..12).map(|_| ("ADBE FreePin3 Mesh Atom", mesh())).collect();
|
||||
sspc(1, Some(group(vec![("ADBE FreePin3 Mesh Group", group(meshes))])), vec![])
|
||||
});
|
||||
let (_, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
|
||||
let l = losses(&r, LossCategory::Effects, PUPPET);
|
||||
assert_eq!(l.len(), 1, "{l:#?}");
|
||||
assert!(l[0].ends_with("Mesh 9: no pin groups; Mesh 10: no pin groups; and 2 more; not recognised: ADBE FreePin3 Test Group"), "{}", l[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_puppet_without_a_mesh_loses_nothing_and_keeps_its_fx_switch() {
|
||||
let bytes = bytes_with_effect(PUPPET, |_| sspc(0, Some(group(vec![("ADBE FreePin3 Mesh Group", group(vec![]))])), vec![]));
|
||||
let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
|
||||
let g = g.expect("an EffectCraft Puppet");
|
||||
assert!(!g.enabled, "switched off in the Effect Controls");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, PUPPET), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_puppet_whose_mesh_tree_is_not_found_is_reported() {
|
||||
let bytes = bytes_with_effect(PUPPET, |t| sspc(1, None, vec![("ADBE FreePin3 Other Group", group(vec![("x", record(t, 1.0))]))]));
|
||||
let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
|
||||
assert!(g.is_some(), "the effect still arrives");
|
||||
let l = losses(&r, LossCategory::Effects, PUPPET);
|
||||
assert_eq!(l.len(), 1, "{l:#?}");
|
||||
assert!(
|
||||
l[0].ends_with(
|
||||
"imported without meshes or pins: its mesh group (ADBE FreePin3 ARAP Group / ADBE FreePin3 Mesh Group) was not found or is not a group; \
|
||||
not recognised: ADBE FreePin3 Other Group"
|
||||
),
|
||||
"{}",
|
||||
l[0]
|
||||
);
|
||||
}
|
||||
@@ -626,9 +626,10 @@ fn bogus_effect_structure_is_reported_not_imported_blindly() {
|
||||
"{r:?}"
|
||||
);
|
||||
|
||||
// No project-level definitions: a Ramp with an empty parT cannot be read and is reported; one
|
||||
// with its own parT still arrives. (In the single-case file one of the two had its own; in
|
||||
// the combined file an earlier case holds the definitions, so both take the project copy.)
|
||||
// No project-level definitions: a Ramp with an empty parT borrows the definitions of a Ramp
|
||||
// in the file that has its own parT (#3170). In the single-case file one of the two has its
|
||||
// own, so both arrive; in the case cut from the combined file an earlier case (not kept)
|
||||
// held them, so neither has readable definitions and both are reported.
|
||||
let own = {
|
||||
let bytes = skip!(crate::common::try_fixture("383-fx-ramp-on-100x100-start-10-20.aep"));
|
||||
let file = parse(&bytes).unwrap();
|
||||
@@ -646,8 +647,9 @@ fn bogus_effect_structure_is_reported_not_imported_blindly() {
|
||||
}
|
||||
}));
|
||||
let r = report_of(&bytes);
|
||||
assert_eq!(r.imported.effects, own, "{r:?}");
|
||||
assert_eq!(r.count(effectcraft_aep::import::LossCategory::Effects), 2 - own, "{r:?}");
|
||||
let arrive = if own > 0 { 2 } else { 0 };
|
||||
assert_eq!(r.imported.effects, arrive, "{r:?}");
|
||||
assert_eq!(r.count(effectcraft_aep::import::LossCategory::Effects), 2 - arrive, "{r:?}");
|
||||
assert!(try_all(&bytes)); // 368, the first Gradient Ramp of its file, has its own parT: it still arrives.
|
||||
let bytes = skip!(mutate("368-fx-ramp-default.aep", &mut |c| {
|
||||
if let Body::List { list_type, .. } = &mut c.body
|
||||
|
||||
@@ -77,6 +77,167 @@ fn a_sequence_with_long_names_is_not_expanded_past_the_byte_budget() {
|
||||
assert_eq!(longest, 0, "the sequence was not expanded");
|
||||
}
|
||||
|
||||
/// The project with the sequence's naming (the chunks between `Als2` and `opti` in its `Pin `)
|
||||
/// replaced by `naming`, and the `sspc` numbering (start, end, digits at 172, 176, 180) set to
|
||||
/// `numbering` when given.
|
||||
fn with_sequence_naming(naming: &[Chunk<'static>], numbering: Option<[u32; 3]>) -> Vec<u8> {
|
||||
let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
|
||||
let mut patched = 0;
|
||||
visit(&mut file.chunks, &mut |c| {
|
||||
if !c.is_list(b"Pin ") {
|
||||
return;
|
||||
}
|
||||
let Body::List { children, .. } = &mut c.body else { return };
|
||||
let Some(als) = children.iter().position(|k| k.is_list(b"Als2")) else { return };
|
||||
let Some(opti) = children.iter().position(|k| k.is(b"opti")) else { return };
|
||||
assert!(als < opti);
|
||||
let tail = children.split_off(opti);
|
||||
children.truncate(als + 1);
|
||||
children.extend(naming.iter().map(shorten));
|
||||
children.extend(tail);
|
||||
if let Some(numbering) = numbering {
|
||||
let sspc = children.iter_mut().find(|k| k.is(b"sspc")).unwrap();
|
||||
for (at, v) in [172, 176, 180].into_iter().zip(numbering) {
|
||||
set_data(sspc, at, &v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
patched += 1;
|
||||
});
|
||||
assert_eq!(patched, 1, "the synthetic project has one sequence");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// `bytes` with the first file name the sequence stores in its png `opti` (byte 58, NUL-terminated)
|
||||
/// set to `name`.
|
||||
fn with_first_file(bytes: &[u8], name: &str) -> Vec<u8> {
|
||||
let mut file: AepFile<'_> = parse(bytes).unwrap();
|
||||
let mut patched = 0;
|
||||
visit(&mut file.chunks, &mut |c| {
|
||||
if c.is(b"opti") && c.bytes().is_some_and(|d| d.get(..4) == Some(&b"png!"[..])) {
|
||||
let mut z = name.as_bytes().to_vec();
|
||||
z.push(0);
|
||||
set_data(c, 58, &z);
|
||||
patched += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(patched, 1, "the synthetic project has one png item");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// The imported sequence item's footage.
|
||||
fn the_footage(p: &Project) -> effectcraft_project::Footage {
|
||||
p.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).expect("the sequence")
|
||||
}
|
||||
|
||||
/// The file names (without the folder) of a footage's sequence.
|
||||
fn file_names(f: &effectcraft_project::Footage) -> Vec<String> {
|
||||
f.sequence.iter().map(|s| s.rsplit(['\\', '/']).next().unwrap_or_default().to_string()).collect()
|
||||
}
|
||||
|
||||
/// A copy of `c` that borrows for any lifetime (a chunk is covariant in its lifetime).
|
||||
fn shorten<'a>(c: &Chunk<'static>) -> Chunk<'a> {
|
||||
c.clone()
|
||||
}
|
||||
|
||||
/// A `Utf8` string chunk holding `s`.
|
||||
fn utf8(s: &str) -> Chunk<'static> {
|
||||
Chunk::data(*b"Utf8", s.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// A sequence whose naming has only a suffix (no filename prefix) imports as a sequence of its files.
|
||||
#[test]
|
||||
fn a_sequence_without_a_filename_prefix_imports_its_files() {
|
||||
// `0001.png` .. `0003.png`: After Effects omits the empty prefix, so only `.png` follows the
|
||||
// alias. Before, `.png` was read as the prefix, the suffix was missing and the item became a
|
||||
// video linked to its folder.
|
||||
let (p, r) = run(&with_sequence_naming(&[utf8(".png")], None), true);
|
||||
r.unwrap();
|
||||
let f = the_footage(&p);
|
||||
assert_eq!(f.kind, FootageKind::Sequence);
|
||||
assert_eq!(file_names(&f), ["0001.png", "0002.png", "0003.png"]);
|
||||
assert!(f.path.ends_with("0001.png"), "{}", f.path);
|
||||
}
|
||||
|
||||
/// A single `Utf8` naming part is read as the suffix or the prefix depending on how the stored first file name matches it.
|
||||
#[test]
|
||||
fn a_single_naming_part_is_placed_by_the_stored_first_file_name() {
|
||||
// `0001_v2.png` ..: the one `Utf8` is the suffix, as the first file name (`opti`) ends with it.
|
||||
let bytes = with_first_file(&with_sequence_naming(&[utf8("_v2.png")], None), "0001_v2.png");
|
||||
let (p, r) = run(&bytes, true);
|
||||
r.unwrap();
|
||||
let f = the_footage(&p);
|
||||
assert_eq!(f.kind, FootageKind::Sequence);
|
||||
assert_eq!(file_names(&f), ["0001_v2.png", "0002_v2.png", "0003_v2.png"]);
|
||||
// `shot_0001` ..: a name without an extension; the one `Utf8` is the prefix.
|
||||
let bytes = with_first_file(&with_sequence_naming(&[utf8("shot_")], None), "shot_0001");
|
||||
let (p, r) = run(&bytes, true);
|
||||
r.unwrap();
|
||||
assert_eq!(file_names(&the_footage(&p)), ["shot_0001", "shot_0002", "shot_0003"]);
|
||||
// Without that evidence (the stored first file is `shot_0001.png`), a part that is no extension
|
||||
// is not guessed: the item is reported.
|
||||
let bytes = with_sequence_naming(&[utf8("_v2.png")], None);
|
||||
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
|
||||
}
|
||||
|
||||
/// A file list containing a name with a separator, drive colon, dot directory or control character is refused and reported.
|
||||
#[test]
|
||||
fn a_file_list_with_a_name_that_is_not_a_plain_file_name_is_reported() {
|
||||
// The listed names are joined to the folder: a separator, a drive colon or a dot directory
|
||||
// would point outside it, and a NUL or another control character is no file name, so such a
|
||||
// list is not read.
|
||||
for hostile in ["..\\..\\x.png", "../x.png", "C:x.png", "..", ".", "x\0.png", "x\n.png"] {
|
||||
let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 2u32.to_le_bytes().to_vec()), utf8("a.png"), utf8(hostile)]);
|
||||
let bytes = with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0]));
|
||||
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
|
||||
}
|
||||
}
|
||||
|
||||
/// A file list with a missing or too short count chunk is refused and reported.
|
||||
#[test]
|
||||
fn a_file_list_without_a_readable_count_is_reported() {
|
||||
// No `StVS`, or one too short for its `u32`: the list is not read rather than taken whole.
|
||||
for count in [None, Some(vec![2u8, 0])] {
|
||||
let mut kids: Vec<Chunk<'static>> = count.map(|c| Chunk::data(*b"StVS", c)).into_iter().collect();
|
||||
kids.extend([utf8("a.png"), utf8("b.png")]);
|
||||
let bytes = with_sequence_naming(&[Chunk::list(*b"StVc", kids)], Some([u32::MAX, u32::MAX, 0]));
|
||||
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
|
||||
}
|
||||
}
|
||||
|
||||
/// A force-alphabetical sequence imports its stored file list in the stored order.
|
||||
#[test]
|
||||
fn a_sequence_imported_with_force_alphabetical_order_imports_its_file_list() {
|
||||
// Force alphabetical order: `LIST StVc` with `StVS` (the count, little-endian) and one `Utf8`
|
||||
// per file instead of the prefix and suffix; the numbering in `sspc` is not set. Before, the
|
||||
// item became a video linked to its folder.
|
||||
let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 3u32.to_le_bytes().to_vec()), utf8("b_take.png"), utf8("a_take.png"), utf8("c_take.png")]);
|
||||
let (p, r) = run(&with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])), true);
|
||||
let r = r.unwrap();
|
||||
let f = the_footage(&p);
|
||||
assert_eq!(f.kind, FootageKind::Sequence);
|
||||
// The stored order is kept.
|
||||
assert_eq!(file_names(&f), ["b_take.png", "a_take.png", "c_take.png"]);
|
||||
assert!(f.path.ends_with("b_take.png"), "{}", f.path);
|
||||
assert!(losses(&r, LossCategory::DamagedData).is_empty(), "{:?}", r.report.losses);
|
||||
// The count bounds the list.
|
||||
let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 2u32.to_le_bytes().to_vec()), utf8("x.png"), utf8("y.png"), utf8("z.png")]);
|
||||
let (p, r) = run(&with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])), true);
|
||||
r.unwrap();
|
||||
assert_eq!(file_names(&the_footage(&p)), ["x.png", "y.png"]);
|
||||
}
|
||||
|
||||
/// A sequence with no naming or an empty file list is reported and linked to its folder.
|
||||
#[test]
|
||||
fn a_sequence_whose_naming_cannot_be_read_is_reported() {
|
||||
// No naming at all after the alias. Before, the item silently linked its folder.
|
||||
let bytes = with_sequence_naming(&[], None);
|
||||
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
|
||||
// An empty file list is no naming either.
|
||||
let empty = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 0u32.to_le_bytes().to_vec())]);
|
||||
let bytes = with_sequence_naming(&[empty], Some([u32::MAX, u32::MAX, 0]));
|
||||
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_solid_with_a_nan_channel_is_repaired_and_reported() {
|
||||
let nan = f32::NAN.to_be_bytes();
|
||||
@@ -273,6 +434,47 @@ fn layer_times_with_a_zero_denominator_are_reported() {
|
||||
assert!(losses(&r.unwrap(), LossCategory::DamagedData).is_empty());
|
||||
}
|
||||
|
||||
/// The time stretch of every imported layer.
|
||||
fn stretches(p: &Project) -> Vec<f64> {
|
||||
p.items.values().filter_map(|i| if let ItemKind::Comp(c) = &i.kind { Some(c) } else { None }).flat_map(|c| c.layers.iter().map(|l| l.stretch)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_stretch_outside_one_to_9900_percent_is_clamped_and_reported() {
|
||||
// ldta byte 8 is the stretch numerator (i32), byte 108 the denominator (u32). 1/(2^31 - 1) is
|
||||
// about 5e-8 %: imported as stored, the layer and audio time divided by ~0 (#3247).
|
||||
for (num, den, want) in [(1i32, i32::MAX as u32, 1.0), (-1, i32::MAX as u32, -1.0), (i32::MAX, 1, 9900.0), (i32::MIN, 1, -9900.0)] {
|
||||
let bytes = mutate(&mut |c| {
|
||||
if c.id == *b"ldta" {
|
||||
set_data(c, 8, &num.to_be_bytes());
|
||||
set_data(c, 108, &den.to_be_bytes());
|
||||
}
|
||||
});
|
||||
let (p, r) = run(&bytes, true);
|
||||
let r = r.unwrap();
|
||||
let found = losses(&r, LossCategory::DamagedData);
|
||||
assert!(found.iter().any(|d| d.contains("time stretch") && d.contains("outside")), "{num}/{den}: {found:?}");
|
||||
let s = stretches(&p);
|
||||
assert!(!s.is_empty() && s.iter().all(|v| *v == want), "{num}/{den}: {s:?}");
|
||||
let (p, r) = run(&bytes, false);
|
||||
assert!(r.is_err(), "{num}/{den}: strict import must refuse");
|
||||
assert!(p.items.is_empty());
|
||||
}
|
||||
// A small stretch within the range (1/64: 1.5625 %) and -9900 % itself are no damage.
|
||||
for (num, den, want) in [(1i32, 64u32, 1.5625), (-99, 1, -9900.0)] {
|
||||
let bytes = mutate(&mut |c| {
|
||||
if c.id == *b"ldta" {
|
||||
set_data(c, 8, &num.to_be_bytes());
|
||||
set_data(c, 108, &den.to_be_bytes());
|
||||
}
|
||||
});
|
||||
let (p, r) = run(&bytes, true);
|
||||
let r = r.unwrap();
|
||||
assert!(!losses(&r, LossCategory::DamagedData).iter().any(|d| d.contains("time stretch")), "{num}/{den}: {:?}", r.report.losses);
|
||||
assert!(stretches(&p).iter().all(|v| *v == want), "{num}/{den}: {:?}", stretches(&p));
|
||||
}
|
||||
}
|
||||
|
||||
/// The project with `nhed` bytes from `at` replaced.
|
||||
fn header(at: usize, bytes: &[u8]) -> Vec<u8> {
|
||||
mutate(&mut |c| {
|
||||
@@ -392,6 +594,186 @@ fn default_state_effect_records_import_like_written_ones() {
|
||||
assert_eq!(clip, vec![("clipToOutputBlack".to_string(), effectcraft_keyframe::Value::Enum(1))]);
|
||||
}
|
||||
|
||||
/// Sets the `cdat` of the value record `param` in the value group `tdgp` (the first `f64`), and
|
||||
/// its state to `tdsb` when given. True when the group has that record.
|
||||
fn patch_record(tdgp: &mut Chunk<'_>, param: &str, tdsb: Option<u32>, value: f64) -> bool {
|
||||
let kids = tdgp.children_mut();
|
||||
let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(param.as_bytes()))) else {
|
||||
return false;
|
||||
};
|
||||
let Some(record) = kids.get_mut(at + 1).filter(|k| k.is_list(b"tdbs")) else { return false };
|
||||
for k in record.children_mut() {
|
||||
if k.is(b"tdsb")
|
||||
&& let Some(state) = tdsb
|
||||
{
|
||||
set_data(k, 0, &state.to_be_bytes());
|
||||
} else if k.is(b"cdat") {
|
||||
set_data(k, 0, &value.to_be_bytes());
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The project without its project-level effect definitions (`EfdG`; some files have none, seen
|
||||
/// in the projects inside After Effects-made Motion Graphics templates) and with a second Glow
|
||||
/// just above the first on the layer: a copy of it whose `parT` is empty, as such files write
|
||||
/// every instance of a type but one. `edit_copy` is applied to every chunk of the copy; with
|
||||
/// `empty_original` the original's `parT` is emptied too.
|
||||
fn second_glow_without_definitions(edit_copy: &mut dyn FnMut(&mut Chunk<'_>), empty_original: bool) -> Vec<u8> {
|
||||
fn empty_part(fx: &mut Chunk<'_>) {
|
||||
for k in fx.children_mut() {
|
||||
if let Body::List { list_type, children, .. } = &mut k.body
|
||||
&& list_type == b"parT"
|
||||
{
|
||||
children.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
|
||||
let mut added = 0;
|
||||
visit(&mut file.chunks, &mut |c| {
|
||||
if let Body::List { list_type, children, .. } = &mut c.body {
|
||||
if list_type == b"EfdG" {
|
||||
*list_type = *b"XfdG";
|
||||
return;
|
||||
}
|
||||
if list_type != b"tdgp" {
|
||||
return;
|
||||
}
|
||||
let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2"))) else {
|
||||
return;
|
||||
};
|
||||
let (Some(name), Some(mut fx)) = (children.get(at).cloned(), children.get(at + 1).filter(|k| k.is_list(b"sspc")).cloned()) else { return };
|
||||
empty_part(&mut fx);
|
||||
edit_copy(&mut fx);
|
||||
visit(fx.children_mut(), edit_copy);
|
||||
if empty_original && let Some(original) = children.get_mut(at + 1) {
|
||||
empty_part(original);
|
||||
}
|
||||
children.insert(at, fx);
|
||||
children.insert(at, name);
|
||||
added += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(added, 1, "one Glow on the layer");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// The imported Glows in stack order, each as its property values by property id.
|
||||
fn glow_values(p: &Project) -> Vec<Vec<(String, effectcraft_keyframe::Value)>> {
|
||||
let glow = effectcraft_project::GroupKind::Effect { effect: "ec.stylize.glow".to_string() };
|
||||
let mut glows = Vec::new();
|
||||
for item in p.items.values() {
|
||||
if let ItemKind::Comp(c) = &item.kind {
|
||||
for layer in &c.layers {
|
||||
for node in layer.effects().map(|fx| fx.children.as_slice()).unwrap_or_default() {
|
||||
if let effectcraft_project::Node::Group(g) = node
|
||||
&& g.kind == glow
|
||||
{
|
||||
let mut values = Vec::new();
|
||||
g.walk("", &mut |_, prop| values.push((prop.match_id.clone(), prop.value.clone())));
|
||||
glows.push(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
glows
|
||||
}
|
||||
|
||||
/// The value of property `id` in `values`.
|
||||
fn value_of<'v>(values: Option<&'v Vec<(String, effectcraft_keyframe::Value)>>, id: &str) -> Option<&'v effectcraft_keyframe::Value> {
|
||||
values?.iter().find(|(i, _)| i == id).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// Every instance of an effect type but one has an empty `parT`; without a project-level copy the
|
||||
/// others borrow the definitions of that one (#3170: 129 Color Controls of a template were
|
||||
/// reported "without readable parameter definitions" and dropped).
|
||||
#[test]
|
||||
fn an_instance_without_definitions_imports_with_those_of_another_instance() {
|
||||
// Both Glows import; before, the copy was reported and not imported.
|
||||
let (p, r) = run(&second_glow_without_definitions(&mut |_| {}, false), true);
|
||||
let r = r.unwrap();
|
||||
let found = losses(&r, LossCategory::Effects);
|
||||
assert!(!found.iter().any(|d| d.contains("without readable parameter definitions") || d.contains("no parameter definition")), "{found:?}");
|
||||
assert_eq!(r.report.imported.effects, 3, "Levels and both Glows: {:?}", r.report.losses);
|
||||
let glows = glow_values(&p);
|
||||
assert_eq!(glows.len(), 2);
|
||||
assert_eq!(glows.first(), glows.get(1), "the copy has the same records, so the same values");
|
||||
|
||||
// The values come from each instance's own records: the copy's A & B Midpoint (`-0011`) set
|
||||
// to 0.75 arrives as 75 % in the copy only; the Glow it borrows from keeps its own.
|
||||
let mut patched = 0;
|
||||
let bytes = second_glow_without_definitions(
|
||||
&mut |c| {
|
||||
if c.is_list(b"tdgp") && patch_record(c, "ADBE Glo2-0011", None, 0.75) {
|
||||
patched += 1;
|
||||
}
|
||||
},
|
||||
false,
|
||||
);
|
||||
assert_eq!(patched, 1, "the copy's record");
|
||||
let (p, r) = run(&bytes, true);
|
||||
r.unwrap();
|
||||
let glows = glow_values(&p);
|
||||
let seventy_five = effectcraft_keyframe::Value::Scalar(75.0);
|
||||
assert_eq!(value_of(glows.first(), "abMidpoint"), Some(&seventy_five));
|
||||
let own = value_of(glows.get(1), "abMidpoint");
|
||||
assert!(own.is_some() && own != Some(&seventy_five), "{own:?}");
|
||||
}
|
||||
|
||||
/// A value record that the borrowed definitions do not name is reported, not dropped silently.
|
||||
#[test]
|
||||
fn a_value_record_the_borrowed_definitions_do_not_name_is_reported() {
|
||||
let bytes = second_glow_without_definitions(
|
||||
&mut |c| {
|
||||
if c.is(b"tdmn") && c.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2-0011")) {
|
||||
set_data(c, 0, b"ADBE Glo2-0099\0");
|
||||
}
|
||||
},
|
||||
false,
|
||||
);
|
||||
let (_, r) = run(&bytes, true);
|
||||
let r = r.unwrap();
|
||||
let found = losses(&r, LossCategory::Effects);
|
||||
assert!(found.iter().any(|d| d.contains("ADBE Glo2-0099") && d.contains("no parameter definition")), "{found:?}");
|
||||
assert_eq!(r.report.imported.effects, 3, "{:?}", r.report.losses);
|
||||
}
|
||||
|
||||
/// When no instance of the type keeps its definitions, every instance is reported.
|
||||
#[test]
|
||||
fn instances_without_any_definitions_are_reported() {
|
||||
let (p, r) = run(&second_glow_without_definitions(&mut |_| {}, true), true);
|
||||
let r = r.unwrap();
|
||||
let found = losses(&r, LossCategory::Effects);
|
||||
assert_eq!(found.iter().filter(|d| d.contains("ADBE Glo2") && d.contains("without readable parameter definitions, not imported")).count(), 2, "{found:?}");
|
||||
assert_eq!(r.report.imported.effects, 1, "Levels only: {:?}", r.report.losses);
|
||||
assert!(glow_values(&p).is_empty());
|
||||
}
|
||||
|
||||
/// The reader's side: the copy's definitions come from the other instance.
|
||||
#[test]
|
||||
fn an_instance_without_definitions_reads_those_of_another_instance() {
|
||||
let bytes = second_glow_without_definitions(&mut |_| {}, false);
|
||||
let file = parse(&bytes).unwrap();
|
||||
let project = effectcraft_aep::Project::new(&file).unwrap();
|
||||
let defs = project.effect_definition_index();
|
||||
let mut sources = Vec::new();
|
||||
for item in project.all_items() {
|
||||
let Some(comp) = item.comp() else { continue };
|
||||
for layer in comp.layers() {
|
||||
for fx in layer.effects().iter().filter(|fx| fx.match_name() == "ADBE Glo2") {
|
||||
sources.push(defs.source(fx));
|
||||
let params = defs.params(fx);
|
||||
assert_eq!(params.len(), 16, "Glow's parameters");
|
||||
assert_eq!(params.iter().all(|p| p.borrowed_definition), defs.source(fx) == effectcraft_aep::DefinitionSource::OtherInstance);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The copy is above the instance it borrows from (earlier in the stack).
|
||||
assert_eq!(sources, [effectcraft_aep::DefinitionSource::OtherInstance, effectcraft_aep::DefinitionSource::Instance]);
|
||||
}
|
||||
|
||||
/// A record in a state other than 1 or 3 is not read and is reported.
|
||||
#[test]
|
||||
fn an_effect_record_in_another_state_is_reported_as_damaged() {
|
||||
@@ -403,3 +785,60 @@ fn an_effect_record_in_another_state_is_reported_as_damaged() {
|
||||
assert!(damaged.iter().any(|l| l.contains("unreadable value")), "tdsb {tdsb}: {:?}", r.report.losses);
|
||||
}
|
||||
}
|
||||
|
||||
/// The project with a value record for Glow's Color A (`ADBE Glo2-0012`, which the project does
|
||||
/// not store) in state `tdsb` holding the ARGB doubles `argb` (0..255): a copy of the A & B
|
||||
/// Midpoint record (`-0011`) made a 4-component colour without its expression.
|
||||
fn with_color_a(tdsb: u32, argb: [f64; 4]) -> Vec<u8> {
|
||||
let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
|
||||
let mut added = 0;
|
||||
visit(&mut file.chunks, &mut |c| {
|
||||
let Body::List { list_type, children, .. } = &mut c.body else { return };
|
||||
if list_type != b"tdgp" {
|
||||
return;
|
||||
}
|
||||
let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2-0011"))) else {
|
||||
return;
|
||||
};
|
||||
let (Some(mut name), Some(mut record)) = (children.get(at).cloned(), children.get(at + 1).filter(|k| k.is_list(b"tdbs")).cloned()) else { return };
|
||||
set_data(&mut name, 0, b"ADBE Glo2-0012\0");
|
||||
if let Body::List { children: kids, .. } = &mut record.body {
|
||||
kids.retain(|k| !k.is(b"Utf8"));
|
||||
for k in kids.iter_mut() {
|
||||
if k.is(b"tdsb") {
|
||||
set_data(k, 0, &tdsb.to_be_bytes());
|
||||
} else if k.is(b"tdb4") {
|
||||
// 4 components; no expression (bytes 76, 77 and 120).
|
||||
set_data(k, 3, &[4]);
|
||||
for byte in [76, 77, 120] {
|
||||
set_data(k, byte, &[0]);
|
||||
}
|
||||
} else if k.is(b"cdat") {
|
||||
*k = Chunk::data(*b"cdat", argb.iter().flat_map(|v| v.to_be_bytes()).collect::<Vec<u8>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
children.insert(at + 2, record);
|
||||
children.insert(at + 2, name);
|
||||
added += 1;
|
||||
});
|
||||
assert_eq!(added, 2, "the instance and the EfdG copy");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// A colour recorded in the default state (`tdsb` 3) reads like a written one (#3170's "parameter
|
||||
/// Color ... holds an unreadable value" on 0.21.1, which read only written records).
|
||||
#[test]
|
||||
fn a_default_state_colour_record_holds_the_value() {
|
||||
let argb = [255.0, 51.0, 153.0, 255.0];
|
||||
let (written, r1) = run(&with_color_a(1, argb), true);
|
||||
let (default_state, r3) = run(&with_color_a(3, argb), true);
|
||||
let (r1, r3) = (r1.unwrap(), r3.unwrap());
|
||||
assert!(losses(&r3, LossCategory::DamagedData).is_empty(), "{:?}", r3.report.losses);
|
||||
assert_eq!(r3.report.losses, r1.report.losses);
|
||||
let colour = |p: &Project| value_of(glow_values(p).first(), "colorA").cloned();
|
||||
let (unchanged, _) = run(SYNTHETIC, true);
|
||||
assert!(colour(&written).is_some());
|
||||
assert_eq!(colour(&default_state), colour(&written));
|
||||
assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default");
|
||||
}
|
||||
@@ -19,3 +19,4 @@ effectcraft-model = { workspace = true }
|
||||
[dev-dependencies]
|
||||
effectcraft-script = { workspace = true }
|
||||
effectcraft-host = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
@@ -346,32 +346,60 @@ impl ConfinedFootage {
|
||||
fn inside(&self, f: &Footage) -> bool {
|
||||
std::iter::once(&f.path).chain(&f.sequence).filter(|p| !p.is_empty()).all(|p| self.confinement.check(p, Access::Read).is_ok())
|
||||
}
|
||||
|
||||
/// [`Self::inside`] for a read: a refused one is counted on this thread, so the render
|
||||
/// [`recording`](FootageSource::recording) it can't list what it made without the footage.
|
||||
fn readable(&self, f: &Footage) -> bool {
|
||||
let inside = self.inside(f);
|
||||
if !inside {
|
||||
let _ = REFUSED_READS.try_with(|n| n.set(n.get().wrapping_add(1)));
|
||||
}
|
||||
inside
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Footage reads refused on this thread (see [`ConfinedFootage::readable`]).
|
||||
static REFUSED_READS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The number of reads this thread has refused for lying outside the allowed roots.
|
||||
fn refused_reads() -> u64 {
|
||||
REFUSED_READS.try_with(std::cell::Cell::get).unwrap_or(0)
|
||||
}
|
||||
|
||||
impl FootageSource for ConfinedFootage {
|
||||
/// The frame from the inner source, or `None` when the footage lies outside the allowed roots.
|
||||
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
self.inside(footage).then(|| self.inner.frame(item, footage, t)).flatten()
|
||||
self.readable(footage).then(|| self.inner.frame(item, footage, t)).flatten()
|
||||
}
|
||||
/// The frame scaled to `width` by `height`, or `None` when the footage lies outside the allowed roots.
|
||||
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
|
||||
self.inside(footage).then(|| self.inner.frame_at_size(item, footage, t, width, height)).flatten()
|
||||
self.readable(footage).then(|| self.inner.frame_at_size(item, footage, t, width, height)).flatten()
|
||||
}
|
||||
/// The frame with an explicit size and float curve, or `None` when the footage lies outside the allowed roots.
|
||||
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
|
||||
self.inside(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten()
|
||||
self.readable(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten()
|
||||
}
|
||||
/// The footage audio from the inner source, or `None` when the footage lies outside the allowed roots.
|
||||
fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
self.inside(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten()
|
||||
self.readable(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten()
|
||||
}
|
||||
/// The auxiliary channels of the footage frame, or `None` when the footage lies outside the allowed roots.
|
||||
fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
|
||||
self.inside(footage).then(|| self.inner.aux(item, footage, t)).flatten()
|
||||
self.readable(footage).then(|| self.inner.aux(item, footage, t)).flatten()
|
||||
}
|
||||
/// Forwards the auxiliary footage paths to the inner source.
|
||||
fn set_aux_footage(&self, paths: &[String]) {
|
||||
self.inner.set_aux_footage(paths)
|
||||
}
|
||||
/// The 3D model for the footage, or `None` when the footage lies outside the allowed roots.
|
||||
fn model(&self, item: ItemId, footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
|
||||
self.inside(footage).then(|| self.inner.model(item, footage)).flatten()
|
||||
self.readable(footage).then(|| self.inner.model(item, footage)).flatten()
|
||||
}
|
||||
/// The rasterised vector footage at `scale`, or `None` when the footage lies outside the allowed roots.
|
||||
fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
|
||||
self.inside(footage).then(|| self.inner.vector_frame(item, footage, scale)).flatten()
|
||||
self.readable(footage).then(|| self.inner.vector_frame(item, footage, scale)).flatten()
|
||||
}
|
||||
/// Says why footage outside the roots shows nothing, so the agent can act on it.
|
||||
fn problem(&self, footage: &Footage) -> Option<String> {
|
||||
@@ -392,15 +420,29 @@ impl FootageSource for ConfinedFootage {
|
||||
fn set_conform_folder(&self, folder: Option<PathBuf>) {
|
||||
self.inner.set_conform_folder(folder)
|
||||
}
|
||||
/// The inner source's cache budget.
|
||||
fn cache_budget(&self) -> Option<usize> {
|
||||
self.inner.cache_budget()
|
||||
}
|
||||
/// Runs `f` under the inner source's cancel flag.
|
||||
fn cancellable(&self, cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
|
||||
self.inner.cancellable(cancel, f)
|
||||
}
|
||||
/// Whether the inner source has been called off.
|
||||
fn called_off(&self) -> bool {
|
||||
self.inner.called_off()
|
||||
}
|
||||
/// The files read inside the roots, as the inner source notes them. Footage outside reads
|
||||
/// nothing, and what is made without it can't be listed by its files: a refused read makes
|
||||
/// the list incomplete, so it never outlives the session in a disk cache (another session
|
||||
/// with other roots would read the footage).
|
||||
fn recording(&self, reads: &effectcraft_engine::render::reads::ReadLog, f: &mut dyn FnMut()) {
|
||||
let refused = refused_reads();
|
||||
self.inner.recording(reads, f);
|
||||
if refused_reads() != refused {
|
||||
reads.mark_incomplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's file reads, only inside the read roots: what a project or an imported file
|
||||
@@ -593,6 +635,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Footage outside the roots shows nothing: a render that asked for it can't be listed by its
|
||||
/// files (#3184), or a disk cache would keep the empty picture for a session with other
|
||||
/// roots. Reads inside the roots record as the inner source tells.
|
||||
#[test]
|
||||
fn a_refused_footage_read_makes_the_render_unlisted() {
|
||||
use effectcraft_engine::render::reads::{FileStamp, ReadFile, ReadLog};
|
||||
/// Reads every file, noting it.
|
||||
struct Noting;
|
||||
impl FootageSource for Noting {
|
||||
/// Returns a 1x1 frame and notes the footage path it was asked for.
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
NOTED.with(|n| n.borrow_mut().push(f.path.clone()));
|
||||
Some(Arc::new(Image::new(1, 1)))
|
||||
}
|
||||
/// Runs `f`, then reports every path noted meanwhile to `reads` as a file with a missing stamp.
|
||||
fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) {
|
||||
f();
|
||||
for p in NOTED.with(|n| std::mem::take(&mut *n.borrow_mut())) {
|
||||
reads.note(ReadFile::new(p, FileStamp::Missing));
|
||||
}
|
||||
}
|
||||
}
|
||||
thread_local! {
|
||||
static NOTED: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
// (a fresh directory, removed when the test ends, panicking or not)
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path();
|
||||
let (inside, outside) = (base.join("in"), base.join("elsewhere"));
|
||||
for d in [&inside, &outside] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
let c = Confinement::new(std::slice::from_ref(&inside), &[]).unwrap();
|
||||
let src = ConfinedFootage::new(Arc::new(Noting), c);
|
||||
let footage = |d: &std::path::Path| Footage { path: d.join("a.png").to_string_lossy().into_owned(), ..Default::default() };
|
||||
let read = |f: &Footage| {
|
||||
let log = ReadLog::new();
|
||||
let mut got = None;
|
||||
src.recording(&log, &mut || got = src.frame(ItemId(1), f, Tick::ZERO));
|
||||
(got.is_some(), log.into_files())
|
||||
};
|
||||
let (shown, files) = read(&footage(&inside));
|
||||
assert!(shown && files.is_complete() && files.len() == 1, "inside the roots: {files:?}");
|
||||
let (shown, files) = read(&footage(&outside));
|
||||
assert!(!shown && !files.is_complete(), "outside the roots: {files:?}");
|
||||
// The next render on the thread is not affected.
|
||||
assert!(read(&footage(&inside)).1.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_and_outside_paths_of_newly_listed_commands_are_refused() {
|
||||
let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id()));
|
||||
|
||||
@@ -619,6 +619,39 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
// ---------------------------------------------------------------- EXtractoR
|
||||
|
||||
/// EXtractoR's channel name for After Effects' `(copy)`: the effect input's own channel of the
|
||||
/// same position (red's red ... alpha's alpha), as the earlier effects left it, never a footage
|
||||
/// channel of some name (an OpenEXR's `A` is the file's alpha, not the input's).
|
||||
pub const EXTRACTOR_COPY: &str = "(copy)";
|
||||
|
||||
/// Whether an EXtractoR channel name is [`EXTRACTOR_COPY`].
|
||||
pub fn is_extractor_copy(name: &str) -> bool {
|
||||
name.trim().eq_ignore_ascii_case(EXTRACTOR_COPY)
|
||||
}
|
||||
|
||||
/// The layer's own channel an EXtractoR output channel at position `k` reads when no footage
|
||||
/// channel of that name exists: `R`/`G`/`B`/`A` (or the long names) by name, [`EXTRACTOR_COPY`]
|
||||
/// its own position.
|
||||
pub fn extractor_own_channel(k: usize, name: &str) -> Option<usize> {
|
||||
if is_extractor_copy(name) {
|
||||
return (k < 4).then_some(k);
|
||||
}
|
||||
match name.trim().to_ascii_uppercase().as_str() {
|
||||
"R" | "RED" => Some(0),
|
||||
"G" | "GREEN" => Some(1),
|
||||
"B" | "BLUE" => Some(2),
|
||||
"A" | "ALPHA" => Some(3),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The footage channel an EXtractoR output channel reads by name: none for an empty name or
|
||||
/// [`EXTRACTOR_COPY`] (the input's own channel).
|
||||
pub fn extractor_aux_channel<'a>(a: &'a AuxChannels, name: &str) -> Option<&'a [f32]> {
|
||||
let n = name.trim();
|
||||
if n.is_empty() || is_extractor_copy(n) { None } else { a.get(n) }
|
||||
}
|
||||
|
||||
fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let a = aux(ctx);
|
||||
let names = [ctx.params.s("red"), ctx.params.s("green"), ctx.params.s("blue"), ctx.params.s("alpha")];
|
||||
@@ -626,17 +659,11 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let unmult = ctx.params.b("unMult");
|
||||
let clip = ctx.params.b("clip");
|
||||
let idx = a.as_ref().map(|a| index_map(&b, a));
|
||||
// Each output channel: an aux channel by name, or the layer's own R/G/B/A.
|
||||
let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() {
|
||||
"R" | "RED" => Some(0),
|
||||
"G" | "GREEN" => Some(1),
|
||||
"B" | "BLUE" => Some(2),
|
||||
"A" | "ALPHA" => Some(3),
|
||||
"" => None,
|
||||
_ => None,
|
||||
};
|
||||
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.as_ref().and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect();
|
||||
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k)) {
|
||||
// Each output channel: an aux channel by name, or the layer's own R/G/B/A (`(copy)`: its
|
||||
// own position, never an aux channel).
|
||||
let own = |k: usize, n: &str| extractor_own_channel(k, n);
|
||||
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.as_ref().and_then(|a| extractor_aux_channel(a, n))).collect();
|
||||
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(k, n) == Some(k)) {
|
||||
return b;
|
||||
}
|
||||
let src = b.img.clone();
|
||||
@@ -652,7 +679,7 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
o[k] = match (src_ch[k], idx.as_ref().and_then(|m| m[i])) {
|
||||
(Some(ch), Some(j)) => ch[j],
|
||||
(Some(_), None) => 0.0,
|
||||
(None, _) => match own(names[k]) {
|
||||
(None, _) => match own(k, names[k]) {
|
||||
Some(c) => straight[c],
|
||||
None => {
|
||||
if k == 3 {
|
||||
|
||||
@@ -457,6 +457,38 @@ fn extractor_maps_named_channels() {
|
||||
assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]);
|
||||
}
|
||||
|
||||
/// `(copy)` (After Effects' EXtractoR choice, imported from .aep) is the effect input's own
|
||||
/// channel as earlier effects left it, never the OpenEXR's plane of the same name: here the input
|
||||
/// buffer differs from the footage's R, G, B and A planes, as after an earlier effect.
|
||||
#[test]
|
||||
fn extractor_copy_reads_the_current_input_not_the_footage_plane() {
|
||||
let (w, h) = (4usize, 3usize);
|
||||
let mut a = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
// The file's own planes (what the footage held before any effect) and a depth channel.
|
||||
a.channels = ["R", "G", "B", "A"].iter().map(|n| (n.to_string(), vec![0.9; w * h])).collect();
|
||||
a.channels.push(("Z".into(), vec![0.7; w * h]));
|
||||
let host = AuxHost(Arc::new(a));
|
||||
let env = || EffectEnv { host: Some(&host), ..Default::default() };
|
||||
// The input after an earlier effect: straight (0.2, 0.3, 0.4) at alpha 0.5, premultiplied.
|
||||
let input = Image::filled(w as u32, h as u32, [0.1, 0.15, 0.2, 0.5]);
|
||||
let copy = || Value::Str(EXTRACTOR_COPY.into());
|
||||
// All four `(copy)`: the input unchanged.
|
||||
let all = [("red", copy()), ("green", copy()), ("blue", copy()), ("alpha", copy())];
|
||||
let o = run_fx("ec.3d.extractor", &all, input.clone(), 0.0, env()).img;
|
||||
assert_eq!(o.data, input.data);
|
||||
// Mixed with a named channel (no pass-through): red from Z, green and alpha copied, blue the
|
||||
// footage's B plane.
|
||||
let vals = [("red", Value::Str("Z".into())), ("green", copy()), ("blue", Value::Str("B".into())), ("alpha", copy())];
|
||||
let px = run_fx("ec.3d.extractor", &vals, input.clone(), 0.0, env()).img.get(1, 1);
|
||||
let want = [0.7 * 0.5, 0.3 * 0.5, 0.9 * 0.5, 0.5];
|
||||
for (c, (got, want)) in px.iter().zip(want).enumerate() {
|
||||
assert!((got - want).abs() < 1e-6, "channel {c}: {got} != {want} ({px:?})");
|
||||
}
|
||||
// `A` by name is the footage's alpha plane (0.9), not the input's alpha (0.5).
|
||||
let named = [("alpha", Value::Str("A".into())), ("red", Value::Str("Z".into()))];
|
||||
assert!((run_fx("ec.3d.extractor", &named, input, 0.0, env()).img.get(1, 1)[3] - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_extract_antialias_averages_sub_pixels() {
|
||||
// Depth at twice the layer resolution, alternating 0 / 1000 per aux column.
|
||||
|
||||
@@ -705,32 +705,37 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
let grey = || col(0.5, 0.5, 0.5);
|
||||
// After Effects' valid ranges (a value past the slider's drag range is still kept: .aep
|
||||
// import, 1200), the slider over the range After Effects' slider covers.
|
||||
let wb = || slider(-300.0, 300.0, -100.0, 100.0, 1);
|
||||
let tone = || slider(-150.0, 150.0, -100.0, 100.0, 1);
|
||||
let sat = || slider(0.0, 300.0, 0.0, 200.0, 1);
|
||||
let mut lum = vec![
|
||||
p("highDynamicRange", "High Dynamic Range", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None", "Custom"])),
|
||||
// A .cube file path (or the file's text) for Input LUT = Custom.
|
||||
p("basicCorrection/inputLutFile", "Input LUT File", Value::Str(String::new()), ParamUi::Text),
|
||||
p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), bipolar()),
|
||||
p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
|
||||
p("basicCorrection/tone/contrast", "Contrast", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/highlights", "Highlights", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/shadows", "Shadows", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/whites", "Whites", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/blacks", "Blacks", num(0.0), bipolar()),
|
||||
p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), wb()),
|
||||
p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), wb()),
|
||||
p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-7.0, 7.0, -5.0, 5.0, 2)),
|
||||
p("basicCorrection/tone/contrast", "Contrast", num(0.0), tone()),
|
||||
p("basicCorrection/tone/highlights", "Highlights", num(0.0), tone()),
|
||||
p("basicCorrection/tone/shadows", "Shadows", num(0.0), tone()),
|
||||
p("basicCorrection/tone/whites", "Whites", num(0.0), tone()),
|
||||
p("basicCorrection/tone/blacks", "Blacks", num(0.0), tone()),
|
||||
p("basicCorrection/tone/hdrWhite", "HDR White", num(100.0), slider(100.0, 10000.0, 100.0, 4000.0, 0)),
|
||||
p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), bipolar()),
|
||||
p("basicCorrection/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), tone()),
|
||||
p("basicCorrection/saturation", "Saturation", num(100.0), sat()),
|
||||
p("creative/look", "Look", Value::Enum(0), popup(&LUMETRI_LOOKS)),
|
||||
p("creative/lookFile", "Look File", Value::Str(String::new()), ParamUi::Text),
|
||||
p("creative/lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), pct()),
|
||||
p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), slider(0.0, 150.0, 0.0, 100.0, 1)),
|
||||
p("creative/adjustments/sharpen", "Sharpen", num(0.0), bipolar()),
|
||||
p("creative/adjustments/vibrance", "Vibrance", num(0.0), bipolar()),
|
||||
p("creative/adjustments/creativeSaturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("creative/adjustments/creativeSaturation", "Saturation", num(100.0), sat()),
|
||||
p("creative/adjustments/shadowTint", "Shadow Tint", grey(), ParamUi::Color),
|
||||
p("creative/adjustments/highlightTint", "Highlight Tint", grey(), ParamUi::Color),
|
||||
p("creative/adjustments/tintBalance", "Tint Balance", num(0.0), bipolar()),
|
||||
p("creative/adjustments/tintBalance", "Tint Balance", num(0.0), tone()),
|
||||
];
|
||||
const CURVES: [(&str, &str); 12] = [
|
||||
("curves/curveMasterShadows", "RGB Curve Shadows"),
|
||||
@@ -786,13 +791,13 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("hslSecondary/key/maskStyle", "Mask Style", Value::Enum(0), popup(&HSL_MASK_STYLES)),
|
||||
p("hslSecondary/key/invertMask", "Invert Mask", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("hslSecondary/refine/denoise", "Denoise", num(0.0), pct()),
|
||||
p("hslSecondary/refine/blur", "Blur", num(0.0), pct()),
|
||||
p("hslSecondary/refine/blur", "Blur", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||
p("hslSecondary/correction/correctionWheel", "Correction", grey(), ParamUi::Color),
|
||||
p("hslSecondary/correction/temperature", "Temperature", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/tint", "Tint", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/contrast", "Contrast", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/temperature", "Temperature", num(0.0), wb()),
|
||||
p("hslSecondary/correction/tint", "Tint", num(0.0), wb()),
|
||||
p("hslSecondary/correction/contrast", "Contrast", num(0.0), tone()),
|
||||
p("hslSecondary/correction/sharpen", "Sharpen", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("hslSecondary/correction/saturation", "Saturation", num(100.0), sat()),
|
||||
p("vignette/vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
|
||||
p("vignette/vignetteMidpoint", "Midpoint", num(50.0), pct()),
|
||||
p("vignette/vignetteRoundness", "Roundness", num(0.0), bipolar()),
|
||||
|
||||
@@ -116,8 +116,9 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time,
|
||||
pub use ocio::color_stabilizer_maps;
|
||||
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
|
||||
pub use channel3d::{
|
||||
CRYPTO_LAYERS, CryptoAux, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu,
|
||||
crypto_layer_name, crypto_layer_named, id_color, matte_only_px, reads_aux, selection_hashes,
|
||||
CRYPTO_LAYERS, CryptoAux, EXTRACTOR_COPY, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu,
|
||||
crypto_layer_name, crypto_layer_named, extractor_aux_channel, extractor_own_channel, id_color, is_extractor_copy, matte_only_px, reads_aux,
|
||||
selection_hashes,
|
||||
};
|
||||
pub use ocio::{ColorOp, Straight, Tf, color_program};
|
||||
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan};
|
||||
|
||||
@@ -208,7 +208,7 @@ pub(crate) fn source_frame(
|
||||
t: Tick,
|
||||
expr: Option<&dyn ExprHost>,
|
||||
) -> Option<(Arc<Image>, [f64; 2])> {
|
||||
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[], reads: None };
|
||||
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
|
||||
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
|
||||
&& f.has_video
|
||||
|
||||
@@ -20,13 +20,16 @@
|
||||
//! calls [`FrameServer::resume`] for the next. Backend Auto picks CPU or GPU per comp from
|
||||
//! the frames' total times ([`effectcraft_render::AutoPick`]).
|
||||
//! - Disk cache: a render request may carry the frame's disk-cache key ([`FrameMsg::Render`]
|
||||
//! `disk`); the transport stores the finished frame under it (the browser: in the Origin
|
||||
//! Private File System) and reports [`FrameReply::Stored`].
|
||||
//! `disk`); the transport stores the finished frame under it with the source files it read
|
||||
//! ([`FrameServer::take_files`], `disk_cache::frame_entry`; the browser: in the Origin Private
|
||||
//! File System) and reports [`FrameReply::Stored`]. A frame whose files can't all be named is
|
||||
//! not stored. The page checks an entry's files before it uses it (#3184).
|
||||
//! - Layer buffers on disk: with `layers`, the server's layer cache is backed by a
|
||||
//! [`PrefetchStore`] ([`FrameServer::set_layer_store`]): before the request the transport
|
||||
//! fetches the entries named in `prefetch` (recent misses that are on disk, chosen by the
|
||||
//! page, `disk_cache::LayerPrefetch`), so the lookups in the middle of the render find them;
|
||||
//! the reply lists the layer keys that missed and those served from the store
|
||||
//! page, `disk_cache::LayerPrefetch`), so the lookups in the middle of the render find them
|
||||
//! (each entry keeps the source files its buffer was made of and is used only while they are
|
||||
//! unchanged, #3184); the reply lists the layer keys that missed and those served from the store
|
||||
//! ([`FrameReply::Frame`] `layer_misses` / `layer_hits`), and slow buffers queue up for the
|
||||
//! transport to write after the frame ([`PrefetchStore::take_writes`], reported as
|
||||
//! [`FrameReply::Stored`] with `layer`).
|
||||
@@ -37,6 +40,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{ItemId, Project};
|
||||
use effectcraft_render::reads::{ReadFiles, ReadLog};
|
||||
use effectcraft_render::{Accelerator, AutoKey, Backend, ExprHost, FootageSource, Image, LayerCache, LayerStore, PrefetchStore, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -177,7 +181,7 @@ pub enum FrameReply {
|
||||
passes: u32,
|
||||
#[serde(default)]
|
||||
gpu: bool,
|
||||
/// Layer-cache lookups that missed the store (salted keys, 32 hex digits).
|
||||
/// Layer-cache lookups that missed the store (disk keys, 32 hex digits).
|
||||
#[serde(default, rename = "layerMisses", skip_serializing_if = "Vec::is_empty")]
|
||||
layer_misses: Vec<String>,
|
||||
/// Layer buffers served from the store (fetched from disk).
|
||||
@@ -260,11 +264,12 @@ pub struct FrameServer {
|
||||
revision: Option<u64>,
|
||||
/// The frame being rendered in passes.
|
||||
pending: Option<Pending>,
|
||||
/// The disk cache's layer buffers (see the module docs) and the project's salt.
|
||||
/// The disk cache's layer buffers (see the module docs).
|
||||
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>>,
|
||||
/// The source files the last finished frame read ([`FrameServer::take_files`]).
|
||||
files: Option<ReadFiles>,
|
||||
}
|
||||
|
||||
/// A frame whose readbacks are in flight.
|
||||
@@ -284,6 +289,7 @@ struct Pending {
|
||||
pub const MAX_PASSES: u32 = 24;
|
||||
|
||||
impl FrameServer {
|
||||
/// A frame server over `footage` and the optional expression host, with an empty layer cache.
|
||||
pub fn new(footage: Arc<dyn FootageSource>, expr: Option<Arc<dyn ExprHost>>) -> FrameServer {
|
||||
FrameServer {
|
||||
footage,
|
||||
@@ -295,17 +301,30 @@ impl FrameServer {
|
||||
revision: None,
|
||||
pending: None,
|
||||
layer_store: None,
|
||||
salt: 0,
|
||||
virtual_id: None,
|
||||
files: 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.
|
||||
/// Identity of virtual footage files, used when native metadata cannot describe the path:
|
||||
/// what the disk entries of frames and layer buffers keep for them and are checked against.
|
||||
pub fn set_virtual_id(&mut self, id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>) {
|
||||
self.cache.set_virtual_id(id.clone());
|
||||
self.virtual_id = id;
|
||||
}
|
||||
|
||||
/// The source files the frame of the last [`FrameReply::Frame`] read, with their identities
|
||||
/// as read: what its disk entry keeps (`disk_cache::frame_entry`). Taken once.
|
||||
pub fn take_files(&mut self) -> Option<ReadFiles> {
|
||||
self.files.take()
|
||||
}
|
||||
|
||||
/// The identity of footage paths with no native metadata (the browser file table), to store
|
||||
/// with a frame's disk entry.
|
||||
pub fn virtual_id(&self) -> Option<&Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>> {
|
||||
self.virtual_id.as_ref()
|
||||
}
|
||||
|
||||
/// 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>>) {
|
||||
@@ -316,11 +335,6 @@ impl FrameServer {
|
||||
self.layer_store.as_ref()
|
||||
}
|
||||
|
||||
/// The salt of the replica's layer keys on disk (keys are `(salt, key)`).
|
||||
pub fn salt(&self) -> u64 {
|
||||
self.salt
|
||||
}
|
||||
|
||||
/// Render with `accel` when the request's backend asks for it (the worker's GPU). Its miss
|
||||
/// gate keeps placeholder buffers of unfinished passes out of the layer cache.
|
||||
pub fn set_accel(&mut self, accel: Option<Arc<dyn Accelerator>>) {
|
||||
@@ -347,8 +361,6 @@ 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_with(&p, self.virtual_id.as_deref());
|
||||
self.cache.set_store_salt(self.salt);
|
||||
self.project = Some(Arc::new(p));
|
||||
self.revision = Some(revision);
|
||||
Ok(())
|
||||
@@ -392,7 +404,7 @@ impl FrameServer {
|
||||
}
|
||||
FrameMsg::Render { id, revision, comp, time, opts, layers, .. } => {
|
||||
let store = self.layer_store.clone().filter(|_| layers);
|
||||
self.cache.set_store(store.map(|s| (s as Arc<dyn LayerStore>, self.salt)));
|
||||
self.cache.set_store(store.map(|s| s as Arc<dyn LayerStore>));
|
||||
let fail = |error: String, stale: bool| vec![(FrameReply::Failed { id, error, stale }, None)];
|
||||
if self.revision != Some(revision) {
|
||||
return fail(format!("the replica is at {:?}, not {revision}", self.revision), true);
|
||||
@@ -432,12 +444,16 @@ impl FrameServer {
|
||||
/// Run the next pass of the frame in progress; its reply once it is done.
|
||||
pub fn resume(&mut self) -> Vec<(FrameReply, Option<Vec<u8>>)> {
|
||||
let Some(mut pr) = self.pending.take() else { return vec![] };
|
||||
self.files = None;
|
||||
pr.passes += 1;
|
||||
let accel = self.accel.clone().filter(|_| pr.opts.backend != Backend::Cpu);
|
||||
let give_up = pr.passes > MAX_PASSES;
|
||||
// (each pass renders the whole frame: the last one's files are the frame's)
|
||||
let reads = ReadLog::new();
|
||||
let mut r = Renderer::new(&pr.project, self.footage.as_ref(), pr.opts);
|
||||
r.expr = self.expr.as_deref();
|
||||
r.cache = Some(&self.cache);
|
||||
r.reads = Some(&reads);
|
||||
if !give_up {
|
||||
r.accel = accel.as_deref();
|
||||
}
|
||||
@@ -451,6 +467,7 @@ impl FrameServer {
|
||||
}
|
||||
let ms = pr.t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let gpu = r.accel.is_some() && r.active_accel().is_some();
|
||||
self.files = Some(reads.files());
|
||||
let hex = |v: Vec<u128>| v.into_iter().map(|k| format!("{k:032x}")).collect::<Vec<_>>();
|
||||
let (layer_misses, layer_hits) = match &self.layer_store {
|
||||
Some(st) => (hex(st.take_misses()), hex(st.take_hits())),
|
||||
|
||||
@@ -69,7 +69,8 @@ fn layer_buffers_go_through_the_disk_store() {
|
||||
assert!(!layer_misses.is_empty() && layer_hits.is_empty());
|
||||
let writes = store_a.take_writes();
|
||||
assert!(!writes.is_empty());
|
||||
assert!(writes.iter().all(|(k, _)| (*k >> 64) as u64 == a.salt()), "keys are salted per project");
|
||||
// Sealed with the files each buffer was made of (none here: the demo reads no footage).
|
||||
assert!(writes.iter().all(|(_, e)| effectcraft_render::disk_cache::read_layer_entry(e).is_some_and(|(_, f)| f.is_empty())));
|
||||
let mut index = DiskIndex::default();
|
||||
let mut disk = std::collections::HashMap::new();
|
||||
for (k, e) in writes {
|
||||
@@ -375,3 +376,138 @@ fn every_frame_begin_has_a_frame_end() {
|
||||
assert!(matches!(w.handle(req(6, s.revision, comp, cpu))[0].0, FrameReply::Frame { id: 6, .. }));
|
||||
assert_eq!(load(), (3, 3));
|
||||
}
|
||||
|
||||
/// Footage that reads one file from memory (the browser's file table) for every frame.
|
||||
struct OneVirtualFile {
|
||||
/// Tell which files it reads (else every render's list is incomplete).
|
||||
records: bool,
|
||||
}
|
||||
|
||||
impl FootageSource for OneVirtualFile {
|
||||
fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::filled(4, 4, [1.0, 0.5, 0.0, 1.0])))
|
||||
}
|
||||
fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) {
|
||||
if self.records {
|
||||
reads.note(effectcraft_render::reads::ReadFile::new("/clip.png", effectcraft_render::reads::FileStamp::Virtual));
|
||||
} else {
|
||||
reads.mark_incomplete();
|
||||
}
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
/// The worker hands the transport the files each frame read, so its disk entry keeps them; the
|
||||
/// page reads the entry back only while those files are unchanged. A frame whose files can't
|
||||
/// all be named is not stored (#3184).
|
||||
#[test]
|
||||
fn the_worker_reports_the_files_of_each_frame_for_its_disk_entry() {
|
||||
use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_render::disk_cache::{frame_entry, read_frame_entry};
|
||||
use effectcraft_time::FrameRate;
|
||||
let mut p = Project::default();
|
||||
let f = Footage { path: "/clip.png".into(), width: 4, height: 4, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() };
|
||||
let fid = p.add_item("clip.png", effectcraft_color::Label::None, None, ItemKind::Footage(f));
|
||||
let mut comp = Comp::new(4, 4, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (4, 4), None)].into();
|
||||
let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
|
||||
let id = |mark: u64| move |path: &str| (path == "/clip.png").then_some((10, mark));
|
||||
let (one, two) = (id(1), id(2));
|
||||
for records in [true, false] {
|
||||
let mut w = FrameServer::new(Arc::new(OneVirtualFile { records }), None);
|
||||
w.set_virtual_id(Some(Arc::new(one)));
|
||||
let mut m = Mirror::default();
|
||||
assert!(w.handle(round(&m.sync(1, &p).unwrap())).is_empty());
|
||||
assert!(w.take_files().is_none(), "no frame yet");
|
||||
let out = w.handle(round(&FrameMsg::Render {
|
||||
id: 1,
|
||||
revision: 1,
|
||||
comp: cid,
|
||||
time: Tick::ZERO,
|
||||
opts: Box::default(),
|
||||
disk: Some(format!("{:032x}", 5u128)),
|
||||
layers: false,
|
||||
prefetch: vec![],
|
||||
}));
|
||||
let (FrameReply::Frame { width, height, .. }, Some(px)) = &out[0] else { panic!("{:?}", out[0].0) };
|
||||
let files = w.take_files().expect("the frame's files");
|
||||
assert!(w.take_files().is_none(), "taken once");
|
||||
let vid = w.virtual_id().cloned();
|
||||
let entry = frame_entry(*width, *height, px, &files, vid.as_deref());
|
||||
if !records {
|
||||
assert!(!files.is_complete() && entry.is_none(), "an incomplete list is not stored");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(files.files().iter().map(|f| &*f.path).collect::<Vec<_>>(), ["/clip.png"]);
|
||||
let entry = entry.expect("stored");
|
||||
assert_eq!(read_frame_entry(&entry, Some(&one)).map(|f| f.rgba), Some(px.clone()));
|
||||
assert!(read_frame_entry(&entry, Some(&two)).is_none(), "the file was replaced: a miss");
|
||||
}
|
||||
}
|
||||
|
||||
/// No salt work at all (#3184, replacing the background salt of #3106): syncing a project with a
|
||||
/// large image sequence nobody shows, patching it and rendering with the layer store never asks
|
||||
/// the file hook about the sequence (the salt asked about every file at every sync). Only the
|
||||
/// file the rendered buffer reads is asked about: to store its entry and, in another worker, to
|
||||
/// check it before the buffer from the store is used.
|
||||
#[test]
|
||||
fn the_worker_asks_only_about_the_files_its_buffers_read() {
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_time::FrameRate;
|
||||
use std::sync::Mutex;
|
||||
let mut p = Project::default();
|
||||
let f = Footage { path: "/clip.png".into(), width: 4, height: 4, has_video: true, ..Default::default() };
|
||||
let fid = p.add_item("clip.png", effectcraft_color::Label::None, None, ItemKind::Footage(f));
|
||||
let seq = Footage {
|
||||
kind: FootageKind::Sequence,
|
||||
path: "/seq/f_00000.png".into(),
|
||||
sequence: (0..5000).map(|i| format!("/seq/f_{i:05}.png")).collect(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
p.add_item("seq", effectcraft_color::Label::None, None, ItemKind::Footage(seq));
|
||||
let mut comp = Comp::new(4, 4, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (4, 4), None)].into();
|
||||
let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
|
||||
let asked: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let worker = || {
|
||||
let mut w = FrameServer::new(Arc::new(OneVirtualFile { records: true }), None);
|
||||
let seen = asked.clone();
|
||||
w.set_virtual_id(Some(Arc::new(move |path: &str| {
|
||||
seen.lock().unwrap().push(path.to_string());
|
||||
(path == "/clip.png").then_some((10, 1))
|
||||
})));
|
||||
let store = Arc::new(PrefetchStore::new(1 << 20).with_min_ms(0));
|
||||
w.set_layer_store(Some(store.clone()));
|
||||
(w, store)
|
||||
};
|
||||
let render = |w: &mut FrameServer, revision: u64, prefetch: Vec<String>| {
|
||||
let out = w.handle(round(&FrameMsg::Render { id: 1, revision, comp: cid, time: Tick::ZERO, opts: Box::default(), disk: None, layers: true, prefetch }));
|
||||
let FrameReply::Frame { layer_hits, .. } = &out[0].0 else { panic!("{:?}", out[0].0) };
|
||||
layer_hits.len()
|
||||
};
|
||||
// Worker A: synced, patched, rendered; its buffer is queued with its file.
|
||||
let (mut a, store_a) = worker();
|
||||
let mut m = Mirror::default();
|
||||
assert!(a.handle(round(&m.sync(1, &p).unwrap())).is_empty());
|
||||
let mut edited = p.clone();
|
||||
edited.item_mut(cid).unwrap().name = "Renamed".into();
|
||||
assert!(a.handle(round(&m.sync(2, &edited).unwrap())).is_empty());
|
||||
assert!(asked.lock().unwrap().is_empty(), "syncing looks at no file");
|
||||
assert_eq!(render(&mut a, 2, vec![]), 0);
|
||||
let writes = store_a.take_writes();
|
||||
assert!(!writes.is_empty(), "the clip's buffer is stored");
|
||||
// Worker B: fetched before its frame, checked, used.
|
||||
let (mut b, store_b) = worker();
|
||||
let mut m = Mirror::default();
|
||||
assert!(b.handle(round(&m.sync(2, &edited).unwrap())).is_empty());
|
||||
for (k, e) in &writes {
|
||||
assert!(store_b.provide(*k, e));
|
||||
}
|
||||
assert!(render(&mut b, 2, writes.iter().map(|(k, _)| format!("{k:032x}")).collect()) > 0, "served from the store");
|
||||
let asked = asked.lock().unwrap();
|
||||
assert!(!asked.is_empty(), "the hook names the buffer's file");
|
||||
assert!(asked.iter().all(|p| p == "/clip.png"), "only the file the buffer read: {:?}", &asked[..asked.len().min(5)]);
|
||||
}
|
||||
@@ -345,6 +345,7 @@ fn warning(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))}))
|
||||
}
|
||||
@@ -378,6 +379,7 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
@@ -515,6 +517,7 @@ impl CryptoProbe {
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
|
||||
let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
|
||||
@@ -586,6 +589,7 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let frame = probe_frame(&ctx, layer, pos);
|
||||
let o = s.render_opts(Default::default());
|
||||
@@ -780,6 +784,7 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Hit> {
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string();
|
||||
let driven = prop.expr.as_ref().is_some_and(|e| e.enabled);
|
||||
|
||||
@@ -49,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text",
|
||||
["Layer", "New"],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)",
|
||||
"{text?, name? (layer name; default the text, which the name then follows; a given name stays), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)",
|
||||
has_comp,
|
||||
new_text
|
||||
),
|
||||
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Layer Settings...",
|
||||
["Layer"],
|
||||
Some("Cmd+Shift+Y"),
|
||||
"{layer?, name?, color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}",
|
||||
"{layer?, name? (an empty name makes a text layer's name follow its text again), color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}",
|
||||
has_layer_settings,
|
||||
layer_settings
|
||||
),
|
||||
@@ -114,7 +114,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_layers,
|
||||
set_switch
|
||||
),
|
||||
cmd!("layer.rename", "Rename", [], Some("Enter"), "{layer?, name}", has_layers, rename),
|
||||
cmd!(
|
||||
"layer.rename",
|
||||
"Rename",
|
||||
[],
|
||||
Some("Enter"),
|
||||
"{layer?, name} — a text layer's name follows its text until renamed; an empty name follows it again",
|
||||
has_layers,
|
||||
rename
|
||||
),
|
||||
cmd!("layer.setComment", "Layer Comment", [], None, "{layers?, comment}", has_layers, set_comment),
|
||||
cmd!("layer.setBlendMode", "Blending Mode", [], None, "{layers?, mode?: Normal|Multiply|Screen|…, step?: ±1}", has_layers, blend),
|
||||
cmd!(
|
||||
@@ -210,7 +218,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
"{layer?, range?: [start, end] (characters; default all), text? (also renames a text layer whose name still follows its text), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
has_layers,
|
||||
set_text
|
||||
),
|
||||
|
||||
@@ -207,14 +207,14 @@ pub(super) fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
KV::Str(t) => TextDoc::plain(&t),
|
||||
_ => TextDoc::default(),
|
||||
};
|
||||
let before = effectcraft_project::text_layer_name(&doc.text);
|
||||
apply_text_params("layer.setText", p, &mut doc, range.clone())?;
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc.clone())));
|
||||
if let Some(first) = doc.text.lines().next()
|
||||
&& str_p(p, "text").is_some()
|
||||
&& !first.is_empty()
|
||||
&& (l.name.starts_with("Text") || l.name.is_empty())
|
||||
{
|
||||
l.name = first.chars().take(40).collect();
|
||||
// Text layers are named after their text until renamed (#3233).
|
||||
let after = effectcraft_project::text_layer_name(&doc.text);
|
||||
if str_p(p, "text").is_some() && !after.is_empty() && l.name_follows_text(&before) {
|
||||
l.name = after;
|
||||
l.name_follows_text = Some(true);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -187,8 +187,10 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let edit = b_p(p, "edit").unwrap_or(false);
|
||||
let id = s.edit("New Text Layer", None, |proj, st| {
|
||||
let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect());
|
||||
let name: String = layer_name.clone().unwrap_or_else(|| effectcraft_project::text_layer_name(&doc.text));
|
||||
let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None);
|
||||
// Named after its text, the name follows the text until renamed; a given name does not.
|
||||
l.name_follows_text = Some(layer_name.as_deref().is_none_or(str::is_empty));
|
||||
if let Some(pr) = l.props.prop_mut("text/sourceText") {
|
||||
pr.value = KV::Text(Box::new(doc.clone()));
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
|
||||
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
|
||||
|
||||
@@ -139,13 +139,37 @@ fn drop_3d_values(l: &mut Layer) {
|
||||
pub(super) fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.rename")?;
|
||||
let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string();
|
||||
let t = s.time();
|
||||
s.edit("Rename Layer", None, |proj, _| {
|
||||
layer_mut(proj, cid, lid)?.name = name.clone();
|
||||
set_layer_name(layer_mut(proj, cid, lid)?, &name, t);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Give layer `l` the name `name` the user typed (comp time `t` picks the text). A text layer's
|
||||
/// name follows its text until the user renames it; an empty name makes it follow the text again
|
||||
/// (After Effects, #3233). Committing the unchanged name changes nothing.
|
||||
pub(super) fn set_layer_name(l: &mut Layer, name: &str, t: effectcraft_time::Tick) {
|
||||
let text_name =
|
||||
l.props.prop("text/sourceText").filter(|_| matches!(l.source, effectcraft_project::LayerSource::Text)).map(|pr| match pr.value_at(l.layer_time(t)) {
|
||||
KV::Text(d) => effectcraft_project::text_layer_name(&d.text),
|
||||
KV::Str(txt) => effectcraft_project::text_layer_name(&txt),
|
||||
_ => String::new(),
|
||||
});
|
||||
match text_name {
|
||||
Some(first) if name.trim().is_empty() => {
|
||||
l.name = if first.is_empty() { "Text".to_string() } else { first };
|
||||
l.name_follows_text = Some(true);
|
||||
}
|
||||
Some(_) if name != l.name => {
|
||||
l.name = name.to_string();
|
||||
l.name_follows_text = Some(false);
|
||||
}
|
||||
_ => l.name = name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Timeline's Comment column.
|
||||
pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
|
||||
@@ -157,7 +157,7 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// layers, which are solids) edit their Solid Settings. With `affectAll: false`, a solid other
|
||||
/// layers also use is copied into a new solid for this layer instead of being changed for all of
|
||||
/// them (After Effects' "Affect all layers that use this solid"). `name` renames the layer and
|
||||
/// its solid.
|
||||
/// its solid; an empty one makes a text layer's name follow its text again.
|
||||
pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.settings")?;
|
||||
// Cameras and lights open their own settings (Camera Settings / Light Settings).
|
||||
@@ -171,11 +171,18 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = p.get("height").and_then(Value::as_u64).map(limits::size);
|
||||
let pixel_aspect = pixel_aspect_p(p)?;
|
||||
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
// An empty name gives a text layer its text's name again, as `layer.rename` does (#3233);
|
||||
// other layers keep theirs.
|
||||
let follow_text = str_p(p, "name").is_some_and(|n| n.trim().is_empty());
|
||||
let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true);
|
||||
let t = s.time();
|
||||
let solid = s.edit("Layer Settings", None, |proj, _| {
|
||||
let src = layer_mut(proj, cid, lid)?.source.clone();
|
||||
if let Some(n) = &name {
|
||||
layer_mut(proj, cid, lid)?.name = n.clone();
|
||||
// A user name: a text layer's name stops following its text (#3233).
|
||||
super::switches::set_layer_name(layer_mut(proj, cid, lid)?, n, t);
|
||||
} else if follow_text && matches!(src, LayerSource::Text) {
|
||||
super::switches::set_layer_name(layer_mut(proj, cid, lid)?, "", t);
|
||||
}
|
||||
let LayerSource::Solid { item } = src else { return Ok(None) };
|
||||
let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1;
|
||||
|
||||
@@ -59,9 +59,16 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(d)) if span > 0.0 => old * d / span,
|
||||
_ => old,
|
||||
};
|
||||
if !new.is_finite() {
|
||||
return Err(bad("layer.timeStretch", "the stretch factor must be a number"));
|
||||
}
|
||||
if new.abs() < 1.0 {
|
||||
return Err(bad("layer.timeStretch", "the stretch factor must be at least 1 %"));
|
||||
}
|
||||
// After Effects' largest stretch; a project file holds no more either (#3247).
|
||||
if new.abs() > effectcraft_project::limits::MAX_STRETCH {
|
||||
return Err(bad("layer.timeStretch", "the stretch factor must be at most 9900 %"));
|
||||
}
|
||||
// The hold point keeps showing the same layer time.
|
||||
let h = match hold {
|
||||
"current" => cti,
|
||||
@@ -194,6 +201,7 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
|
||||
.layers
|
||||
|
||||
@@ -73,6 +73,8 @@ struct PathInfo {
|
||||
span: (f64, f64),
|
||||
}
|
||||
|
||||
/// Reads a mask or shape path at the current time and returns its comp-space vertices, value, expression reference and layer name.
|
||||
/// Errors when the property is missing, is not a path or has no vertices.
|
||||
fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<PathInfo> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
@@ -87,6 +89,7 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<P
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let KV::Path(path) = ctx.value(layer, pr) else { return Err(bad(cmd, "that property is not a path")) };
|
||||
if path.vertices.is_empty() {
|
||||
@@ -165,6 +168,7 @@ fn pin_targets(s: &Session, p: &Value, cmd: &str) -> Result<Option<(ItemId, Vec<
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let mut out = vec![];
|
||||
for (_, lid, uid) in pins {
|
||||
|
||||
@@ -1380,6 +1380,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
convert_one_expr(s, cid, lid, uid)
|
||||
}
|
||||
|
||||
/// Replaces the expression on one property with keyframes sampled from its evaluated values.
|
||||
/// Returns the number of properties converted.
|
||||
fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Result<Value> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
|
||||
@@ -1397,6 +1399,7 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
|
||||
t += fd;
|
||||
|
||||
@@ -39,8 +39,17 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
|
||||
let fx = layer.effects()?;
|
||||
let index = fx.groups().position(|g| g.uid == fx_uid)?;
|
||||
let g = fx.groups().nth(index)?;
|
||||
let ctx =
|
||||
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let ctx = EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: t,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = Some(&s.layer_cache);
|
||||
|
||||
@@ -734,7 +734,11 @@ fn stop(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.poll_render();
|
||||
// The suspended script's frontend applies post-render edits before resuming it. A
|
||||
// control inspection must not apply those edits while the undo-group guard is active.
|
||||
if s.script_ui.render_host.is_none() {
|
||||
s.poll_render();
|
||||
}
|
||||
let items: Vec<Value> = s.project.render_queue.iter().enumerate().map(|(k, i)| item_json(s, i, k)).collect();
|
||||
Ok(json!({"items": items, "rendering": s.is_rendering(), "progress": s.render_progress()}))
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ impl Env {
|
||||
fn of(s: &Session, cid: ItemId) -> Env {
|
||||
Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() }
|
||||
}
|
||||
/// The evaluation context of `comp` in `proj` at the session's current time.
|
||||
fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> {
|
||||
EvalCtx {
|
||||
project: proj,
|
||||
@@ -57,6 +58,7 @@ impl Env {
|
||||
footage: Some(self.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! Positions are character indices. The selection is `anchor..caret` (either order).
|
||||
|
||||
use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV};
|
||||
use effectcraft_project::{ItemId, LayerId, LayerSource};
|
||||
use effectcraft_project::{ItemId, LayerId, LayerSource, text_layer_name};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -288,10 +288,6 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit {
|
||||
}
|
||||
}
|
||||
|
||||
fn first_line_name(text: &str) -> String {
|
||||
text.lines().next().unwrap_or("").chars().take(40).collect()
|
||||
}
|
||||
|
||||
/// Edit a layer's Source Text at the CTI (one undo step, or merged by `merge`), then set the
|
||||
/// session's selection.
|
||||
pub(crate) fn edit_doc(
|
||||
@@ -319,12 +315,13 @@ pub(crate) fn edit_doc(
|
||||
// already has) must not turn a static Str into Text or add a keyframe at the CTI: that
|
||||
// would change the project and record an empty undo step.
|
||||
if doc != original {
|
||||
let before = first_line_name(&original.text);
|
||||
let after = first_line_name(&doc.text);
|
||||
let before = text_layer_name(&original.text);
|
||||
let after = text_layer_name(&doc.text);
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc)));
|
||||
// Text layers are named after their text until renamed.
|
||||
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
|
||||
// Text layers are named after their text until renamed (#3233).
|
||||
if before != after && !after.is_empty() && l.name_follows_text(&before) {
|
||||
l.name = after;
|
||||
l.name_follows_text = Some(true);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -418,14 +418,24 @@ fn set_view_camera(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"view": v.id()}))
|
||||
}
|
||||
|
||||
/// Returns the active comp's 3D view state, with the comp size, for the 3D view commands.
|
||||
fn get_3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default();
|
||||
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
|
||||
let ctx =
|
||||
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let ctx = EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: t,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx));
|
||||
let f = cam.forward();
|
||||
let lights: Vec<Value> = comp
|
||||
@@ -567,8 +577,17 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// through each layer's world matrix; cameras and lights contribute their position).
|
||||
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
|
||||
let Some(comp) = s.project.comp(cid) else { return vec![] };
|
||||
let ctx =
|
||||
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let ctx = EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: t,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let mut out = vec![];
|
||||
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
|
||||
let m = ctx.world_matrix(l);
|
||||
|
||||
@@ -215,20 +215,18 @@ impl Scanner {
|
||||
}
|
||||
}
|
||||
|
||||
/// A disk observation prepared entirely by the existing worker, including the potentially
|
||||
/// expensive sequence stamps used for disk-cache keys. The UI only swaps a current snapshot.
|
||||
/// A disk observation prepared entirely by the existing worker. The UI only swaps a current
|
||||
/// snapshot (the disk cache needs nothing: its entries check their own files when read, #3184).
|
||||
pub(crate) struct Update {
|
||||
before: Arc<Project>,
|
||||
after: Arc<Project>,
|
||||
paths: BTreeSet<String>,
|
||||
newly_missing: usize,
|
||||
cache_enabled: bool,
|
||||
salt: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
/// Prepare a replacement only when availability changed. Project cloning and optional disk
|
||||
/// salt computation belong on the worker; apply later rejects an outdated project snapshot.
|
||||
pub(crate) fn prepare(project: &Arc<Project>, sources: &[Source], missing: &[bool], cache_enabled: bool) -> Option<Update> {
|
||||
/// Prepare a replacement only when availability changed. Project cloning belongs on the
|
||||
/// worker; apply later rejects an outdated project snapshot.
|
||||
pub(crate) fn prepare(project: &Arc<Project>, sources: &[Source], missing: &[bool]) -> Option<Update> {
|
||||
let mut after = project.clone();
|
||||
let mut paths = BTreeSet::new();
|
||||
let mut newly_missing = 0;
|
||||
@@ -245,22 +243,17 @@ pub(crate) fn prepare(project: &Arc<Project>, sources: &[Source], missing: &[boo
|
||||
if paths.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let salt = cache_enabled.then(|| (Session::footage_fingerprint(&after), crate::render::disk_cache::footage_salt(&after)));
|
||||
Some(Update { before: project.clone(), after, paths, newly_missing, cache_enabled, salt })
|
||||
Some(Update { before: project.clone(), after, paths, newly_missing })
|
||||
}
|
||||
|
||||
impl Update {
|
||||
/// False means a newer project or cache configuration needs a fresh observation.
|
||||
/// False means a newer project needs a fresh observation.
|
||||
pub fn apply(self, s: &mut Session) -> bool {
|
||||
if !Arc::ptr_eq(&s.project, &self.before) || s.disk_cache.is_some() != self.cache_enabled {
|
||||
if !Arc::ptr_eq(&s.project, &self.before) {
|
||||
return false;
|
||||
}
|
||||
let dirty = s.is_dirty();
|
||||
s.project = self.after;
|
||||
if let Some((fingerprint, salt)) = self.salt {
|
||||
s.disk_salt = Some((fingerprint, salt));
|
||||
s.layer_cache.set_disk_salt(salt);
|
||||
}
|
||||
s.bump();
|
||||
if !dirty {
|
||||
s.mark_saved();
|
||||
@@ -371,7 +364,7 @@ mod tests {
|
||||
fn stale_observation_cannot_mark_a_replacement_missing() {
|
||||
let (mut s, id) = session(Arc::new(Disk::default()));
|
||||
let old = sources(&s);
|
||||
let update = prepare(&s.project, &old, &[true, true], false).unwrap();
|
||||
let update = prepare(&s.project, &old, &[true, true]).unwrap();
|
||||
if let ItemKind::Footage(f) = &mut Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind {
|
||||
f.path = "/new.png".into();
|
||||
}
|
||||
@@ -398,9 +391,12 @@ mod tests {
|
||||
assert_eq!(disk.looks.load(Ordering::Relaxed), 2, "one file per source, no scan of an unmounted folder");
|
||||
}
|
||||
|
||||
/// An availability change does no disk-cache work (#3184): the cache stays attached and
|
||||
/// no file is looked at for it, on the worker or when the UI applies the observation (it
|
||||
/// used to stamp every footage file for a new layer salt).
|
||||
#[test]
|
||||
fn applying_prepared_cache_salt_does_not_restat_files_on_the_ui_thread() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-availability-salt-{}", std::process::id()));
|
||||
fn applying_an_availability_change_does_no_disk_cache_work() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-availability-cache-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("plate.png");
|
||||
std::fs::write(&file, b"plate").unwrap();
|
||||
@@ -413,16 +409,13 @@ mod tests {
|
||||
);
|
||||
let cache = crate::render::disk_cache::DiskCache::open(dir.join("cache"), 1 << 20).unwrap();
|
||||
s.disk_cache = Some(cache.clone());
|
||||
s.layer_cache.set_disk(Some((cache, 0)));
|
||||
s.layer_cache.set_disk(Some(cache.clone()));
|
||||
s.mark_saved();
|
||||
let update = prepare(&s.project, &sources(&s), &[false], true).unwrap();
|
||||
let expected = update.salt.unwrap().1;
|
||||
// The filesystem can change again after the worker's observation. Applying it must
|
||||
// retain that observation's salt, rather than scan the disk a second time on the UI.
|
||||
let update = prepare(&s.project, &sources(&s), &[false]).unwrap();
|
||||
std::fs::remove_file(file).unwrap();
|
||||
assert_ne!(crate::render::disk_cache::footage_salt(&update.after), expected);
|
||||
assert!(update.apply(&mut s));
|
||||
assert_eq!(s.disk_salt.map(|(_, salt)| salt), Some(expected));
|
||||
assert!(s.layer_cache.disk().is_some_and(|d| Arc::ptr_eq(&d, &cache)), "still attached");
|
||||
assert_eq!(cache.file_checks(), 0, "no file looked at for the disk cache");
|
||||
assert!(!s.is_dirty());
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
|
||||
@@ -272,7 +272,7 @@ mod tests {
|
||||
let id = source.id;
|
||||
let mut recovered = source.footage.clone();
|
||||
recovered.path = "/new/project/plate.png".into();
|
||||
let observed = super::super::availability::prepare(&s.project, std::slice::from_ref(&source), &[true], false).unwrap();
|
||||
let observed = super::super::availability::prepare(&s.project, std::slice::from_ref(&source), &[true]).unwrap();
|
||||
assert!(observed.apply(&mut s));
|
||||
assert!(matches!(&s.project.item(id).unwrap().kind, ItemKind::Footage(f) if f.missing));
|
||||
let result = apply(&mut s, vec![(source, Found::Moved(Box::new(recovered)))]);
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! or PDF page an item shows and its interpretation (alpha, conformed frame rate, pixel aspect,
|
||||
//! fields, colour). A Photoshop layer is found again by name when layers were added or removed
|
||||
//! around it. The media layer then forgets the file and the layer cache is emptied, so the next
|
||||
//! render reads the new pixels.
|
||||
//! render reads the new pixels; File ▸ Reload Footage also deletes the disk cache entries made of
|
||||
//! the file (#3184).
|
||||
//!
|
||||
//! The automatic part compares each footage file's size and modification time with what they
|
||||
//! were when the file was last seen. The desktop app keeps a [`Watcher`]: a worker thread that
|
||||
@@ -76,7 +77,6 @@ pub struct Watched {
|
||||
reload: BTreeSet<String>,
|
||||
sources: Arc<Vec<Source>>,
|
||||
project: Option<Arc<effectcraft_project::Project>>,
|
||||
cache_enabled: bool,
|
||||
reload_mode: String,
|
||||
stored_paths: crate::doc_path::StoredPaths,
|
||||
}
|
||||
@@ -85,8 +85,7 @@ impl PartialEq for Watched {
|
||||
/// Compare watched inputs, excluding unrelated project edits whose latest Arc is refreshed
|
||||
/// separately without waking the worker or restarting partial sequence scans.
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.cache_enabled == other.cache_enabled
|
||||
&& self.stored_paths == other.stored_paths
|
||||
self.stored_paths == other.stored_paths
|
||||
&& self.reload_mode == other.reload_mode
|
||||
&& self.reload == other.reload
|
||||
&& (Arc::ptr_eq(&self.sources, &other.sources) || self.sources == other.sources)
|
||||
@@ -100,7 +99,6 @@ pub fn watched(s: &Session) -> Watched {
|
||||
reload: automatic_paths(s),
|
||||
sources: Arc::new(if s.check_footage_on_open { availability::sources(s) } else { Vec::new() }),
|
||||
project: Some(s.project.clone()),
|
||||
cache_enabled: s.disk_cache.is_some(),
|
||||
reload_mode: s.prefs.import.auto_reload_footage.clone(),
|
||||
stored_paths: s.stored_paths.clone(),
|
||||
}
|
||||
@@ -139,10 +137,10 @@ pub struct Scan {
|
||||
}
|
||||
|
||||
impl Scan {
|
||||
/// Prepare availability and cache state before the session applies the observation.
|
||||
/// Prepare the availability change before the session applies the observation.
|
||||
/// Automatic callers construct this result on the worker; explicit commands run inline.
|
||||
fn new(watched: Watched, observation: Observation, policy: ReloadPolicy) -> Self {
|
||||
let update = watched.project.as_ref().and_then(|p| availability::prepare(p, &watched.sources, &observation.missing, watched.cache_enabled));
|
||||
let update = watched.project.as_ref().and_then(|p| availability::prepare(p, &watched.sources, &observation.missing));
|
||||
Self { watched, observation, update, policy }
|
||||
}
|
||||
|
||||
@@ -281,7 +279,17 @@ fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usi
|
||||
}
|
||||
let n = updates.len();
|
||||
apply_reloads(s, updates, undoable)?;
|
||||
forget(s, &paths);
|
||||
forget_media(s, &paths);
|
||||
// An explicit reload also reads files again that kept their size and time (the check on hit
|
||||
// can't tell those apart): their entries in the disk cache go (#3184), when the cache is
|
||||
// switched on if it is off now. An automatic one only follows a changed stamp, which the
|
||||
// check catches.
|
||||
if undoable {
|
||||
match &s.disk_cache {
|
||||
Some(dc) => dc.drop_files(paths.iter().map(String::as_str)),
|
||||
None => s.pending_disk_drops.extend(paths),
|
||||
}
|
||||
}
|
||||
Ok((n, failed))
|
||||
}
|
||||
|
||||
@@ -320,14 +328,9 @@ fn apply_reloads(s: &mut Session, updates: Vec<(ItemId, Footage)>, undoable: boo
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make the media layer and the layer cache let go of `paths`, so renders read them again.
|
||||
fn forget(s: &mut Session, paths: &BTreeSet<String>) {
|
||||
forget_media(s, paths);
|
||||
s.refresh_disk_salt(true);
|
||||
}
|
||||
|
||||
/// Invalidate decoded and rendered buffers without probing the disk for a new salt.
|
||||
/// Availability updates already carry their worker-computed salt; content reload refreshes it.
|
||||
/// Make the media layer and the layer cache let go of `paths`, so renders read them again. Disk
|
||||
/// cache entries check the files they were made of when read (#3184); File ▸ Reload Footage
|
||||
/// also drops the entries of its files ([`reload`]).
|
||||
pub(crate) fn forget_media(s: &mut Session, paths: &BTreeSet<String>) {
|
||||
for p in paths {
|
||||
s.footage.forget(p);
|
||||
@@ -374,7 +377,7 @@ pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option<Stamp>)>) -> crate
|
||||
return Ok(json!({"files": 0, "reloaded": 0}));
|
||||
}
|
||||
// Changed frames of image sequences (their items keep their metadata).
|
||||
forget(s, &changed);
|
||||
forget_media(s, &changed);
|
||||
let files = changed.len();
|
||||
s.toast(if reloaded > 0 {
|
||||
format!("Reloaded {reloaded} footage item(s) changed on disk")
|
||||
@@ -391,7 +394,6 @@ pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result<Value> {
|
||||
reload: reload_paths(s),
|
||||
sources: Arc::new(availability::sources(s)),
|
||||
project: Some(s.project.clone()),
|
||||
cache_enabled: s.disk_cache.is_some(),
|
||||
reload_mode: s.prefs.import.auto_reload_footage.clone(),
|
||||
stored_paths: s.stored_paths.clone(),
|
||||
};
|
||||
@@ -452,12 +454,7 @@ impl Watcher {
|
||||
pub fn update(&mut self, s: &Session) {
|
||||
let same_sources = availability::sources_match(s, &self.paths.sources);
|
||||
let same_data = self.paths.project.as_ref().is_some_and(|before| data_paths(before).eq(data_paths(&s.project)));
|
||||
if same_sources
|
||||
&& same_data
|
||||
&& self.paths.stored_paths == s.stored_paths
|
||||
&& self.paths.reload_mode == s.prefs.import.auto_reload_footage
|
||||
&& self.paths.cache_enabled == s.disk_cache.is_some()
|
||||
{
|
||||
if same_sources && same_data && self.paths.stored_paths == s.stored_paths && self.paths.reload_mode == s.prefs.import.auto_reload_footage {
|
||||
let project = Some(s.project.clone());
|
||||
self.paths.project = project.clone();
|
||||
self.pending.lock().unwrap_or_else(PoisonError::into_inner).paths.project = project;
|
||||
|
||||
@@ -97,7 +97,7 @@ pub use error::{EngineError, Result};
|
||||
pub use event::Event;
|
||||
pub use fonts::{FontRow, font_features, font_menu, font_preview, font_styles, text_families};
|
||||
pub use services::{FsServices, Importer, Services};
|
||||
pub use session::{DiskSaltJob, upgrade_effects};
|
||||
pub use session::upgrade_effects;
|
||||
|
||||
pub struct Session {
|
||||
pub project: Arc<Project>,
|
||||
@@ -171,6 +171,9 @@ pub struct Session {
|
||||
pub crypto_probe: Arc<std::sync::Mutex<commands::CryptoProbeSlot>>,
|
||||
/// The persistent disk cache (Settings ▸ Media & Disk Cache), when enabled.
|
||||
pub disk_cache: Option<Arc<effectcraft_render::disk_cache::DiskCache>>,
|
||||
/// Files File ▸ Reload Footage reloaded while the disk cache was off: their entries are
|
||||
/// dropped when it is switched on ([`Session::configure_disk_cache`]), before it is used.
|
||||
pub pending_disk_drops: std::collections::BTreeSet<String>,
|
||||
/// Settings (Preferences).
|
||||
pub prefs: prefs::Prefs,
|
||||
/// Bumped whenever settings change (frontends re-apply theme, labels…).
|
||||
@@ -254,14 +257,6 @@ pub struct Session {
|
||||
/// Each footage file's size and modification time when last seen (Settings ▸ Import ▸
|
||||
/// Reload Footage Changed on Disk, [`footage_reload`]).
|
||||
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
|
||||
/// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]).
|
||||
pub disk_salt: Option<(u64, u64)>,
|
||||
/// Take the disk cache's footage salt on a worker thread ([`Session::refresh_disk_salt`]):
|
||||
/// frontends with an event loop (the desktop app) turn this on and call
|
||||
/// [`Session::poll_disk_salt`] each frame; headless sessions take it at once.
|
||||
pub disk_salt_background: bool,
|
||||
/// The footage salt being taken in the background.
|
||||
pub disk_salt_job: Option<DiskSaltJob>,
|
||||
/// The footage files effects read the channels of, as last given to the footage source
|
||||
/// ([`Session::refresh_aux_footage`]); `None` = not given yet.
|
||||
pub aux_footage: Option<Vec<String>>,
|
||||
@@ -356,6 +351,7 @@ impl Default for Session {
|
||||
layer_cache: Arc::new(LayerCache::default()),
|
||||
crypto_probe: Default::default(),
|
||||
disk_cache: None,
|
||||
pending_disk_drops: Default::default(),
|
||||
prefs: prefs::Prefs::default(),
|
||||
prefs_revision: 0,
|
||||
config: None,
|
||||
@@ -391,9 +387,6 @@ impl Default for Session {
|
||||
check_footage_on_open: false,
|
||||
stored_paths: Default::default(),
|
||||
footage_stamps: Default::default(),
|
||||
disk_salt: None,
|
||||
disk_salt_background: false,
|
||||
disk_salt_job: None,
|
||||
aux_footage: None,
|
||||
script_calls: ScriptCalls::default(),
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use effectcraft_project::render_queue::{OutputFormat, OutputModule, PostRenderAc
|
||||
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
|
||||
use effectcraft_render::{ExprHost, FootageSource};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Event, Session};
|
||||
|
||||
@@ -113,6 +114,14 @@ pub struct JobShared {
|
||||
pub cancel: AtomicBool,
|
||||
pub state: Mutex<JobState>,
|
||||
pub updates: Mutex<Vec<ItemUpdate>>,
|
||||
/// A waiting control request retains its own items across requeue/removal/project edits.
|
||||
reply: Mutex<Option<RenderReply>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RenderReply {
|
||||
items: Vec<Value>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
@@ -120,6 +129,48 @@ pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
}
|
||||
|
||||
impl JobShared {
|
||||
/// Retain the requesting command's items before the UI can edit or replace the queue.
|
||||
pub fn retain_reply_items(&self, items: Vec<Value>) {
|
||||
*lock(&self.reply) = Some(RenderReply { items, finished: false });
|
||||
}
|
||||
|
||||
/// Whether completion and post-render actions have been applied to this run.
|
||||
pub fn reply_finished(&self) -> bool {
|
||||
lock(&self.reply).as_ref().is_some_and(|reply| reply.finished)
|
||||
}
|
||||
|
||||
/// Final items for this run, independent of the current project and any later render.
|
||||
pub fn completed_items(&self) -> Option<Vec<Value>> {
|
||||
lock(&self.reply).as_ref().filter(|reply| reply.finished).map(|reply| reply.items.clone())
|
||||
}
|
||||
|
||||
fn update_reply(&self, updates: &[ItemUpdate]) {
|
||||
let mut reply = lock(&self.reply);
|
||||
let Some(reply) = reply.as_mut() else { return };
|
||||
for update in updates {
|
||||
let id = match update {
|
||||
ItemUpdate::Started { id, .. } | ItemUpdate::Finished { id, .. } => *id,
|
||||
};
|
||||
let Some(item) = reply.items.iter_mut().find(|item| item["id"].as_u64() == Some(id)).and_then(Value::as_object_mut) else { continue };
|
||||
match update {
|
||||
ItemUpdate::Started { unix, .. } => {
|
||||
item.insert("status".into(), json!(RenderStatus::Rendering));
|
||||
item.insert("statusLabel".into(), json!(RenderStatus::Rendering.label()));
|
||||
item.insert("started".into(), json!(unix));
|
||||
item.insert("render_time".into(), Value::Null);
|
||||
}
|
||||
ItemUpdate::Finished { status, seconds, output, .. } => {
|
||||
item.insert("status".into(), json!(status));
|
||||
item.insert("statusLabel".into(), json!(status.label()));
|
||||
item.insert("render_time".into(), json!(seconds));
|
||||
if let Some(output) = output {
|
||||
item.insert("last_output".into(), json!(output));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> JobState {
|
||||
lock(&self.state).clone()
|
||||
}
|
||||
@@ -626,8 +677,11 @@ impl Session {
|
||||
pub fn poll_render(&mut self) -> bool {
|
||||
self.drain_remote_render();
|
||||
let Some(job) = &self.render_job else { return false };
|
||||
let updates = std::mem::take(&mut *lock(&job.shared.updates));
|
||||
// Observe completion before draining updates so a worker finishing between the two
|
||||
// reads cannot leave its final item statuses behind when this job is removed.
|
||||
let finished = job.is_finished();
|
||||
let updates = std::mem::take(&mut *lock(&job.shared.updates));
|
||||
job.shared.update_reply(&updates);
|
||||
let changed = !updates.is_empty();
|
||||
let mut post: Vec<(u64, String)> = vec![];
|
||||
if changed {
|
||||
@@ -659,6 +713,7 @@ impl Session {
|
||||
}
|
||||
self.bump();
|
||||
}
|
||||
let mut completed = None;
|
||||
if finished && let Some(mut job) = self.render_job.take() {
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
@@ -685,12 +740,18 @@ impl Session {
|
||||
self.events.push(Event::Frontend { command: "renderQueue.notify".into(), params: serde_json::json!({"message": msg, "error": failed > 0}) });
|
||||
}
|
||||
self.events.push(Event::Toast { message: msg, error: failed > 0 });
|
||||
completed = Some(job.shared);
|
||||
}
|
||||
for (id, path) in post {
|
||||
if let Err(e) = self.post_render_action(id, &path) {
|
||||
self.events.push(Event::Toast { message: format!("Post-render action: {e}"), error: true });
|
||||
}
|
||||
}
|
||||
if let Some(shared) = completed
|
||||
&& let Some(reply) = lock(&shared.reply).as_mut()
|
||||
{
|
||||
reply.finished = true;
|
||||
}
|
||||
changed || finished
|
||||
}
|
||||
|
||||
|
||||
@@ -388,6 +388,18 @@ pub type ScriptUiDispatch = fn(&mut Session, &ScriptUiEvent) -> std::result::Res
|
||||
/// and updates [`Session::script_ui`]. Frontends call it every frame (see [`ScriptUi::run_tasks`]).
|
||||
pub type ScriptTaskRunner = fn(&mut Session) -> Value;
|
||||
|
||||
/// Resume a suspended script once its Render Queue worker has finished. `None` means the
|
||||
/// script is still rendering; `Some` is its eventual result (or a real modal-dialog yield).
|
||||
pub type ScriptRenderPoll = fn(&mut Session) -> Option<Value>;
|
||||
|
||||
pub const RENDER_WAIT_MESSAGE: &str = "A script is rendering. Stop the render or wait for it to finish before editing.";
|
||||
|
||||
/// These inspections do not run callbacks or collect unrelated background edits. In
|
||||
/// particular, `journal: false` is not a read-only guarantee (`engine.batch`, `jobs.wait`).
|
||||
pub fn allowed_during_render(command: &str) -> bool {
|
||||
matches!(command, "renderQueue.stop" | "renderQueue.list" | "editor.state" | "app.capabilities" | "command.list" | "scriptui.list" | "scriptui.get")
|
||||
}
|
||||
|
||||
/// The open script windows.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -401,6 +413,16 @@ pub struct ScriptUi {
|
||||
/// The scripting engine's scheduled-task runner (set with [`Session::script`]).
|
||||
#[serde(skip)]
|
||||
pub tasks: Option<ScriptTaskRunner>,
|
||||
/// A frontend that services [`Self::poll_render`] may opt a script invocation into yielding
|
||||
/// during a blocking Render Queue call. Headless and lifecycle scripts remain synchronous.
|
||||
#[serde(skip)]
|
||||
pub yield_renders: bool,
|
||||
/// The script host suspended in a Render Queue call. While set, frontends must not allow
|
||||
/// other edits or script callbacks to interleave with its open undo groups.
|
||||
#[serde(skip)]
|
||||
pub render_host: Option<u32>,
|
||||
#[serde(skip)]
|
||||
pub poll_render: Option<ScriptRenderPoll>,
|
||||
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
|
||||
/// tasks of scripts without an open window stop then, except those of Startup scripts.
|
||||
#[serde(skip)]
|
||||
|
||||
@@ -10,4 +10,4 @@ mod dispatch;
|
||||
mod edit;
|
||||
mod render;
|
||||
|
||||
pub use edit::{DiskSaltJob, upgrade_effects};
|
||||
pub use edit::upgrade_effects;
|
||||
@@ -7,6 +7,9 @@ impl Session {
|
||||
|
||||
/// Run a command by id.
|
||||
pub fn execute(&mut self, id: &str, params: Value) -> Result<Value> {
|
||||
if self.script_ui.render_host.is_some() && !crate::scriptui::allowed_during_render(id) {
|
||||
return Err(EngineError::Other(crate::scriptui::RENDER_WAIT_MESSAGE.into()));
|
||||
}
|
||||
let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
if let Err(why) = (spec.enabled)(self) {
|
||||
// Explicit targets (agents, scripts) don't need a UI selection.
|
||||
@@ -57,6 +60,9 @@ impl Session {
|
||||
/// Whether `id` names a known command whose `enabled` predicate returns `Ok` for this session;
|
||||
/// an unknown id or a predicate error reads as disabled.
|
||||
pub fn is_enabled(&self, id: &str) -> bool {
|
||||
if self.script_ui.render_host.is_some() && !crate::scriptui::allowed_during_render(id) {
|
||||
return false;
|
||||
}
|
||||
commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok())
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ impl Session {
|
||||
/// undo step under `label`, unless it merges with the current merge key (then the redo branch
|
||||
/// is dropped). An `Err` from `f` propagates with the session unchanged.
|
||||
fn edit_inner<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
|
||||
if self.script_ui.render_host.is_some() {
|
||||
return Err(EngineError::Other(crate::scriptui::RENDER_WAIT_MESSAGE.into()));
|
||||
}
|
||||
let before = self.project.clone();
|
||||
let mut p = (*self.project).clone();
|
||||
let mut st = self.state.clone();
|
||||
@@ -97,118 +100,17 @@ impl Session {
|
||||
if Arc::ptr_eq(&self.project, &self.saved_project) {
|
||||
self.saved_revision = self.revision;
|
||||
}
|
||||
self.refresh_disk_salt(false);
|
||||
self.refresh_aux_footage();
|
||||
self.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
}
|
||||
|
||||
/// Salt the disk cache's layer keys with the project's footage files
|
||||
/// ([`effectcraft_render::disk_cache::footage_salt`]) and attach the disk cache to the layer
|
||||
/// cache with it. Taking it reads the size and time of every file, every frame of an image
|
||||
/// sequence included, so it is taken again only when the footage items changed, or with
|
||||
/// `force` (a project opened, footage reloaded, the disk cache attached), not at every edit:
|
||||
/// with a few large sequences that cost each keystroke in a text field over half a second
|
||||
/// (#1926).
|
||||
///
|
||||
/// With [`Session::disk_salt_background`] the files are read on a worker thread: an imported
|
||||
/// or opened project with thousands of sequence frames on a network share took half a minute
|
||||
/// to stamp, all of it on the UI thread. Until [`Session::poll_disk_salt`] picks the salt up,
|
||||
/// renders use the memory cache only (keys under the old salt may name other files, or
|
||||
/// another project's items) and this returns `None`.
|
||||
pub fn refresh_disk_salt(&mut self, force: bool) -> Option<u64> {
|
||||
let Some(dc) = self.disk_cache.clone() else {
|
||||
self.disk_salt = None;
|
||||
self.disk_salt_job = None;
|
||||
return None;
|
||||
};
|
||||
self.poll_disk_salt();
|
||||
let footage = Self::footage_fingerprint(&self.project);
|
||||
if !force {
|
||||
if let Some((seen, salt)) = self.disk_salt
|
||||
&& seen == footage
|
||||
{
|
||||
return Some(salt);
|
||||
}
|
||||
if self.disk_salt_job.as_ref().is_some_and(|j| j.footage == footage) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if self.disk_salt_background && !cfg!(target_arch = "wasm32") {
|
||||
let project = self.project.clone();
|
||||
let slot: Arc<std::sync::Mutex<Option<u64>>> = Arc::default();
|
||||
let out = slot.clone();
|
||||
let spawned = std::thread::Builder::new().name("ec-disk-salt".into()).spawn(move || {
|
||||
let salt = effectcraft_render::disk_cache::footage_salt(&project);
|
||||
if let Ok(mut s) = out.lock() {
|
||||
*s = Some(salt);
|
||||
}
|
||||
});
|
||||
// Without a thread the salt is taken here, as before.
|
||||
if let Ok(thread) = spawned {
|
||||
self.disk_salt = None;
|
||||
self.disk_salt_job = Some(DiskSaltJob { footage, slot, thread });
|
||||
self.layer_cache.set_disk(None);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
self.disk_salt_job = None;
|
||||
let salt = effectcraft_render::disk_cache::footage_salt(&self.project);
|
||||
self.disk_salt = Some((footage, salt));
|
||||
self.layer_cache.set_disk(Some((dc, salt)));
|
||||
Some(salt)
|
||||
}
|
||||
|
||||
/// Pick up a footage salt taken in the background ([`Session::refresh_disk_salt`]) and attach
|
||||
/// the disk cache with it. Returns whether one is still being taken (frontends call this each
|
||||
/// frame while it is).
|
||||
pub fn poll_disk_salt(&mut self) -> bool {
|
||||
let Some(job) = &self.disk_salt_job else { return false };
|
||||
// (looked at first: a worker that has finished has left its salt in the slot)
|
||||
let finished = job.thread.is_finished();
|
||||
let salt = job.slot.lock().ok().and_then(|mut s| s.take());
|
||||
let footage = job.footage;
|
||||
match salt {
|
||||
Some(salt) => {
|
||||
self.disk_salt_job = None;
|
||||
if let Some(dc) = self.disk_cache.clone() {
|
||||
self.disk_salt = Some((footage, salt));
|
||||
self.layer_cache.set_disk(Some((dc, salt)));
|
||||
}
|
||||
false
|
||||
}
|
||||
None if finished => {
|
||||
// The worker stopped without a salt: the disk cache stays off until the next
|
||||
// edit takes it again.
|
||||
log::warn!("disk cache: the footage files could not be stamped; the disk cache is off until the next edit");
|
||||
self.disk_salt_job = None;
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory identity of the footage settings represented by a disk salt. Workers use the
|
||||
/// same fingerprint when preparing availability changes without disk access on the UI.
|
||||
pub(crate) fn footage_fingerprint(project: &effectcraft_project::Project) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for (id, item) in &project.items {
|
||||
if let effectcraft_project::ItemKind::Footage(f) = &item.kind {
|
||||
id.hash(&mut h);
|
||||
format!("{f:?}").hash(&mut h);
|
||||
}
|
||||
if let Some(proxy) = &item.proxy {
|
||||
id.hash(&mut h);
|
||||
format!("{proxy:?}").hash(&mut h);
|
||||
}
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes
|
||||
/// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay
|
||||
/// valid ([`effectcraft_project::Comp::same_pixels`]).
|
||||
pub fn set_resolution(&mut self, comp: ItemId, r: effectcraft_project::Resolution) -> Result<()> {
|
||||
if self.script_ui.render_host.is_some() {
|
||||
return Err(EngineError::Other(crate::scriptui::RENDER_WAIT_MESSAGE.into()));
|
||||
}
|
||||
if self.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -238,6 +140,9 @@ impl Session {
|
||||
/// on-canvas editor gets a new IME epoch only when its own document changed) and the session is
|
||||
/// bumped. Returns `false`, changing nothing, when there is nothing to undo.
|
||||
pub fn undo(&mut self) -> bool {
|
||||
if self.script_ui.render_host.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some((label, p)) = self.history.undo.pop() else { return false };
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
let probe = commands::kept_crypto_probe(self);
|
||||
@@ -256,6 +161,9 @@ impl Session {
|
||||
/// text document starts a new IME epoch) and the session is bumped. Returns `false`, changing
|
||||
/// nothing, when there is nothing to redo.
|
||||
pub fn redo(&mut self) -> bool {
|
||||
if self.script_ui.render_host.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some((label, p)) = self.history.redo.pop() else { return false };
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
let probe = commands::kept_crypto_probe(self);
|
||||
@@ -322,11 +230,6 @@ impl Session {
|
||||
}
|
||||
self.layer_cache.clear();
|
||||
self.project = Arc::new(p);
|
||||
// Its files may have changed since they were last stamped.
|
||||
self.disk_salt = None;
|
||||
// A salt still being taken is for the old project's files: dropped, so that the next refresh
|
||||
// stamps the new project anew (a matching fingerprint would otherwise keep the stale job).
|
||||
self.disk_salt_job = None;
|
||||
// The set of footage effects read the channels of is sent again, even when it is empty.
|
||||
self.aux_footage = None;
|
||||
self.history = History::default();
|
||||
@@ -389,11 +292,3 @@ pub fn upgrade_effects(p: &mut Project) {
|
||||
p.next_id = next;
|
||||
p.schema = effectcraft_project::SCHEMA_VERSION;
|
||||
}
|
||||
|
||||
/// A footage salt being taken on a worker thread ([`Session::refresh_disk_salt`]).
|
||||
pub struct DiskSaltJob {
|
||||
/// The footage it is taken for ([`Session::footage_fingerprint`]).
|
||||
footage: u64,
|
||||
slot: Arc<std::sync::Mutex<Option<u64>>>,
|
||||
thread: std::thread::JoinHandle<()>,
|
||||
}
|
||||
@@ -137,10 +137,6 @@ impl Session {
|
||||
// over the store of a roto test running beside it. Serialise them with the roto tests.
|
||||
#[cfg(test)]
|
||||
crate::tests::roto::hold_roto_cache_test_lock();
|
||||
// Another setting changed (the disk cache stays as it is): its salt still holds, and
|
||||
// stamping every footage file again would cost each settings change seconds with large
|
||||
// sequences.
|
||||
let kept = self.disk_cache.as_ref().is_some_and(|c| c.folder() == folder.as_path());
|
||||
let dc = match &self.disk_cache {
|
||||
Some(c) if c.folder() == folder.as_path() => {
|
||||
c.set_max_bytes(max);
|
||||
@@ -162,11 +158,17 @@ impl Session {
|
||||
}
|
||||
},
|
||||
};
|
||||
// Files reloaded while the cache was off: their entries go before anything is read from
|
||||
// it (until the writer has deleted them they are misses, and no frame is linked).
|
||||
if !self.pending_disk_drops.is_empty() {
|
||||
dc.drop_files(std::mem::take(&mut self.pending_disk_drops));
|
||||
}
|
||||
self.disk_cache = Some(dc.clone());
|
||||
// The reserve applies from the start (a new folder's watcher is idle).
|
||||
self.poll_disk_space();
|
||||
// Attaches the disk cache to the layer cache once the footage salt is known.
|
||||
self.refresh_disk_salt(!kept);
|
||||
// Used at once (no file is looked at first: each entry checks the source files it lists
|
||||
// when it is read, #3184).
|
||||
self.layer_cache.set_disk(Some(dc.clone()));
|
||||
// Roto Brush / Object Matte segmentations persist there too (held weakly: switching the
|
||||
// disk cache off drops it).
|
||||
let store: std::sync::Arc<dyn effectcraft_effects::roto::SegStore> = dc.clone();
|
||||
|
||||
@@ -214,6 +214,7 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap();
|
||||
let (px, py) = buf.to_px([x, y]);
|
||||
|
||||
@@ -76,11 +76,13 @@ fn disk_cache_settings_persistence_and_invalidation() {
|
||||
assert!(s2.layer_cache.disk().is_none());
|
||||
}
|
||||
|
||||
/// The comp key comes from the project in memory: a footage file that changes on disk doesn't
|
||||
/// change it (the entries check their own files, #3184); edits that change pixels do.
|
||||
#[test]
|
||||
fn footage_changes_change_the_salt() {
|
||||
let dir = folder("salt");
|
||||
fn comp_content_keys_come_from_memory() {
|
||||
let dir = folder("keys");
|
||||
let mut s = session(&dir);
|
||||
let foot = std::env::temp_dir().join(format!("effectcraft-salt-{}.png", std::process::id()));
|
||||
let foot = std::env::temp_dir().join(format!("effectcraft-keys-{}.png", std::process::id()));
|
||||
std::fs::write(&foot, b"one").unwrap();
|
||||
s.edit("add", None, |p, _| {
|
||||
let f = effectcraft_project::Footage {
|
||||
@@ -108,12 +110,10 @@ fn footage_changes_change_the_salt() {
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let a = effectcraft_render::disk_cache::footage_salt(&s.project);
|
||||
std::fs::write(&foot, b"two, longer").unwrap();
|
||||
let b = effectcraft_render::disk_cache::footage_salt(&s.project);
|
||||
assert_ne!(a, b);
|
||||
let cid = project(&mut s);
|
||||
let k1 = effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
|
||||
std::fs::write(&foot, b"two, longer").unwrap();
|
||||
assert_eq!(effectcraft_render::disk_cache::comp_content_key(&s.project, cid), k1, "no file is looked at");
|
||||
s.execute("prop.set", json!({"layer": "#1", "path": "transform/opacity", "value": 50})).unwrap();
|
||||
let k2 = effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
|
||||
assert_ne!(k1, k2);
|
||||
@@ -137,66 +137,51 @@ fn footage_changes_change_the_salt() {
|
||||
assert_ne!(set_proxy(&mut s, false), k3);
|
||||
}
|
||||
|
||||
/// The desktop app stamps footage files for the disk cache's salt on a worker thread: importing an
|
||||
/// After Effects project with thousands of sequence frames on a network share stamped them all
|
||||
/// inside the import's edit, freezing the UI for half a minute. Until the salt lands the disk
|
||||
/// cache sits out (keys under the old salt may name other files); a newer footage change
|
||||
/// supersedes a salt still being taken; a settings change keeps the salt.
|
||||
/// No salt and no scan (#3184, replacing the background footage salt of #3106): the disk cache
|
||||
/// is attached to the layer cache as soon as it is switched on, and stays attached through
|
||||
/// edits that add footage (a large image sequence included), settings changes and opening a
|
||||
/// project, none of which looks at a single file for it. The first render may use it at once.
|
||||
#[test]
|
||||
fn footage_salt_is_taken_off_the_ui_thread_and_the_disk_cache_waits_for_it() {
|
||||
let dir = folder("bg-salt");
|
||||
fn the_disk_cache_is_attached_at_once_and_edits_do_no_file_work() {
|
||||
let dir = folder("attach");
|
||||
let mut s = session(&dir);
|
||||
s.disk_salt_background = true;
|
||||
let at = |n: &str| std::env::temp_dir().join(format!("effectcraft-bg-salt-{}-{n}.png", std::process::id()));
|
||||
let add = |s: &mut Session, path: &std::path::Path| {
|
||||
let f = effectcraft_project::Footage {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: 1,
|
||||
height: 1,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
s.edit("add", None, |p, _| {
|
||||
p.add_item("f.png", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let dc = s.disk_cache.clone().unwrap();
|
||||
let attached = |s: &Session| s.layer_cache.disk().is_some_and(|d| std::sync::Arc::ptr_eq(&d, &dc));
|
||||
assert!(attached(&s), "attached at once");
|
||||
// A sequence of 5000 frames that don't even exist.
|
||||
let seq_dir = std::env::temp_dir().join(format!("effectcraft-no-scan-{}", std::process::id()));
|
||||
let f = effectcraft_project::Footage {
|
||||
path: seq_dir.join("f_00000.exr").to_string_lossy().to_string(),
|
||||
kind: effectcraft_project::FootageKind::Sequence,
|
||||
sequence: (0..5000).map(|i| seq_dir.join(format!("f_{i:05}.exr")).to_string_lossy().to_string()).collect(),
|
||||
width: 8,
|
||||
height: 8,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
let wait = |s: &mut Session| {
|
||||
let t0 = std::time::Instant::now();
|
||||
while s.poll_disk_salt() {
|
||||
assert!(t0.elapsed().as_secs() < 20, "the salt never landed");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
};
|
||||
assert!(s.layer_cache.disk().is_some(), "attached with its salt");
|
||||
std::fs::write(at("a"), b"one").unwrap();
|
||||
add(&mut s, &at("a"));
|
||||
// The edit returned before the files were stamped, and the disk cache waits for the salt.
|
||||
assert!(s.disk_salt.is_none() && s.layer_cache.disk().is_none());
|
||||
// Edits that leave the footage alone don't start over (and may pick up the salt meanwhile).
|
||||
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 8, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let r = s.refresh_disk_salt(false);
|
||||
assert!(r.is_none() || r == Some(effectcraft_render::disk_cache::footage_salt(&s.project)), "{r:?}");
|
||||
// New footage while it is taken: the salt for the newest footage lands.
|
||||
std::fs::write(at("b"), b"two").unwrap();
|
||||
add(&mut s, &at("b"));
|
||||
wait(&mut s);
|
||||
let salt = effectcraft_render::disk_cache::footage_salt(&s.project);
|
||||
assert_eq!(s.disk_salt.map(|(_, salt)| salt), Some(salt));
|
||||
assert_eq!(s.refresh_disk_salt(false), Some(salt));
|
||||
assert!(s.layer_cache.disk().is_some(), "attached again");
|
||||
// Another setting keeps the salt and the disk cache attached.
|
||||
s.edit("add", None, |p, _| {
|
||||
p.add_item("seq", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(attached(&s), "an import keeps it attached");
|
||||
let cid = project(&mut s);
|
||||
s.execute("prop.set", json!({"layer": "#1", "path": "transform/opacity", "value": 50})).unwrap();
|
||||
s.execute_checked("prefs.set", json!({"key": "disk.diskCacheMaxGb", "value": 30})).unwrap();
|
||||
assert!(s.layer_cache.disk().is_some() && s.disk_salt.map(|(_, salt)| salt) == Some(salt));
|
||||
// Turned off and on again: stamped anew, in the background.
|
||||
assert!(attached(&s), "edits and other settings keep it attached");
|
||||
let same = (*s.project).clone();
|
||||
s.replace_project(same, None);
|
||||
assert!(attached(&s), "opening a project keeps it attached");
|
||||
// The first render uses it: a solid reads no file, so nothing is checked either.
|
||||
s.render(cid, s.time(), RenderOpts::default());
|
||||
dc.flush();
|
||||
assert!(dc.stats().entries > 0, "written by the first render");
|
||||
assert_eq!(dc.file_checks(), 0, "no file looked at for the disk cache");
|
||||
// Switched off and on: attached again at once.
|
||||
s.execute_checked("prefs.set", json!({"key": "disk.diskCacheEnabled", "value": false})).unwrap();
|
||||
assert!(s.layer_cache.disk().is_none());
|
||||
s.execute_checked("prefs.set", json!({"key": "disk.diskCacheEnabled", "value": true})).unwrap();
|
||||
wait(&mut s);
|
||||
assert_eq!(s.refresh_disk_salt(false), Some(salt));
|
||||
let _ = std::fs::remove_file(at("a"));
|
||||
let _ = std::fs::remove_file(at("b"));
|
||||
assert!(s.layer_cache.disk().is_some());
|
||||
}
|
||||
|
||||
/// Marker edits keep the disk key (the sound of a playing preview must not wait for frames to
|
||||
@@ -258,38 +243,227 @@ fn protected_markers_with_a_duration_change_the_content_key() {
|
||||
assert_ne!(key(&s), k1, "its duration");
|
||||
}
|
||||
|
||||
/// A project opened while a footage salt is still being taken must not keep that salt when it
|
||||
/// names the same footage: its files may have changed since the worker read them.
|
||||
/// With expressions the comp key covers what they can read, not the whole project (#3184,
|
||||
/// #1492): an expression reading only its own comp keeps the key through edits of another comp
|
||||
/// and of unused footage; `comp("Other")` makes "Other" count (and which comps have that name),
|
||||
/// not a third comp; a computed name counts every item.
|
||||
#[test]
|
||||
fn opening_a_project_drops_the_footage_salt_being_taken() {
|
||||
let dir = folder("bg-salt-open");
|
||||
let mut s = session(&dir);
|
||||
s.disk_salt_background = true;
|
||||
let path = std::env::temp_dir().join(format!("effectcraft-bg-salt-open-{}.png", std::process::id()));
|
||||
std::fs::write(&path, b"one").unwrap();
|
||||
let f = effectcraft_project::Footage {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: 1,
|
||||
height: 1,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
fn expressions_reach_only_what_they_name() {
|
||||
let mut s = Session::default();
|
||||
let comp = |s: &mut Session, name: &str| {
|
||||
let c = s.execute("comp.new", json!({"name": name, "width": 8, "height": 8, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"comp": c, "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
(effectcraft_project::ItemId(c), (c, l))
|
||||
};
|
||||
s.edit("add", None, |p, _| {
|
||||
p.add_item("f.png", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f));
|
||||
let (main, ml) = comp(&mut s, "Main");
|
||||
let (_, ol) = comp(&mut s, "Other");
|
||||
let (third, tl) = comp(&mut s, "Third");
|
||||
let unused = s.edit("add", None, |p, _| {
|
||||
let f = effectcraft_project::Footage { path: "/nowhere/a.png".into(), has_video: true, ..Default::default() };
|
||||
Ok(p.add_item("a.png", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)))
|
||||
});
|
||||
let unused = unused.unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, main);
|
||||
let expr = |s: &mut Session, (comp, layer): (u64, u64), text: &str| {
|
||||
s.execute("prop.setExpression", json!({"comp": comp, "layer": layer, "path": "transform/opacity", "expression": text})).unwrap();
|
||||
};
|
||||
let opacity = |s: &mut Session, (comp, layer): (u64, u64), v: f64| {
|
||||
s.execute("prop.set", json!({"comp": comp, "layer": layer, "path": "transform/opacity", "value": v})).unwrap();
|
||||
};
|
||||
let repath = |s: &mut Session, path: &str| {
|
||||
let path = path.to_string();
|
||||
s.edit("repath", None, move |p, _| {
|
||||
if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(unused).map(|i| &mut i.kind) {
|
||||
f.path = path;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
// Its own comp only.
|
||||
expr(&mut s, ml, "thisComp.layer(1).transform.rotation + wiggle(1, 1)[0]");
|
||||
expr(&mut s, ol, "wiggle(2, 10)");
|
||||
let k0 = key(&s);
|
||||
opacity(&mut s, ol, 40.0);
|
||||
expr(&mut s, tl, "time * 10");
|
||||
repath(&mut s, "/nowhere/b.png");
|
||||
s.execute("comp.addMarker", json!({"comp": third.0, "time": 0.5})).unwrap();
|
||||
assert_eq!(key(&s), k0, "edits elsewhere keep the key");
|
||||
// An edit in the comp itself changes it.
|
||||
opacity(&mut s, ml, 30.0);
|
||||
assert_ne!(key(&s), k0);
|
||||
// `comp("Other")`: Other counts, Third doesn't.
|
||||
expr(&mut s, ml, "comp(\"Other\").layer(1).transform.opacity");
|
||||
let k1 = key(&s);
|
||||
expr(&mut s, ol, "wiggle(3, 10)");
|
||||
let k2 = key(&s);
|
||||
assert_ne!(k2, k1, "the comp it names");
|
||||
opacity(&mut s, tl, 20.0);
|
||||
repath(&mut s, "/nowhere/c.png");
|
||||
assert_eq!(key(&s), k2, "a comp it doesn't name");
|
||||
// Another comp taking the name changes what it may read.
|
||||
s.edit("rename", None, |p, _| {
|
||||
p.item_mut(third).unwrap().name = "Other".into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// The file changes after the edit started the salt, before the same footage is opened again.
|
||||
std::fs::write(&path, b"a longer content").unwrap();
|
||||
let same = (*s.project).clone();
|
||||
s.replace_project(same, None);
|
||||
let t0 = std::time::Instant::now();
|
||||
while s.poll_disk_salt() {
|
||||
assert!(t0.elapsed().as_secs() < 20, "the salt never landed");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let k3 = key(&s);
|
||||
assert_ne!(k3, k2, "a second comp named Other");
|
||||
// A computed name reaches every item.
|
||||
expr(&mut s, ml, "comp(\"Oth\" + \"er\").layer(1).transform.opacity");
|
||||
let k4 = key(&s);
|
||||
repath(&mut s, "/nowhere/d.png");
|
||||
assert_ne!(key(&s), k4, "unused footage counts with a computed name");
|
||||
}
|
||||
|
||||
/// Markers count only for the comps whose expressions can read them (#1083): an expression in
|
||||
/// another comp that reads markers leaves this comp's markers out of its key.
|
||||
#[test]
|
||||
fn markers_count_only_where_an_expression_can_read_them() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
let other = s.execute("comp.new", json!({"name": "N", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||||
let ol = s.execute("layer.newSolid", json!({"comp": other, "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.setExpression", json!({"comp": other, "layer": ol, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"comp": cid.0, "time": 0.5, "comment": "beat"})).unwrap();
|
||||
assert_eq!(key(&s), k0, "no expression of M reads its markers");
|
||||
s.execute("prop.setExpression", json!({"comp": other, "layer": ol, "path": "transform/opacity", "expression": "comp(\"M\").marker.numKeys"})).unwrap();
|
||||
let other_key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, effectcraft_project::ItemId(other));
|
||||
let n0 = other_key(&s);
|
||||
s.execute("comp.addMarker", json!({"comp": cid.0, "time": 1.0})).unwrap();
|
||||
assert_ne!(other_key(&s), n0, "N reads M's markers");
|
||||
assert_eq!(key(&s), k0, "M itself still doesn't");
|
||||
}
|
||||
|
||||
/// File ▸ Reload Footage drops the disk entries made of the item's files (#3184): a file
|
||||
/// replaced by one with the same size and modification time is read again (the check on hit
|
||||
/// can't tell), and entries of other files stay.
|
||||
#[test]
|
||||
fn reload_footage_drops_the_entries_of_its_files() {
|
||||
use crate::render::reads::{FileStamp, ReadFile, ReadFiles};
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> std::result::Result<effectcraft_project::Footage, String> {
|
||||
Ok(effectcraft_project::Footage {
|
||||
path: path.into(),
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: 1,
|
||||
height: 1,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
let now = effectcraft_render::disk_cache::footage_salt(&s.project);
|
||||
assert_eq!(s.disk_salt.map(|(_, salt)| salt), Some(now), "the salt reflects the file as it is now");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let dir = folder("reload");
|
||||
let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() };
|
||||
s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": &dir, "disk.diskCacheEnabled": true}})).unwrap();
|
||||
let src = std::path::Path::new(&dir).join("src");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
let mut add = |name: &str| {
|
||||
let p = src.join(name);
|
||||
std::fs::write(&p, b"aa").unwrap();
|
||||
let path = p.to_string_lossy().to_string();
|
||||
let f = effectcraft_project::Footage {
|
||||
path: path.clone(),
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: 1,
|
||||
height: 1,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
let id = s.edit("add", None, |p, _| Ok(p.add_item(name, effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)))).unwrap();
|
||||
(id, path)
|
||||
};
|
||||
let (a, a_path) = add("a.png");
|
||||
let (_, b_path) = add("b.png");
|
||||
let dc = s.disk_cache.clone().unwrap();
|
||||
let read = |p: &str| -> ReadFiles { [ReadFile::new(p, FileStamp::native(p).unwrap())].into_iter().collect() };
|
||||
dc.put_frame_now(1, 1, 1, &[1; 4], &read(&a_path));
|
||||
dc.put_frame_now(2, 1, 1, &[2; 4], &read(&b_path));
|
||||
dc.put_layer_now(3, &effectcraft_effects::Buf { img: effectcraft_raster::Image::new(1, 1), offset: [0.0; 2], scale: 1.0 }, &read(&a_path));
|
||||
// Other bytes, same size, the old time back.
|
||||
let t = std::fs::metadata(&a_path).unwrap().modified().unwrap();
|
||||
std::fs::write(&a_path, b"AA").unwrap();
|
||||
std::fs::File::options().append(true).open(&a_path).unwrap().set_modified(t).unwrap();
|
||||
assert!(dc.contains(crate::render::disk_cache::Kind::Frame, 1));
|
||||
s.execute_checked("file.reloadFootage", json!({"item": a.0})).unwrap();
|
||||
dc.flush();
|
||||
assert!(dc.get_frame(1, None).is_none(), "the reloaded file's frame renders again");
|
||||
assert!(dc.get_layer(3, None).is_none(), "and its layer buffer");
|
||||
assert!(dc.get_frame(2, None).is_some(), "another file's frame stays");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Reload Footage while the disk cache is off (#3184): when it is switched on later in the
|
||||
/// session, the entries made of the reloaded files are dropped before anything is read from it
|
||||
/// (a file replaced by one with the same size and time would pass the check on hit); entries of
|
||||
/// other files stay.
|
||||
#[test]
|
||||
fn reload_footage_while_the_disk_cache_is_off_drops_its_entries_when_it_is_on() {
|
||||
use crate::render::disk_cache::{DiskCache, Kind};
|
||||
use crate::render::reads::{FileStamp, ReadFile, ReadFiles};
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> std::result::Result<effectcraft_project::Footage, String> {
|
||||
Ok(effectcraft_project::Footage {
|
||||
path: path.into(),
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: 1,
|
||||
height: 1,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
let dir = folder("reload-off");
|
||||
let src = std::path::Path::new(&dir).join("src");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() };
|
||||
let mut add = |name: &str| {
|
||||
let p = src.join(name);
|
||||
std::fs::write(&p, b"aa").unwrap();
|
||||
let path = p.to_string_lossy().to_string();
|
||||
let f = effectcraft_project::Footage {
|
||||
path: path.clone(),
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: 1,
|
||||
height: 1,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
let id = s.edit("add", None, |p, _| Ok(p.add_item(name, effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f)))).unwrap();
|
||||
(id, path)
|
||||
};
|
||||
let (a, a_path) = add("a.png");
|
||||
let (_, b_path) = add("b.png");
|
||||
// Entries from an earlier session in the cache folder.
|
||||
let read = |p: &str| -> ReadFiles { [ReadFile::new(p, FileStamp::native(p).unwrap())].into_iter().collect() };
|
||||
{
|
||||
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
dc.put_frame_now(1, 1, 1, &[1; 4], &read(&a_path));
|
||||
dc.put_frame_now(2, 1, 1, &[2; 4], &read(&b_path));
|
||||
dc.put_layer_now(3, &effectcraft_effects::Buf { img: effectcraft_raster::Image::new(1, 1), offset: [0.0; 2], scale: 1.0 }, &read(&a_path));
|
||||
}
|
||||
// Other bytes, same size, the old time back; reloaded while the cache is off.
|
||||
let t = std::fs::metadata(&a_path).unwrap().modified().unwrap();
|
||||
std::fs::write(&a_path, b"AA").unwrap();
|
||||
std::fs::File::options().append(true).open(&a_path).unwrap().set_modified(t).unwrap();
|
||||
assert!(s.disk_cache.is_none());
|
||||
s.execute_checked("file.reloadFootage", json!({"item": a.0})).unwrap();
|
||||
// Switched on: the reloaded file's entries are misses from the start, and then gone.
|
||||
s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": &dir, "disk.diskCacheEnabled": true}})).unwrap();
|
||||
let dc = s.disk_cache.clone().unwrap();
|
||||
assert!(dc.get_frame(1, None).is_none(), "the reloaded file's frame renders again");
|
||||
assert!(dc.get_layer(3, None).is_none(), "and its layer buffer");
|
||||
dc.flush();
|
||||
assert!(!dc.contains(Kind::Frame, 1) && !dc.contains(Kind::Layer, 3));
|
||||
assert!(dc.get_frame(2, None).is_some(), "another file's frame stays");
|
||||
assert!(s.pending_disk_drops.is_empty(), "applied once");
|
||||
drop(dc);
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -103,6 +103,24 @@ fn commands_cap_sizes_at_30000_px() {
|
||||
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
|
||||
}
|
||||
|
||||
/// Time Stretch takes After Effects' range, ±1 % to ±9900 %: before, 1e300 % was set, and a
|
||||
/// project file holding it opened at 9900 % (#3247).
|
||||
#[test]
|
||||
fn time_stretch_refuses_more_than_9900_percent() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let layer = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let stretch = |s: &Session| s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == layer).unwrap().stretch;
|
||||
for pct in [1e300, -1e300, 9900.5] {
|
||||
assert!(s.execute("layer.timeStretch", json!({"layers": [layer], "percent": pct})).is_err(), "{pct}");
|
||||
assert_eq!(stretch(&s), 100.0, "{pct}");
|
||||
}
|
||||
for pct in [limits::MAX_STRETCH, -limits::MAX_STRETCH] {
|
||||
s.execute("layer.timeStretch", json!({"layers": [layer], "percent": pct})).unwrap();
|
||||
assert_eq!(stretch(&s), pct);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() {
|
||||
// Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A
|
||||
|
||||
@@ -40,7 +40,17 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
|
||||
fn frame(s: &Session, id: u64) -> (Vec3, Vec3) {
|
||||
let c = cid(s);
|
||||
let comp = s.project.comp(c).unwrap();
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let ctx = EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: c,
|
||||
comp,
|
||||
time: Tick::ZERO,
|
||||
expr: None,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap());
|
||||
(e, f)
|
||||
}
|
||||
@@ -244,7 +254,7 @@ fn parented_camera_keeps_its_frame_under_an_offset_null() {
|
||||
fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) {
|
||||
let c = cid(s);
|
||||
let comp = s.project.comp(c).unwrap();
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[], reads: None };
|
||||
layer_frame(&ctx, comp.layer(LayerId(id)).unwrap())
|
||||
}
|
||||
|
||||
|
||||
@@ -269,30 +269,31 @@ fn dpx_and_cineon_runs_import_as_sequences() {
|
||||
assert_eq!(got, [("plate.[0001-0003].dpx".to_string(), 3), ("scan_[01-02].cin".to_string(), 2)], "{r}");
|
||||
}
|
||||
|
||||
/// #1926: an edit that leaves the footage alone doesn't stamp every frame of its image sequences
|
||||
/// again for the disk cache's salt (with large sequences each keystroke in a text field took over
|
||||
/// half a second); Reload Footage does, and so does new footage.
|
||||
/// #1926, #3184: an edit does no file work at all for the disk cache (it used to stamp every
|
||||
/// frame of the project's image sequences for a salt; with large sequences each keystroke in a
|
||||
/// text field took over half a second): the cache stays attached and no file is looked at, after
|
||||
/// edits, after a frame of the sequence was rewritten, after Reload Footage and new footage.
|
||||
/// Entries check the files they list when they are read instead.
|
||||
#[test]
|
||||
fn edits_keep_the_disk_salt_and_reload_stamps_the_files_again() {
|
||||
fn edits_do_no_disk_cache_file_work() {
|
||||
let dir = folder(&RUN);
|
||||
let cache = std::env::temp_dir().join(format!("ec-seq-salt-{}", std::process::id()));
|
||||
let cache = std::env::temp_dir().join(format!("ec-seq-nofiles-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&cache);
|
||||
let mut s = session();
|
||||
s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": cache.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap();
|
||||
let dc = s.disk_cache.clone().expect("a disk cache");
|
||||
let attached = |s: &Session| s.layer_cache.disk().is_some_and(|d| std::sync::Arc::ptr_eq(&d, &dc));
|
||||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 32, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": true, "addToComp": true})).unwrap();
|
||||
let item = r["items"][0].clone();
|
||||
let salt = s.refresh_disk_salt(false).expect("a disk cache");
|
||||
// A frame rewritten in place: same footage settings, another size on disk.
|
||||
std::fs::write(at(&dir, "shot_0004.png"), b"a new render of this frame").unwrap();
|
||||
s.execute("layer.newText", json!({"text": "title"})).unwrap();
|
||||
s.execute("layer.setText", json!({"text": "title 2"})).unwrap();
|
||||
assert_eq!(s.refresh_disk_salt(false), Some(salt), "edits don't stamp the files again");
|
||||
s.execute("file.reloadFootage", json!({"items": [item]})).unwrap();
|
||||
let reloaded = s.refresh_disk_salt(false).unwrap();
|
||||
assert_ne!(reloaded, salt, "Reload Footage stamps them again");
|
||||
std::fs::write(at(&dir, "extra.png"), b"").unwrap();
|
||||
s.execute("file.import", json!({"paths": [at(&dir, "extra.png")]})).unwrap();
|
||||
assert_ne!(s.refresh_disk_salt(false), Some(reloaded), "new footage counts");
|
||||
assert!(attached(&s), "attached all along");
|
||||
assert_eq!(dc.file_checks(), 0, "no file looked at for the disk cache");
|
||||
let _ = std::fs::remove_dir_all(&cache);
|
||||
}
|
||||
@@ -1417,3 +1417,107 @@ fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() {
|
||||
assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied");
|
||||
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected");
|
||||
}
|
||||
|
||||
fn layer_name(s: &Session, t: u64) -> String {
|
||||
s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// Replace the text with `text` in the on-canvas editor (everything selected, typed, committed).
|
||||
fn retype(s: &mut Session, t: u64, text: &str) {
|
||||
begin_edit(s, t);
|
||||
s.execute("text.insert", json!({"text": text})).unwrap();
|
||||
s.execute("text.endEdit", json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// #3233: a text layer's name follows its Source Text, edited on canvas or by `layer.setText`,
|
||||
/// until the user renames it (After Effects). A user name stays, even one that starts with
|
||||
/// "Text", and so does a copy's; renaming to an empty name follows the text again.
|
||||
#[test]
|
||||
fn a_text_layer_name_follows_its_text_until_renamed() {
|
||||
let (mut s, t) = session("Hello");
|
||||
assert_eq!(layer_name(&s, t), "Hello");
|
||||
retype(&mut s, t, "World");
|
||||
assert_eq!(layer_name(&s, t), "World", "a new text layer follows its text");
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "Again"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Again");
|
||||
s.execute("layer.rename", json!({"layer": t, "name": "Text Title"})).unwrap();
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "Changed"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Text Title", "layer.setText keeps the user's name");
|
||||
retype(&mut s, t, "Typed");
|
||||
assert_eq!(layer_name(&s, t), "Text Title", "typing keeps the user's name");
|
||||
s.execute("layer.select", json!({"layers": [t]})).unwrap();
|
||||
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
|
||||
let copy_name = layer_name(&s, copy);
|
||||
s.execute("layer.setText", json!({"layer": copy, "text": "Copied"})).unwrap();
|
||||
assert_eq!(layer_name(&s, copy), copy_name, "a copy keeps the user's name too");
|
||||
// Renaming to an empty name: the name is the text's again, and follows it.
|
||||
s.execute("layer.rename", json!({"layer": t, "name": ""})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Typed", "an empty name shows the text");
|
||||
retype(&mut s, t, "Followed");
|
||||
assert_eq!(layer_name(&s, t), "Followed", "and follows it again");
|
||||
// Committing the unchanged name (Enter, Enter) does not stop the following.
|
||||
s.execute("layer.rename", json!({"layer": t, "name": "Followed"})).unwrap();
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "Still"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Still");
|
||||
// A name given in Layer Settings is the user's too. The UI offers Layer Settings only for
|
||||
// solids, nulls, cameras and lights (as After Effects does); this guards the command, which
|
||||
// agents can call on any layer.
|
||||
s.execute("layer.settings", json!({"layer": t, "name": "Settings Name"})).unwrap();
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "Later"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Settings Name", "Layer Settings names it for good");
|
||||
// An empty Layer Settings name follows the text again, as `layer.rename` does.
|
||||
s.execute("layer.settings", json!({"layer": t, "name": ""})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Later", "an empty Layer Settings name shows the text");
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "Follows"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Follows", "and follows it again");
|
||||
// A layer given a name when it is made does not follow.
|
||||
let named = s.execute("layer.newText", json!({"text": "Body", "name": "Caption"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setText", json!({"layer": named, "text": "Other"})).unwrap();
|
||||
assert_eq!(layer_name(&s, named), "Caption");
|
||||
}
|
||||
|
||||
/// #3233: After Effects breaks paragraphs with a bare carriage return; the name is the first
|
||||
/// paragraph, without the break, and an older layer named after it counts as following.
|
||||
#[test]
|
||||
fn a_text_layer_name_ends_at_a_bare_carriage_return() {
|
||||
let (mut s, t) = session("A\rB");
|
||||
assert_eq!(layer_name(&s, t), "A");
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "C\rD"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "C");
|
||||
retype(&mut s, t, "E\rF");
|
||||
assert_eq!(layer_name(&s, t), "E");
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(t)).unwrap();
|
||||
l.name = "Para1".into();
|
||||
l.name_follows_text = None;
|
||||
l.props.prop_mut("text/sourceText").unwrap().value = effectcraft_keyframe::Value::Text(Box::new(TextDoc::plain("Para1\rPara2")));
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "Next\rLine"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Next", "an older layer named after its first paragraph follows");
|
||||
}
|
||||
|
||||
/// #3233: projects saved before the state was recorded load without it; there a name that is
|
||||
/// the text's, empty, or the default "Text"/"Text 2"… follows the text, any other name stays.
|
||||
#[test]
|
||||
fn older_text_layers_without_the_name_state_follow_as_before() {
|
||||
let (mut s, t) = session("Hello");
|
||||
let mut v = serde_json::to_value(s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap()).unwrap();
|
||||
v.as_object_mut().unwrap().remove("name_follows_text");
|
||||
let old: effectcraft_project::Layer = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(old.name_follows_text, None, "an older project has no state");
|
||||
for (name, follows) in [("Hello", true), ("", true), ("Text", true), ("Text 3", true), ("Text Title", false), ("Text 3b", false), ("Title", false)] {
|
||||
let l = effectcraft_project::Layer { name: name.into(), ..old.clone() };
|
||||
assert_eq!(l.name_follows_text("Hello"), follows, "{name:?}");
|
||||
}
|
||||
let legacy = |s: &mut Session, name: &str| {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(t)).unwrap();
|
||||
l.name = name.into();
|
||||
l.name_follows_text = None;
|
||||
};
|
||||
legacy(&mut s, "Hello");
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "World"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "World", "a name equal to the text follows it");
|
||||
legacy(&mut s, "Title");
|
||||
retype(&mut s, t, "Typed");
|
||||
assert_eq!(layer_name(&s, t), "Title", "another name stays");
|
||||
}
|
||||
@@ -297,6 +297,7 @@ impl<'a> Resolver<'a> {
|
||||
}
|
||||
self.comp(c)?.layer(LayerId(l))
|
||||
}
|
||||
/// The evaluation context for comp `c` at time `t` seconds, or `None` when the comp does not exist.
|
||||
fn ctx_at(&self, c: u64, t: f64) -> Option<EvalCtx<'a>> {
|
||||
let comp = self.comp(c)?;
|
||||
let same_comp = c == self.ctx.comp_id.0;
|
||||
@@ -309,6 +310,7 @@ impl<'a> Resolver<'a> {
|
||||
footage: self.ctx.footage,
|
||||
property_overrides: if same_comp { self.ctx.property_overrides } else { &[] },
|
||||
media_overrides: if same_comp { self.ctx.media_overrides } else { &[] },
|
||||
reads: self.ctx.reads,
|
||||
})
|
||||
}
|
||||
fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> {
|
||||
|
||||
@@ -172,6 +172,7 @@ fn evaluate_out(
|
||||
footage: ctx.footage,
|
||||
property_overrides: ctx.property_overrides,
|
||||
media_overrides: ctx.media_overrides,
|
||||
reads: ctx.reads,
|
||||
};
|
||||
let comp = ctx.comp_id.0;
|
||||
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
//! expression (its footage source; [`EvalCtx::footage`]): source → masks → effects for
|
||||
//! `postEffect = true`, the bare source otherwise, in layer space (before the transform). Buffers
|
||||
//! are cached per thread for the frame, so many expressions sampling the same layer at the same
|
||||
//! time render it once.
|
||||
//! time render it once. The source files a sampled render reads are kept with its buffer and
|
||||
//! noted in the evaluating render's log ([`EvalCtx::reads`]) on every use, so a frame whose
|
||||
//! expressions sample footage lists that footage too (#3184).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{ItemId, Layer};
|
||||
use effectcraft_render::reads::{ReadFiles, ReadLog};
|
||||
use effectcraft_render::{EvalCtx, NoFootage, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
@@ -26,7 +29,8 @@ struct Sampled {
|
||||
type Key = (usize, u64, u64, u64, bool, u64);
|
||||
|
||||
thread_local! {
|
||||
static CACHE: RefCell<HashMap<Key, Option<Sampled>>> = RefCell::new(HashMap::new());
|
||||
/// The sampled buffers and the source files each was made of.
|
||||
static CACHE: RefCell<HashMap<Key, (Option<Sampled>, Arc<ReadFiles>)>> = RefCell::new(HashMap::new());
|
||||
/// Nested sampleImage renders (a sampled layer whose expressions sample again).
|
||||
static DEPTH: RefCell<usize> = const { RefCell::new(0) };
|
||||
}
|
||||
@@ -42,13 +46,21 @@ fn fingerprint(layer: &Layer) -> u64 {
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Renders `layer` of `comp` at time `t` for an expression sample, before or after effects as `post` says.
|
||||
/// Returns `None` once the nesting depth limit is reached.
|
||||
fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Option<Sampled> {
|
||||
let depth = DEPTH.with(|d| *d.borrow());
|
||||
if depth >= MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let key: Key = (ctx.project as *const _ as usize, comp.0, layer.id.0, t.to_bits(), post, fingerprint(layer));
|
||||
if let Some(hit) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
|
||||
let note = |files: &ReadFiles| {
|
||||
if let Some(log) = ctx.reads {
|
||||
log.note_all(files);
|
||||
}
|
||||
};
|
||||
if let Some((hit, files)) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
|
||||
note(&files);
|
||||
return hit;
|
||||
}
|
||||
DEPTH.with(|d| *d.borrow_mut() += 1);
|
||||
@@ -60,20 +72,24 @@ fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Opti
|
||||
}
|
||||
let _pop = Pop;
|
||||
let footage: &dyn effectcraft_render::FootageSource = ctx.footage.unwrap_or(&NoFootage);
|
||||
let log = ReadLog::new();
|
||||
let mut r = Renderer::new(ctx.project, footage, RenderOpts { motion_blur: false, ..Default::default() });
|
||||
r.expr = ctx.expr;
|
||||
r.reads = Some(&log);
|
||||
let ectx = r.eval_ctx(comp, Tick::from_seconds_f64(t))?;
|
||||
let out = if post {
|
||||
r.content_buf(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale })
|
||||
} else {
|
||||
r.layer_source(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img), offset: b.offset, scale: b.scale })
|
||||
};
|
||||
let files = Arc::new(log.into_files());
|
||||
note(&files);
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.len() >= MAX_CACHE {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, out.clone());
|
||||
c.insert(key, (out.clone(), files));
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1332,6 +1332,7 @@ fn source_dimensions_follow_media_replacement() {
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[media],
|
||||
reads: None,
|
||||
};
|
||||
assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0));
|
||||
}
|
||||
@@ -1554,6 +1555,7 @@ fn text_expression_selector_values() {
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let doc = effectcraft_render::text::source_text(&ctx, l).unwrap();
|
||||
let lay = effectcraft_text::layout_doc(&doc);
|
||||
@@ -1606,6 +1608,43 @@ fn sample_image_reads_rendered_pixels() {
|
||||
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0);
|
||||
}
|
||||
|
||||
/// The footage `sampleImage` renders is a file the evaluating render read (#3184): it is noted in
|
||||
/// the render's log, also when the sampled buffer comes from the per-thread cache.
|
||||
#[test]
|
||||
fn sample_image_notes_the_files_it_reads() {
|
||||
use effectcraft_render::reads::{FileStamp, ReadFile, ReadLog};
|
||||
/// A grey picture of any footage, noting `/clip.png` for every read.
|
||||
struct Clip;
|
||||
impl effectcraft_render::FootageSource for Clip {
|
||||
fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option<std::sync::Arc<effectcraft_render::Image>> {
|
||||
let mut img = effectcraft_render::Image::new(f.width, f.height);
|
||||
img.data.fill([0.5, 0.5, 0.5, 1.0]);
|
||||
Some(std::sync::Arc::new(img))
|
||||
}
|
||||
fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) {
|
||||
f();
|
||||
reads.note(ReadFile::new("/clip.png", FileStamp::Native { len: 3, modified: Some(1) }));
|
||||
}
|
||||
}
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(20, 20, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let footage =
|
||||
effectcraft_project::Footage { path: "/clip.png".into(), width: 8, height: 8, has_video: true, frame_rate: FrameRate::FPS_30, ..Default::default() };
|
||||
let item = p.add_item("Clip", Label::Aqua, None, ItemKind::Footage(footage));
|
||||
let layer = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item }, (8, 8), None);
|
||||
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let c = p.comp(cid).unwrap();
|
||||
for pass in ["rendered", "cached"] {
|
||||
let log = ReadLog::new();
|
||||
let ctx = EvalCtx { footage: Some(&Clip), reads: Some(&log), ..EvalCtx::new(&p, cid, c, Tick::ZERO) };
|
||||
let px = crate::sample::sample_image(ctx, cid, &layer, [4.0, 4.0], [0.5, 0.5], false, 0.0).unwrap();
|
||||
assert!((px[0] - 0.5).abs() < 1e-3 && px[3] > 0.99, "{pass}: {px:?}");
|
||||
let files = log.into_files();
|
||||
assert_eq!(files.files().iter().map(|f| &*f.path).collect::<Vec<_>>(), ["/clip.png"], "{pass}");
|
||||
assert!(files.is_complete(), "{pass}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A huge sample point saturates the pixel coordinates; it must not overflow the loop bounds.
|
||||
#[test]
|
||||
fn sample_image_huge_point_is_transparent() {
|
||||
|
||||
@@ -292,19 +292,14 @@ fn extractor(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: Option<&AuxChannels>) ->
|
||||
let names = [pr.s("red"), pr.s("green"), pr.s("blue"), pr.s("alpha")];
|
||||
let (bp, wp) = (pr.f("blackPoint") as f32, pr.f("whitePoint") as f32);
|
||||
let unmult = pr.b("unMult");
|
||||
let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() {
|
||||
"R" | "RED" => Some(0u32),
|
||||
"G" | "GREEN" => Some(1),
|
||||
"B" | "BLUE" => Some(2),
|
||||
"A" | "ALPHA" => Some(3),
|
||||
_ => None,
|
||||
};
|
||||
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect();
|
||||
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k as u32)) {
|
||||
// `(copy)`: the input's own channel of the same position, never an aux channel.
|
||||
let own = |k: usize, n: &str| effectcraft_effects::extractor_own_channel(k, n).and_then(|c| u32::try_from(c).ok());
|
||||
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.and_then(|a| effectcraft_effects::extractor_aux_channel(a, n))).collect();
|
||||
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(k, n) == Some(k as u32)) {
|
||||
return Some(b);
|
||||
}
|
||||
// Per output channel: 0..3 = the layer's own channel, 4 = aux, 5 = default (0, alpha 1).
|
||||
let sources: [u32; 4] = std::array::from_fn(|k| if src_ch[k].is_some() { 4 } else { own(names[k]).unwrap_or(5) });
|
||||
let sources: [u32; 4] = std::array::from_fn(|k| if src_ch[k].is_some() { 4 } else { own(k, names[k]).unwrap_or(5) });
|
||||
let plane = match a {
|
||||
Some(a) if src_ch.iter().any(Option::is_some) => {
|
||||
let tex = aux_texture(e, a, [src_ch[0], src_ch[1], src_ch[2], src_ch[3]])?;
|
||||
|
||||
@@ -123,6 +123,7 @@ pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene {
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
|
||||
adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height))
|
||||
@@ -315,6 +316,7 @@ pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
|
||||
(ctx, run)
|
||||
|
||||
@@ -288,4 +288,7 @@ fn extractor() {
|
||||
);
|
||||
case("ec.3d.extractor", &[("red", Value::Str("A".into())), ("blackPoint", n(0.2)), ("whitePoint", n(0.8)), ("clip", on())]);
|
||||
case("ec.3d.extractor", &[("green", Value::Str("".into())), ("alpha", Value::Str("nothing".into()))]);
|
||||
// `(copy)` reads the input's own channel, not the footage's R plane of aux_scene.
|
||||
let copy = || Value::Str(effectcraft_effects::EXTRACTOR_COPY.into());
|
||||
case("ec.3d.extractor", &[("red", copy()), ("green", Value::Str("Coverage".into())), ("alpha", copy())]);
|
||||
}
|
||||
@@ -343,6 +343,7 @@ fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_eng
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
ctx.value(layer, layer.props.prop(path).unwrap())
|
||||
}
|
||||
@@ -606,6 +607,7 @@ fn stereo_rig_expressions_follow_the_master_camera() {
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3()
|
||||
};
|
||||
@@ -624,3 +626,35 @@ fn stereo_rig_expressions_follow_the_master_camera() {
|
||||
assert!(close(v3(&s, "transform/position"), [300.0 - d, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
|
||||
assert!(close(v3(&s, "transform/poi"), [300.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi"));
|
||||
}
|
||||
|
||||
/// A text layer showing a 3D model's name (`thisComp.layer("Model").source.name`): renaming the
|
||||
/// model gives the comp a new disk key and the text shows the new name (expressions on).
|
||||
#[test]
|
||||
fn renaming_a_model_a_text_expression_reads_changes_the_key_and_the_text() {
|
||||
use effectcraft_engine::Session;
|
||||
let mut s = super::session();
|
||||
let cid = effectcraft_project::ItemId(
|
||||
s.execute("comp.new", json!({"name": "M", "width": 160, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(),
|
||||
);
|
||||
let model = s
|
||||
.edit("add model", None, |p, _| {
|
||||
let f = effectcraft_project::Footage { kind: effectcraft_project::FootageKind::Model, ..Default::default() };
|
||||
let model = p.add_item("chair.glb", Default::default(), None, effectcraft_project::ItemKind::Footage(f));
|
||||
let comp = p.comp(cid).cloned().unwrap();
|
||||
let mut l = effectcraft_project::build::layer(p, &comp, "Model", effectcraft_project::LayerSource::Model { item: model }, (16, 16), None);
|
||||
// (not drawn: no model file; an expression still reads its source)
|
||||
l.switches.video = false;
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
Ok(model)
|
||||
})
|
||||
.unwrap();
|
||||
let text = s.execute("layer.newText", json!({"comp": cid.0, "text": "x"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.setExpression", json!({"comp": cid.0, "layer": text, "path": "text/sourceText", "expression": "thisComp.layer(\"Model\").source.name"}))
|
||||
.unwrap();
|
||||
let key = effectcraft_engine::render::disk_cache::comp_content_key(&s.project, cid);
|
||||
let shown = |s: &mut Session| s.execute("prop.get", json!({"comp": cid.0, "layer": text, "path": "text/sourceText"})).unwrap()["evaluated"].to_string();
|
||||
assert!(shown(&mut s).contains("chair.glb"), "{}", shown(&mut s));
|
||||
s.execute("project.rename", json!({"item": model.0, "name": "table.glb"})).unwrap();
|
||||
assert_ne!(effectcraft_engine::render::disk_cache::comp_content_key(&s.project, cid), key, "the comp's frames on disk hold the old name");
|
||||
assert!(shown(&mut s).contains("table.glb"), "the text shows the new name: {}", shown(&mut s));
|
||||
}
|
||||
@@ -522,11 +522,31 @@ impl Importer<'_> {
|
||||
// ---- timing
|
||||
let start = tick_in(ti.start);
|
||||
let dur = tick_in(ti.duration);
|
||||
let speed = if ti.speed.abs() < 1e-9 { 1.0 } else { ti.speed.abs() };
|
||||
// A zero, tiny (below 1e-9) or non-finite speed plays at 100 %. Otherwise the layer's
|
||||
// stretch (100 / speed) is held to the range the app supports (±1 % to ±9900 %): a speed
|
||||
// of 1e300 or 1e-300 from a damaged file made the layer and audio time divide by ~0
|
||||
// (#3247). The speed follows the stretch so the start time below matches it.
|
||||
let raw = ti.speed.abs();
|
||||
let (speed, stretch) = if !raw.is_finite() || raw < 1e-9 {
|
||||
// A speed of exactly 0 keeps its old silent 100 %; a tiny or non-finite one is named.
|
||||
if !raw.is_finite() || raw > 0.0 {
|
||||
self.warn(format!("clip \"{}\": a speed of {} is not supported (100 % used)", ti.name, ti.speed));
|
||||
}
|
||||
(1.0, 100.0)
|
||||
} else {
|
||||
let stretch = effectcraft_project::limits::stretch(100.0 / raw);
|
||||
if stretch.to_bits() == (100.0 / raw).to_bits() {
|
||||
(raw, stretch)
|
||||
} else {
|
||||
let speed = 100.0 / stretch;
|
||||
self.warn(format!("clip \"{}\": a speed of {:e} % is outside what a layer supports ({} % used)", ti.name, raw * 100.0, speed * 100.0));
|
||||
(speed, stretch)
|
||||
}
|
||||
};
|
||||
let reverse = ti.reverse || ti.speed < 0.0;
|
||||
let src_in = tick_in(ti.source_in);
|
||||
let span = Tick((dur.0 as f64 * speed).round() as i64);
|
||||
l.stretch = if reverse { -100.0 / speed } else { 100.0 / speed };
|
||||
l.stretch = if reverse { -stretch } else { stretch };
|
||||
l.start_time =
|
||||
if reverse { start + Tick(((src_in + span).0 as f64 / speed).round() as i64) } else { start - Tick((src_in.0 as f64 / speed).round() as i64) };
|
||||
l.in_point = start;
|
||||
|
||||
@@ -314,6 +314,24 @@ fn imports_fcp7_xml_fcpxml_and_otio() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip speed past what a layer's stretch supports (±1 % to ±9900 %) is held to it and named.
|
||||
/// Before, a speed of 1e6 gave a stretch of 1e-4 % and the layer and audio time divided by ~0
|
||||
/// (#3247).
|
||||
#[test]
|
||||
fn an_extreme_clip_speed_is_held_to_the_stretch_range() {
|
||||
for (speed, stretch) in [(1e6, 1.0), (1e-6, 9900.0)] {
|
||||
let (mut doc, main) = edit();
|
||||
doc.sequence_mut(main).unwrap().video_tracks[1].items[0].speed = speed;
|
||||
let (bytes, _) = fc::export(&doc, main, fc::Format::Otio, &Default::default()).unwrap();
|
||||
let (p, r) = import_doc(&bytes, "Main.otio", &mut probe);
|
||||
let main_comp = r.comps.iter().copied().find(|c| p.item(*c).unwrap().name == "Main").expect("Main comp");
|
||||
let c = p.comp(main_comp).unwrap();
|
||||
let stretched: Vec<f64> = c.layers.iter().map(|l| l.stretch).filter(|s| (s - 100.0).abs() > 1e-9).collect();
|
||||
assert_eq!(stretched, [stretch], "speed {speed}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("speed") && w.contains("outside")), "speed {speed}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_aaf_and_omf() {
|
||||
let (doc, main) = edit();
|
||||
|
||||
@@ -431,7 +431,12 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[
|
||||
// Below 1 % (or 0) the layer time divides by ~0, and layer.timeStretch refuses it too.
|
||||
let sr = f(j, "sr").unwrap_or(1.0);
|
||||
l.stretch = if sr.is_finite() && sr.abs() >= 0.01 {
|
||||
sr * 100.0
|
||||
// Past ±9900 % too (#3247): the largest stretch a layer supports, sign kept.
|
||||
let pct = effectcraft_project::limits::stretch(sr * 100.0);
|
||||
if pct.to_bits() != (sr * 100.0).to_bits() {
|
||||
cx.im.warn(format!("{name}: a time stretch of {} % is not supported ({pct} % used)", sr * 100.0));
|
||||
}
|
||||
pct
|
||||
} else {
|
||||
cx.im.warn(format!("{name}: a time stretch of {} % is not supported (100 % used)", sr * 100.0));
|
||||
100.0
|
||||
|
||||
@@ -520,6 +520,21 @@ fn zero_time_stretch_is_not_imported() {
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_stretch_past_9900_percent_is_held_to_it() {
|
||||
// `sr` 1e300 was imported as 1e302 % (#3247); the sign of a reversed layer is kept.
|
||||
for (sr, want) in [(1e300, 9900.0), (-1e300, -9900.0), (99.0, 9900.0)] {
|
||||
let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [],
|
||||
"layers": [{"ty": 1, "ind": 1, "sc": "#ff0000", "sw": 20, "sh": 20, "sr": sr, "ip": 0, "op": 30, "st": 0, "ks": {}}]});
|
||||
let mut q = Project::default();
|
||||
let imp = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap();
|
||||
let l = &q.comp(imp.comp).unwrap().layers[0];
|
||||
assert_eq!(l.stretch, want, "sr {sr}");
|
||||
let warned = imp.warnings.iter().any(|w| w.contains("time stretch"));
|
||||
assert_eq!(warned, sr != 99.0, "sr {sr}: {:?}", imp.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_glyphs_are_reported_when_dropped() {
|
||||
let glyph = |ch: &str, fam: &str| json!({"ch": ch, "size": 100, "style": "Regular", "w": 60, "fFamily": fam, "data": {"shapes": []}});
|
||||
|
||||
@@ -36,3 +36,7 @@ rayon = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# (tests render comps that read layers through effects; already a dependency of effectcraft-render)
|
||||
effectcraft-effects = { workspace = true }
|
||||
@@ -17,6 +17,7 @@ use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
|
||||
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, split_channel};
|
||||
use effectcraft_raster::{AuxChannels, Image};
|
||||
use effectcraft_render::reads::{ReadFile, ReadFiles};
|
||||
use exr::block::UncompressedBlock;
|
||||
use exr::block::chunk::TileCoordinates;
|
||||
use exr::image::read::image::ReadImage;
|
||||
@@ -477,8 +478,8 @@ fn layered_picture_of(aux: &AuxChannels, op: AlphaOp) -> Option<Image> {
|
||||
/// decoded anyway), else of the picture's channels alone (also when the cache has no channels
|
||||
/// for the file: a read that failed, or a broken part the picture doesn't use). `Err(Some(why))`
|
||||
/// when the file is too large to decode, `Err(None)` when it isn't a readable multi-layer EXR.
|
||||
pub(crate) fn layered_picture(path: &str, bytes: Arc<[u8]>, op: AlphaOp, shared: bool) -> Result<Image, Option<String>> {
|
||||
if shared && let Some(d) = cache().get(path, Use::Picture, || Some(bytes.clone())) {
|
||||
pub(crate) fn layered_picture(path: &str, bytes: Arc<[u8]>, file: &ReadFile, op: AlphaOp, shared: bool) -> Result<Image, Option<String>> {
|
||||
if shared && let Some(d) = cache().get(path, Use::Picture, || read_as(file, bytes.clone())) {
|
||||
return layered_picture_of(&d.aux, op).ok_or(None);
|
||||
}
|
||||
let (d, rgb, a) = picture_only(&bytes).map_err(|r| match r {
|
||||
@@ -499,8 +500,8 @@ pub(crate) fn layered_picture(path: &str, bytes: Arc<[u8]>, op: AlphaOp, shared:
|
||||
/// decoder shows for a file it reads, [`layered_image`]'s rule for one it refuses. `None` when
|
||||
/// the planes can't stand in for the `image` crate's picture (decode with it), or the file is
|
||||
/// not a readable EXR.
|
||||
pub(crate) fn cached_picture(path: &str, bytes: Arc<[u8]>, op: AlphaOp) -> Option<Image> {
|
||||
let d = cache().get(path, Use::Picture, || Some(bytes))?;
|
||||
pub(crate) fn cached_picture(path: &str, bytes: Arc<[u8]>, file: &ReadFile, op: AlphaOp) -> Option<Image> {
|
||||
let d = cache().get(path, Use::Picture, || read_as(file, bytes))?;
|
||||
let aux = &d.aux;
|
||||
match d.rgb {
|
||||
Rgb::None => layered_picture_of(aux, op),
|
||||
@@ -536,8 +537,16 @@ const CACHE_FILES: usize = 64;
|
||||
/// rendered at once. The oldest stops being kept when another comes.
|
||||
const HELD_FILES: usize = 4;
|
||||
|
||||
/// A file's decode: done once, by the first caller; the others wait for it.
|
||||
type Slot = Arc<OnceLock<Option<Arc<Decoded>>>>;
|
||||
/// A file's decode: done once, by the first caller; the others wait for it. With the files read
|
||||
/// for it (see [`crate::reads`]), noted again on every hit.
|
||||
type Slot = Arc<OnceLock<(Option<Arc<Decoded>>, ReadFiles)>>;
|
||||
|
||||
/// Bytes the caller already read from `file`, given to the cache as read from it (so the entry
|
||||
/// keeps the file's identity).
|
||||
fn read_as(file: &ReadFile, bytes: Arc<[u8]>) -> Option<Arc<[u8]>> {
|
||||
crate::reads::note(file.clone());
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
path: String,
|
||||
@@ -554,8 +563,8 @@ impl Entry {
|
||||
fn in_use(&self) -> bool {
|
||||
match self.slot.get() {
|
||||
None => true,
|
||||
Some(None) => false,
|
||||
Some(Some(d)) => Arc::strong_count(d) > 1 || Arc::strong_count(&d.aux) > 1,
|
||||
Some((None, _)) => false,
|
||||
Some((Some(d), _)) => Arc::strong_count(d) > 1 || Arc::strong_count(&d.aux) > 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -648,30 +657,18 @@ impl ChannelCache {
|
||||
// is safe because the decode never waits on rayon's global pool (the exr crate
|
||||
// decompresses on a pool of its own and this thread only receives the blocks), so the
|
||||
// decoding thread can't be waiting for this one.
|
||||
let v = slot
|
||||
.get_or_init(|| {
|
||||
decoded_here = true;
|
||||
self.decodes.fetch_add(1, Ordering::Relaxed);
|
||||
#[cfg(test)]
|
||||
count_decode(true);
|
||||
let _abandon = Abandon { cache: self, slot: &slot };
|
||||
let bytes = read()?;
|
||||
// The planes it will allocate count against the budget while it decodes.
|
||||
if let Ok(meta) = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes[..]), false)
|
||||
&& let Some(need) = planes_bytes(&meta, &|_| true)
|
||||
{
|
||||
self.account(&slot, need.min(MAX_DECODE_BYTES));
|
||||
}
|
||||
match decode(&bytes, &|_| true) {
|
||||
Ok(d) => Some(Arc::new(d)),
|
||||
Err(Refused::TooLarge(why)) => {
|
||||
log::warn!("media: {path}: {why}");
|
||||
None
|
||||
}
|
||||
Err(Refused::Unreadable) => None,
|
||||
}
|
||||
})
|
||||
.clone();
|
||||
let (v, files) = slot.get_or_init(|| {
|
||||
decoded_here = true;
|
||||
self.decodes.fetch_add(1, Ordering::Relaxed);
|
||||
#[cfg(test)]
|
||||
count_decode(true);
|
||||
let _abandon = Abandon { cache: self, slot: &slot };
|
||||
let (bytes, files) = crate::reads::captured(read);
|
||||
(bytes.and_then(|bytes| self.decode_file(path, &slot, &bytes)), files)
|
||||
});
|
||||
// The file(s) the decode was made of, read now or for an earlier caller.
|
||||
crate::reads::note_all(files);
|
||||
let v = v.clone();
|
||||
if decoded_here {
|
||||
self.account(&slot, v.as_ref().map_or(0, |d| d.bytes()));
|
||||
}
|
||||
@@ -692,6 +689,24 @@ impl ChannelCache {
|
||||
v
|
||||
}
|
||||
|
||||
/// Decode the file at `path` whose bytes are `bytes` for `slot` (see [`ChannelCache::get`]).
|
||||
fn decode_file(&self, path: &str, slot: &Slot, bytes: &[u8]) -> Option<Arc<Decoded>> {
|
||||
// The planes it will allocate count against the budget while it decodes.
|
||||
if let Ok(meta) = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(bytes), false)
|
||||
&& let Some(need) = planes_bytes(&meta, &|_| true)
|
||||
{
|
||||
self.account(slot, need.min(MAX_DECODE_BYTES));
|
||||
}
|
||||
match decode(bytes, &|_| true) {
|
||||
Ok(d) => Some(Arc::new(d)),
|
||||
Err(Refused::TooLarge(why)) => {
|
||||
log::warn!("media: {path}: {why}");
|
||||
None
|
||||
}
|
||||
Err(Refused::Unreadable) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop least recently used files not in use until the rest fit the budget, keeping `newest`.
|
||||
fn evict(entries: &mut Vec<Entry>, budget: usize, newest: &Slot) {
|
||||
while entries.iter().map(|e| e.bytes).sum::<usize>() > budget || entries.len() > CACHE_FILES {
|
||||
@@ -996,7 +1011,7 @@ mod tests {
|
||||
let path = "/474-shared-fallback.exr";
|
||||
assert!(cache().get(path, Use::Channels, || None).is_none(), "a read that failed");
|
||||
let op = AlphaOp::of(&Footage::default(), FloatCurve::Linear);
|
||||
let img = layered_picture(path, bytes, op, true).unwrap();
|
||||
let img = layered_picture(path, bytes, &ReadFile::new(path, effectcraft_render::reads::FileStamp::Virtual), op, true).unwrap();
|
||||
assert_eq!(img.get(1, 1), [0.5, 0.5, 0.5, 1.0]);
|
||||
}
|
||||
|
||||
@@ -1174,7 +1189,8 @@ mod tests {
|
||||
for curve in [FloatCurve::Srgb, FloatCurve::Linear, FloatCurve::Gamma24] {
|
||||
let op = AlphaOp::of(&Footage { alpha, premul_color: [0.2, 0.4, 0.6], ..Default::default() }, curve);
|
||||
let want = crate::convert::dynamic_to_image(&img, op);
|
||||
let got = cached_picture(&path, bytes.as_slice().into(), op).unwrap();
|
||||
let got = cached_picture(&path, bytes.as_slice().into(), &ReadFile::new(path.as_str(), effectcraft_render::reads::FileStamp::Virtual), op)
|
||||
.unwrap();
|
||||
assert_eq!((got.width, got.height), (want.width, want.height));
|
||||
assert_eq!(bits(&got), bits(&want), "file {k} {alpha:?} {curve:?}");
|
||||
}
|
||||
|
||||
+25
-1
@@ -25,6 +25,7 @@ mod layered;
|
||||
pub use layered::vector_doc;
|
||||
mod pool;
|
||||
mod probe;
|
||||
mod reads;
|
||||
mod stream;
|
||||
mod wav_markers;
|
||||
mod webp_anim;
|
||||
@@ -85,7 +86,15 @@ pub fn is_importable(path: &std::path::Path) -> bool {
|
||||
/// A whole file's bytes, refusing anything that isn't a regular file: a project can name
|
||||
/// `/dev/zero` or a FIFO as footage, and reading those never ends (#2134).
|
||||
pub(crate) fn read_regular_file(path: &std::path::Path) -> std::io::Result<Vec<u8>> {
|
||||
read_regular_file_stamped(path).map(|(bytes, _)| bytes)
|
||||
}
|
||||
|
||||
/// [`read_regular_file`] with the file's identity, from the metadata of the open file (no
|
||||
/// second look at the path).
|
||||
pub(crate) fn read_regular_file_stamped(path: &std::path::Path) -> std::io::Result<(Vec<u8>, effectcraft_render::reads::FileStamp)> {
|
||||
use std::io::Read;
|
||||
#[cfg(test)]
|
||||
count_open(path);
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let meta = file.metadata()?;
|
||||
if !meta.is_file() {
|
||||
@@ -95,7 +104,22 @@ pub(crate) fn read_regular_file(path: &std::path::Path) -> std::io::Result<Vec<u
|
||||
// A sparse file can claim far more than memory: an error, not an abort.
|
||||
bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?;
|
||||
file.read_to_end(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
Ok((bytes, effectcraft_render::reads::FileStamp::of_metadata(&meta)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static OPENS: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// Count an open of a file (tests: which files a render touched on disk).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn count_open(path: &std::path::Path) {
|
||||
OPENS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_path_buf());
|
||||
}
|
||||
|
||||
/// How many times files under `dir` were opened so far (tests).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn opens_under(dir: &std::path::Path) -> usize {
|
||||
OPENS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).iter().filter(|p| p.starts_with(dir)).count()
|
||||
}
|
||||
|
||||
pub(crate) fn ext_of(path: &std::path::Path) -> String {
|
||||
|
||||
+132
-70
@@ -6,6 +6,7 @@ use std::sync::{Arc, Condvar, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId};
|
||||
use effectcraft_raster::{Image, Px};
|
||||
use effectcraft_render::reads::{FileStamp, ReadFile, ReadFiles, ReadLog};
|
||||
use effectcraft_render::{FloatCurve, FootageSource};
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use filmcraft_media::{FrameRequest, SharedSource};
|
||||
@@ -14,6 +15,7 @@ use image::metadata::Orientation;
|
||||
use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned};
|
||||
use crate::downmix::Order;
|
||||
use crate::gif_anim::{self, Anim};
|
||||
use crate::reads;
|
||||
use crate::{MediaError, Result};
|
||||
|
||||
/// Default frame-cache budget: 1 GiB of decoded frames.
|
||||
@@ -53,6 +55,8 @@ struct Key {
|
||||
|
||||
struct Entry {
|
||||
img: Arc<Image>,
|
||||
/// The files the frame was decoded from (see [`crate::reads`]).
|
||||
files: Arc<ReadFiles>,
|
||||
stamp: u64,
|
||||
bytes: usize,
|
||||
}
|
||||
@@ -106,18 +110,20 @@ struct Lru {
|
||||
}
|
||||
|
||||
impl Lru {
|
||||
fn get(&mut self, k: &Key) -> Option<Arc<Image>> {
|
||||
/// The cached image and its read files for `k`, marking the entry as most recently used.
|
||||
fn get(&mut self, k: &Key) -> Option<(Arc<Image>, Arc<ReadFiles>)> {
|
||||
let e = self.map.get_mut(k)?;
|
||||
self.order.remove(&e.stamp);
|
||||
self.clock += 1;
|
||||
e.stamp = self.clock;
|
||||
self.order.insert(e.stamp, k.clone());
|
||||
Some(e.img.clone())
|
||||
Some((e.img.clone(), e.files.clone()))
|
||||
}
|
||||
fn insert(&mut self, k: Key, img: Arc<Image>, budget: usize) {
|
||||
/// Stores an image with its read files under `k` and evicts the least recently used entries until the cache fits `budget` bytes.
|
||||
fn insert(&mut self, k: Key, img: Arc<Image>, files: Arc<ReadFiles>, budget: usize) {
|
||||
let bytes = img.data.len() * std::mem::size_of::<Px>() + 64;
|
||||
self.clock += 1;
|
||||
if let Some(old) = self.map.insert(k.clone(), Entry { img, stamp: self.clock, bytes }) {
|
||||
if let Some(old) = self.map.insert(k.clone(), Entry { img, files, stamp: self.clock, bytes }) {
|
||||
self.order.remove(&old.stamp);
|
||||
self.bytes -= old.bytes;
|
||||
}
|
||||
@@ -180,8 +186,9 @@ struct CacheState {
|
||||
}
|
||||
|
||||
/// A movie/audio source by path ([`Opened`]), opened by the first caller while the others wait
|
||||
/// for it (`None`: failed to open; not retried until `forget`).
|
||||
type SourceSlot = Arc<OnceLock<Option<Opened>>>;
|
||||
/// for it (`None`: failed to open; not retried until `forget`), with the file's identity as
|
||||
/// opened.
|
||||
type SourceSlot = Arc<OnceLock<(Option<Opened>, ReadFile)>>;
|
||||
|
||||
/// An opened movie/audio source, the turn its picture is shown with and the order its decoded
|
||||
/// audio channels arrive in.
|
||||
@@ -211,7 +218,7 @@ fn channel_order(src: &SharedSource, header: Option<&[u8]>, file: Option<&dyn fi
|
||||
}
|
||||
|
||||
/// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load).
|
||||
type GifSlot = Arc<OnceLock<std::result::Result<Arc<Anim>, String>>>;
|
||||
type GifSlot = Arc<OnceLock<(std::result::Result<Arc<Anim>, String>, ReadFile)>>;
|
||||
|
||||
struct Inner {
|
||||
/// Opened movie/audio sources by path.
|
||||
@@ -220,8 +227,9 @@ struct Inner {
|
||||
opened: AtomicU64,
|
||||
/// In-memory files registered with `add_bytes` (web builds, tests).
|
||||
files: Mutex<HashMap<String, Arc<[u8]>>>,
|
||||
/// Parsed 3D models by path (`None`: failed to load; not retried until `forget`).
|
||||
models: Mutex<HashMap<String, Option<Arc<effectcraft_model::Model>>>>,
|
||||
/// Parsed 3D models by path (`None`: failed to load; not retried until `forget`), with the
|
||||
/// files read for them (the model's and the resources it names).
|
||||
models: Mutex<HashMap<String, (Option<Arc<effectcraft_model::Model>>, Arc<ReadFiles>)>>,
|
||||
/// Decoded animated GIFs and WebPs by path, loaded by the first caller while the others wait (an
|
||||
/// `Err` is not retried until `forget`).
|
||||
gifs: Mutex<HashMap<Arc<str>, GifSlot>>,
|
||||
@@ -439,11 +447,13 @@ impl MediaPool {
|
||||
}
|
||||
|
||||
/// The conformed-audio file of `path` at `rate` (named by a hash of the path, size and
|
||||
/// modification time, so an edited file conforms again, and of `CONFORM_VERSION`).
|
||||
/// modification time, so an edited file conforms again, and of `CONFORM_VERSION`). Notes
|
||||
/// `path` as read with that identity (see [`crate::reads`]): its samples come from it.
|
||||
pub fn conformed_path(&self, path: &str, rate: u32) -> Option<std::path::PathBuf> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let folder = lock(&self.inner.conform).clone()?;
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
reads::note(ReadFile::new(path, FileStamp::of_metadata(&meta)));
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(CONFORM_VERSION, path, meta.len(), meta.modified().ok()).hash(&mut h);
|
||||
Some(folder.join(format!("{:016x}_{rate}.ecaf", h.finish())))
|
||||
@@ -612,16 +622,20 @@ impl Inner {
|
||||
|
||||
/// A 3D model file and its sibling resources (buffers, textures, MTL files), parsed once.
|
||||
fn model(&self, path: &str) -> Option<Arc<effectcraft_model::Model>> {
|
||||
if let Some(m) = lock(&self.models).get(path) {
|
||||
return m.clone();
|
||||
let found = lock(&self.models).get(path).cloned();
|
||||
if let Some((m, files)) = found {
|
||||
reads::note_all(&files);
|
||||
return m;
|
||||
}
|
||||
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_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())
|
||||
let (loaded, files) = reads::captured(|| {
|
||||
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_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())
|
||||
})
|
||||
});
|
||||
let m = match loaded {
|
||||
Ok(m) => Some(Arc::new(m)),
|
||||
@@ -630,14 +644,29 @@ impl Inner {
|
||||
None
|
||||
}
|
||||
};
|
||||
lock(&self.models).entry(path.to_string()).or_insert(m).clone()
|
||||
let (m, files) = lock(&self.models).entry(path.to_string()).or_insert((m, Arc::new(files))).clone();
|
||||
reads::note_all(&files);
|
||||
m
|
||||
}
|
||||
|
||||
/// The bytes of the file at `path`, noted as read (see [`crate::reads`]).
|
||||
fn read(&self, path: &str) -> Result<Arc<[u8]>> {
|
||||
if let Some(b) = lock(&self.files).get(path) {
|
||||
return Ok(b.clone());
|
||||
let (bytes, file) = self.read_file(path);
|
||||
reads::note(file);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The bytes of the file at `path` and its identity as read ([`FileStamp::Missing`] when it
|
||||
/// can't be read), not noted.
|
||||
fn read_file(&self, path: &str) -> (Result<Arc<[u8]>>, ReadFile) {
|
||||
let in_memory = lock(&self.files).get(path).cloned();
|
||||
if let Some(b) = in_memory {
|
||||
return (Ok(b), ReadFile::new(path, FileStamp::Virtual));
|
||||
}
|
||||
match crate::read_regular_file_stamped(std::path::Path::new(path)) {
|
||||
Ok((bytes, stamp)) => (Ok(bytes.into()), ReadFile::new(path, stamp)),
|
||||
Err(e) => (Err(MediaError::Io(format!("{path}: {e}"))), ReadFile::new(path, FileStamp::Missing)),
|
||||
}
|
||||
crate::read_regular_file(std::path::Path::new(path)).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// The opened movie/audio source for `path` ([`Opened`]). The first caller opens it, outside
|
||||
@@ -645,35 +674,42 @@ impl Inner {
|
||||
/// files are decoded whole); callers asking for the same file meanwhile wait for that instead
|
||||
/// of opening it again: a preview's frame workers, audio feeder, conform and waveform threads
|
||||
/// each decoded a long FLAC in full at once (#408).
|
||||
/// Each call notes the file as read (see [`crate::reads`]), with its identity as opened.
|
||||
fn opened(&self, path: &Arc<str>) -> Option<Opened> {
|
||||
let slot = lock(&self.sources).entry(path.clone()).or_default().clone();
|
||||
slot.get_or_init(|| {
|
||||
self.opened.fetch_add(1, Ordering::Relaxed);
|
||||
// Files on disk are read in place (the index, then samples on demand) rather than
|
||||
// whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes.
|
||||
let in_memory = lock(&self.files).contains_key(&**path);
|
||||
let file = std::path::Path::new(&**path);
|
||||
let in_place = if in_memory { None } else { crate::stream::open_source(file) };
|
||||
let in_place = in_place.map(|src| {
|
||||
let (header, reader) = (crate::stream::movie_header(file), crate::stream::file_reader(file));
|
||||
src.map(|src| Opened::new(src, header.as_deref(), reader.as_deref()))
|
||||
let (opened, file) = slot.get_or_init(|| self.open(path));
|
||||
reads::note(file.clone());
|
||||
opened.clone()
|
||||
}
|
||||
|
||||
/// Open the movie/audio file at `path` (see [`Inner::opened`]), with its identity as opened.
|
||||
fn open(&self, path: &Arc<str>) -> (Option<Opened>, ReadFile) {
|
||||
self.opened.fetch_add(1, Ordering::Relaxed);
|
||||
// Files on disk are read in place (the index, then samples on demand) rather than
|
||||
// whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes.
|
||||
let in_memory = lock(&self.files).contains_key(&**path);
|
||||
let file = std::path::Path::new(&**path);
|
||||
let in_place = if in_memory { None } else { crate::stream::open_source(file) };
|
||||
let in_place = in_place.map(|(src, stamp)| {
|
||||
let (header, reader) = (crate::stream::movie_header(file), crate::stream::file_reader(file));
|
||||
(src.map(|src| Opened::new(src, header.as_deref(), reader.as_deref())), ReadFile::new(path.clone(), stamp))
|
||||
});
|
||||
let (opened, read) = in_place.unwrap_or_else(|| {
|
||||
let (bytes, read) = self.read_file(path);
|
||||
let opened = bytes.and_then(|bytes| {
|
||||
let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let src = filmcraft_codecs::open_bytes(&name, bytes.clone()).map_err(MediaError::from)?;
|
||||
Ok(Opened::new(src, Some(&bytes), Some(&filmcraft_media::reader::MemReader(bytes.clone()))))
|
||||
});
|
||||
let opened = in_place.unwrap_or_else(|| {
|
||||
self.read(path).and_then(|bytes| {
|
||||
let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let src = filmcraft_codecs::open_bytes(&name, bytes.clone()).map_err(MediaError::from)?;
|
||||
Ok(Opened::new(src, Some(&bytes), Some(&filmcraft_media::reader::MemReader(bytes.clone()))))
|
||||
})
|
||||
});
|
||||
match opened {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
self.note_problem(path, "", &e);
|
||||
None
|
||||
}
|
||||
(opened, read)
|
||||
});
|
||||
match opened {
|
||||
Ok(s) => (Some(s), read),
|
||||
Err(e) => {
|
||||
self.note_problem(path, "", &e);
|
||||
(None, read)
|
||||
}
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember why the movie at `path` cannot be shown (`codec` is the footage's, when known)
|
||||
@@ -694,13 +730,15 @@ impl Inner {
|
||||
/// map's lock; callers asking for the same file meanwhile wait for that.
|
||||
fn gif(&self, path: &Arc<str>) -> Result<Arc<Anim>> {
|
||||
let slot = lock(&self.gifs).entry(path.clone()).or_default().clone();
|
||||
let loaded = slot.get_or_init(|| {
|
||||
let loaded = self.read(path).and_then(|bytes| Anim::load(&bytes));
|
||||
loaded.map(Arc::new).map_err(|e| {
|
||||
let (loaded, file) = slot.get_or_init(|| {
|
||||
let (bytes, file) = self.read_file(path);
|
||||
let loaded = bytes.and_then(|bytes| Anim::load(&bytes)).map(Arc::new).map_err(|e| {
|
||||
log::warn!("media: cannot decode {path}: {e}");
|
||||
e.to_string()
|
||||
})
|
||||
});
|
||||
(loaded, file)
|
||||
});
|
||||
reads::note(file.clone());
|
||||
loaded.clone().map_err(|e| MediaError::Decode(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
@@ -783,19 +821,15 @@ impl Inner {
|
||||
let mut c = lock(&self.cache);
|
||||
// sequential playback of a movie: read the next frame ahead
|
||||
if playback && movie {
|
||||
let prev = c.last.insert(loc.key.path.clone(), loc.key.frame);
|
||||
if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) {
|
||||
let next_t = t + footage.frame_rate.frame_duration();
|
||||
let mut next = Self::locate(footage, next_t, float);
|
||||
next.key.size = loc.key.size;
|
||||
if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) {
|
||||
self.prefetch(footage, next_t, size, float);
|
||||
}
|
||||
}
|
||||
self.prefetch_next(&mut c, &loc, footage, t, size, float);
|
||||
}
|
||||
// (Each exit notes the files the frame came from, see `crate::reads`; a cached frame
|
||||
// notes those it was decoded from, without touching them.)
|
||||
loop {
|
||||
if let Some(img) = c.lru.get(&loc.key) {
|
||||
if let Some((img, files)) = c.lru.get(&loc.key) {
|
||||
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||
drop(c);
|
||||
reads::note_all(&files);
|
||||
return Ok(img);
|
||||
}
|
||||
if !c.inflight.contains(&loc.key) {
|
||||
@@ -808,7 +842,9 @@ impl Inner {
|
||||
if rayon::current_thread_index().is_some() {
|
||||
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||
drop(c);
|
||||
return Ok(Arc::new(self.decode(&loc, footage)?));
|
||||
let (img, files) = reads::captured(|| self.decode(&loc, footage));
|
||||
reads::note_all(&files);
|
||||
return Ok(Arc::new(img?));
|
||||
}
|
||||
c = self.done.wait(c).unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
}
|
||||
@@ -816,11 +852,26 @@ impl Inner {
|
||||
c.inflight.insert(loc.key.clone());
|
||||
drop(c);
|
||||
let _guard = Inflight { inner: self, key: loc.key.clone() };
|
||||
let img = Arc::new(self.decode(&loc, footage)?);
|
||||
lock(&self.cache).lru.insert(loc.key, img.clone(), self.budget());
|
||||
let (img, files) = reads::captured(|| self.decode(&loc, footage));
|
||||
reads::note_all(&files);
|
||||
let img = Arc::new(img?);
|
||||
lock(&self.cache).lru.insert(loc.key, img.clone(), Arc::new(files), self.budget());
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Sequential playback of a movie (`loc`, at `t`): read the frame after it ahead.
|
||||
fn prefetch_next(self: &Arc<Self>, c: &mut CacheState, loc: &Loc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) {
|
||||
let prev = c.last.insert(loc.key.path.clone(), loc.key.frame);
|
||||
if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) {
|
||||
let next_t = t + footage.frame_rate.frame_duration();
|
||||
let mut next = Self::locate(footage, next_t, float);
|
||||
next.key.size = loc.key.size;
|
||||
if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) {
|
||||
self.prefetch(footage, next_t, size, float);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn prefetch(self: &Arc<Self>, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) {
|
||||
let (me, f) = (self.clone(), footage.clone());
|
||||
@@ -856,13 +907,17 @@ impl Inner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes the still image at `loc` and applies the footage alpha and float handling.
|
||||
/// A placeholder path decodes to colour bars.
|
||||
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)?;
|
||||
let (bytes, file) = self.read_file(path);
|
||||
reads::note(file.clone());
|
||||
let bytes = bytes?;
|
||||
if let Some(img) = crate::dpx::decode(path, &bytes)? {
|
||||
return Ok(dynamic_to_image(&img, op));
|
||||
}
|
||||
@@ -880,7 +935,7 @@ impl Inner {
|
||||
let shared = lock(&self.aux_footage).contains(&footage.path);
|
||||
if shared
|
||||
&& path.to_ascii_lowercase().ends_with(".exr")
|
||||
&& let Some(img) = crate::exr_channels::cached_picture(path, bytes.clone(), op)
|
||||
&& let Some(img) = crate::exr_channels::cached_picture(path, bytes.clone(), &file, op)
|
||||
{
|
||||
return Ok(img);
|
||||
}
|
||||
@@ -889,7 +944,7 @@ impl Inner {
|
||||
match crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)) {
|
||||
Ok(img) => Ok(dynamic_to_image(&img, op)),
|
||||
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
|
||||
Err(e) => crate::exr_channels::layered_picture(path, bytes, op, shared)
|
||||
Err(e) => crate::exr_channels::layered_picture(path, bytes, &file, op, shared)
|
||||
.map_err(|why| MediaError::Decode(format!("{path}: {}", why.unwrap_or_else(|| e.to_string())))),
|
||||
}
|
||||
}
|
||||
@@ -957,6 +1012,13 @@ impl FootageSource for MediaPool {
|
||||
fn cancellable(&self, cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
|
||||
filmcraft_media::cancel::with_cancel(cancel, f)
|
||||
}
|
||||
/// Every read of a file notes it (see [`crate::reads`]): a frame's image file or sequence
|
||||
/// frame, a movie, a model and the files it names, an EXR's channels, a movie's audio. A
|
||||
/// frame (or other entry) served from memory notes the files it was made of, as they were
|
||||
/// when read, without reading them again. A missing sequence frame (colour bars) reads none.
|
||||
fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) {
|
||||
reads::recording(reads, f)
|
||||
}
|
||||
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
self.frame_sized(item, footage, t, None, FloatCurve::Srgb)
|
||||
}
|
||||
@@ -1207,13 +1269,13 @@ mod tests {
|
||||
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);
|
||||
c.insert(k(i), img(), Arc::default(), budget);
|
||||
}
|
||||
assert_eq!(c.map.len(), 3);
|
||||
assert!(c.get(&k(9)).is_some() && c.get(&k(6)).is_none());
|
||||
// touching 7 makes 8 the oldest
|
||||
c.get(&k(7));
|
||||
c.insert(k(10), img(), budget);
|
||||
c.insert(k(10), img(), Arc::default(), budget);
|
||||
assert!(c.get(&k(7)).is_some() && c.get(&k(8)).is_none());
|
||||
assert_eq!(c.spare.len(), MAX_SPARE);
|
||||
assert_eq!(c.take_spare(256).len(), 256);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Which files the [`MediaPool`](crate::MediaPool) reads for a render
|
||||
//! ([`FootageSource::recording`](effectcraft_render::FootageSource::recording)).
|
||||
//!
|
||||
//! Every read of a file notes it, with the identity it had when read, in the innermost scope open
|
||||
//! on this thread. The pool's caches (decoded frames, opened movies, GIFs, models, EXR channels)
|
||||
//! keep what their entries were made of and note it again on each hit, so a frame served from
|
||||
//! memory reports its file without touching the disk.
|
||||
//!
|
||||
//! The scopes are per thread: a read noted while a render's scope is open on the thread belongs
|
||||
//! to that render. A thread waiting inside a decode can run another job meanwhile (rayon); that
|
||||
//! job's reads go to its own scope when it opens one, and otherwise to the scope below it, which
|
||||
//! then lists a file more than it read (never fewer).
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use effectcraft_render::reads::{ReadFile, ReadFiles, ReadLog};
|
||||
|
||||
/// Scopes nested deeper than this are not opened (a thread that keeps taking jobs while it waits
|
||||
/// cannot pile them up without bound): their reads go to the scope below, and they come back
|
||||
/// incomplete.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
thread_local! {
|
||||
/// The open scopes on this thread, innermost last, each with the files noted in it.
|
||||
static SCOPES: RefCell<Vec<ReadFiles>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Note `file` in the innermost open scope on this thread (nothing when none is open).
|
||||
pub(crate) fn note(file: ReadFile) {
|
||||
let _ = SCOPES.try_with(|s| {
|
||||
if let Ok(mut s) = s.try_borrow_mut()
|
||||
&& let Some(top) = s.last_mut()
|
||||
{
|
||||
top.add(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Note every file of `files` in the innermost open scope on this thread.
|
||||
pub(crate) fn note_all(files: &ReadFiles) {
|
||||
if files.is_empty() && files.is_complete() {
|
||||
return;
|
||||
}
|
||||
let _ = SCOPES.try_with(|s| {
|
||||
if let Ok(mut s) = s.try_borrow_mut()
|
||||
&& let Some(top) = s.last_mut()
|
||||
{
|
||||
top.extend(files);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Closes the scope it opened, also when what runs inside panics: its files go to `out`.
|
||||
struct Scope<'a> {
|
||||
out: Out<'a>,
|
||||
opened: bool,
|
||||
}
|
||||
|
||||
enum Out<'a> {
|
||||
/// A render's log ([`recording`]).
|
||||
Log(&'a ReadLog),
|
||||
/// The caller's ([`captured`]).
|
||||
Files(&'a mut ReadFiles),
|
||||
}
|
||||
|
||||
impl Scope<'_> {
|
||||
/// Starts collecting the files read on this thread into `out`.
|
||||
/// At the nesting limit the scope stays closed and reports an incomplete list.
|
||||
fn open(out: Out<'_>) -> Scope<'_> {
|
||||
let opened = SCOPES
|
||||
.try_with(|s| match s.try_borrow_mut() {
|
||||
Ok(mut s) if s.len() < MAX_DEPTH => {
|
||||
s.push(ReadFiles::default());
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.unwrap_or(false);
|
||||
Scope { out, opened }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scope<'_> {
|
||||
/// Ends the scope and hands the collected files to its output, marked incomplete when the scope could not be opened.
|
||||
fn drop(&mut self) {
|
||||
let files = if self.opened { SCOPES.try_with(|s| s.try_borrow_mut().ok().and_then(|mut s| s.pop())).ok().flatten() } else { None };
|
||||
// A scope that could not open saw nothing: what it made can't name its files.
|
||||
let files = files.unwrap_or_else(|| {
|
||||
let mut f = ReadFiles::default();
|
||||
f.mark_incomplete();
|
||||
f
|
||||
});
|
||||
match &mut self.out {
|
||||
Out::Log(log) => log.note_all(&files),
|
||||
Out::Files(out) => out.extend(&files),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with the files it reads on this thread noted in `log`
|
||||
/// ([`FootageSource::recording`](effectcraft_render::FootageSource::recording)).
|
||||
pub(crate) fn recording(log: &ReadLog, f: &mut dyn FnMut()) {
|
||||
let _scope = Scope::open(Out::Log(log));
|
||||
f();
|
||||
}
|
||||
|
||||
/// Run `f` and return what it returns with the files it read on this thread (to keep with a
|
||||
/// cache entry made of them). They are not noted in the enclosing scope: the caller notes them,
|
||||
/// as it does on each later hit of the entry.
|
||||
pub(crate) fn captured<T>(f: impl FnOnce() -> T) -> (T, ReadFiles) {
|
||||
let mut files = ReadFiles::default();
|
||||
let out = {
|
||||
let _scope = Scope::open(Out::Files(&mut files));
|
||||
f()
|
||||
};
|
||||
(out, files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,390 @@
|
||||
//! The files a render reads, as the media pool reports them (#3184): exactly the files the frame
|
||||
//! was made of, also when its pixels come from memory.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, Project, Solid, Value};
|
||||
use effectcraft_render::reads::{FileStamp, ReadFiles, ReadLog};
|
||||
use effectcraft_render::{LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_time::FrameRate;
|
||||
|
||||
use crate::MediaPool;
|
||||
|
||||
const RATE: FrameRate = FrameRate::FPS_30;
|
||||
const SIZE: (u32, u32) = (16, 8);
|
||||
|
||||
/// A fresh folder for test `name` (on the build drive, not the system temp folder).
|
||||
fn folder(name: &str) -> PathBuf {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../../target/test-reads-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A PNG at `path`, a flat grey of `level`.
|
||||
fn png(path: &Path, level: u8) -> String {
|
||||
image::RgbaImage::from_pixel(SIZE.0, SIZE.1, image::Rgba([level, level, level, 255])).save(path).unwrap();
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// An image sequence of `n` PNG frames in `dir`.
|
||||
fn sequence(dir: &Path, n: usize) -> Footage {
|
||||
let files: Vec<String> = (0..n).map(|i| png(&dir.join(format!("seq_{i:04}.png")), 20 + i as u8 * 10)).collect();
|
||||
let mut f = Footage { kind: FootageKind::Sequence, sequence: files, frame_rate: RATE, ..crate::probe(dir.join("seq_0000.png")).unwrap() };
|
||||
f.sync_sequence_duration();
|
||||
f
|
||||
}
|
||||
|
||||
/// A still footage item of `path`.
|
||||
fn still(path: &Path, level: u8) -> Footage {
|
||||
png(path, level);
|
||||
crate::probe(path).unwrap()
|
||||
}
|
||||
|
||||
fn comp() -> Comp {
|
||||
Comp::new(SIZE.0, SIZE.1, RATE, RATE.tick_of(10))
|
||||
}
|
||||
|
||||
fn footage_layer(p: &mut Project, comp: &Comp, name: &str, f: Footage) -> Layer {
|
||||
let item = p.add_item(name, Label::Aqua, None, ItemKind::Footage(f));
|
||||
build::layer(p, comp, name, LayerSource::Footage { item }, SIZE, Some(comp.duration))
|
||||
}
|
||||
|
||||
fn add_comp(p: &mut Project, name: &str, mut comp: Comp, layers: Vec<Layer>) -> ItemId {
|
||||
comp.layers = layers.into();
|
||||
p.add_item(name, Label::Sandstone, None, ItemKind::Comp(comp.into()))
|
||||
}
|
||||
|
||||
/// Render frame `frame` of `comp` with `pool` and `cache`: the picture's top-left pixel and the
|
||||
/// files the render reports.
|
||||
fn render(p: &Project, pool: &MediaPool, cache: &LayerCache, comp: ItemId, frame: i64) -> ([f32; 4], ReadFiles) {
|
||||
let log = ReadLog::new();
|
||||
let mut r = Renderer::new(p, pool, RenderOpts::default());
|
||||
r.cache = Some(cache);
|
||||
r.reads = Some(&log);
|
||||
let img = r.comp_frame(comp, RATE.tick_of(frame));
|
||||
(img.get(0, 0), log.into_files())
|
||||
}
|
||||
|
||||
fn paths(files: &ReadFiles) -> Vec<String> {
|
||||
files.files().iter().map(|f| f.path.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Every listed file has the identity it has on disk now.
|
||||
fn assert_stamps_match_disk(files: &ReadFiles) {
|
||||
for f in files.files() {
|
||||
assert_eq!(Some(f.stamp), FileStamp::native(&f.path), "{}", f.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// (a) One frame of a comp with an image-sequence layer reads that frame's file and nothing else
|
||||
/// of the sequence; a large sequence in the project that no layer uses adds nothing, not even a
|
||||
/// look at its files.
|
||||
#[test]
|
||||
fn a_sequence_frame_reports_only_its_own_file() {
|
||||
let dir = folder("sequence");
|
||||
let unused_dir = dir.join("unused");
|
||||
let mut p = Project::default();
|
||||
let c = comp();
|
||||
let seq = sequence(&dir, 5);
|
||||
let (frame_2, frame_4) = (seq.sequence[2].clone(), seq.sequence[4].clone());
|
||||
let layer = footage_layer(&mut p, &c, "Seq", seq);
|
||||
// 5000 frames nobody shows (they don't even exist).
|
||||
let unused = Footage {
|
||||
kind: FootageKind::Sequence,
|
||||
sequence: (0..5000).map(|i| unused_dir.join(format!("big_{i:05}.exr")).to_string_lossy().to_string()).collect(),
|
||||
frame_rate: RATE,
|
||||
width: SIZE.0,
|
||||
height: SIZE.1,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
p.add_item("Unused", Label::Aqua, None, ItemKind::Footage(unused));
|
||||
let cid = add_comp(&mut p, "C", c, vec![layer]);
|
||||
let (pool, cache) = (MediaPool::new(), LayerCache::default());
|
||||
let opened = crate::opens_under(&dir);
|
||||
let (px, files) = render(&p, &pool, &cache, cid, 2);
|
||||
assert!((px[0] - 40.0 / 255.0).abs() < 0.01, "frame 2 shows: {px:?}");
|
||||
assert_eq!(paths(&files), [frame_2]);
|
||||
assert!(files.is_complete());
|
||||
assert_stamps_match_disk(&files);
|
||||
assert_eq!(crate::opens_under(&dir) - opened, 1, "one file opened: the frame's");
|
||||
assert_eq!(crate::opens_under(&unused_dir), 0, "the unused sequence was not looked at");
|
||||
// Another frame reads its own file, also through the viewer's wrapper of the pool.
|
||||
let viewer = effectcraft_render::CancellableFootage { inner: &pool, cancel: Default::default() };
|
||||
let log = ReadLog::new();
|
||||
let mut r = Renderer::new(&p, &viewer, RenderOpts::default());
|
||||
r.cache = Some(&cache);
|
||||
r.reads = Some(&log);
|
||||
r.comp_frame(cid, RATE.tick_of(4));
|
||||
let files = log.into_files();
|
||||
assert_eq!(paths(&files), [frame_4]);
|
||||
assert!(files.is_complete());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// (b) A precomp reports the files of what it draws, and an effect that reads another layer
|
||||
/// reports that layer's files (the layer itself is hidden: its pixels come only through the
|
||||
/// effect).
|
||||
#[test]
|
||||
fn precomps_and_layers_read_by_effects_report_their_files() {
|
||||
let dir = folder("nested");
|
||||
let mut p = Project::default();
|
||||
let c = comp();
|
||||
let seq = sequence(&dir, 4);
|
||||
let nested_frame = seq.sequence[3].clone();
|
||||
let inner_layer = footage_layer(&mut p, &c, "Seq", seq);
|
||||
let inner = add_comp(&mut p, "Inner", c.clone(), vec![inner_layer]);
|
||||
let pre = build::layer(&mut p, &c, "Inner", LayerSource::Comp { item: inner }, SIZE, Some(c.duration));
|
||||
let matte_path = dir.join("matte.png");
|
||||
let mut matte = footage_layer(&mut p, &c, "Matte", still(&matte_path, 255));
|
||||
matte.switches.video = false;
|
||||
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: SIZE.0, height: SIZE.1, pixel_aspect: 1.0 }));
|
||||
let mut solid = build::layer(&mut p, &c, "S", LayerSource::Solid { item: sid }, SIZE, Some(c.duration));
|
||||
let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap();
|
||||
let mut next = p.next_id;
|
||||
let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [SIZE.0 as f64, SIZE.1 as f64]);
|
||||
p.next_id = next;
|
||||
fx.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(matte.id.0));
|
||||
solid.props.sub_mut("effects").unwrap().children.push(fx.into());
|
||||
let cid = add_comp(&mut p, "Outer", c, vec![solid, matte, pre]);
|
||||
let (pool, cache) = (MediaPool::new(), LayerCache::default());
|
||||
let (_, files) = render(&p, &pool, &cache, cid, 3);
|
||||
let mut want = vec![matte_path.to_string_lossy().to_string(), nested_frame];
|
||||
want.sort();
|
||||
assert_eq!(paths(&files), want);
|
||||
assert!(files.is_complete());
|
||||
assert_stamps_match_disk(&files);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// (c) A frame rendered again comes from memory (the sequence frame from the media pool, the
|
||||
/// still layer from the layer cache) and reports the same files, with the identities they had
|
||||
/// when read, without opening anything: here the files are even gone by then.
|
||||
#[test]
|
||||
fn a_frame_served_from_memory_reports_its_files_without_touching_them() {
|
||||
let dir = folder("memory");
|
||||
let mut p = Project::default();
|
||||
let c = comp();
|
||||
// The sequence at half opacity over the still: both show.
|
||||
let mut seq = footage_layer(&mut p, &c, "Seq", sequence(&dir, 3));
|
||||
seq.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0);
|
||||
let plate = footage_layer(&mut p, &c, "Plate", still(&dir.join("plate.png"), 128));
|
||||
let cid = add_comp(&mut p, "C", c, vec![seq, plate]);
|
||||
let (pool, cache) = (MediaPool::new(), LayerCache::default());
|
||||
let (px, first) = render(&p, &pool, &cache, cid, 1);
|
||||
assert_eq!(first.len(), 2, "{:?}", paths(&first));
|
||||
assert_stamps_match_disk(&first);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let opened = crate::opens_under(&dir);
|
||||
let hits = cache.stats().hits;
|
||||
let (again_px, again) = render(&p, &pool, &cache, cid, 1);
|
||||
assert_eq!(again_px, px, "the same picture");
|
||||
assert_eq!(again, first, "the same files, as they were when read");
|
||||
assert!(cache.stats().hits > hits, "the still layer came from the layer cache");
|
||||
// The media pool alone (a new layer cache) reports them too.
|
||||
let (_, from_pool) = render(&p, &pool, &LayerCache::default(), cid, 1);
|
||||
assert_eq!(from_pool, first);
|
||||
assert_eq!(crate::opens_under(&dir), opened, "nothing was opened on disk");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- layer entries on disk (#3184)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_render::disk_cache::{DiskCache, Kind};
|
||||
|
||||
/// A "session": a new media pool and layer cache over the disk cache in `cache` (opened anew,
|
||||
/// as after a restart), writing every layer buffer however quick to render.
|
||||
fn session(cache: &Path) -> (MediaPool, LayerCache, Arc<DiskCache>) {
|
||||
let dc = DiskCache::open(cache, 1 << 30).unwrap();
|
||||
dc.set_min_layer_ms(0);
|
||||
let lc = LayerCache::default();
|
||||
lc.set_disk(Some(dc.clone()));
|
||||
(MediaPool::new(), lc, dc)
|
||||
}
|
||||
|
||||
/// Give `path` a later modification time, so a rewrite within the clock's resolution counts.
|
||||
fn touch_later(path: &Path) {
|
||||
let t = std::fs::metadata(path).unwrap().modified().unwrap() + std::time::Duration::from_secs(7);
|
||||
std::fs::File::options().append(true).open(path).unwrap().set_modified(t).unwrap();
|
||||
}
|
||||
|
||||
/// Two still plates (the upper one at half opacity, so both show), and a 5000-frame sequence
|
||||
/// in the project that no layer uses (its files don't even exist).
|
||||
fn plates(dir: &Path) -> (Project, ItemId, PathBuf) {
|
||||
let mut p = Project::default();
|
||||
let c = comp();
|
||||
let a = dir.join("a.png");
|
||||
let mut top = footage_layer(&mut p, &c, "A", still(&a, 60));
|
||||
top.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0);
|
||||
let bottom = footage_layer(&mut p, &c, "B", still(&dir.join("b.png"), 200));
|
||||
let unused = dir.join("unused");
|
||||
let seq = Footage {
|
||||
kind: FootageKind::Sequence,
|
||||
sequence: (0..5000).map(|i| unused.join(format!("big_{i:05}.exr")).to_string_lossy().to_string()).collect(),
|
||||
frame_rate: RATE,
|
||||
width: SIZE.0,
|
||||
height: SIZE.1,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
p.add_item("Unused", Label::Aqua, None, ItemKind::Footage(seq));
|
||||
let cid = add_comp(&mut p, "C", c, vec![top, bottom]);
|
||||
(p, cid, a)
|
||||
}
|
||||
|
||||
/// Correct and no scan: after a restart, only the layer buffer whose source file changed is
|
||||
/// rendered again (only that file is opened); the other comes from disk with its file list, so
|
||||
/// the frame's list is complete again (its frame entry can be stored). The disk cache checks
|
||||
/// exactly the files its entries list, never the unused sequence. The re-rendered buffer
|
||||
/// replaces its entry: the next restart opens nothing.
|
||||
#[test]
|
||||
fn a_changed_file_renders_only_the_layer_buffers_that_read_it_again() {
|
||||
let dir = folder("layer-disk");
|
||||
let (src, cache) = (dir.join("src"), dir.join("cache"));
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
let (p, cid, a) = plates(&src);
|
||||
let (pool, lc, dc) = session(&cache);
|
||||
let (px, files) = render(&p, &pool, &lc, cid, 0);
|
||||
assert!(files.is_complete() && files.len() == 2, "{:?}", paths(&files));
|
||||
dc.flush();
|
||||
let entries = dc.keys(Kind::Layer).len();
|
||||
assert!(entries >= 2, "both plates' buffers are on disk: {entries}");
|
||||
drop((pool, lc, dc));
|
||||
|
||||
// Restart without a change: everything from disk, nothing opened, a complete list.
|
||||
let (pool, lc, dc) = session(&cache);
|
||||
let opened = crate::opens_under(&src);
|
||||
let (again, files_again) = render(&p, &pool, &lc, cid, 0);
|
||||
assert_eq!(again, px);
|
||||
assert_eq!(files_again, files, "the files of the entries, as they were read");
|
||||
assert!(files_again.is_complete(), "a frame made of buffers from disk can be stored");
|
||||
assert_eq!(crate::opens_under(&src), opened, "nothing opened");
|
||||
let checks = dc.file_checks();
|
||||
assert!(checks >= 2 && checks as usize <= entries, "one check per entry used: {checks} of {entries}");
|
||||
drop((pool, lc, dc));
|
||||
|
||||
// a.png gets new pixels: only the top plate renders again.
|
||||
png(&a, 120);
|
||||
touch_later(&a);
|
||||
let (pool, lc, dc) = session(&cache);
|
||||
let opened = crate::opens_under(&src);
|
||||
let (changed, files_changed) = render(&p, &pool, &lc, cid, 0);
|
||||
assert_ne!(changed, px, "the new pixels show");
|
||||
assert_eq!(crate::opens_under(&src) - opened, 1, "only the changed file is read");
|
||||
assert!(files_changed.is_complete() && files_changed.len() == 2);
|
||||
assert_stamps_match_disk(&files_changed);
|
||||
assert!(dc.file_checks() as usize <= entries, "only listed files are checked");
|
||||
assert_eq!(crate::opens_under(&src.join("unused")), 0);
|
||||
dc.flush();
|
||||
drop((pool, lc, dc));
|
||||
|
||||
// Its entry was replaced: the next restart opens nothing and shows the new pixels.
|
||||
let (pool, lc, _dc) = session(&cache);
|
||||
let opened = crate::opens_under(&src);
|
||||
let (last, _) = render(&p, &pool, &lc, cid, 0);
|
||||
assert_eq!(last, changed);
|
||||
assert_eq!(crate::opens_under(&src), opened, "both from disk");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Two projects whose footage items have the same ids but name different files never share a
|
||||
/// layer entry: the layer key names the item's path and settings.
|
||||
#[test]
|
||||
fn projects_with_the_same_item_ids_never_share_a_layer_entry() {
|
||||
let dir = folder("layer-disk-projects");
|
||||
let (src, cache) = (dir.join("src"), dir.join("cache"));
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
let mut p = Project::default();
|
||||
let c = comp();
|
||||
let layer = footage_layer(&mut p, &c, "Plate", still(&src.join("one.png"), 40));
|
||||
let LayerSource::Footage { item } = layer.source else { panic!("a footage layer") };
|
||||
let cid = add_comp(&mut p, "C", c, vec![layer]);
|
||||
let mut other = p.clone();
|
||||
other.item_mut(item).unwrap().kind = ItemKind::Footage(still(&src.join("two.png"), 220));
|
||||
let (pool, lc, dc) = session(&cache);
|
||||
let (one, _) = render(&p, &pool, &lc, cid, 0);
|
||||
dc.flush();
|
||||
drop((pool, lc, dc));
|
||||
let (pool, lc, dc) = session(&cache);
|
||||
let hits = dc.stats().hits;
|
||||
let (two, files) = render(&other, &pool, &lc, cid, 0);
|
||||
assert!((two[0] - 220.0 / 255.0).abs() < 0.01, "the other project's own file shows: {two:?}");
|
||||
assert_ne!(two, one);
|
||||
assert_eq!(dc.stats().hits, hits, "no entry of the first project is used");
|
||||
assert_eq!(paths(&files), [src.join("two.png").to_string_lossy().to_string()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Safe: a damaged layer entry, a source file that is missing, one replaced by something
|
||||
/// unreadable (a folder), and one back with other pixels never serve a stale buffer after a
|
||||
/// restart; the damaged entry is rendered again, replaced and then used.
|
||||
#[test]
|
||||
fn missing_unreadable_files_and_damaged_layer_entries_never_serve_stale_buffers() {
|
||||
let dir = folder("layer-disk-safe");
|
||||
let (src, cache) = (dir.join("src"), dir.join("cache"));
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
let mut p = Project::default();
|
||||
let c = comp();
|
||||
let path = src.join("plate.png");
|
||||
let layer = footage_layer(&mut p, &c, "Plate", still(&path, 90));
|
||||
let cid = add_comp(&mut p, "C", c, vec![layer]);
|
||||
let (pool, lc, dc) = session(&cache);
|
||||
let (shown, _) = render(&p, &pool, &lc, cid, 0);
|
||||
assert!(shown[3] > 0.99, "the plate shows: {shown:?}");
|
||||
dc.flush();
|
||||
drop((pool, lc, dc));
|
||||
|
||||
// A damaged entry: a miss, deleted, rendered again and stored anew.
|
||||
let layers = cache.join("v1").join(Kind::Layer.dir());
|
||||
for shard in std::fs::read_dir(&layers).unwrap().flatten() {
|
||||
for f in std::fs::read_dir(shard.path()).unwrap().flatten() {
|
||||
let mut d = std::fs::read(f.path()).unwrap();
|
||||
let n = d.len() / 2;
|
||||
d[n] ^= 0x5a;
|
||||
std::fs::write(f.path(), d).unwrap();
|
||||
}
|
||||
}
|
||||
let (pool, lc, dc) = session(&cache);
|
||||
let opened = crate::opens_under(&src);
|
||||
let (again, files) = render(&p, &pool, &lc, cid, 0);
|
||||
assert_eq!(again, shown);
|
||||
assert_eq!(crate::opens_under(&src) - opened, 1, "read again, not served from the damaged entry");
|
||||
assert!(files.is_complete());
|
||||
dc.flush();
|
||||
drop((pool, lc, dc));
|
||||
let (pool, lc, dc) = session(&cache);
|
||||
let hits = dc.stats().hits;
|
||||
assert_eq!(render(&p, &pool, &lc, cid, 0).0, shown);
|
||||
assert!(dc.stats().hits > hits, "the rewritten entry is used");
|
||||
drop((pool, lc, dc));
|
||||
|
||||
// The file is gone: not the old plate.
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let (pool, lc, dc) = session(&cache);
|
||||
let hits = dc.stats().hits;
|
||||
let (gone, files) = render(&p, &pool, &lc, cid, 0);
|
||||
assert!(gone[3] < 0.01, "a missing file shows nothing, never the cached plate: {gone:?}");
|
||||
assert_eq!(dc.stats().hits, hits);
|
||||
assert!(files.files().iter().all(|f| f.stamp == FileStamp::Missing), "{files:?}");
|
||||
dc.flush();
|
||||
drop((pool, lc, dc));
|
||||
|
||||
// Something unreadable at its path (a folder): still not the old plate.
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
let (pool, lc, _dc) = session(&cache);
|
||||
let (unreadable, _) = render(&p, &pool, &lc, cid, 0);
|
||||
assert!(unreadable[3] < 0.01, "{unreadable:?}");
|
||||
std::fs::remove_dir(&path).unwrap();
|
||||
|
||||
// Back with other pixels: those show, not the entry made of the old file.
|
||||
png(&path, 240);
|
||||
touch_later(&path);
|
||||
let (pool, lc, _dc) = session(&cache);
|
||||
let (back, _) = render(&p, &pool, &lc, cid, 0);
|
||||
assert!((back[0] - 240.0 / 255.0).abs() < 0.01, "{back:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use effectcraft_render::reads::FileStamp;
|
||||
use filmcraft_media::SharedSource;
|
||||
|
||||
use crate::Result;
|
||||
@@ -19,17 +20,22 @@ use crate::Result;
|
||||
pub(crate) struct FileReader {
|
||||
file: std::fs::File,
|
||||
len: u64,
|
||||
/// The file's identity when it was opened.
|
||||
stamp: FileStamp,
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
impl FileReader {
|
||||
/// Opens `path` for reading and records its length and modification time.
|
||||
pub(crate) fn open(path: &Path) -> std::io::Result<Self> {
|
||||
#[cfg(test)]
|
||||
crate::count_open(path);
|
||||
let file = std::fs::File::open(path)?;
|
||||
let m = file.metadata()?;
|
||||
if !m.is_file() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a file", path.display())));
|
||||
}
|
||||
Ok(Self { file, len: m.len() })
|
||||
Ok(Self { file, len: m.len(), stamp: FileStamp::of_metadata(&m) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,16 +78,19 @@ fn name_of(path: &Path) -> String {
|
||||
/// Open a movie/audio file for the media pool, reading only what its container needs; files the
|
||||
/// reader openers don't handle are read whole and opened from memory, exactly as before. `None`
|
||||
/// when the file can't be opened in place (the caller reads it whole, which reports the error).
|
||||
/// With the file's identity as opened.
|
||||
#[cfg(any(unix, windows))]
|
||||
pub(crate) fn open_source(path: &Path) -> Option<Result<SharedSource>> {
|
||||
pub(crate) fn open_source(path: &Path) -> Option<(Result<SharedSource>, FileStamp)> {
|
||||
let reader = FileReader::open(path).ok()?;
|
||||
let stamp = reader.stamp;
|
||||
let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader);
|
||||
let opened = filmcraft_media::reader::open_reader(&name_of(path), reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers());
|
||||
Some(opened.map_err(Into::into))
|
||||
Some((opened.map_err(Into::into), stamp))
|
||||
}
|
||||
|
||||
/// Without a native file system there is no in-place source: always `None`.
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(crate) fn open_source(_path: &Path) -> Option<Result<SharedSource>> {
|
||||
pub(crate) fn open_source(_path: &Path) -> Option<(Result<SharedSource>, FileStamp)> {
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
@@ -935,6 +935,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
|
||||
Layer {
|
||||
id,
|
||||
name: comp.unique_layer_name(name),
|
||||
name_follows_text: None,
|
||||
source,
|
||||
label,
|
||||
comment: String::new(),
|
||||
|
||||
@@ -299,6 +299,8 @@ impl Layer {
|
||||
let Layer {
|
||||
id,
|
||||
name,
|
||||
// Whether the name follows the text changes no pixels until the name changes.
|
||||
name_follows_text: _,
|
||||
source,
|
||||
label,
|
||||
comment,
|
||||
@@ -470,10 +472,29 @@ pub enum AutoOrient {
|
||||
TowardsPointOfInterest,
|
||||
}
|
||||
|
||||
/// A stretch written as `null` (a NaN or infinite stretch: JSON holds neither, and auto-saves
|
||||
/// write it so) reads as NaN, so the project still opens and loading brings it to 100 % with a
|
||||
/// note ([`crate::limits::stretch`], #3247) instead of the whole file being refused. An infinite
|
||||
/// stretch therefore also reopens at 100 %, not at ±9900 %.
|
||||
fn stretch_or_nan<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||
Ok(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
/// `t × mul / div` in ticks, within [`Tick::MIN`]..=[`Tick::MAX`] (the cast saturates, NaN is 0),
|
||||
/// so a layer time of an extreme stretch can't overflow later tick arithmetic.
|
||||
fn scaled(t: Tick, mul: f64, div: f64) -> Tick {
|
||||
Tick(((t.0 as f64 * mul / div) as i64).clamp(Tick::MIN.0, Tick::MAX.0))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Layer {
|
||||
pub id: LayerId,
|
||||
pub name: String,
|
||||
/// A text layer's name follows the first line of its Source Text until the user renames it
|
||||
/// (After Effects). `Some(true)`: it follows, `Some(false)`: the user named it. `None` (older
|
||||
/// projects, layers made before this was recorded): see [`Layer::name_follows_text()`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name_follows_text: Option<bool>,
|
||||
pub source: LayerSource,
|
||||
pub label: Label,
|
||||
#[serde(default)]
|
||||
@@ -484,6 +505,7 @@ pub struct Layer {
|
||||
pub in_point: Tick,
|
||||
pub out_point: Tick,
|
||||
/// Time stretch in percent (100 = normal, negative = reversed).
|
||||
#[serde(deserialize_with = "stretch_or_nan")]
|
||||
pub stretch: f64,
|
||||
pub switches: Switches,
|
||||
pub blend_mode: BlendMode,
|
||||
@@ -512,7 +534,26 @@ pub struct Layer {
|
||||
pub props: PropGroup,
|
||||
}
|
||||
|
||||
/// The name a text layer takes from its text: the first 40 characters of the first line. A line
|
||||
/// ends at a line feed or at a bare carriage return (After Effects' paragraph break).
|
||||
pub fn text_layer_name(text: &str) -> String {
|
||||
text.split(['\r', '\n']).next().unwrap_or("").chars().take(40).collect()
|
||||
}
|
||||
|
||||
/// A text layer's default name when its text gives none: "Text", numbered "Text 2", "Text 3"…
|
||||
fn is_default_text_name(name: &str) -> bool {
|
||||
name == "Text" || name.strip_prefix("Text ").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
/// Whether this text layer's name follows its Source Text, whose first line gives the name
|
||||
/// `current` (see [`text_layer_name`]). Without a recorded state (an older project), a name
|
||||
/// that is empty, the text's own, or the default "Text"/"Text 2"… follows; any other name was
|
||||
/// given by the user.
|
||||
pub fn name_follows_text(&self, current: &str) -> bool {
|
||||
self.name_follows_text.unwrap_or_else(|| self.name.is_empty() || self.name == current || is_default_text_name(&self.name))
|
||||
}
|
||||
|
||||
/// The layers this layer reads: its parent, track matte, layer parameters (Displacement Map,
|
||||
/// Audio Spectrum's Audio Layer…) and tracker targets.
|
||||
pub fn referenced_layers(&self) -> Vec<LayerId> {
|
||||
@@ -555,15 +596,24 @@ impl Layer {
|
||||
t >= self.in_point && t < self.out_point
|
||||
}
|
||||
|
||||
/// The time stretch the layer plays at: [`Layer::stretch`] within
|
||||
/// [`limits::stretch`](crate::limits::stretch) (a damaged value that got past loading, 0 or
|
||||
/// NaN, never divides the layer's time by zero).
|
||||
pub fn effective_stretch(&self) -> f64 {
|
||||
crate::limits::stretch(self.stretch)
|
||||
}
|
||||
|
||||
/// Layer time for a comp time (stretch-aware).
|
||||
pub fn layer_time(&self, comp_t: Tick) -> Tick {
|
||||
let d = comp_t - self.start_time;
|
||||
if (self.stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / self.stretch) as i64) }
|
||||
let s = self.effective_stretch();
|
||||
if (s - 100.0).abs() < 1e-9 { d } else { scaled(d, 100.0, s) }
|
||||
}
|
||||
|
||||
/// Comp time for a layer time.
|
||||
pub fn comp_time(&self, layer_t: Tick) -> Tick {
|
||||
if (self.stretch - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + Tick((layer_t.0 as f64 * self.stretch / 100.0) as i64) }
|
||||
let s = self.effective_stretch();
|
||||
if (s - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + scaled(layer_t, s, 100.0) }
|
||||
}
|
||||
|
||||
pub fn transform(&self) -> Option<&PropGroup> {
|
||||
|
||||
@@ -35,6 +35,7 @@ mod footage;
|
||||
mod item;
|
||||
mod layer;
|
||||
mod layers;
|
||||
mod reach;
|
||||
mod settings;
|
||||
|
||||
pub use comp::{Comp, Guide, Renderer, Resolution};
|
||||
@@ -43,9 +44,10 @@ pub use item::NO_LAYER;
|
||||
pub use item::{Heard, Item, ItemId, ItemKind, LayerId};
|
||||
pub use layer::{
|
||||
AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte, is_audio_effect,
|
||||
reads_other_frames,
|
||||
reads_other_frames, text_layer_name,
|
||||
};
|
||||
pub use layers::Layers;
|
||||
pub use reach::{ExprReach, PixelReach, expression_reach};
|
||||
pub use settings::{
|
||||
APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView,
|
||||
TimeDisplayStyle, is_newer_version, saved_by, saved_view,
|
||||
|
||||
@@ -27,6 +27,30 @@ pub const MAX_PIXEL_ASPECT: f64 = 10.0;
|
||||
pub const MOTION_BLUR_SAMPLES: (u32, u32) = (2, 64);
|
||||
/// Composition Settings ▸ Advanced ▸ Adaptive Sample Limit.
|
||||
pub const ADAPTIVE_SAMPLE_LIMIT: (u32, u32) = (16, 256);
|
||||
/// The smallest time stretch, in percent, either way (After Effects sets a stretch between 0 and
|
||||
/// 1 % to 1 %, between -1 % and 0 to -1 %; `layer.timeStretch` refuses less).
|
||||
pub const MIN_STRETCH: f64 = 1.0;
|
||||
/// The largest time stretch, in percent, either way (After Effects' `Layer.stretch` range is
|
||||
/// -9900 to 9900).
|
||||
pub const MAX_STRETCH: f64 = 9900.0;
|
||||
|
||||
/// A layer's time stretch from [`MIN_STRETCH`] to [`MAX_STRETCH`] % either way, its sign (a
|
||||
/// reversed layer) kept; 100 % for 0 or NaN. Layer and comp time divide by the stretch, and the
|
||||
/// audio mix reads `100 / stretch` source samples per sample, so a stretch of 1e-300 % or NaN
|
||||
/// from a damaged file must not reach them (#3247).
|
||||
pub fn stretch(v: f64) -> f64 {
|
||||
if v.is_nan() || v == 0.0 {
|
||||
return 100.0;
|
||||
}
|
||||
// Infinity clamps to the largest stretch with its sign.
|
||||
v.signum() * v.abs().clamp(MIN_STRETCH, MAX_STRETCH)
|
||||
}
|
||||
|
||||
/// A stretch for a note: a plain number in a readable range, scientific notation beyond it (a
|
||||
/// plain 1e-300 would print 300 digits).
|
||||
fn percent(v: f64) -> String {
|
||||
if v == 0.0 || (1e-3..1e7).contains(&v.abs()) { format!("{v} %") } else { format!("{v:e} %") }
|
||||
}
|
||||
|
||||
/// A width or height from 1 to [`MAX_SIZE`].
|
||||
pub fn size(v: u64) -> u32 {
|
||||
@@ -97,9 +121,11 @@ fn repair_comp(name: &str, c: &Comp, notes: &mut Vec<String>) -> Option<Comp> {
|
||||
let duration = c.duration.max(rate.frame_duration());
|
||||
let samples = c.motion_blur_samples.clamp(MOTION_BLUR_SAMPLES.0, MOTION_BLUR_SAMPLES.1);
|
||||
let adaptive = c.motion_blur_adaptive_limit.clamp(ADAPTIVE_SAMPLE_LIMIT.0, ADAPTIVE_SAMPLE_LIMIT.1);
|
||||
let stretch_ok = |l: &crate::Layer| stretch(l.stretch).to_bits() == l.stretch.to_bits();
|
||||
let unchanged = (width, height, rate, duration, samples, adaptive)
|
||||
== (c.width, c.height, c.frame_rate, c.duration, c.motion_blur_samples, c.motion_blur_adaptive_limit)
|
||||
&& pa.to_bits() == c.pixel_aspect.to_bits();
|
||||
&& pa.to_bits() == c.pixel_aspect.to_bits()
|
||||
&& c.layers.iter().all(stretch_ok);
|
||||
if unchanged {
|
||||
return None;
|
||||
}
|
||||
@@ -125,6 +151,12 @@ fn repair_comp(name: &str, c: &Comp, notes: &mut Vec<String>) -> Option<Comp> {
|
||||
}
|
||||
(n.width, n.height, n.pixel_aspect, n.frame_rate, n.duration) = (width, height, pa, rate, duration);
|
||||
(n.motion_blur_samples, n.motion_blur_adaptive_limit) = (samples, adaptive);
|
||||
// Only the layers out of range are copied (the others stay shared).
|
||||
for l in n.layers.iter_mut_where(|l| !stretch_ok(l)) {
|
||||
let fixed = stretch(l.stretch);
|
||||
note(notes, name, &format!("layer '{}' time stretch", l.name), percent(l.stretch), percent(fixed));
|
||||
l.stretch = fixed;
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
@@ -217,6 +249,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stretch_is_held_to_one_to_9900_percent_with_its_sign() {
|
||||
assert_eq!(stretch(f64::NAN), 100.0);
|
||||
assert_eq!(stretch(0.0), 100.0);
|
||||
assert_eq!(stretch(-0.0), 100.0);
|
||||
assert_eq!(stretch(1e-300), MIN_STRETCH);
|
||||
assert_eq!(stretch(-1e-300), -MIN_STRETCH);
|
||||
assert_eq!(stretch(0.5), MIN_STRETCH);
|
||||
assert_eq!(stretch(1e300), MAX_STRETCH);
|
||||
assert_eq!(stretch(f64::INFINITY), MAX_STRETCH);
|
||||
assert_eq!(stretch(f64::NEG_INFINITY), -MAX_STRETCH);
|
||||
for v in [1.0, -1.0, 33.3, 100.0, -100.0, 200.0, 9900.0, -9900.0] {
|
||||
assert_eq!(stretch(v).to_bits(), v.to_bits(), "{v} is in range");
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer whose stretch got past loading (0, NaN, 1e-300 %, set in memory) still maps time
|
||||
/// through a stretch in range: before, 0 and 1e-300 % saturated every layer time to the end of
|
||||
/// time and NaN collapsed it to 0 (#3247).
|
||||
#[test]
|
||||
fn layer_and_comp_time_use_a_stretch_in_range() {
|
||||
let mut p = crate::Project::default();
|
||||
let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(10.0));
|
||||
let mut l = crate::build::layer(&mut p, &comp, "L", crate::LayerSource::Camera, (64, 64), None);
|
||||
l.start_time = Tick::from_seconds_f64(1.0);
|
||||
let at = |s: f64| Tick::from_seconds_f64(s);
|
||||
for (bad, effective) in [(0.0, 100.0), (f64::NAN, 100.0), (1e-300, 1.0), (-1e-300, -1.0), (1e300, 9900.0), (f64::NEG_INFINITY, -9900.0)] {
|
||||
l.stretch = bad;
|
||||
assert_eq!(l.effective_stretch(), effective, "{bad}");
|
||||
// One comp second after the start is 100 / stretch layer seconds.
|
||||
let want = Tick((at(1.0).0 as f64 * 100.0 / effective) as i64);
|
||||
assert_eq!(l.layer_time(at(2.0)), want, "{bad}");
|
||||
// And back, within the rounding of the two conversions.
|
||||
assert!((l.comp_time(l.layer_time(at(2.0))) - at(2.0)).abs() <= Tick(100), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stretch_out_of_range_in_a_file_is_corrected_and_reported() {
|
||||
let mut p = crate::Project::default();
|
||||
let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(2.0));
|
||||
let mut c = comp.clone();
|
||||
for (name, s) in [("tiny", 1e-300), ("reversed tiny", -1e-300), ("zero", 0.0), ("huge", 1e300), ("reversed huge", -5e4), ("fine", -50.0)] {
|
||||
let mut l = crate::build::layer(&mut p, &comp, name, crate::LayerSource::Camera, (64, 64), None);
|
||||
l.stretch = s;
|
||||
c.layers.push(l);
|
||||
}
|
||||
// Added through `add_item`, the comp is held to the limits too: write the values back in.
|
||||
let id = p.add_item("Comp", effectcraft_color::Label::None, None, ItemKind::Comp(Arc::new(c.clone())));
|
||||
assert!(p.comp(id).unwrap().layers.iter().all(|l| l.stretch.abs() >= MIN_STRETCH && l.stretch.abs() <= MAX_STRETCH));
|
||||
let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap();
|
||||
for (k, l) in c.layers.iter().enumerate() {
|
||||
v["items"][id.0.to_string()]["kind"]["layers"][k]["stretch"] = serde_json::json!(l.stretch);
|
||||
}
|
||||
let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap();
|
||||
let got: Vec<f64> = back.comp(id).unwrap().layers.iter().map(|l| l.stretch).collect();
|
||||
assert_eq!(got, [1.0, -1.0, 100.0, 9900.0, -9900.0, -50.0]);
|
||||
assert_eq!(
|
||||
notes,
|
||||
[
|
||||
"Comp: layer 'tiny' time stretch 1e-300 % → 1 %",
|
||||
"Comp: layer 'reversed tiny' time stretch -1e-300 % → -1 %",
|
||||
"Comp: layer 'zero' time stretch 0 % → 100 %",
|
||||
"Comp: layer 'huge' time stretch 1e300 % → 9900 %",
|
||||
"Comp: layer 'reversed huge' time stretch -50000 % → -9900 %",
|
||||
]
|
||||
);
|
||||
// A NaN stretch is written as null (an auto-save): the file opens, at 100 %, and says so.
|
||||
// Before, the whole project was refused.
|
||||
v["items"][id.0.to_string()]["kind"]["layers"][0]["stretch"] = serde_json::Value::Null;
|
||||
let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap();
|
||||
assert_eq!(back.comp(id).unwrap().layers.iter().next().unwrap().stretch, 100.0);
|
||||
assert_eq!(notes.first().map(String::as_str), Some("Comp: layer 'tiny' time stretch NaN % → 100 %"));
|
||||
let mut nan = p.clone();
|
||||
nan.comp_mut(id).unwrap().layers.iter_mut().for_each(|l| l.stretch = f64::NAN);
|
||||
let (back, _) = crate::Project::from_json_repaired(&nan.to_json_compact()).unwrap();
|
||||
assert!(back.comp(id).unwrap().layers.iter().all(|l| l.stretch == 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comp_in_range_is_not_copied() {
|
||||
let c = Comp::new(1920, 1080, FrameRate::FPS_24, Tick::from_seconds_f64(2.0));
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
//! What a composition's pixels can depend on, from the project in memory: the items it draws
|
||||
//! and, when expressions are among them, what those expressions can read (#3184). The disk
|
||||
//! cache keys a comp's frames by exactly these items, so an edit elsewhere in the project keeps
|
||||
//! them.
|
||||
//!
|
||||
//! An expression reaches its own comp's layers (`thisComp`, `thisLayer`), the sources of the
|
||||
//! layers it can read (`layer.source`, a precomp's layers through it), and other items by name:
|
||||
//! `comp("Name")` and `footage("Name")`. A name given as a plain string literal is followed; a
|
||||
//! computed name (`comp("Shot " + index)`), the object model's constructors and internals
|
||||
//! (`new Comp(7)`, `__h`), dynamic code (`eval`, `Function`) and anything the scanner can't read
|
||||
//! for sure (a regular expression literal, a template string, an escape in a name) count as
|
||||
//! reaching every item of the project. The scan errs on the side of reaching more: an item
|
||||
//! wrongly left out would serve a stale frame, one wrongly let in only renders again.
|
||||
//!
|
||||
//! Known limit (`ponytail`): an expression that builds the names of the object model's private
|
||||
//! fields at run time into a variable and writes them with a computed key it reads from that
|
||||
//! variable could forge another comp's id. That is deliberate tampering with the internals, not
|
||||
//! an expression one writes; the scan refuses every direct form of it (`__` names, the
|
||||
//! constructors, `Object`, `JSON`, `call`/`apply`/`bind`, computed writes and computed keys).
|
||||
//! Upgrade: have the expression host record which items an evaluation reached.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::{Comp, ItemId, ItemKind, LayerId, Node, Project, PropGroup, Value};
|
||||
|
||||
/// What an expression's text can reach outside its own comp and the layers it can read.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ExprReach {
|
||||
/// Only items named by string literals: comps by `comp("…")`, footage by `footage("…")`.
|
||||
Names { comps: Vec<String>, footage: Vec<String> },
|
||||
/// Any item of the project.
|
||||
Anything,
|
||||
}
|
||||
|
||||
/// Names that hand an expression another item by id, run code built at run time, or give it the
|
||||
/// tools to forge the object model's private fields.
|
||||
const REACHES_ANYTHING: &[&str] = &[
|
||||
"Comp",
|
||||
"Footage",
|
||||
"Layer",
|
||||
"Prop",
|
||||
"Markers",
|
||||
"eval",
|
||||
"Function",
|
||||
"globalThis",
|
||||
"this",
|
||||
"self",
|
||||
"window",
|
||||
"arguments",
|
||||
"constructor",
|
||||
"prototype",
|
||||
"Object",
|
||||
"Reflect",
|
||||
"Proxy",
|
||||
"JSON",
|
||||
"call",
|
||||
"apply",
|
||||
"bind",
|
||||
"class",
|
||||
"with",
|
||||
"import",
|
||||
"require",
|
||||
"fromCharCode",
|
||||
"fromCodePoint",
|
||||
"getOwnPropertyDescriptor",
|
||||
"getOwnPropertyDescriptors",
|
||||
"getPrototypeOf",
|
||||
"setPrototypeOf",
|
||||
"defineProperty",
|
||||
];
|
||||
|
||||
/// Keywords after which a `/` starts a regular expression.
|
||||
const BEFORE_REGEX: &[&str] = &["return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw", "case", "do", "else", "yield", "await"];
|
||||
|
||||
/// Keywords whose `( … )` a regular expression may follow (`if (a) /x/.test(s)`).
|
||||
const CONTROL: &[&str] = &["if", "while", "for", "with"];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Tok<'a> {
|
||||
Ident(&'a str),
|
||||
/// A string literal's text between its quotes; `plain` when it has no escape.
|
||||
Str {
|
||||
text: &'a str,
|
||||
plain: bool,
|
||||
},
|
||||
Num,
|
||||
Punct(u8),
|
||||
}
|
||||
|
||||
/// The tokens of `text`, or `None` when it can't be read for sure.
|
||||
fn tokens(text: &str) -> Option<Vec<Tok<'_>>> {
|
||||
let b = text.as_bytes();
|
||||
let mut out: Vec<Tok<'_>> = Vec::new();
|
||||
// Per open `(`: whether a control keyword is before it; whether the last `)` closed one
|
||||
// (only looked at right after a `)`).
|
||||
let mut parens: Vec<bool> = Vec::new();
|
||||
let mut control_close = false;
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
match c {
|
||||
b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c => i += 1,
|
||||
b'/' if b.get(i + 1) == Some(&b'/') => {
|
||||
while b.get(i).is_some_and(|c| *c != b'\n') {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'/' if b.get(i + 1) == Some(&b'*') => {
|
||||
let end = text.get(i + 2..)?.find("*/")?;
|
||||
i += 2 + end + 2;
|
||||
}
|
||||
b'/' => {
|
||||
if regex_may_start(out.last(), control_close) {
|
||||
return None;
|
||||
}
|
||||
out.push(Tok::Punct(c));
|
||||
i += 1;
|
||||
}
|
||||
b'"' | b'\'' => {
|
||||
let start = i + 1;
|
||||
let mut j = start;
|
||||
let mut plain = true;
|
||||
loop {
|
||||
match *b.get(j)? {
|
||||
q if q == c => break,
|
||||
b'\n' | b'\r' => return None,
|
||||
b'\\' => {
|
||||
// Only escapes that can't spell a name: `\n`, `\t`, a quote...
|
||||
if !matches!(b.get(j + 1)?, b'n' | b'r' | b't' | b'b' | b'f' | b'v' | b'\'' | b'"' | b'\\') {
|
||||
return None;
|
||||
}
|
||||
plain = false;
|
||||
j += 2;
|
||||
}
|
||||
_ => j += 1,
|
||||
}
|
||||
}
|
||||
let s = text.get(start..j)?;
|
||||
if s.contains("__") {
|
||||
return None;
|
||||
}
|
||||
out.push(Tok::Str { text: s, plain });
|
||||
i = j + 1;
|
||||
}
|
||||
b'0'..=b'9' => {
|
||||
while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'.') {
|
||||
i += 1;
|
||||
}
|
||||
out.push(Tok::Num);
|
||||
}
|
||||
b'.' if b.get(i + 1).is_some_and(u8::is_ascii_digit) => {
|
||||
i += 1;
|
||||
while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'.') {
|
||||
i += 1;
|
||||
}
|
||||
out.push(Tok::Num);
|
||||
}
|
||||
c if c.is_ascii_alphabetic() || c == b'_' || c == b'$' => {
|
||||
let start = i;
|
||||
while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'$') {
|
||||
i += 1;
|
||||
}
|
||||
out.push(Tok::Ident(text.get(start..i)?));
|
||||
}
|
||||
// A template string (code inside `${}`), an escaped identifier, a name in another
|
||||
// alphabet: not followed.
|
||||
b'`' | b'\\' => return None,
|
||||
c if !c.is_ascii() => return None,
|
||||
c => {
|
||||
match c {
|
||||
b'(' => parens.push(matches!(out.last(), Some(Tok::Ident(k)) if CONTROL.contains(k))),
|
||||
b')' => control_close = parens.pop().unwrap_or(false),
|
||||
_ => {}
|
||||
}
|
||||
out.push(Tok::Punct(c));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Whether a `/` after `prev` may start a regular expression literal (which the scanner doesn't
|
||||
/// read: its text could hide a quote).
|
||||
fn regex_may_start(prev: Option<&Tok<'_>>, control_close: bool) -> bool {
|
||||
match prev {
|
||||
None => true,
|
||||
Some(Tok::Ident(k)) => BEFORE_REGEX.contains(k),
|
||||
Some(Tok::Num | Tok::Str { .. }) => false,
|
||||
Some(Tok::Punct(b')')) => control_close,
|
||||
Some(Tok::Punct(b']')) => false,
|
||||
Some(Tok::Punct(_)) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the expression `text` can reach by name, or [`ExprReach::Anything`].
|
||||
pub fn expression_reach(text: &str) -> ExprReach {
|
||||
expression_names(text).map_or(ExprReach::Anything, |(comps, footage)| ExprReach::Names { comps, footage })
|
||||
}
|
||||
|
||||
/// The comp names and footage names an expression references by literal name.
|
||||
/// Returns `None` when it can reach anything by a computed name or cannot be tokenised.
|
||||
fn expression_names(text: &str) -> Option<(Vec<String>, Vec<String>)> {
|
||||
let toks = tokens(text)?;
|
||||
let (mut comps, mut footage) = (Vec::new(), Vec::new());
|
||||
// Per open `[`: a computed key (`{[k]: v}`), a computed member (`o[k]`).
|
||||
let mut brackets: Vec<(bool, bool)> = Vec::new();
|
||||
for (i, t) in toks.iter().enumerate() {
|
||||
let prev = i.checked_sub(1).and_then(|p| toks.get(p));
|
||||
let next = |n: usize| toks.get(i + n);
|
||||
match *t {
|
||||
Tok::Ident(name) if name.starts_with("__") || REACHES_ANYTHING.contains(&name) => return None,
|
||||
Tok::Ident(name @ ("comp" | "footage")) => {
|
||||
// Only a call with a plain literal, not a property of something else.
|
||||
let (Some(Tok::Punct(b'(')), Some(Tok::Str { text, plain: true }), Some(Tok::Punct(b')'))) = (next(1), next(2), next(3)) else { return None };
|
||||
if matches!(prev, Some(Tok::Punct(b'.'))) {
|
||||
return None;
|
||||
}
|
||||
if name == "comp" { comps.push((*text).to_string()) } else { footage.push((*text).to_string()) }
|
||||
}
|
||||
Tok::Punct(b'[') => {
|
||||
let key = matches!(prev, Some(Tok::Punct(b'{' | b',')) | Some(Tok::Ident("get" | "set" | "static" | "async")) | Some(Tok::Punct(b'*')));
|
||||
let member = matches!(prev, Some(Tok::Ident(_) | Tok::Str { .. } | Tok::Num | Tok::Punct(b')' | b']' | b'.')));
|
||||
brackets.push((key, member));
|
||||
}
|
||||
Tok::Str { .. } if brackets.last().is_some_and(|(_, member)| *member) => return None,
|
||||
Tok::Punct(b']') => {
|
||||
let (key, _) = brackets.pop().unwrap_or((true, true));
|
||||
// A computed key (`{[k]: v}`, `{[k]() {}}`) or a write through brackets (`o[k] = v`,
|
||||
// `o[k] ||= v`, `for (o[k] in …)`, `[o[k]] = v`) could name a private field.
|
||||
if key && matches!(next(1), Some(Tok::Punct(b':' | b'('))) {
|
||||
return None;
|
||||
}
|
||||
if assigns(&toks, i + 1) || matches!(next(1), Some(Tok::Ident("in" | "of"))) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// A destructuring write (`({a: o[k]} = v)`).
|
||||
Tok::Punct(b'}') if assigns(&toks, i + 1) => return None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some((comps, footage))
|
||||
}
|
||||
|
||||
/// Whether the tokens from `at` are an assignment operator (`=`, `+=`, `>>>=`, `??=`...), not a
|
||||
/// comparison (`==`, `<=`) or an arrow (`=>`).
|
||||
fn assigns(toks: &[Tok<'_>], at: usize) -> bool {
|
||||
let mut op = Vec::new();
|
||||
for t in toks.iter().skip(at).take(5) {
|
||||
match t {
|
||||
Tok::Punct(c) if b"+-*/%&|^<>?=".contains(c) => op.push(*c),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let Some(eq) = op.iter().position(|c| *c == b'=') else { return false };
|
||||
let (before, after) = op.split_at(eq);
|
||||
if after.get(1).is_some_and(|c| *c == b'=' || *c == b'>') {
|
||||
return false;
|
||||
}
|
||||
matches!(before, b"" | b"+" | b"-" | b"*" | b"/" | b"%" | b"&" | b"|" | b"^" | b"**" | b"<<" | b">>" | b">>>" | b"&&" | b"||" | b"??")
|
||||
}
|
||||
|
||||
/// The items a comp's pixels can depend on ([`Project::pixel_reach`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PixelReach {
|
||||
/// The items whose state counts; `None`: every item of the project.
|
||||
pub items: Option<BTreeSet<ItemId>>,
|
||||
/// Expressions are among them: they can read every layer of the comps they reach (hidden
|
||||
/// and silent ones too) and the markers, which therefore count as well (#1083).
|
||||
pub expressions: bool,
|
||||
/// The names expressions look items up by (`true`: a comp's, `false`: footage's), each with
|
||||
/// the ids of the items of that kind that have it now, in id order (the first one is what
|
||||
/// the lookup finds; another item taking or giving up the name changes it).
|
||||
pub names: Vec<(bool, String, Vec<ItemId>)>,
|
||||
}
|
||||
|
||||
/// Each property of a group and its subgroups (iterative: no depth limit to hit).
|
||||
fn each_prop<'a>(g: &'a PropGroup, f: &mut dyn FnMut(&'a crate::Property) -> Option<()>) -> Option<()> {
|
||||
let mut stack = vec![g];
|
||||
while let Some(g) = stack.pop() {
|
||||
for n in &g.children {
|
||||
match n {
|
||||
Node::Prop(p) => f(p)?,
|
||||
Node::Group(sub) => stack.push(sub),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// The items comp `comp` draws ([`Self::silent_layers`] aside, with `heard`) and the
|
||||
/// footage its precomp layers swap in (Essential Graphics Media Replacement); with an
|
||||
/// expression in any of its comps, everything those expressions can reach
|
||||
/// ([`expression_reach`]). Memory only: no file is looked at.
|
||||
pub fn pixel_reach(&self, comp: ItemId, heard: &crate::Heard) -> PixelReach {
|
||||
let Some(drawn) = self.reach_walk(comp, &|id, c| self.silent_layers(id, c, heard), &mut |_| Some(vec![])) else { return PixelReach::every() };
|
||||
let has_expression = |g: &PropGroup| each_prop(g, &mut |p| p.expr.is_none().then_some(())).is_none();
|
||||
if !drawn.iter().filter_map(|id| self.comp(*id)).any(|c| c.layers.iter().any(|l| has_expression(&l.props))) {
|
||||
return PixelReach { items: Some(drawn), expressions: false, names: vec![] };
|
||||
}
|
||||
// Expressions: every layer of a comp they reach counts, and what they name.
|
||||
let mut names: BTreeMap<(bool, String), Vec<ItemId>> = BTreeMap::new();
|
||||
let mut scan = |p: &crate::Property| match p.expr.as_ref().map(|e| expression_reach(&e.text)) {
|
||||
None => Some(vec![]),
|
||||
Some(ExprReach::Anything) => None,
|
||||
Some(ExprReach::Names { comps, footage }) => {
|
||||
let mut found = vec![];
|
||||
for (is_comp, name) in comps.into_iter().map(|n| (true, n)).chain(footage.into_iter().map(|n| (false, n))) {
|
||||
let ids = self.named(is_comp, &name);
|
||||
found.extend(ids.iter().copied());
|
||||
names.insert((is_comp, name), ids);
|
||||
}
|
||||
Some(found)
|
||||
}
|
||||
};
|
||||
let Some(mut items) = self.reach_walk(comp, &|_, _| BTreeSet::new(), &mut scan) else { return PixelReach::every() };
|
||||
// A source's data is read by its name (`layer.source.sourceData`).
|
||||
let footage_names: BTreeSet<&str> =
|
||||
items.iter().filter_map(|id| self.item(*id)).filter(|i| matches!(i.kind, ItemKind::Footage(_))).map(|i| i.name.as_str()).collect();
|
||||
let same_name: Vec<ItemId> =
|
||||
self.items.values().filter(|i| matches!(i.kind, ItemKind::Footage(_)) && footage_names.contains(i.name.as_str())).map(|i| i.id).collect();
|
||||
items.extend(same_name);
|
||||
PixelReach { items: Some(items), expressions: true, names: names.into_iter().map(|((c, n), ids)| (c, n, ids)).collect() }
|
||||
}
|
||||
|
||||
/// The items of one kind (`is_comp`: comps, else footage) named `name`, in id order.
|
||||
fn named(&self, is_comp: bool, name: &str) -> Vec<ItemId> {
|
||||
self.items
|
||||
.values()
|
||||
.filter(|i| i.name == name && if is_comp { matches!(i.kind, ItemKind::Comp(_)) } else { matches!(i.kind, ItemKind::Footage(_)) })
|
||||
.map(|i| i.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The items reached from `comp` through its layers (but those `quiet` names) and the items
|
||||
/// `scan` returns for each of their properties; `None` when something reaches every item.
|
||||
/// (A stack and a visited set: each item is visited once, cycles included.)
|
||||
fn reach_walk(
|
||||
&self,
|
||||
comp: ItemId,
|
||||
quiet: &dyn Fn(ItemId, &Comp) -> BTreeSet<LayerId>,
|
||||
scan: &mut dyn FnMut(&crate::Property) -> Option<Vec<ItemId>>,
|
||||
) -> Option<BTreeSet<ItemId>> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut stack = vec![comp];
|
||||
while let Some(id) = stack.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(c) = self.comp(id) else { continue };
|
||||
let quiet = quiet(id, c);
|
||||
for l in c.layers.iter().filter(|l| !quiet.contains(&l.id)) {
|
||||
// Every source item: a 3D model's too (it is drawn, and `layer.source` reads its
|
||||
// name and size).
|
||||
if let Some(item) = l.source.item() {
|
||||
stack.push(item);
|
||||
}
|
||||
stack.extend(self.media_replacements(l)?);
|
||||
let mut reached = vec![];
|
||||
each_prop(&l.props, &mut |p| {
|
||||
reached.extend(scan(p)?);
|
||||
Some(())
|
||||
})?;
|
||||
stack.extend(reached);
|
||||
}
|
||||
}
|
||||
Some(seen)
|
||||
}
|
||||
|
||||
/// The footage a precomp layer swaps in through its Essential Properties (Media Replacement):
|
||||
/// the items its overridden properties name. `None` when they can't be listed (an
|
||||
/// expression or keyframes choose the item over time).
|
||||
fn media_replacements(&self, layer: &crate::Layer) -> Option<Vec<ItemId>> {
|
||||
let over = crate::essential::overridden(layer);
|
||||
let Some(g) = crate::essential::group(layer).filter(|_| !over.is_empty()) else { return Some(vec![]) };
|
||||
let mut out = vec![];
|
||||
each_prop(g, &mut |p| {
|
||||
if !over.contains(&p.uid) {
|
||||
return Some(());
|
||||
}
|
||||
if p.expr.is_some() || !p.keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Value::Scalar(id) = p.value {
|
||||
let item = ItemId(id.max(0.0) as u64);
|
||||
if matches!(self.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) {
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
})?;
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl PixelReach {
|
||||
/// Every item of the project, with expressions.
|
||||
fn every() -> PixelReach {
|
||||
PixelReach { items: None, expressions: true, names: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,198 @@
|
||||
use super::*;
|
||||
use crate::{Expression, LayerSource, Solid, build};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
fn names(comps: &[&str], footage: &[&str]) -> ExprReach {
|
||||
ExprReach::Names { comps: comps.iter().map(|s| s.to_string()).collect(), footage: footage.iter().map(|s| s.to_string()).collect() }
|
||||
}
|
||||
|
||||
/// Expressions that read their own comp, or other items by literal names, are followed; every
|
||||
/// way to reach items by a computed name or an id, or text the scanner can't read for sure,
|
||||
/// reaches everything.
|
||||
#[test]
|
||||
fn expression_reach_follows_literal_names_and_refuses_the_rest() {
|
||||
let follows = [
|
||||
("wiggle(2, 20)", names(&[], &[])),
|
||||
("thisComp.layer(\"Comp 1 Layer\").transform.opacity * 2 / 3", names(&[], &[])),
|
||||
("value + [10, 0]; value[0] / 2", names(&[], &[])),
|
||||
("// comp(name) in a comment\n/* footage(x) */ value", names(&[], &[])),
|
||||
("\"a string that says comp(x) and Layer\"", names(&[], &[])),
|
||||
("comp(\"Other\").layer(1).transform.position", names(&["Other"], &[])),
|
||||
("comp( 'A' ).width + comp(\"B\").height", names(&["A", "B"], &[])),
|
||||
("footage(\"data.json\").sourceData.x", names(&[], &["data.json"])),
|
||||
("x = a[0] <= b ? a[1] : 0; y = a[0] == 1", names(&[], &[])),
|
||||
("thisLayer.source.layer(1).name", names(&[], &[])),
|
||||
("if (a) x = 1; else x = 2; (a + b) / 2", names(&[], &[])),
|
||||
("\"Ünïcödé name\"", names(&[], &[])),
|
||||
("const f = (v) => v * 2; f(1)", names(&[], &[])),
|
||||
];
|
||||
for (text, want) in follows {
|
||||
assert_eq!(expression_reach(text), want, "{text}");
|
||||
}
|
||||
let anything = [
|
||||
"comp(\"Shot \" + index).layer(1)",
|
||||
"comp(name)",
|
||||
"var n = \"Other\"; comp(n)",
|
||||
"comp(\"O\\\"ther\")",
|
||||
"x.comp(\"Other\")",
|
||||
"var comp = 1",
|
||||
"footage(thisComp.name)",
|
||||
"new Comp(7).layer(1)",
|
||||
"Layer(7, 3).name",
|
||||
"__h(__COMP, 7)",
|
||||
"o.__c = 7",
|
||||
"eval('1')",
|
||||
"Function('return 1')()",
|
||||
"this.x",
|
||||
"({}).constructor",
|
||||
"Object.getPrototypeOf(thisComp)",
|
||||
"JSON.parse(s)",
|
||||
"thisComp.layer.call(o, 1)",
|
||||
"o['_' + '_c']",
|
||||
"o[k] = 7",
|
||||
"o[k] ||= 7",
|
||||
"o[k] += 7",
|
||||
"[o[k]] = [7]",
|
||||
"({a: o[k]} = {a: 7})",
|
||||
"for (o[k] in x) {}",
|
||||
"({[k]: 7})",
|
||||
"({a: 1, [k]: 7})",
|
||||
"o = {'__c': 7}",
|
||||
"'\\x5f\\x5fc'",
|
||||
"'\\_\\_c'",
|
||||
"`${x}`",
|
||||
"c\\u006fmp('A')",
|
||||
"comp\u{0430}('A')",
|
||||
"/\"/.test(s); comp(n); \"",
|
||||
"return /x/",
|
||||
"if (a) /\"/.test(s)",
|
||||
"x = '",
|
||||
"/* comp(n)",
|
||||
"class X {}",
|
||||
"String.fromCharCode(95)",
|
||||
];
|
||||
for text in anything {
|
||||
assert_eq!(expression_reach(text), ExprReach::Anything, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `a / b` divides after a name, a number, a string and a closing bracket or parenthesis.
|
||||
#[test]
|
||||
fn division_is_not_a_regular_expression() {
|
||||
for text in ["a / b", "2 / 3", "'a' / 2", "x[0] / 2", "(a + b) / 2", "f(x) / 2 / g(y)"] {
|
||||
assert_eq!(expression_reach(text), names(&[], &[]), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
p: Project,
|
||||
main: ItemId,
|
||||
other: ItemId,
|
||||
third: ItemId,
|
||||
unused: ItemId,
|
||||
solid: ItemId,
|
||||
}
|
||||
|
||||
/// Comps "Main" (a solid), "Other" (a solid), "Third" (empty), an unused footage item.
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(64, 64, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let solid =
|
||||
p.add_item("Solid", effectcraft_color::Label::None, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 64, height: 64, pixel_aspect: 1.0 }));
|
||||
let main = p.add_item("Main", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into()));
|
||||
let other = p.add_item("Other", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into()));
|
||||
let third = p.add_item("Third", effectcraft_color::Label::None, None, ItemKind::Comp(comp.clone().into()));
|
||||
let unused = p.add_item("unused.png", effectcraft_color::Label::None, None, ItemKind::Footage(crate::Footage { has_video: true, ..Default::default() }));
|
||||
for c in [main, other] {
|
||||
let l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: solid }, (64, 64), None);
|
||||
p.comp_mut(c).unwrap().layers.push(l);
|
||||
}
|
||||
Fixture { p, main, other, third, unused, solid }
|
||||
}
|
||||
|
||||
fn set_expr(p: &mut Project, comp: ItemId, text: &str) {
|
||||
let l = p.comp_mut(comp).unwrap().layers.first_mut().unwrap();
|
||||
l.props.prop_mut("transform/opacity").unwrap().expr = Some(Expression { text: text.into(), enabled: true });
|
||||
}
|
||||
|
||||
fn reach(p: &Project, comp: ItemId) -> PixelReach {
|
||||
p.pixel_reach(comp, &p.heard_layers())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_reach_is_what_the_comp_draws_and_what_its_expressions_name() {
|
||||
let mut f = fixture();
|
||||
let ids = |r: &PixelReach| r.items.clone().map(|s| s.into_iter().collect::<Vec<_>>());
|
||||
assert_eq!(reach(&f.p, f.main), PixelReach { items: Some([f.solid, f.main].into()), expressions: false, names: vec![] });
|
||||
// An expression reading only its own comp: what it draws, with expressions.
|
||||
set_expr(&mut f.p, f.main, "thisComp.layer(1).transform.opacity");
|
||||
let r = reach(&f.p, f.main);
|
||||
assert!(r.expressions);
|
||||
assert_eq!(ids(&r), Some(vec![f.solid, f.main]));
|
||||
// An expression in another comp doesn't reach this one.
|
||||
set_expr(&mut f.p, f.other, "wiggle(1, 1)");
|
||||
assert_eq!(ids(&reach(&f.p, f.main)), Some(vec![f.solid, f.main]));
|
||||
// A literal name: that comp and what it draws, with the name and the comps that have it.
|
||||
set_expr(&mut f.p, f.main, "comp(\"Other\").layer(1).transform.opacity");
|
||||
let r = reach(&f.p, f.main);
|
||||
assert_eq!(ids(&r), Some(vec![f.solid, f.main, f.other]));
|
||||
assert_eq!(r.names, vec![(true, "Other".to_string(), vec![f.other])]);
|
||||
// Another comp taking the name is part of it, whichever the lookup finds.
|
||||
f.p.item_mut(f.third).unwrap().name = "Other".into();
|
||||
let r = reach(&f.p, f.main);
|
||||
assert_eq!(r.names, vec![(true, "Other".to_string(), vec![f.other, f.third])]);
|
||||
assert!(r.items.unwrap().contains(&f.third));
|
||||
// A name no item has yet still counts (with no item).
|
||||
set_expr(&mut f.p, f.main, "footage(\"later.json\").sourceData");
|
||||
assert_eq!(reach(&f.p, f.main).names, vec![(false, "later.json".to_string(), vec![])]);
|
||||
// A computed name reaches everything.
|
||||
set_expr(&mut f.p, f.main, "comp(\"Ot\" + \"her\").width");
|
||||
assert_eq!(reach(&f.p, f.main), PixelReach { items: None, expressions: true, names: vec![] });
|
||||
// Footage reached as a layer's source brings the footage of its name (its data is read by
|
||||
// name).
|
||||
let comp = f.p.comp(f.main).unwrap().clone();
|
||||
let l = build::layer(&mut f.p, &comp, "U", LayerSource::Footage { item: f.unused }, (64, 64), None);
|
||||
f.p.comp_mut(f.main).unwrap().layers.push(l);
|
||||
let twin = f.p.add_item("unused.png", effectcraft_color::Label::None, None, ItemKind::Footage(crate::Footage::default()));
|
||||
set_expr(&mut f.p, f.main, "value");
|
||||
let r = reach(&f.p, f.main).items.unwrap();
|
||||
assert!(r.contains(&f.unused) && r.contains(&twin), "{r:?}");
|
||||
}
|
||||
|
||||
/// A comp nested in the reach is scanned too: its expression's names count for the outer comp.
|
||||
#[test]
|
||||
fn nested_comps_bring_their_expressions_reach() {
|
||||
let mut f = fixture();
|
||||
let comp = f.p.comp(f.main).unwrap().clone();
|
||||
let pre = build::layer(&mut f.p, &comp, "Pre", LayerSource::Comp { item: f.other }, (64, 64), None);
|
||||
f.p.comp_mut(f.main).unwrap().layers.push(pre);
|
||||
set_expr(&mut f.p, f.other, "comp(\"Third\").width");
|
||||
let r = reach(&f.p, f.main);
|
||||
assert!(r.expressions && r.items.unwrap().contains(&f.third));
|
||||
// A cycle through names ends.
|
||||
let l = build::layer(&mut f.p, &comp, "S", LayerSource::Solid { item: f.solid }, (64, 64), None);
|
||||
f.p.comp_mut(f.third).unwrap().layers.push(l);
|
||||
set_expr(&mut f.p, f.third, "comp(\"Main\").width");
|
||||
assert!(reach(&f.p, f.main).items.unwrap().contains(&f.main));
|
||||
}
|
||||
|
||||
/// A 3D model layer's item is reached like any other source: it is drawn, and an expression
|
||||
/// reads its name and size through `layer.source` (a renamed model left the comp's disk key and
|
||||
/// its old frames in place).
|
||||
#[test]
|
||||
fn model_sources_are_reached() {
|
||||
let mut f = fixture();
|
||||
let model = f.p.add_item(
|
||||
"chair.glb",
|
||||
effectcraft_color::Label::None,
|
||||
None,
|
||||
ItemKind::Footage(crate::Footage { kind: crate::FootageKind::Model, ..Default::default() }),
|
||||
);
|
||||
let comp = f.p.comp(f.main).unwrap().clone();
|
||||
let l = build::layer(&mut f.p, &comp, "Model", LayerSource::Model { item: model }, (64, 64), None);
|
||||
f.p.comp_mut(f.main).unwrap().layers.push(l);
|
||||
assert!(reach(&f.p, f.main).items.unwrap().contains(&model), "drawn");
|
||||
set_expr(&mut f.p, f.main, "thisComp.layer(\"Model\").source.name.length");
|
||||
let r = reach(&f.p, f.main);
|
||||
assert!(r.expressions && r.items.unwrap().contains(&model), "read by an expression");
|
||||
}
|
||||
@@ -26,3 +26,6 @@ rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
+11
-12
@@ -224,7 +224,7 @@ impl<'a> Renderer<'a> {
|
||||
LayerSource::Footage { item } => {
|
||||
let item = ctx.media_item(layer, *item);
|
||||
let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None };
|
||||
let a = self.footage.aux(item, f, ctx.source_time(layer))?;
|
||||
let a = self.read(|| self.footage.aux(item, f, ctx.source_time(layer)))?;
|
||||
// Aux pixels per layer pixel (the layer is the footage at its nominal size). The
|
||||
// planes are only copied when that changes the scale.
|
||||
let scale = a.width as f64 / f.width as f64;
|
||||
@@ -349,20 +349,19 @@ impl<'a> Renderer<'a> {
|
||||
pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
|
||||
// Cached, so an accelerator sees the same buffer (and uploads it once) every frame.
|
||||
let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
let key = self.mask_gate_key(ctx, layer, key);
|
||||
if let Some((b, _)) = self.cached(key) {
|
||||
return Some(b);
|
||||
}
|
||||
let mut foot = self.source(ctx, layer)?;
|
||||
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
|
||||
let foot = Arc::new(foot);
|
||||
let (foot, files) = self.recorded(key, |r| {
|
||||
// (what the masks' expressions read, at shutter times too, goes to the buffer's files)
|
||||
let ctx = &r.with_reads(ctx);
|
||||
let mut foot = r.source(ctx, layer)?;
|
||||
let _ = masks::apply(ctx, layer, &mut foot, r.mask_blur(ctx, layer));
|
||||
Some(foot)
|
||||
});
|
||||
// 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)
|
||||
Some(self.keep(key, Arc::new(foot?), &files).0)
|
||||
}
|
||||
|
||||
/// Draw one 2D layer into `canvas` on the CPU (any kind: adjustment, wireframe, styled…).
|
||||
|
||||
@@ -28,9 +28,9 @@ use crate::{EvalCtx, ExprHost, FootageSource};
|
||||
const MAX_DEPTH: usize = 16;
|
||||
|
||||
/// Fastest a layer's audio plays (source samples per comp sample): 1 % stretch, the smallest
|
||||
/// Time Stretch accepts. A faster layer (a stretch below 1 %, only from a damaged or foreign
|
||||
/// file) is silent instead of reading unbounded source: a last-resort guard, imports clamp
|
||||
/// such stretches.
|
||||
/// Time Stretch accepts. The mix plays the clamped stretch (`Layer::effective_stretch`, #3247),
|
||||
/// so a layer is never faster; a faster speed would be silent instead of reading unbounded
|
||||
/// source: a last-resort guard.
|
||||
const MAX_SPEED: f64 = 100.0;
|
||||
|
||||
/// Most source sample frames one read asks a footage item or precomp for; a longer read is made
|
||||
@@ -176,10 +176,12 @@ impl MixEnv<'_> {
|
||||
if a >= b {
|
||||
return out;
|
||||
}
|
||||
let speed = 100.0 / l.stretch;
|
||||
// Within ±1 % to ±9900 % (#3247): a stretch of 1e-300 % set in memory read about
|
||||
// usize::MAX source samples for one block. A negative stretch plays backwards (#3239).
|
||||
let speed = 100.0 / l.effective_stretch();
|
||||
let aspeed = speed.abs();
|
||||
// A zero or non-finite stretch never gets here (see `mix_into`); NaN is checked anyway.
|
||||
// The tolerance keeps exactly 1 % audible whatever the float noise in the stretch.
|
||||
// The clamped stretch keeps the speed within MAX_SPEED; NaN is checked anyway. The
|
||||
// tolerance keeps exactly 1 % audible whatever the float noise in the stretch.
|
||||
if aspeed.is_nan() || aspeed > MAX_SPEED * (1.0 + 1e-9) {
|
||||
return out;
|
||||
}
|
||||
@@ -357,8 +359,9 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && audible(project, l));
|
||||
let s0 = start.to_units_floor(rate as i64);
|
||||
for l in &comp.layers {
|
||||
// A zero or non-finite stretch has no layer time to play; a negative one plays backwards.
|
||||
if !audible(project, l) || (any_solo && !l.switches.solo) || l.stretch == 0.0 || !l.stretch.is_finite() {
|
||||
// The stretch the layer's time uses (#3247): 0 and NaN play at 100 %, as `layer_time`;
|
||||
// a negative one plays backwards (#3239).
|
||||
if !audible(project, l) || (any_solo && !l.switches.solo) {
|
||||
continue;
|
||||
}
|
||||
// Sample range of the block that falls inside the layer's [in, out).
|
||||
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user