Merge main into gpu-resident-effects
This commit is contained in:
commit
f6497177b4
|
|
@ -34,3 +34,9 @@ jobs:
|
|||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
|
|||
|
||||
## Code-drawn assets (original work, MIT OR Apache-2.0)
|
||||
|
||||
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`.
|
||||
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`, including the original filled favorite star (2026-10-11, EffectCraft contributors).
|
||||
- Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`.
|
||||
- Built-in project templates (Lower Third, Title Card, Logo Reveal, Kinetic Type, Social Square, Vertical Story,
|
||||
Slideshow, 3D Text Orbit) built from engine commands, and their thumbnails rendered by our own renderer:
|
||||
|
|
|
|||
|
|
@ -1143,7 +1143,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1159,6 +1159,7 @@ dependencies = [
|
|||
"objc2-foundation 0.3.2",
|
||||
"pollster 0.4.0",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"winit",
|
||||
"winresource",
|
||||
|
|
@ -1167,7 +1168,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
|
|
@ -1179,14 +1180,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
|
|
@ -1202,7 +1203,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1210,7 +1211,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1227,7 +1228,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1260,7 +1261,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1290,7 +1291,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1307,7 +1308,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1315,7 +1316,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1337,14 +1338,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -1368,7 +1369,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1387,7 +1388,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1399,7 +1400,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1416,7 +1417,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
|
|
@ -1430,6 +1431,7 @@ dependencies = [
|
|||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-media",
|
||||
"filmcraft-time",
|
||||
"gif",
|
||||
|
|
@ -1442,7 +1444,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
|
|
@ -1454,14 +1456,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1473,7 +1475,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-svg",
|
||||
|
|
@ -1487,7 +1489,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1499,7 +1501,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1512,14 +1514,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1528,7 +1530,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1550,7 +1552,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1558,6 +1560,7 @@ dependencies = [
|
|||
"effectcraft-keyframe",
|
||||
"effectcraft-path",
|
||||
"effectcraft-project",
|
||||
"effectcraft-psd",
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"serde_json",
|
||||
|
|
@ -1566,7 +1569,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"ndarray",
|
||||
|
|
@ -1575,7 +1578,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
|
|
@ -1587,7 +1590,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
|
|
@ -1599,7 +1602,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1607,7 +1610,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
|
|
@ -1619,7 +1622,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1651,11 +1654,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -6465,7 +6468,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo
|
|||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 26%). No language has had
|
||||
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 29%). No language has had
|
||||
native-speaker review.
|
||||
|
||||
## Upcoming
|
||||
|
|
@ -117,6 +117,13 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
|||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
|
||||
catalog gained since 9 October are translated: the Home screen (recent files, their Kind and
|
||||
Opened columns, the empty and no-match states), the viewer's toolbar tooltips, magnification,
|
||||
resolution, grid, channel and Fast Previews popups, the Appearance settings, the solid dialog
|
||||
and the project, import, export and colour-management forms. `uk` now carries 495 of the 496
|
||||
panel rows — `RGB` reads the same in Ukrainian, so it falls back to the English source by
|
||||
design — alongside its 618/618 menus.
|
||||
- 2026-10-10: Full parity re-measure against After Effects 2026 26.5 (installed bundle, 26.2–26.5
|
||||
release notes, repository counts): breadth ≈ 91%, ready ≈ 45%, stage alpha. Progress docs
|
||||
follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ effectcraft-ui-egui = { workspace = true }
|
|||
egui = { workspace = true }
|
||||
# `links`: links (Help menu, Discord, Settings) open in the system browser.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app
|
||||
# activation when driven by an agent). The same winit eframe runs on.
|
||||
winit = { version = "0.30", default-features = false }
|
||||
rfd = { workspace = true }
|
||||
pollster = { workspace = true }
|
||||
cpal = { workspace = true }
|
||||
|
|
@ -32,8 +36,6 @@ zbus = "5.19"
|
|||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
|
||||
winit = { version = "0.30", default-features = false }
|
||||
# Keep the control channel responsive when the window is covered (App Nap / occlusion throttling).
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSObject"] }
|
||||
# The native menu bar (NSMenu). MIT OR Apache-2.0; macOS only, never in the web build.
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ fn run<T: SizedSample + FromSample<f32>>(
|
|||
let dev_lat = ts.playback.duration_since(&ts.callback).map(|d| d.as_secs_f64()).unwrap_or(0.0);
|
||||
latency.store((dev_lat * rate) as u64 + frames as u64, Ordering::Relaxed);
|
||||
},
|
||||
|e| eprintln!("effectcraft: audio output: {e}"),
|
||||
|e| log::warn!("audio output: {e}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
|
|
@ -187,7 +187,7 @@ impl AudioDevice for CpalOut {
|
|||
return;
|
||||
}
|
||||
if let Err(e) = stream.play() {
|
||||
eprintln!("effectcraft: audio output: {e}");
|
||||
log::warn!("audio output: {e}");
|
||||
return;
|
||||
}
|
||||
let _ = stop_rx.recv();
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
|||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("effectcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("effectcraft: control server listening on 127.0.0.1:{port}");
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ mod native_menu;
|
|||
// Built everywhere so it stays checked; only the macOS app uses it.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
mod sheet_dialogs;
|
||||
mod window;
|
||||
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use serde_json::json;
|
||||
|
|
@ -88,13 +89,24 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>)
|
|||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
|
||||
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
|
||||
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
|
||||
// feed the compatibility report.
|
||||
effectcraft_engine::logging::install();
|
||||
effectcraft_engine::logging::install_panic_hook();
|
||||
let Some(Args { control_port, files, demo, home }) = parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT").ok()) else {
|
||||
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
};
|
||||
// The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
|
||||
// kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
|
||||
// `--version` leaves no file behind; records logged until now are written to it first.
|
||||
if let Some(dir) = effectcraft_host::config_dir() {
|
||||
match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
|
||||
Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
}
|
||||
}
|
||||
// The font menus list the installed fonts: read their names while the window opens.
|
||||
effectcraft_engine::text::fonts::scan_system_in_background();
|
||||
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
|
||||
|
|
@ -108,8 +120,8 @@ fn main() -> eframe::Result {
|
|||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("EffectCraft")
|
||||
.with_inner_size([1680.0, 1020.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
.with_min_inner_size(window::MIN_SIZE)
|
||||
.with_drag_and_drop(true)
|
||||
// Started for an agent (`--control`): open without taking the user's keyboard focus.
|
||||
.with_active(control_port.is_none())
|
||||
|
|
@ -120,8 +132,8 @@ fn main() -> eframe::Result {
|
|||
wgpu_options: wgpu_options(launch.backends),
|
||||
..Default::default()
|
||||
};
|
||||
// Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never
|
||||
// appears shows how far start-up got (#234).
|
||||
// Start-up milestones go to stderr and the session log, so a window that never appears
|
||||
// shows how far start-up got (#234).
|
||||
log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
|
|
@ -170,11 +182,14 @@ fn main() -> eframe::Result {
|
|||
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
|
||||
}
|
||||
session.load_settings();
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
let saved_window = session.config.as_deref().and_then(window::load);
|
||||
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
|
||||
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
|
||||
let project = files.iter().find(|f| is_project_file(f)).cloned();
|
||||
if let Some(p) = project {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
eprintln!("effectcraft: {e}");
|
||||
log::warn!("{p}: {e}");
|
||||
}
|
||||
} else if demo {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
|
|
@ -183,7 +198,7 @@ fn main() -> eframe::Result {
|
|||
if !media.is_empty()
|
||||
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
|
||||
{
|
||||
eprintln!("effectcraft: {e}");
|
||||
log::warn!("import: {e}");
|
||||
}
|
||||
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
|
||||
// The ScriptUI panels open when the app last quit open again (a failing one is
|
||||
|
|
@ -264,6 +279,7 @@ fn main() -> eframe::Result {
|
|||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events.connect(&cc.egui_ctx),
|
||||
app,
|
||||
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
|
||||
launch,
|
||||
drawn: false,
|
||||
}))
|
||||
|
|
@ -315,8 +331,12 @@ fn startup_failed(e: &eframe::Error, gl_next: bool) {
|
|||
let message = format!(
|
||||
"EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
|
||||
);
|
||||
log::error!("{message}");
|
||||
eprintln!("effectcraft: {message}");
|
||||
// Standard error and the session log; standard error alone when RUST_LOG turned errors off.
|
||||
if log::log_enabled!(log::Level::Error) {
|
||||
log::error!("{message}");
|
||||
} else {
|
||||
eprintln!("effectcraft: {message}");
|
||||
}
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title("EffectCraft")
|
||||
|
|
@ -333,6 +353,8 @@ struct Desktop {
|
|||
/// Documents opened from Finder, the Dock or `open` (macOS).
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events::Opened,
|
||||
/// The window's size and position, saved for the next launch.
|
||||
window: window::Tracker,
|
||||
launch: launch_guard::Launch,
|
||||
/// The first frame was drawn (logged once).
|
||||
drawn: bool,
|
||||
|
|
@ -347,6 +369,7 @@ impl eframe::App for Desktop {
|
|||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui, frame);
|
||||
self.window.frame(ui.ctx(), self.app.session.config.as_deref());
|
||||
if !self.drawn {
|
||||
self.drawn = true;
|
||||
log::info!("first frame drawn");
|
||||
|
|
@ -360,6 +383,7 @@ impl eframe::App for Desktop {
|
|||
|
||||
fn on_exit(&mut self) {
|
||||
self.launch.exited();
|
||||
self.window.save(self.app.session.config.as_deref());
|
||||
self.app.on_exit();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,371 @@
|
|||
//! The main window's size and position. The first launch fits the window to its monitor, opening
|
||||
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
|
||||
//! the size, position and maximized state saved in the config directory (`window.json`), moved
|
||||
//! onto a monitor that is still connected.
|
||||
|
||||
use effectcraft_engine::config::ConfigStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
|
||||
|
||||
const FILE: &str = "window.json";
|
||||
const MAX_BYTES: usize = 4096;
|
||||
/// Seconds a window opened maximized gets to say so before its size is tracked.
|
||||
const MAXIMIZE_WAIT: f64 = 2.0;
|
||||
|
||||
/// The window's geometry between launches.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WindowGeometry {
|
||||
/// The outer top-left corner in physical pixels; `None` where the system doesn't say (Wayland).
|
||||
pub pos: Option<[i32; 2]>,
|
||||
/// The inner size in points; while maximized, the size to return to.
|
||||
pub size: [f32; 2],
|
||||
pub maximized: bool,
|
||||
}
|
||||
|
||||
/// The first window's size, in points.
|
||||
pub const DEFAULT_SIZE: [f32; 2] = [1680.0, 1020.0];
|
||||
/// The smallest window, in points.
|
||||
pub const MIN_SIZE: [f32; 2] = [960.0, 600.0];
|
||||
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
|
||||
/// say how much of the screen they take): a monitor with less room opens it maximized.
|
||||
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
|
||||
|
||||
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Monitor {
|
||||
pos: [i64; 2],
|
||||
size: [i64; 2],
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
|
||||
let scale = scale as f32;
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
|
||||
}
|
||||
|
||||
/// Its size in points.
|
||||
fn points(&self) -> [f32; 2] {
|
||||
self.size.map(|s| s as f32 / self.scale)
|
||||
}
|
||||
|
||||
/// Its size in points less room for a taskbar, Dock or menu bar.
|
||||
fn usable(&self) -> [f32; 2] {
|
||||
let points = self.points();
|
||||
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
|
||||
}
|
||||
|
||||
/// A window of `size` points on this monitor, in physical pixels.
|
||||
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
|
||||
size.map(|s| (s * self.scale) as i64)
|
||||
}
|
||||
|
||||
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
|
||||
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
|
||||
let px = self.pixels(size);
|
||||
let span = |i: usize| {
|
||||
let start = i64::from(pos[i]).max(self.pos[i]);
|
||||
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
|
||||
(end - start).max(0)
|
||||
};
|
||||
span(0).saturating_mul(span(1))
|
||||
}
|
||||
|
||||
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
|
||||
/// points on this monitor.
|
||||
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
|
||||
let px = self.pixels(size);
|
||||
std::array::from_fn(|i| {
|
||||
let lo = self.pos[i];
|
||||
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
|
||||
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
|
||||
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
|
||||
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
|
||||
/// doesn't list them, so nothing to check a saved geometry against) the default size.
|
||||
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
|
||||
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
|
||||
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
|
||||
return default;
|
||||
};
|
||||
let Some(saved) = saved else {
|
||||
let usable = home.usable();
|
||||
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
|
||||
return default;
|
||||
}
|
||||
// A small screen: maximized, with a size that fits once un-maximized.
|
||||
let size = fit(DEFAULT_SIZE, usable);
|
||||
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
|
||||
};
|
||||
// The monitor showing most of the window; none when it was on a monitor that is gone.
|
||||
let shown = saved.pos.and_then(|p| {
|
||||
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
|
||||
(area > 0).then_some(m)
|
||||
});
|
||||
let m = shown.unwrap_or(home);
|
||||
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
|
||||
let room = m.points();
|
||||
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
|
||||
let size = fit(saved.size, if too_big { m.usable() } else { room });
|
||||
WindowGeometry {
|
||||
// Moved fully onto its monitor, or centred on the home monitor.
|
||||
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
|
||||
size,
|
||||
maximized: saved.maximized || too_big,
|
||||
}
|
||||
}
|
||||
|
||||
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
|
||||
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
|
||||
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
|
||||
}
|
||||
|
||||
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
|
||||
/// geometry.
|
||||
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
|
||||
let handles: Vec<_> = window.available_monitors().collect();
|
||||
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
|
||||
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
|
||||
let g = place(saved, &monitors, home);
|
||||
if let Some([x, y]) = g.pos {
|
||||
window.set_outer_position(PhysicalPosition::new(x, y));
|
||||
}
|
||||
// Returns the new size when it applies at once; either way the window gets it.
|
||||
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
|
||||
if g.maximized {
|
||||
window.set_maximized(true);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
|
||||
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
|
||||
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
|
||||
let zoom = ctx.zoom_factor();
|
||||
ctx.input(|i| {
|
||||
let v = i.viewport();
|
||||
if v.minimized == Some(true) || v.fullscreen == Some(true) {
|
||||
return;
|
||||
}
|
||||
if v.maximized == Some(true) {
|
||||
if let Some(g) = geometry {
|
||||
g.maximized = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(inner) = v.inner_rect else { return };
|
||||
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
|
||||
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
|
||||
});
|
||||
}
|
||||
|
||||
/// The geometry saved by the last session; `None` when there is none or it can't be read.
|
||||
pub fn load(config: &dyn ConfigStore) -> Option<WindowGeometry> {
|
||||
let text = config.read_bounded(FILE, MAX_BYTES).ok()??;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// Follows the window and saves its geometry for the next launch: when it has changed (at most
|
||||
/// once a second, so a session that ends badly still has it) and on exit.
|
||||
pub struct Tracker {
|
||||
geometry: Option<WindowGeometry>,
|
||||
saved: Option<WindowGeometry>,
|
||||
last_attempt: f64,
|
||||
/// Opened maximized, and the system hasn't reported it so yet (when the wait ends): the
|
||||
/// window's first frames report the maximized size as an ordinary one, which would replace
|
||||
/// the size to return to.
|
||||
maximizing: Option<f64>,
|
||||
/// Agent-driven runs (`--control`) leave the user's saved window alone.
|
||||
save: bool,
|
||||
}
|
||||
|
||||
impl Tracker {
|
||||
/// `geometry`: what the window opened with.
|
||||
pub fn new(geometry: Option<WindowGeometry>, saved: Option<WindowGeometry>, save: bool) -> Self {
|
||||
let maximizing = geometry.is_some_and(|g| g.maximized).then_some(f64::INFINITY);
|
||||
Self { geometry, saved, last_attempt: f64::NEG_INFINITY, maximizing, save }
|
||||
}
|
||||
|
||||
/// Per frame.
|
||||
pub fn frame(&mut self, ctx: &egui::Context, config: Option<&dyn ConfigStore>) {
|
||||
let (now, maximized) = ctx.input(|i| (i.time, i.viewport().maximized == Some(true)));
|
||||
if let Some(until) = self.maximizing {
|
||||
// A system that never maximizes the window is believed after a moment.
|
||||
let until = until.min(now + MAXIMIZE_WAIT);
|
||||
self.maximizing = (!maximized && now < until).then_some(until);
|
||||
}
|
||||
if self.maximizing.is_none() {
|
||||
track(ctx, &mut self.geometry);
|
||||
}
|
||||
if now - self.last_attempt >= 1.0 {
|
||||
self.last_attempt = now;
|
||||
self.save(config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the geometry when it differs from the saved one.
|
||||
pub fn save(&mut self, config: Option<&dyn ConfigStore>) {
|
||||
let (Some(config), Some(g)) = (config, self.geometry) else { return };
|
||||
if !self.save || self.saved == Some(g) {
|
||||
return;
|
||||
}
|
||||
match serde_json::to_string(&g).map_err(|e| e.to_string()).and_then(|text| config.write(FILE, &text).map_err(|e| e.to_string())) {
|
||||
Ok(()) => self.saved = Some(g),
|
||||
Err(e) => log::warn!("could not save the window geometry: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_engine::config::MemoryConfig;
|
||||
|
||||
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
|
||||
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
|
||||
}
|
||||
|
||||
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
|
||||
Some(WindowGeometry { pos, size, maximized })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_window_fits_its_screen() {
|
||||
// 2560 × 1440: the default size, where the system puts it.
|
||||
let big = [monitor(0, 0, 2560, 1440, 1.0)];
|
||||
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
|
||||
// 1920 × 1080, 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points):
|
||||
// maximized, and a size that fits the screen with room for the taskbar once un-maximized.
|
||||
for (m, size) in [
|
||||
(monitor(0, 0, 1920, 1080, 1.0), [1680.0, 952.0]),
|
||||
(monitor(0, 0, 1600, 900, 1.0), [1536.0, 772.0]),
|
||||
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
|
||||
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
|
||||
] {
|
||||
let g = place(None, &[m], Some(0));
|
||||
assert!(g.maximized, "{m:?}");
|
||||
assert_eq!(g.size, size, "{m:?}");
|
||||
let [x, y] = g.pos.unwrap();
|
||||
let px = m.pixels(g.size);
|
||||
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
|
||||
}
|
||||
// No monitor information: the default size, whatever was saved.
|
||||
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
|
||||
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_window_comes_back_where_it_was() {
|
||||
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
|
||||
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)] {
|
||||
assert_eq!(Some(place(g, &two, Some(0))), g);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_window_stays_on_a_connected_monitor() {
|
||||
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
|
||||
// Last time on a second monitor that is gone: centred on the remaining one.
|
||||
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
|
||||
// Half off the monitor's right and bottom edges: moved fully onto it.
|
||||
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
|
||||
assert_eq!(g.pos, Some([600, 200]));
|
||||
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
|
||||
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
|
||||
// A monitor left of the primary one (negative coordinates) still counts.
|
||||
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
|
||||
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
|
||||
// Nonsense sizes from a damaged preferences file are kept within the screen.
|
||||
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
|
||||
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_geometry_is_tracked_for_the_next_launch() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut geometry = None;
|
||||
let mut frame = |info: egui::ViewportInfo| {
|
||||
let mut input = egui::RawInput::default();
|
||||
input.viewports.insert(egui::ViewportId::ROOT, info);
|
||||
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
|
||||
geometry
|
||||
};
|
||||
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
|
||||
let normal = egui::ViewportInfo {
|
||||
native_pixels_per_point: Some(1.5),
|
||||
inner_rect: Some(rect),
|
||||
outer_rect: Some(rect),
|
||||
maximized: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
// Position in physical pixels, size in points.
|
||||
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
|
||||
assert_eq!(frame(normal.clone()), Some(g));
|
||||
// Maximized or minimized: the size to restore stays.
|
||||
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
|
||||
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
|
||||
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
|
||||
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
|
||||
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
|
||||
assert_eq!(frame(normal), Some(g));
|
||||
}
|
||||
|
||||
/// A window restored maximized keeps the size it returns to, though its first frames report
|
||||
/// the maximized size as an ordinary one.
|
||||
#[test]
|
||||
fn opening_maximized_keeps_the_size_to_return_to() {
|
||||
let ctx = egui::Context::default();
|
||||
let g = WindowGeometry { pos: Some([40, 30]), size: [1000.0, 600.0], maximized: true };
|
||||
let mut tracker = Tracker::new(Some(g), Some(g), true);
|
||||
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1728.0, 1052.0));
|
||||
let small = egui::Rect::from_min_size(egui::pos2(40.0, 30.0), egui::vec2(900.0, 500.0));
|
||||
let frame = |tracker: &mut Tracker, time: f64, rect: egui::Rect, maximized: bool| {
|
||||
let info = egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: Some(maximized), ..Default::default() };
|
||||
let mut input = egui::RawInput { time: Some(time), ..Default::default() };
|
||||
input.viewports.insert(egui::ViewportId::ROOT, info);
|
||||
ctx.run_ui(input, |ui| tracker.frame(ui.ctx(), None)).textures_delta.clear();
|
||||
tracker.geometry
|
||||
};
|
||||
assert_eq!(frame(&mut tracker, 0.0, big, false), Some(g));
|
||||
assert_eq!(frame(&mut tracker, 0.5, big, true), Some(g));
|
||||
// Un-maximized by the user afterwards: tracked as usual.
|
||||
let restored = WindowGeometry { pos: Some([40, 30]), size: [900.0, 500.0], maximized: false };
|
||||
assert_eq!(frame(&mut tracker, 0.6, small, false), Some(restored));
|
||||
// A system that doesn't maximize: the window's real size is tracked after the wait.
|
||||
let mut tracker = Tracker::new(Some(g), Some(g), true);
|
||||
assert_eq!(frame(&mut tracker, 10.0, small, false), Some(g));
|
||||
assert_eq!(frame(&mut tracker, 10.0 + MAXIMIZE_WAIT, small, false), Some(restored));
|
||||
}
|
||||
|
||||
/// The reported bug: the window opened at the default size and place on every launch.
|
||||
#[test]
|
||||
fn the_window_geometry_is_saved_and_loaded() {
|
||||
let config = MemoryConfig::default();
|
||||
assert_eq!(load(&config), None);
|
||||
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: true };
|
||||
let mut tracker = Tracker::new(Some(g), None, true);
|
||||
tracker.save(Some(&config));
|
||||
assert_eq!(load(&config), Some(g));
|
||||
// Positions the system doesn't report (Wayland) round-trip too.
|
||||
tracker.geometry = Some(WindowGeometry { pos: None, ..g });
|
||||
tracker.save(Some(&config));
|
||||
assert_eq!(load(&config), Some(WindowGeometry { pos: None, ..g }));
|
||||
// An agent-driven run leaves the saved window alone.
|
||||
Tracker::new(Some(g), None, false).save(Some(&config));
|
||||
assert_eq!(load(&config).and_then(|g| g.pos), None);
|
||||
// A damaged or oversized file is ignored: the window opens at its default.
|
||||
for text in ["{", "null", "{\"pos\":[1],\"size\":[1,2],\"maximized\":false}", &" ".repeat(MAX_BYTES + 1)] {
|
||||
config.write(FILE, text).unwrap();
|
||||
assert_eq!(load(&config), None, "{text:.20}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -58,4 +58,9 @@ sync_github_name = false
|
|||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "Valentin Klein"
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
|
|
|||
|
|
@ -264,6 +264,21 @@ impl Buf {
|
|||
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
|
||||
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
|
||||
}
|
||||
/// Grow the buffer so it covers a `size`-layer-pixel region starting at its offset (see
|
||||
/// [`cover_plan`]): the whole layer rectangle a sized source's buffer already is. Content
|
||||
/// keeps its place; the new area is transparent. No-op when the buffer already covers it.
|
||||
pub fn cover(&mut self, size: [f64; 2]) {
|
||||
let Some((dx, dy, w, h)) = cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return };
|
||||
let src = std::mem::replace(&mut self.img, Image::new(w, h));
|
||||
let (sw, sh) = (src.width as usize, src.height as usize);
|
||||
for y in 0..sh {
|
||||
let s = y * sw;
|
||||
let d = (y + dy as usize) * w as usize + dx as usize;
|
||||
self.img.data[d..d + sw].copy_from_slice(&src.data[s..s + sw]);
|
||||
}
|
||||
self.offset[0] += dx as f64;
|
||||
self.offset[1] += dy as f64;
|
||||
}
|
||||
/// Measure from `origin` (in the current coordinates): a point `p` becomes `p − origin`.
|
||||
/// Moves between layer space and effect space (the renderer's `effect_bounds`).
|
||||
pub fn rebase(&mut self, origin: [f64; 2]) {
|
||||
|
|
@ -281,6 +296,27 @@ impl Buf {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to
|
||||
/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the
|
||||
/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer
|
||||
/// already covers that region unchanged (footage, solids, the comp below an adjustment).
|
||||
pub fn cover_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
|
||||
if scale <= 0.0 || !scale.is_finite() || !offset[0].is_finite() || !offset[1].is_finite() || !size[0].is_finite() || !size[1].is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Region and content, both as pixel ranges in the buffer's own coordinates.
|
||||
let (rw, rh) = (offset[0] + (size[0] * scale).ceil(), offset[1] + (size[1] * scale).ceil());
|
||||
let (lo_x, lo_y) = (offset[0].min(0.0).floor(), offset[1].min(0.0).floor());
|
||||
let (hi_x, hi_y) = (rw.max(width as f64).ceil(), rh.max(height as f64).ceil());
|
||||
let (nw_f, nh_f) = (hi_x - lo_x, hi_y - lo_y);
|
||||
if !(nw_f <= 16384.0 && nh_f <= 16384.0) {
|
||||
return None;
|
||||
}
|
||||
let (dx, dy) = ((-lo_x) as u32, (-lo_y) as u32);
|
||||
let (nw, nh) = (nw_f.round().max(1.0) as u32, nh_f.round().max(1.0) as u32);
|
||||
(dx != 0 || dy != 0 || nw != width || nh != height).then_some((dx, dy, nw, nh))
|
||||
}
|
||||
|
||||
/// A layer mask at the current time, flattened to a polyline in layer coordinates.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MaskShape {
|
||||
|
|
|
|||
|
|
@ -429,7 +429,8 @@ pub fn overflow(v: f32, mode: u32) -> f32 {
|
|||
}
|
||||
|
||||
/// Composite grey noise `val` over the original pixel by Blending Mode, Opacity and (ours) Blend
|
||||
/// With Original.
|
||||
/// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within
|
||||
/// its masks (#939).
|
||||
pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] {
|
||||
let src = [val * opacity, val * opacity, val * opacity, opacity];
|
||||
let out = match mode {
|
||||
|
|
@ -440,7 +441,7 @@ pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend
|
|||
}
|
||||
};
|
||||
let k = 1.0 - blend_orig;
|
||||
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * k)
|
||||
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k)
|
||||
}
|
||||
|
||||
fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf {
|
||||
|
|
@ -574,6 +575,28 @@ mod tests {
|
|||
assert!(mul.data.iter().any(|p| p[2] > p[0] + 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_stays_inside_the_layer_alpha() {
|
||||
// #939: a masked solid (transparent outside the mask, a soft edge) filled the whole layer.
|
||||
let mut img = Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
let a = match i % 40 {
|
||||
0..10 => 0.0,
|
||||
10..20 => 0.5,
|
||||
_ => 1.0,
|
||||
};
|
||||
*p = p.map(|c| c * a);
|
||||
}
|
||||
for mode in [BLEND_NONE, BLEND_NORMAL, 3] {
|
||||
let out = run_fx("ec.noise.fractal", &[("blendingMode", Value::Enum(mode))], img.clone(), 0.0, EffectEnv::default()).img;
|
||||
for (o, p) in img.data.iter().zip(&out.data) {
|
||||
assert!((o[3] - p[3]).abs() < 1e-6, "mode {mode}: alpha {} became {}", o[3], p[3]);
|
||||
assert!(p[..3].iter().all(|&c| c <= p[3] + 1e-6), "mode {mode}: premultiplied colour above alpha");
|
||||
}
|
||||
assert!(out.data.iter().any(|p| p[3] == 1.0 && (p[0] - 0.2).abs() > 0.01), "mode {mode}: noise drawn inside the mask");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_settings_and_fractal_types_change_the_result() {
|
||||
let base = run("ec.noise.fractal", &[]);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use effectcraft_keyframe::{Interp, Keyframe, Value as KV};
|
||||
use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, PropGroup, Property, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -293,6 +294,9 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
|
|||
match (group, pr.match_id.as_str()) {
|
||||
("transform", "anchor") => !near(&v.as_vec2(), &default_anchor),
|
||||
("transform", "position") => !near(&v.as_vec2(), &comp_center) || v.as_vec3()[2].abs() > 1e-6,
|
||||
// Separate Dimensions: X and Y Position default to the comp centre.
|
||||
("transform", "positionX") => (v.as_f64() - comp_center[0]).abs() > 1e-6,
|
||||
("transform", "positionY") => (v.as_f64() - comp_center[1]).abs() > 1e-6,
|
||||
("transform", "scale") => !near(&v.as_vec3(), &[100.0; 3]),
|
||||
("transform", "opacity") => (v.as_f64() - 100.0).abs() > 1e-6,
|
||||
("transform", "orientation") => !near(&v.as_vec3(), &[0.0; 3]),
|
||||
|
|
@ -301,6 +305,50 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
|
|||
}
|
||||
}
|
||||
|
||||
/// The group a fresh layer of `l`'s kind (`fresh`) has in place of `top`, for the groups whose
|
||||
/// defaults are fixed: Audio, Material / Geometry / Camera / Light Options, a text layer's Path
|
||||
/// and More Options, and a camera's or light's Transform (whose defaults aren't a 2D layer's).
|
||||
fn default_group(top: &PropGroup, l: &Layer, fresh: &Layer) -> Option<PropGroup> {
|
||||
let mut next = 0;
|
||||
let mut ids = Ids(&mut next);
|
||||
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
|
||||
let mut g = match top.match_id.as_str() {
|
||||
"transform" if !camera_or_light => return None,
|
||||
// Added to a layer after it is made (a source with audio, an Advanced 3D comp).
|
||||
"audio" => build::audio(&mut ids),
|
||||
"geometryOptions" if matches!(l.source, LayerSource::Text | LayerSource::Shape) => build::extrusion_geometry_options(&mut ids),
|
||||
"transform" | "cameraOptions" | "lightOptions" | "materialOptions" | "geometryOptions" | "text" => fresh.props.sub(&top.match_id)?.clone(),
|
||||
_ => return None,
|
||||
};
|
||||
match top.match_id.as_str() {
|
||||
// Source Text and the animators are the layer's content, not options with a default.
|
||||
"text" => g.children.retain(|c| matches!(c.match_id(), "pathOptions" | "moreOptions")),
|
||||
// A model's scale and clip come from its file.
|
||||
"geometryOptions" if matches!(l.source, LayerSource::Model { .. }) => g.children.retain(|c| !matches!(c.match_id(), "unitScale" | "animation")),
|
||||
_ => {}
|
||||
}
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Static properties of `g` whose value differs from the same property of `default` (matched by
|
||||
/// match id; properties `default` lacks have no known default and are left out).
|
||||
fn changed_from(g: &PropGroup, default: &PropGroup, out: &mut Vec<Uid>) {
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Prop(pr) => {
|
||||
if default.get(&pr.match_id).is_some_and(|d| d.value != pr.value) && !out.contains(&pr.uid) {
|
||||
out.push(pr.uid);
|
||||
}
|
||||
}
|
||||
Node::Group(sg) => {
|
||||
if let Some(d) = default.sub(&sg.match_id) {
|
||||
changed_from(sg, d, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = str_p(p, "kind").unwrap_or("keyframes").to_string();
|
||||
let cid = super::comp_id(s, p)?;
|
||||
|
|
@ -312,10 +360,27 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (w, h) = effectcraft_render::source_size(&s.project, l);
|
||||
let anchor = [w as f64 / 2.0, h as f64 / 2.0];
|
||||
let center = [comp.width as f64 / 2.0, comp.height as f64 / 2.0];
|
||||
// Properties at the top of the layer's tree (Time Remap): enabled time remapping is a
|
||||
// modification in itself.
|
||||
for pr in l.props.props() {
|
||||
let hit = match kind.as_str() {
|
||||
"keyframes" => pr.is_animated(),
|
||||
"animation" => pr.is_animated() || pr.has_expression(),
|
||||
_ => pr.match_id == "timeRemap" || modified(pr, "", anchor, center),
|
||||
};
|
||||
if hit {
|
||||
out.push(json!({"layer": lid.0, "prop": pr.uid}));
|
||||
}
|
||||
}
|
||||
// Modified: a fresh layer of the same kind gives the defaults to compare against.
|
||||
let fresh = (kind == "modified").then(|| build::layer(&mut Project::default(), comp, "", l.source.clone(), (w, h), None));
|
||||
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
|
||||
for top in l.props.groups() {
|
||||
let hit = |pr: &Property| match kind.as_str() {
|
||||
"keyframes" => pr.is_animated(),
|
||||
"animation" => pr.is_animated() || pr.has_expression(),
|
||||
// A camera's or light's Transform is compared with a fresh one's below.
|
||||
_ if camera_or_light && top.match_id == "transform" => pr.is_animated() || pr.expr.is_some(),
|
||||
_ => modified(pr, &top.match_id, anchor, center),
|
||||
};
|
||||
let mut found = vec![];
|
||||
|
|
@ -335,6 +400,23 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
found.push(g.uid);
|
||||
}
|
||||
}
|
||||
if let Some(fresh) = &fresh {
|
||||
if let Some(default) = default_group(top, l, fresh) {
|
||||
changed_from(top, &default, &mut found);
|
||||
}
|
||||
// Every layer style counts in full, like an effect; so does each overridden
|
||||
// Essential Property.
|
||||
if top.match_id == effectcraft_project::styles::GROUP {
|
||||
found.extend(top.groups().filter(|g| g.match_id != effectcraft_project::styles::BLENDING).map(|g| g.uid));
|
||||
}
|
||||
if let GroupKind::Essential { overridden } = &top.kind {
|
||||
for u in overridden {
|
||||
if !found.contains(u) && (top.find(*u).is_some() || top.find_group(*u).is_some()) {
|
||||
found.push(*u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u})));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
|
||||
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
|
||||
let xml = crate::xml_project::to_xml(&s.project.to_file_json()?).map_err(EngineError::Other)?;
|
||||
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?;
|
||||
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.toast(format!("Saved an XML copy to {path}"));
|
||||
Ok(json!({"path": path, "bytes": xml.len()}))
|
||||
|
|
@ -194,7 +194,7 @@ fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) });
|
||||
return Ok(json!({"needsConfirmation": path}));
|
||||
}
|
||||
let url = format!("file://{}", path.replace(' ', "%20"));
|
||||
let url = super::layer_menu::file_url(path);
|
||||
s.events.push(Event::OpenUrl(url));
|
||||
Ok(json!({"opened": path}))
|
||||
}
|
||||
|
|
@ -288,7 +288,12 @@ fn enable_logging(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn reveal_log(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let file = crate::logging::current_file().unwrap_or_else(|| log_file(s));
|
||||
// Enable Logging's file while it is on, else this run's session log.
|
||||
let file = crate::logging::current_file()
|
||||
.filter(|_| crate::logging::is_enabled())
|
||||
.or_else(crate::logging::session_file)
|
||||
.or_else(crate::logging::current_file)
|
||||
.unwrap_or_else(|| log_file(s));
|
||||
let path = file.to_string_lossy().to_string();
|
||||
let url = super::layer_menu::folder_url(&path);
|
||||
s.events.push(Event::OpenUrl(url.clone()));
|
||||
|
|
@ -616,3 +621,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
query!("help.systemInfo", "System Information", "{}", |s, _| Ok(system_report(s))),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
/// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`).
|
||||
#[test]
|
||||
fn execute_file_opens_an_encoded_file_url() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-exec-url-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("run #1 100%.txt");
|
||||
std::fs::write(&f, "x").unwrap();
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
|
||||
s.drain_events();
|
||||
s.execute("file.executeFile", json!({"path": f.to_string_lossy(), "confirmed": true})).unwrap();
|
||||
let expected = super::super::layer_menu::file_url(&f.to_string_lossy());
|
||||
assert!(expected.ends_with("/run%20%231%20100%25.txt"), "{expected}");
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if *u == expected)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
label: "Run Commands (Batch)",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
alt_shortcuts: &[],
|
||||
params: "{steps: [{command, params?}], label? (undo step name, default Batch), atomic?: bool (default true: a failing step rolls the whole batch back)} → {steps, results: [each step's result]}; a string param \"$N\" or \"$N.key.0\" is step N's result (1-based), e.g. {\"layer\": \"$1.layer\"}",
|
||||
enabled: always,
|
||||
run: batch,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Composition menu.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, Marker};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, Marker, limits};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
|
|||
/// A zero, negative or tiny rate would make a zero-length frame (and divide by zero later).
|
||||
fn check_rate(p: &Value, cmd: &str) -> Result<()> {
|
||||
match f_p(p, "frameRate").or(f_p(p, "fps")) {
|
||||
Some(f) if !(0.001..=1000.0).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
|
||||
Some(f) if !(limits::MIN_FPS..=limits::MAX_FPS).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
|
@ -23,10 +23,10 @@ fn check_rate(p: &Value, cmd: &str) -> Result<()> {
|
|||
fn apply_settings(c: &mut Comp, p: &Value) {
|
||||
let (ow, oh) = (c.width, c.height);
|
||||
if let Some(w) = p.get("width").and_then(Value::as_u64) {
|
||||
c.width = (w as u32).clamp(4, 30000);
|
||||
c.width = limits::size(w).max(4);
|
||||
}
|
||||
if let Some(h) = p.get("height").and_then(Value::as_u64) {
|
||||
c.height = (h as u32).clamp(4, 30000);
|
||||
c.height = limits::size(h).max(4);
|
||||
}
|
||||
// Advanced › Anchor: where the old frame sits in the resized one (0 = top left … 4 = center …
|
||||
// 8 = bottom right). Unparented layers (and all their position keys) move by that offset.
|
||||
|
|
@ -69,7 +69,7 @@ fn apply_settings(c: &mut Comp, p: &Value) {
|
|||
keep_work_area_ordered(c);
|
||||
}
|
||||
if let Some(pa) = f_p(p, "pixelAspect") {
|
||||
c.pixel_aspect = pa.max(0.1);
|
||||
c.pixel_aspect = limits::pixel_aspect(pa.max(limits::MIN_PIXEL_ASPECT));
|
||||
}
|
||||
if let Some(Value::Array(bg)) = p.get("background") {
|
||||
let g = |i: usize| bg.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
||||
|
|
@ -84,10 +84,10 @@ fn apply_settings(c: &mut Comp, p: &Value) {
|
|||
c.shutter_phase = a.clamp(-360.0, 360.0);
|
||||
}
|
||||
if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) {
|
||||
c.motion_blur_samples = (n as u32).clamp(2, 64);
|
||||
c.motion_blur_samples = n.clamp(limits::MOTION_BLUR_SAMPLES.0.into(), limits::MOTION_BLUR_SAMPLES.1.into()) as u32;
|
||||
}
|
||||
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
|
||||
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
|
||||
c.motion_blur_adaptive_limit = n.clamp(limits::ADAPTIVE_SAMPLE_LIMIT.0.into(), limits::ADAPTIVE_SAMPLE_LIMIT.1.into()) as u32;
|
||||
}
|
||||
if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) {
|
||||
c.preserve_frame_rate = b;
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ use effectcraft_project::Marker;
|
|||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
|
||||
use crate::prefs::CompPreset;
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
fn has_roi(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
|
|
@ -17,7 +18,8 @@ fn has_roi(s: &Session) -> std::result::Result<(), String> {
|
|||
/// (all position keys too) so nothing moves on screen.
|
||||
fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &str) -> Result<Value> {
|
||||
let (x, y) = (r[0].round(), r[1].round());
|
||||
let (w, h) = (r[2].round().max(1.0) as u32, r[3].round().max(1.0) as u32);
|
||||
// At most 30,000 px a side, like any composition (#788).
|
||||
let (w, h) = (effectcraft_project::limits::size_f64(r[2]), effectcraft_project::limits::size_f64(r[3]));
|
||||
s.edit(label, None, |proj, st| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
c.width = w;
|
||||
|
|
@ -163,6 +165,65 @@ fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"start": start.seconds(), "end": end.seconds()}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- composition presets
|
||||
|
||||
/// `comp.savePreset`: remember a frame size, pixel aspect and frame rate under a name in the
|
||||
/// settings (shown in Composition Settings ▸ Preset). A preset with the same name is replaced.
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "comp.savePreset";
|
||||
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let num = |k: &str| f_p(p, k).ok_or_else(|| bad(C, format!("missing `{k}`")));
|
||||
let dim = |k: &str| {
|
||||
num(k).and_then(|v| {
|
||||
if v.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&v) { Ok(v as u32) } else { Err(bad(C, format!("`{k}` must be a whole number"))) }
|
||||
})
|
||||
};
|
||||
let preset =
|
||||
CompPreset { name: name.to_string(), width: dim("width")?, height: dim("height")?, pixel_aspect: num("pixelAspect")?, frame_rate: num("frameRate")? };
|
||||
if !preset.is_valid() {
|
||||
return Err(bad(
|
||||
C,
|
||||
format!(
|
||||
"not a valid preset: width and height 4–30000 px, pixel aspect 0.01–100, frame rate 1–999, a name of at most {} characters",
|
||||
CompPreset::MAX_NAME
|
||||
),
|
||||
));
|
||||
}
|
||||
let presets = &mut s.prefs.comp_presets;
|
||||
let replaced = match presets.iter_mut().find(|q| q.name == preset.name) {
|
||||
Some(q) => {
|
||||
*q = preset;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
presets.push(preset);
|
||||
false
|
||||
}
|
||||
};
|
||||
s.save_prefs();
|
||||
s.prefs_revision += 1;
|
||||
Ok(json!({"name": name, "replaced": replaced}))
|
||||
}
|
||||
|
||||
/// `comp.deletePreset`: forget a saved composition preset.
|
||||
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "comp.deletePreset";
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let before = s.prefs.comp_presets.len();
|
||||
s.prefs.comp_presets.retain(|q| q.name != name);
|
||||
if s.prefs.comp_presets.len() == before {
|
||||
return Err(bad(C, format!("no saved preset named `{name}`")));
|
||||
}
|
||||
s.save_prefs();
|
||||
s.prefs_revision += 1;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// `comp.presets`: the saved composition presets.
|
||||
fn list_presets(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
serde_json::to_value(&s.prefs.comp_presets).map_err(|e| bad("comp.presets", e.to_string()))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi),
|
||||
|
|
@ -177,6 +238,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
save_frame
|
||||
),
|
||||
cmd!("comp.copyFrame", "Copy Frame to Clipboard", ["Composition"], None, "{comp?, time?, scale?}", has_comp, copy_frame),
|
||||
cmd!(
|
||||
"comp.savePreset",
|
||||
"Save Composition Preset",
|
||||
[],
|
||||
None,
|
||||
"{name, width, height, pixelAspect, frameRate} (replaces a saved preset with the same name)",
|
||||
always,
|
||||
save_preset
|
||||
),
|
||||
cmd!("comp.deletePreset", "Delete Composition Preset", [], None, "{name}", always, delete_preset),
|
||||
query!("comp.presets", "Composition Presets", "{}", list_presets),
|
||||
cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -405,6 +405,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
label: "Generate Fill Layer",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
alt_shortcuts: &[],
|
||||
params: P,
|
||||
enabled: has_layers,
|
||||
run: generate,
|
||||
|
|
@ -415,6 +416,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
label: "Content-Aware Fill Layer...",
|
||||
menu: &["Layer", "New"],
|
||||
shortcut: None,
|
||||
alt_shortcuts: &[],
|
||||
params: P,
|
||||
enabled: always,
|
||||
run: open_panel,
|
||||
|
|
|
|||
|
|
@ -809,7 +809,7 @@ fn original_path(s: &Session) -> Option<String> {
|
|||
|
||||
fn edit_original(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let path = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?;
|
||||
let url = format!("file://{path}");
|
||||
let url = super::layer_menu::file_url(&path);
|
||||
s.events.push(crate::Event::OpenUrl(url.clone()));
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
|
|
@ -938,3 +938,25 @@ fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
|
|||
fn always_ok(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
/// Edit Original opens the footage as a well-formed `file://` URL, whatever its name contains.
|
||||
#[test]
|
||||
fn edit_original_opens_an_encoded_file_url() {
|
||||
let mut s = crate::Session::default();
|
||||
let id = s
|
||||
.edit("add", None, |p, _| {
|
||||
let f = effectcraft_project::Footage { path: "/clips/take #1.mov".into(), ..Default::default() };
|
||||
Ok(p.add_item("take", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f)))
|
||||
})
|
||||
.unwrap();
|
||||
s.state.project_selection = vec![id];
|
||||
s.drain_events();
|
||||
let r = s.execute("edit.editOriginal", json!({})).unwrap();
|
||||
assert_eq!(r["url"], "file:///clips/take%20%231.mov");
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u == "file:///clips/take%20%231.mov")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,8 +43,18 @@ impl ExprError {
|
|||
pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
|
||||
let Some(comp) = s.project.comp(cid) else { return vec![] };
|
||||
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
|
||||
errors_in(&ctx, s.expr_check, || true).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Evaluate diagnostics against an immutable snapshot. Background callers can cancel between
|
||||
/// properties; cancelled scans return no partial result. Includes hidden/non-solo layers and
|
||||
/// syntax errors on disabled expressions, just like the explicit `expr.errors` query.
|
||||
pub fn errors_in(ctx: &EvalCtx, check: Option<fn(&str) -> std::result::Result<(), String>>, mut keep_going: impl FnMut() -> bool) -> Option<Vec<ExprError>> {
|
||||
let mut out = vec![];
|
||||
for (i, l) in comp.layers.iter().enumerate() {
|
||||
for (i, l) in ctx.comp.layers.iter().enumerate() {
|
||||
if !keep_going() {
|
||||
return None;
|
||||
}
|
||||
let mut props: Vec<(&Property, &Expression)> = vec![];
|
||||
l.props.walk("", &mut |_, p| {
|
||||
if let Some(e) = p.expr.as_ref().filter(|e| !e.text.trim().is_empty()) {
|
||||
|
|
@ -52,20 +62,23 @@ pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
|
|||
}
|
||||
});
|
||||
for (p, e) in props {
|
||||
if !keep_going() {
|
||||
return None;
|
||||
}
|
||||
let msg = if e.enabled {
|
||||
match &ctx.expr {
|
||||
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),
|
||||
Some(h) => h.eval(ctx, l, p, &p.value_at(l.layer_time(ctx.time))).err(),
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
s.expr_check.and_then(|check| check(&e.text).err())
|
||||
check.and_then(|check| check(&e.text).err())
|
||||
};
|
||||
if let Some(message) = msg {
|
||||
out.push(error_of(cid, i, l, p, message, !e.enabled));
|
||||
out.push(error_of(ctx.comp_id, i, l, p, message, !e.enabled));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
keep_going().then_some(out)
|
||||
}
|
||||
|
||||
fn error_of(cid: ItemId, i: usize, l: &Layer, p: &Property, message: String, disabled: bool) -> ExprError {
|
||||
|
|
|
|||
|
|
@ -43,10 +43,19 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?;
|
||||
// XML copies (File ▸ Save a Copy As XML…) open like the JSON project.
|
||||
let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text };
|
||||
let mut proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
|
||||
let (mut proj, repaired) =
|
||||
Project::from_json_repaired(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
|
||||
resolve_relative_footage(&mut proj, path, s.services.as_ref());
|
||||
s.replace_project(proj, Some(path.to_string()));
|
||||
s.restore_view(&effectcraft_project::saved_view(&text));
|
||||
s.note_project_path(path);
|
||||
// Values past the limits (a corrupt or hostile file) were brought within them (#788).
|
||||
if !repaired.is_empty() {
|
||||
let more = repaired.len().saturating_sub(3);
|
||||
let shown = repaired.iter().take(3).cloned().collect::<Vec<_>>().join("; ");
|
||||
let more = if more > 0 { format!(" and {more} more") } else { String::new() };
|
||||
s.toast(format!("This project had values outside EffectCraft's limits, corrected when it opened: {shown}{more}."));
|
||||
}
|
||||
// Written by a newer EffectCraft: what this version doesn't know would be lost on saving.
|
||||
let saved_by = effectcraft_project::saved_by(&text);
|
||||
if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) {
|
||||
|
|
@ -60,7 +69,7 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if s.check_footage_on_open {
|
||||
crate::footage_check::start(s, None, false)?;
|
||||
}
|
||||
Ok(json!({"path": path, "savedBy": saved_by}))
|
||||
Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired}))
|
||||
}
|
||||
|
||||
/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written
|
||||
|
|
@ -89,7 +98,7 @@ fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn
|
|||
/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project
|
||||
/// wouldn't read back.
|
||||
pub(crate) fn file_text(s: &Session, path: &str) -> Result<String> {
|
||||
let json = s.project.to_file_json()?;
|
||||
let json = s.project.to_file_json_with_view(&s.saved_view())?;
|
||||
if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -84,12 +84,12 @@ fn save_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn rate_of(p: &Value) -> FrameRate {
|
||||
f_p(p, "frameRate").map(FrameRate::from_f64).unwrap_or(FrameRate::FPS_29_97)
|
||||
f_p(p, "frameRate").map(|f| limits::frame_rate(FrameRate::from_f64(f), FrameRate::FPS_29_97)).unwrap_or(FrameRate::FPS_29_97)
|
||||
}
|
||||
|
||||
fn placeholder_footage(p: &Value) -> Footage {
|
||||
let w = p.get("width").and_then(Value::as_u64).unwrap_or(1920) as u32;
|
||||
let h = p.get("height").and_then(Value::as_u64).unwrap_or(1080) as u32;
|
||||
let w = p.get("width").and_then(Value::as_u64).map_or(1920, limits::size);
|
||||
let h = p.get("height").and_then(Value::as_u64).map_or(1080, limits::size);
|
||||
Footage {
|
||||
path: String::new(),
|
||||
kind: FootageKind::Video,
|
||||
|
|
@ -133,8 +133,8 @@ fn solid_from(p: &Value, s: &Session) -> Solid {
|
|||
.unwrap_or([0.5, 0.5, 0.5]);
|
||||
Solid {
|
||||
color,
|
||||
width: p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(cw).max(1),
|
||||
height: p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(ch).max(1),
|
||||
width: p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(cw).max(1),
|
||||
height: p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(ch).max(1),
|
||||
pixel_aspect: 1.0,
|
||||
}
|
||||
}
|
||||
|
|
@ -620,7 +620,8 @@ impl Interpretation {
|
|||
),
|
||||
};
|
||||
it.invert_alpha = super::b_p(p, "invertAlpha");
|
||||
it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32);
|
||||
// The Interpret Footage form sends numbers as floats (3.0), which `as_u64` rejects.
|
||||
it.loops = f_p(p, "loop").filter(|x| x.is_finite()).map(|v| v.round().clamp(1.0, 9999.0) as u32);
|
||||
it.par = f_p(p, "pixelAspect").filter(|x| *x > 0.0);
|
||||
it.fields = match str_p(p, "fields") {
|
||||
Some(f) => Some(effectcraft_project::FieldOrder::parse(f).ok_or_else(|| bad(cmd, "fields: off|upper|lower"))?),
|
||||
|
|
|
|||
|
|
@ -179,7 +179,8 @@ fn edit_into_comp(s: &mut Session, p: &Value, ripple: bool) -> Result<Value> {
|
|||
let mut l = build::layer(proj, &comp, &it.name, src, size, Some(len));
|
||||
l.start_time = at - src_in;
|
||||
l.in_point = at;
|
||||
l.out_point = (at + len).min(comp.duration);
|
||||
// Keep the full marked length past the comp's end (After Effects); the comp just doesn't show it.
|
||||
l.out_point = at + len;
|
||||
let lid = l.id;
|
||||
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
cm.layers.insert(0, l);
|
||||
|
|
@ -206,8 +207,12 @@ fn info(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
let item = s.project.item(v.item);
|
||||
let name = item.map(|i| i.name.clone()).unwrap_or_default();
|
||||
let frame = fr.frame_at(v.time);
|
||||
let problem = match item.map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => s.footage.problem(f),
|
||||
_ => None,
|
||||
};
|
||||
Ok(
|
||||
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}),
|
||||
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Frontend-only commands: viewer zoom/resolution/overlays, panels, workspaces, dialogs and app
|
||||
//! Frontend-only commands: viewer zoom/overlays, panels, workspaces, dialogs and app
|
||||
//! chrome. They live in the registry so every menu entry is a command (menus, shortcuts, the
|
||||
//! control channel and MCP all see them); running one emits [`crate::Event::Frontend`] and the UI
|
||||
//! performs it. Headless sessions accept and ignore them.
|
||||
|
|
@ -59,11 +59,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
// Viewer.
|
||||
fe!("view.zoomIn", "Zoom In", ["View"], Some("."), "{}", has_comp),
|
||||
fe!("view.zoomOut", "Zoom Out", ["View"], Some(","), "{}", has_comp),
|
||||
fe!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), "{}", has_comp),
|
||||
fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp),
|
||||
fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp),
|
||||
fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always),
|
||||
fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always),
|
||||
fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always),
|
||||
|
|
@ -73,7 +68,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fe!("view.snapToGrid", "Snap to Grid", ["View"], Some("Cmd+Shift+'"), "{value?}", always),
|
||||
fe!("view.options", "View Options...", ["View"], Some("Cmd+Alt+U"), "{}", has_comp),
|
||||
fe!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H"), "{value?}", always),
|
||||
fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", always),
|
||||
// macOS uses Control+Command+F for native full screen. On Windows and Linux,
|
||||
// Cmd maps to Ctrl; the After Effects-style Ctrl+\ is window.maximizeApp.
|
||||
fe!("view.fullScreen", "Enter Full Screen", ["View"], cfg!(target_os = "macos").then_some("Ctrl+Cmd+F"), "{}", always),
|
||||
fe!(
|
||||
"view.newViewer",
|
||||
"New Viewer",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
|
|||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{
|
||||
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
|
||||
limits,
|
||||
};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -92,8 +93,9 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
|
|||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] });
|
||||
let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.width);
|
||||
let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.height);
|
||||
// 1 to 30,000 px a side (#788): the layer is sized from the same numbers as its solid.
|
||||
let w = p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.width);
|
||||
let h = p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.height);
|
||||
let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect);
|
||||
let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() };
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
|
||||
|
|
@ -114,7 +116,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
|
|||
fn pixel_aspect_p(p: &Value) -> Result<Option<f64>> {
|
||||
match p.get("pixelAspect").and_then(Value::as_f64) {
|
||||
Some(v) if !(v.is_finite() && v > 0.0) => Err(bad("layer.settings", "`pixelAspect` must be a positive number")),
|
||||
v => Ok(v),
|
||||
v => Ok(v.map(limits::pixel_aspect)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,7 +297,10 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let mut l = build::layer(proj, &comp, &it.name, src, size, dur);
|
||||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration());
|
||||
// Footage and precomps keep their full length past the comp's end (After Effects); items
|
||||
// without a duration (solids, comp-length stills) end with the comp.
|
||||
let end = dur.map_or((start + comp.duration).min(comp.duration), |d| start + d);
|
||||
l.out_point = fr.snap_nearest(end).max(start + fr.frame_duration());
|
||||
place(&mut l, position);
|
||||
insert_layer_at(proj, st, cid, l, index)
|
||||
})?;
|
||||
|
|
@ -377,6 +382,9 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string();
|
||||
let v = b_p(p, "value");
|
||||
let label = format!("Layer Switch ({name})");
|
||||
if b_p(p, "others") == Some(true) {
|
||||
return set_switch_others(s, cid, &ids, &name, v, &label);
|
||||
}
|
||||
// `merge`: a switch dragged over several layers in the Timeline is one undo step.
|
||||
let r = s.edit(&label, merge_p(p), |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
|
|
@ -394,6 +402,38 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!(r))
|
||||
}
|
||||
|
||||
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature in the Timeline): the named
|
||||
/// layers stand alone, shown / heard / soloed / unlocked while every other layer the switch
|
||||
/// applies to is the opposite; when they already do, the other layers come back. `value` sets
|
||||
/// the other layers directly.
|
||||
fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, value: Option<bool>, label: &str) -> Result<Value> {
|
||||
let r = s.edit(label, None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
// The state that sets a layer apart: unlocked for Lock, on for the rest.
|
||||
let active = !matches!(name, "lock" | "locked");
|
||||
// Track mattes keep their video off by design.
|
||||
let mattes: Vec<LayerId> =
|
||||
if matches!(name, "video" | "eye") { comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect() } else { Vec::new() };
|
||||
let applies = |l: &Layer| match name {
|
||||
"audio" => l.props.sub("audio").is_some(),
|
||||
"lock" | "locked" => true,
|
||||
_ => l.source.is_av() && !mattes.contains(&l.id),
|
||||
};
|
||||
let mut alone = true;
|
||||
for l in comp.layers.iter().filter(|l| applies(l)) {
|
||||
let Some(on) = switch_value(l, name) else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
|
||||
alone &= on == (ids.contains(&l.id) == active);
|
||||
}
|
||||
let others = value.unwrap_or(alone == active);
|
||||
for l in comp.layers.iter_mut().filter(|l| applies(l)) {
|
||||
let v = if ids.contains(&l.id) { active } else { others };
|
||||
set_switch_value(l, name, v);
|
||||
}
|
||||
Ok(others)
|
||||
})?;
|
||||
Ok(json!(r))
|
||||
}
|
||||
|
||||
fn switch_value(l: &Layer, name: &str) -> Option<bool> {
|
||||
let sw = &l.switches;
|
||||
Some(match name {
|
||||
|
|
@ -857,9 +897,13 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate;
|
||||
let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v)));
|
||||
let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out"));
|
||||
// Relative trims: each layer moves its own in/out point by the same amount, so dragging an
|
||||
// edge of several selected layers trims them all together (as in AE).
|
||||
let (in_delta, out_delta) = (snap("inDelta"), snap("outDelta"));
|
||||
s.edit("Layer Timing", merge_p(p), |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fd = comp.frame_duration();
|
||||
let comp_dur = comp.duration;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
let shift = |l: &mut Layer, d: Tick| {
|
||||
l.start_time += d;
|
||||
|
|
@ -891,6 +935,14 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(o) = outp {
|
||||
l.out_point = o.max(l.in_point + fd);
|
||||
}
|
||||
// Stops at the comp start/end (a point already past it doesn't jump onto
|
||||
// it), keeping at least one frame of layer.
|
||||
if let Some(d) = in_delta {
|
||||
l.in_point = (l.in_point + d).max(Tick::ZERO.min(l.in_point)).min(l.out_point - fd);
|
||||
}
|
||||
if let Some(d) = out_delta {
|
||||
l.out_point = (l.out_point + d).min(comp_dur.max(l.out_point)).max(l.in_point + fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1429,8 +1481,8 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
_ => {}
|
||||
}
|
||||
let color = color_p(p, "color");
|
||||
let w = p.get("width").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let h = p.get("height").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let w = p.get("width").and_then(Value::as_u64).map(limits::size);
|
||||
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);
|
||||
let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true);
|
||||
|
|
@ -1533,7 +1585,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Layer Switch",
|
||||
[],
|
||||
None,
|
||||
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?}",
|
||||
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?, others? (the named layers alone: the other layers get the opposite state, or come back)}",
|
||||
has_layers,
|
||||
set_switch
|
||||
),
|
||||
|
|
@ -1555,7 +1607,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Layer Timing",
|
||||
[],
|
||||
None,
|
||||
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, merge?}",
|
||||
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}",
|
||||
has_layers,
|
||||
timing
|
||||
),
|
||||
|
|
|
|||
|
|
@ -661,7 +661,24 @@ fn open_source(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// Folder containing a file as a `file://` URL.
|
||||
pub(crate) fn folder_url(path: &str) -> String {
|
||||
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
|
||||
format!("file://{dir}")
|
||||
file_url(&dir)
|
||||
}
|
||||
|
||||
/// `path` as a `file://` URL: backslashes become `/`, a Windows drive path gets the third slash
|
||||
/// (`file:///C:/…`), and every byte that isn't a plain path character is percent-encoded (spaces,
|
||||
/// `#`, `%`, `?`, non-ASCII as UTF-8), so the URL names exactly this file.
|
||||
pub(crate) fn file_url(path: &str) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let path = path.replace('\\', "/");
|
||||
let mut url = String::from(if path.starts_with('/') { "file://" } else { "file:///" });
|
||||
for b in path.bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"/-._~:@!$&'()*+,;=".contains(&b) {
|
||||
url.push(char::from(b));
|
||||
} else {
|
||||
let _ = write!(url, "%{b:02X}");
|
||||
}
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -845,3 +862,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{file_url, folder_url};
|
||||
|
||||
#[test]
|
||||
fn file_urls_are_well_formed() {
|
||||
assert_eq!(file_url("C:\\Users\\Ann\\Footage\\clip 1.mov"), "file:///C:/Users/Ann/Footage/clip%201.mov");
|
||||
assert_eq!(file_url("/home/ann/a#b%c?d.mov"), "file:///home/ann/a%23b%25c%3Fd.mov");
|
||||
assert_eq!(file_url("/Users/ann/Café/x.mov"), "file:///Users/ann/Caf%C3%A9/x.mov");
|
||||
assert_eq!(file_url("/plain/path-1_2.~(x).mov"), "file:///plain/path-1_2.~(x).mov", "plain characters stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_url_names_the_containing_folder() {
|
||||
assert_eq!(folder_url("/media/My Clips/a.mov"), "file:///media/My%20Clips");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ pub(crate) mod watch_folder;
|
|||
pub(crate) use app_more::report_text as report_text_for_tests;
|
||||
pub(crate) use app_more::view_command;
|
||||
#[cfg(test)]
|
||||
pub(crate) use file::open as open_for_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) use keys_more::amplitudes as amplitudes_for_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) use mask::split_segment as split_segment_for_tests;
|
||||
|
|
@ -99,6 +101,8 @@ pub struct CommandSpec {
|
|||
pub menu: &'static [&'static str],
|
||||
/// Default shortcut (egui-style names, `Cmd` = ⌘ on macOS / Ctrl elsewhere).
|
||||
pub shortcut: Option<&'static str>,
|
||||
/// Further default shortcuts (the menus and tooltips show `shortcut`).
|
||||
pub alt_shortcuts: &'static [&'static str],
|
||||
/// Parameter description for agents and the MCP schema.
|
||||
pub params: &'static str,
|
||||
pub enabled: Enabled,
|
||||
|
|
@ -109,8 +113,22 @@ pub struct CommandSpec {
|
|||
|
||||
#[macro_export]
|
||||
macro_rules! cmd {
|
||||
// Several default shortcuts: `["PageDown", "Cmd+ArrowRight"]`.
|
||||
($id:literal, $label:literal, [$($m:literal),*], [$sc:literal, $($alt:literal),+], $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::commands::CommandSpec { alt_shortcuts: &[$($alt),+], ..$crate::cmd!($id, $label, [$($m),*], Some($sc), $params, $en, $run) }
|
||||
};
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::commands::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
|
||||
$crate::commands::CommandSpec {
|
||||
id: $id,
|
||||
label: $label,
|
||||
menu: &[$($m),*],
|
||||
shortcut: $sc,
|
||||
alt_shortcuts: &[],
|
||||
params: $params,
|
||||
enabled: $en,
|
||||
run: $run,
|
||||
journal: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
#[macro_export]
|
||||
|
|
@ -121,6 +139,7 @@ macro_rules! query {
|
|||
label: $label,
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
alt_shortcuts: &[],
|
||||
params: $params,
|
||||
enabled: $crate::commands::always,
|
||||
run: $run,
|
||||
|
|
@ -449,17 +468,34 @@ pub(crate) fn merge_p(p: &Value) -> Option<&str> {
|
|||
str_p(p, "merge")
|
||||
}
|
||||
|
||||
/// `comp`: item id or name; default the active comp.
|
||||
/// `comp`: item id or name (an all-digit string that names no comp is taken as an id, as MCP
|
||||
/// clients often send numbers as strings); default the active comp.
|
||||
pub(crate) fn comp_id(s: &Session, p: &Value) -> Result<ItemId> {
|
||||
let missing = |v: &Value| EngineError::NoSuchComp(v.to_string());
|
||||
match p.get("comp") {
|
||||
Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)),
|
||||
Some(v @ Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or_else(|| missing(v)),
|
||||
Some(v @ Value::String(name)) => s
|
||||
.project
|
||||
.items
|
||||
.values()
|
||||
.find(|i| &i.name == name && i.as_comp().is_some())
|
||||
.map(|i| i.id)
|
||||
.or_else(|| digits_id(name).map(ItemId).filter(|id| s.project.comp(*id).is_some()))
|
||||
.ok_or_else(|| missing(v)),
|
||||
_ => s.active_comp_id().ok_or(EngineError::NoComp),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name.
|
||||
/// An all-ASCII-digit string as a number (`"98"` -> 98); anything else (names, `#n`, signs) is `None`.
|
||||
fn digits_id(s: &str) -> Option<u64> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name. An all-digit string
|
||||
/// that names no layer is resolved as the number it spells.
|
||||
pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
|
|
@ -470,7 +506,7 @@ pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
|
|||
if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::<usize>().ok()) {
|
||||
return comp.layers.get(i.checked_sub(1)?).map(|l| l.id);
|
||||
}
|
||||
comp.layers.iter().find(|l| &l.name == s).map(|l| l.id)
|
||||
comp.layers.iter().find(|l| &l.name == s).map(|l| l.id).or_else(|| digits_id(s).and_then(|id| resolve_layer(comp, &Value::from(id))))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,13 @@ fn summary(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
o["layers"] = json!(c.layers.len());
|
||||
o["frameRate"] = json!(c.frame_rate.as_f64());
|
||||
}
|
||||
// Footage that shows no picture although its file is there (a codec or profile the
|
||||
// decoders lack): why, so an agent can tell the user.
|
||||
if let ItemKind::Footage(f) = &i.kind
|
||||
&& let Some(why) = s.footage.problem(f)
|
||||
{
|
||||
o["problem"] = json!(why);
|
||||
}
|
||||
o
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -80,6 +87,7 @@ fn comp_info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"id": cid.0, "name": name, "width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds(),
|
||||
"workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], "background": c.background,
|
||||
"preserveFrameRate": c.preserve_frame_rate, "preserveResolution": c.preserve_resolution,
|
||||
"resolution": c.resolution.label(), "resolutionFactor": c.resolution.factor(),
|
||||
"layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ use super::{CommandSpec, not_yet, not_yet_run};
|
|||
#[allow(unused_macros)]
|
||||
macro_rules! stub {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $params:literal) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
};
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:literal, $params:literal) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,13 @@ fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if added == 0 && !kinds.is_empty() {
|
||||
return Err(bad(C, "unknown animator property"));
|
||||
}
|
||||
// Like After Effects, a scripted animator has no selectors until the script adds one
|
||||
// (addProperty("ADBE Text Selector")); a default 0-100% one would select every character.
|
||||
if kinds.is_empty()
|
||||
&& let Some(sels) = g.sub_mut("selectors")
|
||||
{
|
||||
sels.children.clear();
|
||||
}
|
||||
let uid = g.uid;
|
||||
anims.children.push(g.into());
|
||||
proj.next_id = next;
|
||||
|
|
|
|||
|
|
@ -320,6 +320,21 @@ fn set_3d_view(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(switch_view(s, cid, v))
|
||||
}
|
||||
|
||||
/// The view of one pane of the View Layout: pane 0 is the main view (the same as Switch 3D View),
|
||||
/// panes 1 to 3 are the extra views of a 2- or 4-view layout. UI state, not part of the project.
|
||||
fn set_pane_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let v = view_p(p, "view.setPaneView")?;
|
||||
let pane = p.get("pane").and_then(Value::as_u64).filter(|n| *n <= 3).ok_or_else(|| bad("view.setPaneView", "pane: 0 (main view) to 3"))? as usize;
|
||||
if pane == 0 {
|
||||
switch_view(s, cid, v);
|
||||
} else {
|
||||
views(s, cid).set_pane(pane - 1, v);
|
||||
}
|
||||
let vs = views(s, cid);
|
||||
Ok(json!({"pane": pane, "view": v.id(), "views": [vs.current.id(), vs.pane(0).id(), vs.pane(1).id(), vs.pane(2).id()]}))
|
||||
}
|
||||
|
||||
macro_rules! view_cmd {
|
||||
($f:ident, $v:expr) => {
|
||||
fn $f(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -706,6 +721,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_comp,
|
||||
set_3d_view
|
||||
),
|
||||
cmd!(
|
||||
"view.setPaneView",
|
||||
"Set View of Pane",
|
||||
[],
|
||||
None,
|
||||
"{pane: 0 (main) | 1 | 2 | 3, view: activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}",
|
||||
has_comp,
|
||||
set_pane_view
|
||||
),
|
||||
cmd!("view.3d.activeCamera", "Active Camera", ["View", "Switch 3D View"], Some("F12"), "{comp?}", has_comp, view_active),
|
||||
cmd!("view.3d.default", "Default", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_default),
|
||||
cmd!("view.3d.front", "Front", ["View", "Switch 3D View"], Some("F10"), "{comp?}", has_comp, view_front),
|
||||
|
|
|
|||
|
|
@ -39,7 +39,10 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) ->
|
|||
effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0)
|
||||
}
|
||||
None => {
|
||||
let parsed = effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0)?;
|
||||
// The Timeline shows non-drop timecode (`:`), so typed `:`/`.` text is read as non-drop
|
||||
// too; only explicit `;` fields mean drop-frame (#890).
|
||||
let drop_frame = fr.supports_drop_frame() && text.contains(';');
|
||||
let parsed = effectcraft_time::parse_timecode(text, fr, drop_frame, current).map_err(|e| e.0)?;
|
||||
let relative = text.trim().starts_with('+') || text.trim().starts_with('-');
|
||||
if relative {
|
||||
Ok(parsed)
|
||||
|
|
@ -61,8 +64,9 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) ->
|
|||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let t = if let Some(f) = p.get("frame").and_then(Value::as_i64) {
|
||||
comp.frame_rate.tick_of(f)
|
||||
// Dialog number fields send floats (`5.0`), so a frame is any JSON number, rounded.
|
||||
let t = if let Some(f) = f_p(p, "frame") {
|
||||
comp.frame_rate.tick_of(f.round() as i64)
|
||||
} else if let Some(tc) = str_p(p, "timecode") {
|
||||
let cur = comp.frame_rate.frame_at(s.time());
|
||||
let f = parse_display_time(s, comp, tc, cur).map_err(|e| super::bad("time.set", e))?;
|
||||
|
|
@ -169,10 +173,10 @@ fn with_to(p: &Value, to: &str) -> Value {
|
|||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set),
|
||||
cmd!("time.nextFrame", "Next Frame", [], Some("PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))),
|
||||
cmd!("time.previousFrame", "Previous Frame", [], Some("PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))),
|
||||
cmd!("time.forward10", "Forward 10 Frames", [], Some("Shift+PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))),
|
||||
cmd!("time.back10", "Back 10 Frames", [], Some("Shift+PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))),
|
||||
cmd!("time.nextFrame", "Next Frame", [], ["PageDown", "Cmd+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))),
|
||||
cmd!("time.previousFrame", "Previous Frame", [], ["PageUp", "Cmd+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))),
|
||||
cmd!("time.forward10", "Forward 10 Frames", [], ["Shift+PageDown", "Cmd+Shift+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))),
|
||||
cmd!("time.back10", "Back 10 Frames", [], ["Shift+PageUp", "Cmd+Shift+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))),
|
||||
cmd!("time.step", "Step Frames", [], None, "{frames}", has_comp, step),
|
||||
cmd!("time.start", "Go to Start", [], Some("Home"), "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))),
|
||||
cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! View menu commands that change the project (guides) or headless-relevant editor state (the
|
||||
//! region of interest).
|
||||
//! View menu commands that change the project (guides, each comp's Resolution) or
|
||||
//! headless-relevant editor state (the region of interest).
|
||||
|
||||
use effectcraft_project::Guide;
|
||||
use effectcraft_project::{Guide, Resolution};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
|
||||
|
|
@ -75,6 +75,24 @@ fn region_of_interest(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!(s.state.region_of_interest))
|
||||
}
|
||||
|
||||
/// View ▸ Resolution (and the Composition panel's Resolution popup): the comp's Resolution, saved
|
||||
/// with the project but no undo step ([`Session::set_resolution`]).
|
||||
fn set_resolution(s: &mut Session, p: &Value, r: Resolution) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.set_resolution(cid, r)?;
|
||||
Ok(json!({"comp": cid.0, "resolution": r.label(), "factor": r.factor()}))
|
||||
}
|
||||
|
||||
/// View ▸ Resolution ▸ Custom: `factor` renders every n-th pixel; without one the frontend asks
|
||||
/// for it (the Custom Resolution dialog).
|
||||
fn custom_resolution(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match f_p(p, "factor") {
|
||||
Some(n) => set_resolution(s, p, Resolution::from_factor(n.round().clamp(1.0, 40.0) as u64)),
|
||||
None if p.get("factor").is_some_and(|v| !v.is_null()) => Err(bad("view.res.custom", "factor: a number from 1 to 40")),
|
||||
None => super::frontend(s, "view.res.custom", p),
|
||||
}
|
||||
}
|
||||
|
||||
fn view_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = p.get("views").and_then(Value::as_u64).ok_or_else(|| bad("view.layout", "views: 1|2|4"))?;
|
||||
if !matches!(n, 1 | 2 | 4) {
|
||||
|
|
@ -101,12 +119,26 @@ fn extended_viewer(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!(v))
|
||||
}
|
||||
|
||||
/// A `view.res.*` command setting one [`Resolution`].
|
||||
macro_rules! res {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $r:ident) => {
|
||||
cmd!($id, $label, [$($m),*], $sc, "{comp?}", has_comp, |s, p| set_resolution(s, p, Resolution::$r))
|
||||
};
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide),
|
||||
cmd!("view.clearGuides", "Clear Guides", ["View"], None, "{comp?}", has_guides, clear_guides),
|
||||
cmd!("view.importGuides", "Import Guides...", ["View"], None, "{path}", has_comp, import_guides),
|
||||
cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides),
|
||||
res!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), Full),
|
||||
res!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), Half),
|
||||
res!("view.res.third", "Third", ["View", "Resolution"], None, Third),
|
||||
res!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), Quarter),
|
||||
cmd!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel), comp?}", has_comp, custom_resolution),
|
||||
// The Composition panel's Resolution popup (not in the View menu).
|
||||
res!("view.res.auto", "Resolution: Auto", [], None, Auto),
|
||||
cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout),
|
||||
cmd!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], None, "{value?}", has_comp, share_view_options),
|
||||
cmd!("view.extendedViewer", "Extended Viewer", [], None, "{value?}", always, extended_viewer),
|
||||
|
|
|
|||
|
|
@ -341,7 +341,8 @@ impl Session {
|
|||
let k = new_line.iter().position(|&i| i == target).unwrap_or(0);
|
||||
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
|
||||
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
|
||||
self.project = a.state[target].clone();
|
||||
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.undo = undo;
|
||||
self.history.redo = redo;
|
||||
self.history.branches = branches;
|
||||
|
|
|
|||
|
|
@ -707,9 +707,38 @@ impl Session {
|
|||
self.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
}
|
||||
|
||||
/// 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.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r {
|
||||
return Ok(());
|
||||
}
|
||||
Arc::make_mut(&mut self.project).comp_mut(comp).ok_or(EngineError::NoComp)?.resolution = r;
|
||||
self.bump();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep what lives outside the undo history (each comp's Resolution) as it is in `from`, the
|
||||
/// project before undo, redo or a history jump restored another state.
|
||||
pub(crate) fn keep_view_settings(&mut self, from: &Project) {
|
||||
let keep: Vec<(ItemId, effectcraft_project::Resolution)> =
|
||||
self.project.comps().filter_map(|(id, c)| from.comp(*id).map(|f| (*id, f.resolution)).filter(|(_, r)| *r != c.resolution)).collect();
|
||||
if keep.is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = Arc::make_mut(&mut self.project);
|
||||
for (id, r) in keep {
|
||||
if let Some(c) = p.comp_mut(id) {
|
||||
c.resolution = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let Some((label, p)) = self.history.undo.pop() else { return false };
|
||||
let cur = std::mem::replace(&mut self.project, p);
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.redo.push((label, cur));
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_state();
|
||||
|
|
@ -720,6 +749,7 @@ impl Session {
|
|||
pub fn redo(&mut self) -> bool {
|
||||
let Some((label, p)) = self.history.redo.pop() else { return false };
|
||||
let cur = std::mem::replace(&mut self.project, p);
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.undo.push((label, cur));
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_state();
|
||||
|
|
@ -859,6 +889,26 @@ impl Session {
|
|||
self.events.push(Event::OpenComp(id));
|
||||
}
|
||||
|
||||
/// The open compositions, as a project file records them ([`effectcraft_project::SavedView`]).
|
||||
pub fn saved_view(&self) -> effectcraft_project::SavedView {
|
||||
effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() }
|
||||
}
|
||||
|
||||
/// Reopen the compositions a project file was saved with (#748). Ids that no longer name a
|
||||
/// comp are skipped; with none left the project stays on the comp [`Session::replace_project`]
|
||||
/// opened (its first), as files from before the view was saved do.
|
||||
pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) {
|
||||
let mut open: Vec<ItemId> = Vec::new();
|
||||
for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) {
|
||||
if !open.contains(&id) {
|
||||
open.push(id);
|
||||
}
|
||||
}
|
||||
let Some(active) = view.active_comp.filter(|id| self.project.comp(*id).is_some()).or_else(|| open.first().copied()) else { return };
|
||||
self.state.open_comps = open;
|
||||
self.open_comp(active);
|
||||
}
|
||||
|
||||
pub fn toast(&mut self, msg: impl Into<String>) {
|
||||
self.events.push(Event::Toast { message: msg.into(), error: false });
|
||||
}
|
||||
|
|
@ -1080,10 +1130,14 @@ mod tests_fidelity;
|
|||
#[cfg(test)]
|
||||
mod tests_frame_export;
|
||||
#[cfg(test)]
|
||||
mod tests_fuzz_open;
|
||||
#[cfg(test)]
|
||||
mod tests_key_metadata_order;
|
||||
#[cfg(test)]
|
||||
mod tests_keylight;
|
||||
#[cfg(test)]
|
||||
mod tests_limits;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
#[cfg(test)]
|
||||
mod tests_m137;
|
||||
|
|
|
|||
|
|
@ -1,25 +1,47 @@
|
|||
//! Help ▸ Enable Logging / Reveal Logging File: a `log` logger that, while enabled, appends
|
||||
//! every record to `Logs/EffectCraft Log.txt` in the settings folder (or the temporary folder),
|
||||
//! and always keeps the last few hundred warnings and errors in memory for the System
|
||||
//! Compatibility Report.
|
||||
//! The `log` logger. Records go to standard error and, on the desktop, to a session log that
|
||||
//! every launch starts afresh: `Logs/effectcraft.log` in the settings folder, with the two runs
|
||||
//! before it kept as `effectcraft.1.log` and `effectcraft.2.log`, so the log of a run that
|
||||
//! crashed survives the next start. A launch from the Dock, the Start menu or a launcher has no
|
||||
//! terminal, so that file is what a bug report can attach. The last few hundred warnings and
|
||||
//! errors are also kept in memory for the System Compatibility Report.
|
||||
//!
|
||||
//! Frontends call [`install`] once at startup; [`set_enabled`] (the `help.enableLogging`
|
||||
//! command) turns file logging on and off. `RUST_LOG` (a level such as `debug`, or per target:
|
||||
//! `effectcraft=debug,wgpu=warn`) also prints what it lets through to stderr, so a start-up that
|
||||
//! fails before the window opens leaves a trace.
|
||||
//! Levels: `info` for EffectCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that (a level such as `debug`, or per target:
|
||||
//! `effectcraft=debug,wgpu=warn`).
|
||||
//!
|
||||
//! Help ▸ Enable Logging ([`set_enabled`], the `help.enableLogging` command) additionally appends
|
||||
//! every EffectCraft record to `Logs/EffectCraft Log.txt`, across launches.
|
||||
//!
|
||||
//! Frontends call [`install`] once at startup and the desktop app [`attach_session_log`] once the
|
||||
//! settings folder is known; records logged before that are kept (up to [`MAX_PENDING`]) and
|
||||
//! written first. Writing never panics: a file that can't be created or written leaves standard
|
||||
//! error as the only sink. Never log the control-channel token.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The session log's name inside the `Logs` folder.
|
||||
pub const SESSION_LOG: &str = "effectcraft.log";
|
||||
/// How many previous session logs are kept (`effectcraft.1.log` … `effectcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The session log stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the session log is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The filter when `RUST_LOG` is unset or empty.
|
||||
pub const DEFAULT_FILTER: &str = "warn,effectcraft=info";
|
||||
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
/// The `RUST_LOG` filter for stderr: (target prefix or every target, level).
|
||||
static STDERR: OnceLock<Vec<(Option<String>, LevelFilter)>> = OnceLock::new();
|
||||
/// The filter for standard error and the session log: (target prefix or every target, level).
|
||||
static FILTER: OnceLock<Vec<(Option<String>, LevelFilter)>> = OnceLock::new();
|
||||
static STATE: Mutex<State> = Mutex::new(State { file: None, recent: VecDeque::new() });
|
||||
static SESSION: Mutex<Sink> = Mutex::new(Sink::new(MAX_FILE_BYTES));
|
||||
|
||||
struct State {
|
||||
file: Option<PathBuf>,
|
||||
|
|
@ -32,15 +54,16 @@ const RECENT: usize = 300;
|
|||
|
||||
impl log::Log for Logger {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || to_stderr(m)
|
||||
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || shown(m)
|
||||
}
|
||||
fn log(&self, r: &log::Record) {
|
||||
if !self.enabled(r.metadata()) {
|
||||
return;
|
||||
}
|
||||
let line = format!("{} {:<5} {}: {}", unix_secs(), r.level(), r.target(), r.args());
|
||||
if to_stderr(r.metadata()) {
|
||||
eprintln!("{line}");
|
||||
let line = format_line(×tamp(unix_millis()), r.level(), &thread_name(), r.target(), &r.args().to_string());
|
||||
if shown(r.metadata()) {
|
||||
let _ = writeln!(std::io::stderr(), "{line}");
|
||||
SESSION.lock().unwrap_or_else(PoisonError::into_inner).write(&format!("{line}\n"));
|
||||
}
|
||||
let Ok(mut st) = STATE.lock() else { return };
|
||||
if r.level() <= log::Level::Warn {
|
||||
|
|
@ -59,22 +82,62 @@ impl log::Log for Logger {
|
|||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
fn unix_secs() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
fn unix_millis() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn thread_name() -> String {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return std::thread::current().name().unwrap_or("?").to_string();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return "main".to_string();
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970.
|
||||
fn timestamp(ms: u64) -> String {
|
||||
let secs = ms / 1000;
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000)
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most
|
||||
/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days + 719_468;
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + u64::from(m <= 2);
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
/// One log line (without the newline): `<timestamp> <LEVEL> [<thread>] <target>: <message>`.
|
||||
fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}")
|
||||
}
|
||||
|
||||
/// Install the logger (once; later calls and other installed loggers are left alone).
|
||||
pub fn install() {
|
||||
static LOGGER: Logger = Logger;
|
||||
if log::set_logger(&LOGGER).is_ok() {
|
||||
let stderr = STDERR.get_or_init(|| std::env::var("RUST_LOG").map(|s| parse_filter(&s)).unwrap_or_default());
|
||||
log::set_max_level(stderr.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
|
||||
let filter = FILTER.get_or_init(|| {
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
parse_filter(spec.as_deref().unwrap_or(DEFAULT_FILTER))
|
||||
});
|
||||
log::set_max_level(filter.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `RUST_LOG` lets a record through to stderr.
|
||||
fn to_stderr(m: &log::Metadata) -> bool {
|
||||
STDERR.get().is_some_and(|f| m.level() <= level_for(f, m.target()))
|
||||
/// Whether the filter lets a record through to standard error and the session log.
|
||||
fn shown(m: &log::Metadata) -> bool {
|
||||
FILTER.get().is_some_and(|f| m.level() <= level_for(f, m.target()))
|
||||
}
|
||||
|
||||
/// `RUST_LOG`'s directives, as env_logger reads them: `level`, `target=level` or a bare target
|
||||
|
|
@ -110,9 +173,112 @@ pub fn install_panic_hook() {
|
|||
}));
|
||||
}
|
||||
|
||||
/// The log file inside `config_dir` (the settings folder), or the temporary folder.
|
||||
/// The `Logs` folder inside `config_dir` (the settings folder), or in the temporary folder.
|
||||
pub fn log_dir(config_dir: Option<&Path>) -> PathBuf {
|
||||
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs")
|
||||
}
|
||||
|
||||
/// Help ▸ Enable Logging's file inside `config_dir` (the settings folder), or the temporary folder.
|
||||
pub fn log_path(config_dir: Option<&Path>) -> PathBuf {
|
||||
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs").join("EffectCraft Log.txt")
|
||||
log_dir(config_dir).join("EffectCraft Log.txt")
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(SESSION_LOG) } else { dir.join(format!("effectcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous session logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest
|
||||
/// dropped) and create a fresh, empty log file in `dir` (created if missing).
|
||||
fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to
|
||||
/// a fresh `effectcraft.log` there, first the ones logged so far; returns its path.
|
||||
pub fn attach_session_log(dir: &Path) -> Result<PathBuf, String> {
|
||||
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let mut sink = SESSION.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
sink.attach(file);
|
||||
sink.path = Some(path.clone());
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// This run's session log (once [`attach_session_log`] succeeded).
|
||||
pub fn session_file() -> Option<PathBuf> {
|
||||
SESSION.lock().unwrap_or_else(PoisonError::into_inner).path.clone()
|
||||
}
|
||||
|
||||
/// Where lines go besides standard error: kept in memory until a file is attached, then
|
||||
/// appended to it until it reaches its size cap.
|
||||
struct Sink {
|
||||
file: Option<File>,
|
||||
path: Option<PathBuf>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
const fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, path: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() && !self.capped {
|
||||
if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file`, first the lines kept so far.
|
||||
fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink.
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn file logging on (writing to `file`) or off.
|
||||
|
|
@ -123,7 +289,7 @@ pub fn set_enabled(on: bool, file: PathBuf) -> std::io::Result<()> {
|
|||
std::fs::create_dir_all(d)?;
|
||||
}
|
||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&file)?;
|
||||
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", unix_secs(), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
|
||||
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", timestamp(unix_millis()), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
|
||||
}
|
||||
if let Ok(mut st) = STATE.lock() {
|
||||
st.file = Some(file);
|
||||
|
|
@ -136,7 +302,7 @@ pub fn is_enabled() -> bool {
|
|||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The current log file (set once logging was enabled).
|
||||
/// The current Enable Logging file (set once logging was enabled).
|
||||
pub fn current_file() -> Option<PathBuf> {
|
||||
STATE.lock().ok().and_then(|s| s.file.clone())
|
||||
}
|
||||
|
|
@ -148,7 +314,8 @@ pub fn recent() -> Vec<String> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LevelFilter, level_for, parse_filter};
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// `RUST_LOG` picks what start-up prints to stderr (#234).
|
||||
#[test]
|
||||
|
|
@ -162,4 +329,111 @@ mod tests {
|
|||
assert_eq!(level_for(&parse_filter("wgpu=debug"), "winit"), LevelFilter::Off);
|
||||
assert!(parse_filter("").is_empty());
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).unwrap()
|
||||
}
|
||||
|
||||
/// Without `RUST_LOG`, EffectCraft's own info records and everyone's warnings are shown.
|
||||
#[test]
|
||||
fn the_default_filter_shows_effectcraft_info_and_other_crates_warnings() {
|
||||
let f = parse_filter(DEFAULT_FILTER);
|
||||
assert_eq!(level_for(&f, "effectcraft"), LevelFilter::Info);
|
||||
assert_eq!(level_for(&f, "effectcraft_ui_egui::app"), LevelFilter::Info);
|
||||
assert_eq!(level_for(&f, "wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(level_for(&f, "naga"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(0), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(1_791_446_357_728), "2026-10-08T07:59:17.728Z");
|
||||
assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z");
|
||||
let _ = timestamp(u64::MAX);
|
||||
}
|
||||
|
||||
/// Each launch keeps the previous runs' logs, so the log of a run that crashed survives.
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).unwrap();
|
||||
assert_eq!(path, dir.join(SESSION_LOG));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).unwrap();
|
||||
}
|
||||
assert_eq!(read(&dir.join(SESSION_LOG)), "run 4");
|
||||
assert_eq!(read(&dir.join("effectcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("effectcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("effectcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::write(&dir, "a file where the directory should be").unwrap();
|
||||
assert!(rotate(&dir).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).unwrap();
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).unwrap();
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).unwrap();
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}");
|
||||
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "effectcraft_engine::autosave", "disk full\n"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] effectcraft_engine::autosave: disk full"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,6 +322,9 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
|
|||
}
|
||||
"layer.markersLock" => Some(layer?.markers_locked),
|
||||
"layer.mask.hideLocked" => Some(s.state.hide_locked_masks),
|
||||
"view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" | "view.res.custom" => {
|
||||
Some(comp.resolution.label().eq_ignore_ascii_case(command.trim_start_matches("view.res.")))
|
||||
}
|
||||
"view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)),
|
||||
"view.shareViewOptions" => Some(s.state.share_view_options),
|
||||
"view.extendedViewer" => Some(s.prefs.three_d.extended_viewer),
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ pub const LANGUAGES: &[(&str, &str)] = &[
|
|||
("Українська", "uk"),
|
||||
("Español", "es"),
|
||||
("Português (Brasil)", "pt-br"),
|
||||
("Русский", "ru"),
|
||||
("Français", "fr"),
|
||||
("Deutsch", "de"),
|
||||
("Čeština", "cs"),
|
||||
];
|
||||
|
||||
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
|
||||
|
|
@ -172,6 +176,8 @@ page!(Appearance {
|
|||
dark_theme: String = "dark".into(),
|
||||
/// The theme used in light appearance: `light`.
|
||||
light_theme: String = "light".into(),
|
||||
/// Theme shortcuts in the appearance context menu.
|
||||
favorite_themes: Vec<String> = Vec::new(),
|
||||
/// User interface brightness, -1 (darker) … 1 (lighter).
|
||||
brightness: f64 = 0.0,
|
||||
/// The size of the whole interface in percent (75–200), on top of the display's own scale.
|
||||
|
|
@ -332,6 +338,42 @@ pub struct LabelPref {
|
|||
pub color: String,
|
||||
}
|
||||
|
||||
/// A Composition Settings preset the user saved (Composition Settings ▸ Preset ▸ Save): the
|
||||
/// frame size, pixel aspect and frame rate, as in After Effects' own presets.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompPreset {
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixel_aspect: f64,
|
||||
pub frame_rate: f64,
|
||||
}
|
||||
|
||||
impl CompPreset {
|
||||
/// Longest preset name kept.
|
||||
pub const MAX_NAME: usize = 128;
|
||||
|
||||
/// Values a composition accepts (a hand-edited settings file can hold anything).
|
||||
pub fn is_valid(&self) -> bool {
|
||||
let size = 4..=30000;
|
||||
!self.name.trim().is_empty()
|
||||
&& self.name.chars().count() <= Self::MAX_NAME
|
||||
&& size.contains(&self.width)
|
||||
&& size.contains(&self.height)
|
||||
&& self.pixel_aspect.is_finite()
|
||||
&& (0.01..=100.0).contains(&self.pixel_aspect)
|
||||
&& self.frame_rate.is_finite()
|
||||
&& (1.0..=999.0).contains(&self.frame_rate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved composition presets, skipping entries that don't parse instead of losing the list.
|
||||
fn lenient_comp_presets<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<CompPreset>, D::Error> {
|
||||
let v = Vec::<Value>::deserialize(d)?;
|
||||
Ok(v.into_iter().filter_map(|x| serde_json::from_value(x).ok()).collect())
|
||||
}
|
||||
|
||||
fn default_labels() -> Vec<LabelPref> {
|
||||
Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect()
|
||||
}
|
||||
|
|
@ -390,6 +432,9 @@ pub struct Prefs {
|
|||
pub preview: crate::preview::PreviewSettings,
|
||||
/// View ▸ Simulate Output ▸ My Custom RGB.
|
||||
pub custom_rgb: crate::viewer::CustomRgb,
|
||||
/// Composition Settings presets the user saved, in the order saved.
|
||||
#[serde(deserialize_with = "lenient_comp_presets")]
|
||||
pub comp_presets: Vec<CompPreset>,
|
||||
/// Top-level keys this version doesn't know (kept for newer versions).
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
|
|
@ -425,6 +470,7 @@ impl Default for Prefs {
|
|||
recent_fonts: vec![],
|
||||
preview: crate::preview::PreviewSettings::default(),
|
||||
custom_rgb: crate::viewer::CustomRgb::default(),
|
||||
comp_presets: vec![],
|
||||
extra: Extra::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -550,6 +596,7 @@ impl Prefs {
|
|||
/// Clamp values into their valid ranges and fill missing labels.
|
||||
pub fn normalize(&mut self) {
|
||||
self.version = PREFS_VERSION;
|
||||
self.comp_presets.retain(CompPreset::is_valid);
|
||||
if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) {
|
||||
self.startup.window_graphics = Startup::default().window_graphics;
|
||||
}
|
||||
|
|
@ -564,6 +611,8 @@ impl Prefs {
|
|||
a.interval_minutes = a.interval_minutes.clamp(1, 240);
|
||||
a.max_versions = a.max_versions.clamp(1, 99);
|
||||
let ap = &mut self.appearance;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
ap.favorite_themes.retain(|id| is_choice(THEMES, id) && seen.insert(id.clone()));
|
||||
if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) {
|
||||
ap.appearance_mode = Appearance::default().appearance_mode;
|
||||
}
|
||||
|
|
@ -620,6 +669,23 @@ impl Prefs {
|
|||
let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect();
|
||||
return Err(format!("`{key}` expects one of {}", all.join(", ")));
|
||||
}
|
||||
let favorites = if key == "appearance.favoriteThemes" {
|
||||
Some(&value)
|
||||
} else if key == "appearance" {
|
||||
value.get("favoriteThemes")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(favorites) = favorites {
|
||||
let values = favorites.as_array().ok_or("favorite themes must be a list")?;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for value in values {
|
||||
let id = value.as_str().ok_or("favorite theme must be a string")?;
|
||||
if !is_choice(THEMES, id) || !seen.insert(id) {
|
||||
return Err("unknown or duplicate favorite theme".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Older clients set the single theme (by key, or inside the page object): it still picks
|
||||
// a visible theme, fixing the mode to its family.
|
||||
let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none());
|
||||
|
|
@ -639,8 +705,8 @@ impl Prefs {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent is
|
||||
/// kept.
|
||||
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent and
|
||||
/// the saved composition presets are kept.
|
||||
pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> {
|
||||
let d = Prefs::default();
|
||||
let Some(page) = page else {
|
||||
|
|
@ -650,6 +716,7 @@ impl Prefs {
|
|||
recent_presets: std::mem::take(&mut self.recent_presets),
|
||||
recent_fonts: std::mem::take(&mut self.recent_fonts),
|
||||
preview: std::mem::take(&mut self.preview),
|
||||
comp_presets: std::mem::take(&mut self.comp_presets),
|
||||
..d
|
||||
};
|
||||
return Ok(());
|
||||
|
|
|
|||
|
|
@ -116,7 +116,9 @@ pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: boo
|
|||
}
|
||||
|
||||
fn new_comp(cx: &Ctx) -> Comp {
|
||||
Comp::new(cx.psd.width, cx.psd.height, cx.rate, cx.duration)
|
||||
// A PSB can be 300,000 px a side; a composition is at most 30,000 (#788).
|
||||
let size = |v: u32| effectcraft_project::limits::size(v.into());
|
||||
Comp::new(size(cx.psd.width), size(cx.psd.height), cx.rate, cx.duration)
|
||||
}
|
||||
|
||||
fn build_comp(proj: &mut Project, cx: &mut Ctx, name: &str, nodes: &[Node], parent: Option<ItemId>) -> ItemId {
|
||||
|
|
|
|||
|
|
@ -88,7 +88,9 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
|
|||
let entries = crate::menus::entries();
|
||||
for (path, e) in &entries {
|
||||
let key = binding_key(&e.command, &e.params);
|
||||
let defaults = e.shortcut.iter().filter_map(|s| normalize(s)).collect();
|
||||
// A plain entry also gets its command's further default keys.
|
||||
let alt = if e.params.is_null() { crate::commands::find(&e.command).map_or(&[][..], |c| c.alt_shortcuts) } else { &[] };
|
||||
let defaults = e.shortcut.as_deref().into_iter().chain(alt.iter().copied()).filter_map(normalize).collect();
|
||||
push(
|
||||
Bindable {
|
||||
key,
|
||||
|
|
@ -106,7 +108,7 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
|
|||
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
|
||||
continue;
|
||||
}
|
||||
let defaults = c.shortcut.and_then(normalize).into_iter().collect();
|
||||
let defaults = c.shortcut.into_iter().chain(c.alt_shortcuts.iter().copied()).filter_map(normalize).collect();
|
||||
push(
|
||||
Bindable {
|
||||
key: c.id.to_string(),
|
||||
|
|
|
|||
|
|
@ -628,3 +628,61 @@ fn new_text_rejects_malformed_positions_without_creating_a_layer() {
|
|||
s.execute("layer.newText", json!({"text": "No explicit position"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
|
||||
}
|
||||
|
||||
/// The active comp and the open comp tabs are saved with the project and restored on open (#748).
|
||||
#[test]
|
||||
fn reopening_a_project_restores_its_active_comp() {
|
||||
use effectcraft_project::ItemId;
|
||||
let dir = std::env::temp_dir().join(format!("ec-test-748-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("view.ecproj").to_string_lossy().to_string();
|
||||
let mut s = Session::default();
|
||||
let a = s.execute("comp.new", json!({"name": "CompA"})).unwrap()["comp"].as_u64().unwrap();
|
||||
let b = s.execute("comp.new", json!({"name": "CompB"})).unwrap()["comp"].as_u64().unwrap();
|
||||
let c = s.execute("comp.new", json!({"name": "CompC"})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("comp.open", json!({"comp": "CompB"})).unwrap();
|
||||
s.state.open_comps.retain(|id| id.0 != c);
|
||||
s.execute("file.saveAs", json!({"path": path})).unwrap();
|
||||
let mut s2 = Session::default();
|
||||
s2.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!(s2.state.active_comp, Some(ItemId(b)));
|
||||
assert_eq!(s2.state.open_comps, vec![ItemId(a), ItemId(b)]);
|
||||
assert!(!s2.is_dirty());
|
||||
|
||||
// A file from before the view was saved opens on its first comp, as it always did.
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let mut old: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
let obj = old.as_object_mut().unwrap();
|
||||
obj.remove("activeComp");
|
||||
obj.remove("openComps");
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
|
||||
let mut s3 = Session::default();
|
||||
s3.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!((s3.state.active_comp, s3.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)]));
|
||||
|
||||
// Ids that no longer name a comp (edited by hand, or written by another tool) fall back.
|
||||
obj_set(&mut old, json!(999), json!([999, b, "x"]));
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
|
||||
let mut s4 = Session::default();
|
||||
s4.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!((s4.state.active_comp, s4.state.open_comps.clone()), (Some(ItemId(b)), vec![ItemId(b)]));
|
||||
obj_set(&mut old, json!(999), json!([]));
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
|
||||
let mut s5 = Session::default();
|
||||
s5.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!((s5.state.active_comp, s5.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)]));
|
||||
|
||||
// XML copies carry it too.
|
||||
let xml = dir.join("view.ecprojx").to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", json!({"path": xml})).unwrap();
|
||||
let mut s6 = Session::default();
|
||||
s6.execute("file.open", json!({"path": xml})).unwrap();
|
||||
assert_eq!(s6.state.active_comp, Some(ItemId(b)));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
|
||||
fn obj_set(v: &mut serde_json::Value, active: serde_json::Value, open: serde_json::Value) {
|
||||
let o = v.as_object_mut().unwrap();
|
||||
o.insert("activeComp".into(), active);
|
||||
o.insert("openComps".into(), open);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -243,3 +243,25 @@ fn demo_has_3d_showcase() {
|
|||
let lit = img.data.iter().filter(|p| p[3] > 0.99 && p[0] + p[1] + p[2] > 0.3).count();
|
||||
assert!(lit > 2_000, "{lit}");
|
||||
}
|
||||
|
||||
/// #670: the views of the panes of a multi-view layout are chosen per pane, kept per comp, and
|
||||
/// editor states saved before panes existed load with the default Top / Front / Right.
|
||||
#[test]
|
||||
fn layout_panes_choose_their_view() {
|
||||
use effectcraft_render::three_d::{View3D, Views3D};
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
assert_eq!(s.state.views3d.get(&cid).cloned().unwrap_or_default().pane(0), View3D::Top);
|
||||
let r = s.execute("view.setPaneView", json!({"pane": 2, "view": "bottom"})).unwrap();
|
||||
assert_eq!(r["views"], json!(["activeCamera", "top", "bottom", "right"]));
|
||||
assert_eq!(s.state.views3d[&cid].current, View3D::ActiveCamera, "the main view is untouched");
|
||||
s.execute("view.setPaneView", json!({"pane": 0, "view": "custom1"})).unwrap();
|
||||
assert_eq!(s.state.views3d[&cid].current, View3D::Custom1);
|
||||
assert!(s.execute("view.setPaneView", json!({"pane": 4, "view": "top"})).is_err());
|
||||
assert!(s.execute("view.setPaneView", json!({"pane": 1, "view": "nope"})).is_err());
|
||||
let back: crate::EditorState = serde_json::from_value(serde_json::to_value(&s.state).unwrap()).unwrap();
|
||||
assert_eq!(back.views3d[&cid].pane(1), View3D::Bottom);
|
||||
let old: Views3D = serde_json::from_value(json!({"current": "Front"})).unwrap();
|
||||
assert_eq!((old.current, old.pane(0), old.pane(1), old.pane(2)), (View3D::Front, View3D::Top, View3D::Front, View3D::Right));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
//! A project-file fuzzer (gaps G3, #779): File ▸ Open must turn any bytes into a project or an
|
||||
//! error, never a panic, an abort or a hang (AGENTS.md "Never crash").
|
||||
//!
|
||||
//! Dependency-free, deterministic and seeded: seed projects (empty, and the demo cut down to one
|
||||
//! layer at a time, each as `.ecproj` JSON and as `.ecprojx` XML) are mutated byte-wise (flips,
|
||||
//! deletions, splices, truncation, interesting tokens, deep nesting) and structurally (a JSON
|
||||
//! node replaced by an extreme number, another type or another subtree; members dropped,
|
||||
//! elements duplicated), then opened through the real `file.open` path, followed by the queries
|
||||
//! the app and agents run on a fresh project and a thumbnail-size render of the first frame.
|
||||
//! Each input runs on its own thread under `catch_unwind` with a deadline, so a panic or a hang
|
||||
//! is reported (with the iteration to reproduce it) instead of taking the test run down; a stack
|
||||
//! overflow can't be caught and aborts the run, which fails it all the same.
|
||||
//!
|
||||
//! The default run is bounded (iterations and seconds) to stay fast in `cargo test`; a longer run:
|
||||
//! `EFFECTCRAFT_FUZZ_ITERS=200000 EFFECTCRAFT_FUZZ_SECS=600 cargo test -p effectcraft-engine fuzz_`
|
||||
//! (`EFFECTCRAFT_FUZZ_SEED` picks another sequence, `EFFECTCRAFT_FUZZ_START` the first iteration).
|
||||
//! The `mutate` + `open_input` pair is what a cargo-fuzz target would call.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Services, Session};
|
||||
|
||||
const PATH: &str = "/fuzz/input.ecproj";
|
||||
|
||||
/// splitmix64: tiny, good enough for picking mutations.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
/// `0..n` (`0` when `n` is 0).
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
if n == 0 { 0 } else { (self.next() % n as u64) as usize }
|
||||
}
|
||||
fn chance(&mut self, one_in: usize) -> bool {
|
||||
self.below(one_in) == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// One file, served from memory; everything else is missing.
|
||||
struct OneFile(Vec<u8>);
|
||||
|
||||
impl Services for OneFile {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
if path == PATH { Ok(self.0.clone()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string())) }
|
||||
}
|
||||
fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other("the fuzzer writes nothing"))
|
||||
}
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
path == PATH
|
||||
}
|
||||
fn stamp(&self, _: &str) -> Option<(u64, u128)> {
|
||||
None
|
||||
}
|
||||
fn list_dir(&self, _: &str) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
fn is_dir(&self, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `bytes` as a project file, then run what the app and agents read first: `Ok(true)` when
|
||||
/// it opened, `Ok(false)` when it was refused. `Err` only for a crash: a panic in the open
|
||||
/// itself, or one the command guard caught in a query.
|
||||
fn open_input(bytes: Vec<u8>) -> Result<bool, String> {
|
||||
let mut s = Session { services: Arc::new(OneFile(bytes)), ..Default::default() };
|
||||
if crate::commands::open_for_tests(&mut s, &json!({ "path": PATH })).is_err() {
|
||||
return Ok(false); // refused: fine
|
||||
}
|
||||
let crashed = |r: crate::Result<Value>| match r {
|
||||
Err(e) if e.to_string().contains("internal error") => Err(e.to_string()),
|
||||
_ => Ok(()),
|
||||
};
|
||||
crashed(s.execute("project.summary", json!({})))?;
|
||||
let comps: Vec<_> = s.project.comps().map(|(id, c)| (id.0, c.layers.iter().map(|l| l.id.0).collect::<Vec<_>>())).collect();
|
||||
for (comp, layers) in comps.into_iter().take(4) {
|
||||
crashed(s.execute("comp.info", json!({ "comp": comp })))?;
|
||||
for layer in layers.into_iter().take(8) {
|
||||
crashed(s.execute("layer.tree", json!({ "comp": comp, "layer": layer, "depth": 3, "time": 1.0 })))?;
|
||||
}
|
||||
}
|
||||
// The first frame the viewer shows, at thumbnail size.
|
||||
if let Some(id) = s.state.active_comp
|
||||
&& let Some(c) = s.project.comp(id)
|
||||
{
|
||||
let scale = (32.0 / f64::from(c.width.max(c.height).max(1))).min(1.0);
|
||||
effectcraft_render::render_frame(&s.project, id, effectcraft_time::Tick::from_seconds_f64(1.0), scale);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Run one input on its own thread: a panic becomes its message, a hang a timeout.
|
||||
fn run_guarded(bytes: Vec<u8>, deadline: Duration) -> Result<bool, String> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("fuzz-open".into()).stack_size(8 << 20).spawn(move || {
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| open_input(bytes)));
|
||||
let r = match r {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
Err(format!("panic: {}", p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default()))
|
||||
}
|
||||
};
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
return Err(format!("couldn't start the fuzz thread: {e}"));
|
||||
}
|
||||
rx.recv_timeout(deadline).unwrap_or_else(|_| Err(format!("hang: no result within {deadline:?}")))
|
||||
}
|
||||
|
||||
/// Small projects covering what a project holds: the empty project, and the demo cut down to
|
||||
/// one layer of one comp at a time (shape, text, solid, precomp, camera, light, null, effects,
|
||||
/// masks, expressions; other comps keep their first layer), each as JSON and as XML. The whole
|
||||
/// demo (700 kB) would make every input slow.
|
||||
fn seeds() -> Vec<Vec<u8>> {
|
||||
let demo = crate::demo::demo_project();
|
||||
let mut projects = vec![effectcraft_project::Project::default()];
|
||||
for (cid, c) in demo.comps() {
|
||||
for i in 0..c.layers.len() {
|
||||
let mut p = demo.clone();
|
||||
for (id, item) in p.items.iter_mut() {
|
||||
if let effectcraft_project::ItemKind::Comp(c) = &mut item.kind {
|
||||
let keep = if id == cid { i } else { 0 };
|
||||
let layers = &mut Arc::make_mut(c).layers;
|
||||
*layers = layers.get(keep).cloned().into_iter().collect();
|
||||
}
|
||||
}
|
||||
projects.push(p);
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
for p in projects {
|
||||
let json = p.to_file_json().unwrap();
|
||||
out.push(crate::xml_project::to_xml(&json).unwrap().into_bytes());
|
||||
out.push(json.into_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
const TOKENS: &[&str] = &[
|
||||
"{", "}", "[", "]", "\"", ",", ":", "null", "true", "-1", "0", "1e308", "-1e308", "1e-320", "18446744073709551615",
|
||||
"-9223372036854775808", "4294967295", "4294967296", "\\u0000", "\\ud800", "<", ">", "</", "/>", "<arr>", "</arr>", "<obj>",
|
||||
"<m n=\"", "</m>", "<num>", "</num>", "<str>", "</str>", "<null/>", "<!--", "-->", "<?", "?>", "&", ";", "�",
|
||||
"�", "&#;", "&#x;", "&", "\u{feff}", "é", "\u{1F600}",
|
||||
];
|
||||
|
||||
const NUMBERS: &[f64] = &[0.0, -0.0, 1.0, -1.0, 0.5, -0.5, 2.0, 1e-300, 1e15, 1e300, -1e300, 4294967295.0, 4294967296.0, 65536.0, 254016000000.0];
|
||||
const INTEGERS: &[i64] = &[0, -1, 1, 2, 3, 255, 256, 65535, 2147483647, -2147483648, i64::MAX, i64::MIN, 9_007_199_254_740_993];
|
||||
|
||||
/// Every node of `v`, depth first (the root is 0).
|
||||
fn count(v: &Value) -> usize {
|
||||
1 + match v {
|
||||
Value::Array(a) => a.iter().map(count).sum(),
|
||||
Value::Object(m) => m.values().map(count).sum(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `k`-th node (depth first; the root is 0).
|
||||
fn nth<'a>(v: &'a mut Value, k: &mut usize) -> Option<&'a mut Value> {
|
||||
if *k == 0 {
|
||||
return Some(v);
|
||||
}
|
||||
*k -= 1;
|
||||
match v {
|
||||
Value::Array(a) => a.iter_mut().find_map(|x| nth(x, k)),
|
||||
Value::Object(m) => m.values_mut().find_map(|x| nth(x, k)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_mut(v: &mut Value, k: usize) -> Option<&mut Value> {
|
||||
nth(v, &mut { k })
|
||||
}
|
||||
|
||||
fn interesting(r: &mut Rng, around: &Value) -> Value {
|
||||
match r.below(7) {
|
||||
0 => json!(NUMBERS[r.below(NUMBERS.len())]),
|
||||
1 => json!(INTEGERS[r.below(INTEGERS.len())]),
|
||||
2 => match around.as_f64() {
|
||||
Some(x) => json!(x * [-1.0, 2.0, 1e6, 1e-6, 0.0][r.below(5)]),
|
||||
None => Value::Null,
|
||||
},
|
||||
3 => json!(["", "#999", "@0", "effects/#1", "../../x", "\u{0}", "transform/position", &"a".repeat(r.below(300))][r.below(8)]),
|
||||
4 => Value::Bool(r.chance(2)),
|
||||
5 => json!([]),
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// A structural mutation of a project's JSON (when the input still parses).
|
||||
fn mutate_tree(r: &mut Rng, v: &mut Value) {
|
||||
let n = count(v);
|
||||
for _ in 0..1 + r.below(4) {
|
||||
let k = r.below(n.max(1));
|
||||
let donor = node_mut(v, r.below(n.max(1))).map(|d| d.clone()).unwrap_or(Value::Null);
|
||||
let Some(node) = node_mut(v, k) else { return };
|
||||
match r.below(6) {
|
||||
0 | 1 => *node = interesting(r, node),
|
||||
2 => *node = donor,
|
||||
3 => match node {
|
||||
Value::Object(m) if !m.is_empty() => {
|
||||
let key = m.keys().nth(r.below(m.len())).cloned().unwrap_or_default();
|
||||
m.remove(&key);
|
||||
}
|
||||
Value::Array(a) if !a.is_empty() => {
|
||||
a.remove(r.below(a.len()));
|
||||
}
|
||||
_ => *node = Value::Null,
|
||||
},
|
||||
4 => {
|
||||
if let Value::Array(a) = node
|
||||
&& !a.is_empty()
|
||||
{
|
||||
let i = r.below(a.len());
|
||||
let x = a[i].clone();
|
||||
a.insert(r.below(a.len() + 1), x);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Value::Object(m) = node {
|
||||
m.insert(["id", "parent", "source", "schema", "nextId", "layers", "items", "kind"][r.below(8)].into(), interesting(r, &Value::Null));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mutate_bytes(r: &mut Rng, b: &mut Vec<u8>) {
|
||||
for _ in 0..1 + r.below(3) {
|
||||
let len = b.len();
|
||||
match r.below(8) {
|
||||
0 if len > 0 => {
|
||||
let i = r.below(len);
|
||||
b[i] ^= 1 << r.below(8);
|
||||
}
|
||||
1 if len > 0 => {
|
||||
let i = r.below(len);
|
||||
b[i] = r.next() as u8;
|
||||
}
|
||||
2 if len > 0 => {
|
||||
let a = r.below(len);
|
||||
let e = (a + 1 + r.below(64)).min(len);
|
||||
b.drain(a..e);
|
||||
}
|
||||
3 if len > 0 => {
|
||||
let a = r.below(len);
|
||||
let e = (a + 1 + r.below(256)).min(len);
|
||||
let chunk = b[a..e].to_vec();
|
||||
let at = r.below(len + 1);
|
||||
b.splice(at..at, chunk);
|
||||
}
|
||||
4 => {
|
||||
let t = TOKENS[r.below(TOKENS.len())].as_bytes();
|
||||
let at = r.below(len + 1);
|
||||
b.splice(at..at, t.iter().copied());
|
||||
}
|
||||
5 if len > 0 => b.truncate(r.below(len)),
|
||||
6 => {
|
||||
// Deep nesting: what a recursive reader overflows its stack on.
|
||||
let (open, close) = [("[", "]"), ("<arr>", "</arr>"), ("<obj><m n=\"a\">", "</m></obj>")][r.below(3)];
|
||||
let depth = [200, 5_000, 200_000][r.below(3)];
|
||||
// Where the reader expects a value: after an opening token of the same kind.
|
||||
let opens: Vec<usize> = b.windows(open.len().min(5)).enumerate().filter(|(_, w)| *w == &open.as_bytes()[..w.len()]).map(|(i, _)| i).collect();
|
||||
let at = if opens.is_empty() { r.below(len + 1) } else { opens[r.below(opens.len())] };
|
||||
let mut nest = open.repeat(depth).into_bytes();
|
||||
nest.extend(close.repeat(if r.chance(2) { depth } else { 0 }).into_bytes());
|
||||
b.splice(at..at, nest);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One fuzz input from the seeds, decided by the iteration's own generator.
|
||||
fn mutate(r: &mut Rng, seeds: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut b = seeds[r.below(seeds.len())].clone();
|
||||
if r.chance(2) {
|
||||
let text = String::from_utf8_lossy(&b).to_string();
|
||||
let json = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).ok() } else { Some(text) };
|
||||
if let Some(mut v) = json.and_then(|j| serde_json::from_str::<Value>(&j).ok()) {
|
||||
mutate_tree(r, &mut v);
|
||||
let j = v.to_string();
|
||||
b = if r.chance(3) { crate::xml_project::to_xml(&j).unwrap_or(j).into_bytes() } else { j.into_bytes() };
|
||||
}
|
||||
}
|
||||
if r.chance(3) || b.is_empty() {
|
||||
mutate_bytes(r, &mut b);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn env_u64(name: &str, default: u64) -> u64 {
|
||||
std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_file_open_never_crashes() {
|
||||
let seed = env_u64("EFFECTCRAFT_FUZZ_SEED", 0x00EC_F022);
|
||||
let start = env_u64("EFFECTCRAFT_FUZZ_START", 0);
|
||||
let iters = env_u64("EFFECTCRAFT_FUZZ_ITERS", 3_000);
|
||||
let budget = Duration::from_secs(env_u64("EFFECTCRAFT_FUZZ_SECS", 6));
|
||||
let seeds = seeds();
|
||||
// The unmutated seeds open.
|
||||
for s in &seeds {
|
||||
assert_eq!(run_guarded(s.clone(), Duration::from_secs(30)), Ok(true));
|
||||
}
|
||||
let began = Instant::now();
|
||||
let mut failures = vec![];
|
||||
let (mut ran, mut opened) = (0, 0);
|
||||
for i in start..start + iters {
|
||||
if began.elapsed() > budget {
|
||||
break;
|
||||
}
|
||||
let mut r = Rng(seed ^ i.wrapping_mul(0xD6E8_FEB8_6659_FD93));
|
||||
let input = mutate(&mut r, &seeds);
|
||||
ran += 1;
|
||||
match run_guarded(input.clone(), Duration::from_secs(10)) {
|
||||
Ok(o) => opened += usize::from(o),
|
||||
Err(e) => {
|
||||
let head = String::from_utf8_lossy(&input[..input.len().min(200)]).to_string();
|
||||
failures.push(format!("iteration {i} (seed {seed:#x}, {} bytes): {e}\n input starts: {head:?}", input.len()));
|
||||
if failures.len() >= 5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "File ▸ Open crashed on {} of {ran} fuzz inputs:\n{}", failures.len(), failures.join("\n"));
|
||||
eprintln!("fuzz_file_open: {ran} inputs ({opened} opened) in {:?}, no crashes", began.elapsed());
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
//! Sizes, frame rates and motion-blur samples stay within the project limits when a project is
|
||||
//! opened and when a command sets them (#788).
|
||||
|
||||
use effectcraft_project::{ItemKind, limits};
|
||||
use effectcraft_render::RenderOpts;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Event, Session};
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-limits-tests-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// The saved project with `edit` applied to the JSON of every item of kind `kind`.
|
||||
fn tamper(text: &str, kind: &str, edit: impl Fn(&mut Value)) -> String {
|
||||
let mut v: Value = serde_json::from_str(text).unwrap();
|
||||
let items = v["items"].as_object_mut().unwrap();
|
||||
for it in items.values_mut().filter(|it| it["kind"]["type"] == kind) {
|
||||
edit(&mut it["kind"]);
|
||||
}
|
||||
serde_json::to_string(&v).unwrap()
|
||||
}
|
||||
|
||||
/// A project whose comp is 4 billion × 0 px at 10^18 fps, -5 s long with 4 billion motion-blur
|
||||
/// samples, holding a solid of 4 billion px.
|
||||
fn hostile_project() -> String {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 64, "height": 2})).unwrap();
|
||||
let text = s.project.to_file_json().unwrap();
|
||||
let text = tamper(&text, "Comp", |c| {
|
||||
c["width"] = json!(4_000_000_000u64);
|
||||
c["height"] = json!(0);
|
||||
c["frame_rate"] = json!({"num": 1_000_000_000_000_000_000i64, "den": 1});
|
||||
c["duration"] = json!(-5);
|
||||
c["motion_blur_samples"] = json!(4_000_000_000u64);
|
||||
});
|
||||
tamper(&text, "Solid", |so| so["width"] = json!(4_000_000_000u64))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_project_opens_within_the_limits_and_says_what_changed() {
|
||||
let json_path = tmp("Hostile.ecproj");
|
||||
let text = hostile_project();
|
||||
std::fs::write(&json_path, &text).unwrap();
|
||||
let xml_path = tmp("Hostile.ecprojx");
|
||||
std::fs::write(&xml_path, crate::xml_project::to_xml(&text).unwrap()).unwrap();
|
||||
for path in [json_path, xml_path] {
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("file.open", json!({"path": path})).unwrap();
|
||||
let repaired = r["repaired"].as_array().unwrap();
|
||||
assert!(repaired.iter().any(|n| n.as_str().unwrap().contains("4000000000 × 0 px → 30000 × 1 px")), "{path}: {repaired:?}");
|
||||
let toast = s.drain_events().into_iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("outside EffectCraft's limits")));
|
||||
assert!(toast, "{path}");
|
||||
let (cid, c) = s.project.comps().next().map(|(id, c)| (*id, c.clone())).unwrap();
|
||||
assert_eq!((c.width, c.height), (limits::MAX_SIZE, 1));
|
||||
assert_eq!(c.frame_rate.as_f64(), limits::MAX_FPS);
|
||||
assert_eq!(c.duration, c.frame_rate.frame_duration(), "at least one frame");
|
||||
assert_eq!(c.work_area, (Tick::ZERO, c.duration));
|
||||
assert_eq!(c.motion_blur_samples, limits::MOTION_BLUR_SAMPLES.1);
|
||||
let solid = s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
|
||||
assert_eq!((solid.width, solid.height), (limits::MAX_SIZE, 2));
|
||||
// The comp renders at Full resolution, at its (capped) size.
|
||||
let img = s.render(cid, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!((img.width, img.height), (limits::MAX_SIZE, 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_within_the_limits_opens_unchanged() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 24, "duration": 2})).unwrap();
|
||||
let text = s.project.to_file_json().unwrap();
|
||||
let (p, notes) = effectcraft_project::Project::from_json_repaired(&text).unwrap();
|
||||
assert!(notes.is_empty(), "{notes:?}");
|
||||
assert_eq!(p, *s.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_cap_sizes_at_30000_px() {
|
||||
let mut s = Session::default();
|
||||
// 2^32 + 10 used to wrap to 10 px.
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 4_294_967_306u64, "height": 32, "pixelAspect": 1e9})).unwrap();
|
||||
let c = s.active_comp().unwrap().clone();
|
||||
assert_eq!((c.width, c.height, c.pixel_aspect), (limits::MAX_SIZE, 32, limits::MAX_PIXEL_ASPECT));
|
||||
s.execute("comp.settings", json!({"width": 64, "height": 4_294_967_306u64})).unwrap();
|
||||
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, limits::MAX_SIZE));
|
||||
// New Solid: 0 and 4 billion px are brought to 1 and 30,000; the layer matches its solid.
|
||||
s.execute("layer.newSolid", json!({"width": 0, "height": 4_000_000_000u64})).unwrap();
|
||||
let solid = |s: &Session| s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
|
||||
assert_eq!((solid(&s).width, solid(&s).height), (1, limits::MAX_SIZE));
|
||||
// Layer Settings on the new (selected) solid layer.
|
||||
s.execute("layer.settings", json!({"width": 4_000_000_000u64, "height": 8})).unwrap();
|
||||
assert_eq!((solid(&s).width, solid(&s).height), (limits::MAX_SIZE, 8));
|
||||
s.execute("file.importSolid", json!({"width": 99_999_999, "height": 4})).unwrap();
|
||||
assert!(s.project.items.values().any(|i| matches!(&i.kind, ItemKind::Solid(so) if (so.width, so.height) == (limits::MAX_SIZE, 4))));
|
||||
// A placeholder can't ask for a huge frame or a zero frame rate either.
|
||||
s.execute("file.importPlaceholder", json!({"width": 4_000_000_000u64, "height": 8, "frameRate": 0})).unwrap();
|
||||
let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap();
|
||||
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
|
||||
}
|
||||
|
|
@ -52,6 +52,41 @@ fn layer_quality_sampling_frame_blending_and_undo() {
|
|||
assert!(s.execute("layer.quality", json!({"quality": "fancy"})).is_err());
|
||||
}
|
||||
|
||||
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature): the layer stands alone, the
|
||||
/// other layers come back the next time.
|
||||
#[test]
|
||||
fn set_switch_others() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
let c = solid(&mut s, "#0000ff");
|
||||
let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, b, c].map(|l| f(&layer(s, l)));
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "value": false})).unwrap();
|
||||
// B is hidden: it is shown, alone; then the others come back; one undo step each.
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [false, true, false]);
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [false, true, false]);
|
||||
// Solo.
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "solo", "value": true})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "solo", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.solo), [false, false, true]);
|
||||
// Lock: the layer stays unlocked and the others lock, then unlock.
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.locked), [false, true, true]);
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.locked), [false; 3]);
|
||||
// A track matte keeps its video off.
|
||||
s.execute("layer.setTrackMatte", json!({"layer": b, "matte": a})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "value": false})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "video", "others": true})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "video", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [false, true, true]);
|
||||
assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_switches_menu() {
|
||||
let mut s = comp();
|
||||
|
|
@ -1416,3 +1451,16 @@ fn duplicate_with_shape_items_selected_duplicates_them_in_place() {
|
|||
s.execute("edit.duplicate", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), n + 1);
|
||||
}
|
||||
|
||||
/// The Interpret Footage form sends "Loop (times)" as a float (3.0); it must still be applied (#909).
|
||||
#[test]
|
||||
fn interpret_footage_loop_from_form_float() {
|
||||
let mut s = comp();
|
||||
let p = s.execute("file.importPlaceholder", json!({"name": "Shot", "width": 320, "height": 240})).unwrap()["item"].as_u64().unwrap();
|
||||
s.state.project_selection = vec![effectcraft_project::ItemId(p)];
|
||||
s.execute("file.interpretFootage", json!({"loop": 3.0})).unwrap();
|
||||
match &s.project.item(effectcraft_project::ItemId(p)).unwrap().kind {
|
||||
ItemKind::Footage(f) => assert_eq!(f.loop_count, 3),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,6 +185,16 @@ fn scopes_read_the_comp_frame() {
|
|||
|
||||
// ---------------------------------------------------------------- Footage panel
|
||||
|
||||
#[test]
|
||||
fn footage_panel_overlay_keeps_full_length_past_the_comp_end() {
|
||||
let (mut s, cid, _clip, item) = setup(32, 24, 8.0, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]));
|
||||
s.execute("footage.open", json!({"item": item.0})).unwrap();
|
||||
let r = s.execute("footage.overlayEdit", json!({"item": item.0, "in": 0.0, "out": 4.0, "at": 6.0})).unwrap();
|
||||
let ov = LayerId(r["layer"].as_u64().unwrap());
|
||||
let l = s.project.comp(cid).unwrap().layer(ov).unwrap();
|
||||
assert_eq!((l.in_point.seconds(), l.out_point.seconds()), (6.0, 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footage_panel_overlay_and_ripple_insert() {
|
||||
let (mut s, cid, clip, item) = setup(32, 24, 8.0, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]));
|
||||
|
|
@ -629,3 +639,36 @@ fn media_browser_over_a_virtual_tree() {
|
|||
assert_eq!(*b.actions.lock().unwrap(), ["openFolder"]);
|
||||
assert!(s.execute("mediaBrowser.action", json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Footage the source can't decode is reported with its reason by `project.summary` and
|
||||
/// `footage.info`, so an agent can tell the user why the picture is black.
|
||||
#[test]
|
||||
fn footage_that_cannot_be_decoded_reports_why() {
|
||||
struct Broken;
|
||||
impl FootageSource for Broken {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn problem(&self, f: &Footage) -> Option<String> {
|
||||
Some(format!("{} isn't supported by the built-in decoder", f.codec))
|
||||
}
|
||||
}
|
||||
let mut s = Session { footage: Arc::new(Broken), ..Default::default() };
|
||||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap();
|
||||
let f = Footage {
|
||||
kind: FootageKind::Video,
|
||||
path: "a.mp4".into(),
|
||||
codec: "H.264 (High 4:2:2)".into(),
|
||||
width: 64,
|
||||
height: 36,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
let item = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(f));
|
||||
let sum = s.execute("project.summary", json!({})).unwrap();
|
||||
let it = sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(item.0)).unwrap();
|
||||
assert_eq!(it["problem"], json!("H.264 (High 4:2:2) isn't supported by the built-in decoder"));
|
||||
assert!(sum["items"].as_array().unwrap().iter().filter(|i| i["id"] != json!(item.0)).all(|i| i.get("problem").is_none()), "only footage reports one");
|
||||
s.execute("footage.open", json!({"item": item.0})).unwrap();
|
||||
assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,6 +212,50 @@ fn prefs_commands_get_set_reset_and_persist() {
|
|||
);
|
||||
}
|
||||
|
||||
/// Composition Settings presets the user saves live in the settings: they persist, a save with
|
||||
/// an existing name replaces it, they survive Reset All, and invalid ones (from a hand-edited
|
||||
/// settings file) are dropped on load.
|
||||
#[test]
|
||||
fn composition_presets_save_replace_delete_and_persist() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = Session { config: Some(store.clone()), ..Default::default() };
|
||||
let p = |name: &str, w: u32, fps: f64| json!({"name": name, "width": w, "height": 1440, "pixelAspect": 1.0, "frameRate": fps});
|
||||
assert_eq!(s.execute_checked("comp.savePreset", p(" Edit 1440p 60 ", 2560, 60.0)).unwrap(), json!({"name": "Edit 1440p 60", "replaced": false}));
|
||||
s.execute_checked("comp.savePreset", p("Vertical", 1440, 30.0)).unwrap();
|
||||
assert_eq!(s.execute("comp.savePreset", p("Vertical", 1080, 30.0)).unwrap()["replaced"], json!(true));
|
||||
let list = s.execute("comp.presets", json!({})).unwrap();
|
||||
assert_eq!(list.as_array().unwrap().len(), 2);
|
||||
assert_eq!(list[0]["name"], "Edit 1440p 60");
|
||||
assert_eq!((list[0]["width"].clone(), list[0]["frameRate"].clone()), (json!(2560), json!(60.0)));
|
||||
assert_eq!(list[1]["width"], json!(1080), "replaced in place");
|
||||
for bad in [
|
||||
p("", 1920, 30.0),
|
||||
p("Tiny", 2, 30.0),
|
||||
p("Fast", 1920, 5000.0),
|
||||
json!({"name": "x", "width": 1920.5, "height": 1080, "pixelAspect": 1.0, "frameRate": 30}),
|
||||
json!({"name": "x"}),
|
||||
] {
|
||||
assert!(s.execute("comp.savePreset", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
// Persisted, and kept by Reset All.
|
||||
let mut s2 = Session { config: Some(store.clone()), ..Default::default() };
|
||||
s2.load_settings();
|
||||
assert_eq!(s2.prefs.comp_presets, s.prefs.comp_presets);
|
||||
s2.execute("prefs.reset", json!({})).unwrap();
|
||||
assert_eq!(s2.prefs.comp_presets.len(), 2);
|
||||
s2.execute_checked("comp.deletePreset", json!({"name": "Vertical"})).unwrap();
|
||||
assert!(s2.execute("comp.deletePreset", json!({"name": "Vertical"})).is_err(), "already gone");
|
||||
let mut s3 = Session { config: Some(store.clone()), ..Default::default() };
|
||||
s3.load_settings();
|
||||
assert_eq!(s3.prefs.comp_presets.iter().map(|q| q.name.as_str()).collect::<Vec<_>>(), ["Edit 1440p 60"]);
|
||||
// A hand-edited settings file: the broken preset is dropped, the good one kept.
|
||||
let mut v: serde_json::Value = serde_json::from_str(&store.read(PREFS_FILE).unwrap()).unwrap();
|
||||
v["compPresets"].as_array_mut().unwrap().push(json!({"name": "Broken", "width": 0, "height": 1080, "pixelAspect": 1.0, "frameRate": f64::NAN}));
|
||||
v["compPresets"].as_array_mut().unwrap().push(json!({"name": 7}));
|
||||
let back = Prefs::from_json(&v.to_string());
|
||||
assert_eq!(back.comp_presets.iter().map(|q| q.name.as_str()).collect::<Vec<_>>(), ["Edit 1440p 60"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_levels_are_honoured() {
|
||||
let mut s = session_with_comp();
|
||||
|
|
@ -331,6 +375,24 @@ fn defaults_have_no_conflicts_and_match_the_menus() {
|
|||
assert!(t.bindables.len() > 500);
|
||||
}
|
||||
|
||||
/// Cmd+Left / Cmd+Right Arrow step a frame like Page Up / Page Down (Shift: 10 frames).
|
||||
#[test]
|
||||
fn cmd_arrows_step_frames_like_page_keys() {
|
||||
let s = Session::default();
|
||||
let t = s.shortcuts();
|
||||
for (id, keys) in [
|
||||
("time.nextFrame", ["PageDown", "Cmd+ArrowRight"]),
|
||||
("time.previousFrame", ["PageUp", "Cmd+ArrowLeft"]),
|
||||
("time.forward10", ["Shift+PageDown", "Cmd+Shift+ArrowRight"]),
|
||||
("time.back10", ["Shift+PageUp", "Cmd+Shift+ArrowLeft"]),
|
||||
] {
|
||||
assert_eq!(t.keys.get(id).map(Vec::as_slice), Some(keys.map(String::from).as_slice()), "{id}");
|
||||
// The menus and tooltips keep showing the Page key.
|
||||
assert_eq!(t.shortcut_of(id, &json!(null)), Some(keys[0]));
|
||||
}
|
||||
assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_edits_detect_conflicts_and_create_a_custom_preset() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
|
|
@ -587,7 +649,7 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
|
|||
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pt-br"));
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
|
||||
let saved = store.read(PREFS_FILE).unwrap();
|
||||
for bad in [json!("fr"), json!(""), json!(17), json!(null)] {
|
||||
for bad in [json!("xx"), json!(""), json!(17), json!(null)] {
|
||||
assert!(s.execute("prefs.set", json!({"key": "general.language", "value": bad})).is_err());
|
||||
assert_eq!(s.prefs.general.language, "ja");
|
||||
assert_eq!(store.read(PREFS_FILE).unwrap(), saved);
|
||||
|
|
@ -613,6 +675,14 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
|
|||
let mut es = Session { config: Some(store.clone()), ..Default::default() };
|
||||
es.load_settings();
|
||||
assert_eq!(es.prefs.general.language, "es");
|
||||
// Czech uses the same validation, persistence and JSON round-trip as every catalog.
|
||||
assert!(crate::prefs::LANGUAGES.contains(&("Čeština", "cs")));
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap();
|
||||
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("cs"));
|
||||
let mut cs = Session { config: Some(store.clone()), ..Default::default() };
|
||||
cs.load_settings();
|
||||
assert_eq!(cs.prefs.general.language, "cs");
|
||||
assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs");
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
|
||||
let mut reloaded = Session { config: Some(store), ..Default::default() };
|
||||
reloaded.load_settings();
|
||||
|
|
|
|||
|
|
@ -420,3 +420,19 @@ fn camera_focus_commands_undo_and_serde() {
|
|||
assert!(s.execute("camera.linkFocusToLayer", json!({})).is_err());
|
||||
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
|
||||
}
|
||||
|
||||
/// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches.
|
||||
#[test]
|
||||
fn numeric_string_resolves_as_id() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 2.0, "frameRate": 30})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
assert_eq!(s.resolve_comp(Some(&json!(cid.0.to_string()))).unwrap(), cid);
|
||||
assert!(s.resolve_comp(Some(&json!("999999"))).is_err());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
assert_eq!(crate::commands::resolve_layer(comp, &json!(l.to_string())), Some(LayerId(l)));
|
||||
assert_eq!(crate::commands::resolve_layer(comp, &json!("999999")), None);
|
||||
let r = s.execute("layer.tree", json!({"layer": l.to_string(), "comp": cid.0.to_string()})).unwrap();
|
||||
assert_eq!(r["id"].as_u64(), Some(l), "{r}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -414,6 +414,25 @@ fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() {
|
|||
assert_eq!(effectcraft_time::format_timecode_ae(75, fr, true), "0;00;02;15");
|
||||
}
|
||||
|
||||
/// #890: at 29.97 / 59.94 fps the displayed (non-drop) timecode was read back as drop-frame, so
|
||||
/// committing the current-time field moved the playhead to another frame from minute 1 on.
|
||||
#[test]
|
||||
fn time_set_round_trips_the_displayed_timecode_at_drop_frame_rates() {
|
||||
for fps in [29.97, 59.94] {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "T", "width": 64, "height": 64, "frameRate": fps, "duration": 4000})).unwrap();
|
||||
for frame in [0_i64, 1799, 1800, 1801, 3600, 17982, 17983, 100_000] {
|
||||
let r = s.execute("time.set", json!({"frame": frame})).unwrap();
|
||||
let display = r["display"].as_str().unwrap().to_string();
|
||||
let r = s.execute("time.set", json!({"timecode": display})).unwrap();
|
||||
assert_eq!(r["frame"], frame, "{fps} fps: displayed {display}");
|
||||
}
|
||||
// Explicit drop-frame fields still mean drop-frame.
|
||||
let r = s.execute("time.set", json!({"timecode": "0;01;00;02"})).unwrap();
|
||||
assert_eq!(r["frame"], if fps > 30.0 { 3598 } else { 1800 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_set_keeps_the_frame_shown_in_the_current_time_field() {
|
||||
let mut s = Session::default();
|
||||
|
|
@ -712,6 +731,89 @@ fn deleting_time_remap_turns_time_remapping_off() {
|
|||
assert_eq!(prop(&s, l, "timeRemap").keys.len(), 1);
|
||||
}
|
||||
|
||||
/// U (keyframes), the animation reveal and UU (modified) take in Time Remap, which sits at the
|
||||
/// top of the layer's tree rather than in a group.
|
||||
#[test]
|
||||
fn reveal_shortcuts_take_in_time_remap() {
|
||||
let (mut s, l) = precomp_setup();
|
||||
let revealed = |s: &mut Session, kind: &str| -> Vec<u64> {
|
||||
let r = s.execute("anim.reveal", json!({"kind": kind})).unwrap();
|
||||
r["props"].as_array().unwrap().iter().filter(|p| p["layer"] == l).filter_map(|p| p["prop"].as_u64()).collect()
|
||||
};
|
||||
for kind in ["keyframes", "animation", "modified"] {
|
||||
assert!(revealed(&mut s, kind).is_empty(), "{kind}: nothing to reveal before remapping");
|
||||
}
|
||||
s.execute("layer.enableTimeRemap", json!({})).unwrap();
|
||||
let uid = prop(&s, l, "timeRemap").uid;
|
||||
for kind in ["keyframes", "animation", "modified"] {
|
||||
assert_eq!(revealed(&mut s, kind), vec![uid], "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
/// UU on a layer nothing was changed on shows nothing, whatever its kind: cameras, lights, text
|
||||
/// and nulls have their own Transform defaults, and Separate Dimensions keeps the comp centre.
|
||||
#[test]
|
||||
fn modified_reveal_is_empty_for_untouched_layers() {
|
||||
let (mut s, solid) = setup();
|
||||
let mut layers = vec![("solid", solid)];
|
||||
for (cmd, params) in [
|
||||
("layer.newText", json!({"text": "Hi"})),
|
||||
("layer.newShape", json!({})),
|
||||
("layer.newNull", json!({})),
|
||||
("layer.newCamera", json!({})),
|
||||
("layer.newLight", json!({})),
|
||||
("layer.newLight", json!({"type": "ambient"})),
|
||||
] {
|
||||
let r = s.execute(cmd, params.clone()).unwrap_or_else(|e| panic!("{cmd} {params}: {e}"));
|
||||
layers.push((cmd, r["layer"].as_u64().unwrap()));
|
||||
}
|
||||
s.execute("prop.separateDimensions", json!({"layer": solid})).unwrap();
|
||||
for (what, l) in layers {
|
||||
s.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||||
let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap();
|
||||
let paths: Vec<String> = r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(&s, l, u)).collect();
|
||||
assert!(paths.is_empty(), "{what}: {paths:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Match-id path of property or group `uid` of `layer`.
|
||||
fn path_of(s: &Session, layer: u64, uid: u64) -> String {
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap();
|
||||
let mut out = String::new();
|
||||
for n in l.props.node_chain(uid).unwrap_or_default() {
|
||||
out.push('/');
|
||||
out.push_str(n.match_id());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// UU takes in static changes outside Transform: Material, Camera and Light Options, a
|
||||
/// separated position, and layer styles in full.
|
||||
#[test]
|
||||
fn modified_reveal_takes_in_options_and_layer_styles() {
|
||||
let (mut s, solid) = setup();
|
||||
let modified = |s: &mut Session, l: u64| -> Vec<String> {
|
||||
s.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||||
let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap();
|
||||
r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(s, l, u)).collect()
|
||||
};
|
||||
s.execute("prop.set", json!({"layer": solid, "path": "materialOptions/diffuse", "value": 80})).unwrap();
|
||||
assert_eq!(modified(&mut s, solid), ["/materialOptions/diffuse"]);
|
||||
s.execute("prop.separateDimensions", json!({"layer": solid})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": solid, "path": "transform/positionX", "value": 10})).unwrap();
|
||||
assert_eq!(modified(&mut s, solid), ["/transform/positionX", "/materialOptions/diffuse"]);
|
||||
s.execute("layer.style.dropShadow", json!({"layers": [solid]})).unwrap();
|
||||
assert!(modified(&mut s, solid).contains(&"/layerStyles/dropShadow".to_string()), "{:?}", modified(&mut s, solid));
|
||||
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/blurLevel", "value": 50})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 15})).unwrap();
|
||||
assert_eq!(modified(&mut s, cam), ["/transform/rotation", "/cameraOptions/blurLevel"]);
|
||||
let light = s.execute("layer.newLight", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": light, "path": "lightOptions/intensity", "value": 40})).unwrap();
|
||||
assert_eq!(modified(&mut s, light), ["/lightOptions/intensity"]);
|
||||
}
|
||||
|
||||
/// Enable Time Remapping on a layer extended past its source's end (its last frame frozen) puts
|
||||
/// the end key at the source's end, not at the extended out point (#290).
|
||||
#[test]
|
||||
|
|
@ -946,3 +1048,75 @@ fn proportional_share_is_linear_in_selection_order() {
|
|||
assert_eq!(proportional_share(0, 0), 1.0);
|
||||
assert_eq!(proportional_share(usize::MAX, 3), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_item_keeps_footage_longer_than_the_comp_at_full_length() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "T", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let f = effectcraft_project::Footage {
|
||||
width: 100,
|
||||
height: 100,
|
||||
kind: effectcraft_project::FootageKind::Video,
|
||||
has_video: true,
|
||||
duration: effectcraft_time::Tick::from_seconds_f64(10.0),
|
||||
..Default::default()
|
||||
};
|
||||
let item = std::sync::Arc::make_mut(&mut s.project).add_item("long.mp4", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f));
|
||||
let l = s.execute("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap();
|
||||
assert_eq!(layer.out_point.seconds(), 10.0);
|
||||
}
|
||||
|
||||
/// View > Go to Time's dialog sends its Frame field as a float; `time.set` used to reject it
|
||||
/// with "need `time`, `frame` or `timecode`".
|
||||
#[test]
|
||||
fn go_to_time_accepts_a_float_frame_from_the_dialog() {
|
||||
let (mut s, _) = setup();
|
||||
let r = s.execute("time.set", json!({"frame": 12.0})).unwrap();
|
||||
assert_eq!(r["frame"], json!(12));
|
||||
let r = s.execute("time.set", json!({"frame": 7})).unwrap();
|
||||
assert_eq!(r["frame"], json!(7));
|
||||
}
|
||||
|
||||
/// Dragging a trim edge with several layers selected trims every selected layer by the same
|
||||
/// amount from its own in/out point (as in AE), clamped to the comp and to one frame of layer;
|
||||
/// a whole merged drag is one undo step.
|
||||
#[test]
|
||||
fn relative_trim_moves_every_selected_layer_by_the_same_delta() {
|
||||
let (mut s, a) = setup();
|
||||
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [a], "in": 0.5, "out": 2.0})).unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [b], "in": 1.0, "out": 3.5})).unwrap();
|
||||
let io = |s: &Session, l: u64| {
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap();
|
||||
(l.in_point.seconds(), l.out_point.seconds())
|
||||
};
|
||||
let near = |x: f64, y: f64| (x - y).abs() < 1e-9;
|
||||
let undo_len = s.history.undo.len();
|
||||
// In-point drag in frame steps, merged into one edit.
|
||||
for _ in 0..3 {
|
||||
s.execute("layer.timing", json!({"layers": [a, b], "inDelta": 0.1, "merge": "bar-trim"})).unwrap();
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), undo_len + 1);
|
||||
let ((ai, ao), (bi, bo)) = (io(&s, a), io(&s, b));
|
||||
assert!(near(ai, 0.8) && near(ao, 2.0) && near(bi, 1.3) && near(bo, 3.5), "{ai} {ao} {bi} {bo}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(near(io(&s, a).0, 0.5) && near(io(&s, b).0, 1.0));
|
||||
s.history.merge_key = None;
|
||||
// Out-point drag: b hits the comp end (4 s) while a keeps going.
|
||||
s.execute("layer.timing", json!({"layers": [a, b], "outDelta": 1.0})).unwrap();
|
||||
assert!(near(io(&s, a).1, 3.0) && near(io(&s, b).1, 4.0), "{:?} {:?}", io(&s, a), io(&s, b));
|
||||
// In-point drag left: a stops at the comp start.
|
||||
s.execute("layer.timing", json!({"layers": [a, b], "inDelta": -0.8})).unwrap();
|
||||
assert!(near(io(&s, a).0, 0.0) && near(io(&s, b).0, 0.2), "{:?} {:?}", io(&s, a), io(&s, b));
|
||||
// Trimming past the other edge keeps one frame of layer.
|
||||
s.execute("layer.timing", json!({"layers": [a, b], "inDelta": 10.0})).unwrap();
|
||||
let fd = 1.0 / 30.0;
|
||||
assert!(near(io(&s, a).0, 3.0 - fd) && near(io(&s, b).0, 4.0 - fd), "{:?} {:?}", io(&s, a), io(&s, b));
|
||||
s.execute("layer.timing", json!({"layers": [a, b], "outDelta": -10.0})).unwrap();
|
||||
assert!(near(io(&s, a).1, 3.0) && near(io(&s, b).1, 4.0), "{:?} {:?}", io(&s, a), io(&s, b));
|
||||
// A point already outside the comp trims from where it is instead of jumping onto the edge.
|
||||
s.execute("layer.timing", json!({"layers": [a], "in": -1.0})).unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap();
|
||||
assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -574,3 +574,66 @@ fn graph_transform_box_scales_key_times_and_values() {
|
|||
assert_eq!(p.keys[1].value.as_f64(), 50.0);
|
||||
assert!(s.execute("keys.transform", json!({"timeScale": 0})).is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- resolution
|
||||
|
||||
#[test]
|
||||
fn each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file() {
|
||||
use effectcraft_project::Resolution;
|
||||
let mut s = comp();
|
||||
let a = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "S", "color": "#ff0000", "width": 10, "height": 10})).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("ec-resolution-{}.ecproj", std::process::id()));
|
||||
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
|
||||
let (steps, pixels) = (s.history.undo.len(), s.active_comp_arc().unwrap());
|
||||
let r = s.execute_checked("view.res.half", json!({})).unwrap();
|
||||
assert_eq!((r["resolution"].clone(), r["factor"].clone()), (json!("Half"), json!(2)));
|
||||
// No undo step, but the project is modified (it's saved with it).
|
||||
assert_eq!(s.history.undo.len(), steps);
|
||||
assert!(s.is_dirty());
|
||||
assert!(s.active_comp().unwrap().same_pixels(&pixels));
|
||||
// Another comp has its own; switching back shows comp A's again.
|
||||
s.execute("comp.new", json!({"name": "B", "width": 320, "height": 180, "frameRate": 30, "duration": 5})).unwrap();
|
||||
let b = s.active_comp_id().unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
|
||||
s.execute_checked("view.res.custom", json!({"factor": 6})).unwrap();
|
||||
s.open_comp(a);
|
||||
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Half);
|
||||
assert_eq!(s.execute("comp.info", json!({})).unwrap()["resolution"], json!("Half"));
|
||||
assert_eq!(crate::menus::checked(&s, "view.res.half", &json!({})), Some(true));
|
||||
assert_eq!(crate::menus::checked(&s, "view.res.full", &json!({})), Some(false));
|
||||
// Undo and redo restore the comps, not their resolutions.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project.comp(b).is_none(), "undid New Composition");
|
||||
assert_eq!(s.project.comp(a).unwrap().resolution, Resolution::Half);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.comp(b).unwrap().resolution, Resolution::Custom(6));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layers.is_empty(), "undid the solid");
|
||||
assert_eq!(s.project.comp(a).unwrap().resolution, Resolution::Half);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
// The comp's explicit `comp` parameter; the same value again changes nothing.
|
||||
s.execute_checked("view.res.quarter", json!({"comp": b.0})).unwrap();
|
||||
let rev = s.revision;
|
||||
s.execute_checked("view.res.quarter", json!({"comp": b.0})).unwrap();
|
||||
assert_eq!(s.revision, rev);
|
||||
// Saved with the project and read back.
|
||||
s.execute("file.save", json!({})).unwrap();
|
||||
assert!(!s.is_dirty());
|
||||
let mut o = Session::default();
|
||||
o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(o.project.comp(a).unwrap().resolution, Resolution::Half);
|
||||
assert_eq!(o.project.comp(b).unwrap().resolution, Resolution::Quarter);
|
||||
let _ = std::fs::remove_file(path);
|
||||
// Custom without a factor asks the frontend (the Custom Resolution dialog).
|
||||
s.drain_events();
|
||||
s.execute_checked("view.res.custom", json!({})).unwrap();
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "view.res.custom")));
|
||||
assert!(s.execute_checked("view.res.custom", json!({"factor": "big"})).is_err());
|
||||
s.execute_checked("view.res.auto", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@
|
|||
//!
|
||||
//! Nothing here touches the project; edits go through commands.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use effectcraft_color::{ColorSpace, space};
|
||||
use effectcraft_geom::{Mat3, vec2};
|
||||
use effectcraft_project::{Layer, LayerId};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{Layer, LayerId, Property};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_render::{EvalCtx, ExprHost};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -274,10 +276,45 @@ pub struct SnapOptions {
|
|||
pub grid_spacing: f64,
|
||||
}
|
||||
|
||||
type SnapValues = HashMap<(u64, u64, u64, i64), Result<Value, String>>;
|
||||
|
||||
/// Bounds, vertices and transforms can ask for the same expression repeatedly. Share its
|
||||
/// value only within this immutable snap query; a new pointer update starts a fresh cache.
|
||||
struct SnapExpressions<'a> {
|
||||
inner: &'a dyn ExprHost,
|
||||
values: Mutex<SnapValues>,
|
||||
}
|
||||
|
||||
impl ExprHost for SnapExpressions<'_> {
|
||||
fn eval(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value) -> Result<Value, String> {
|
||||
let key = (ctx.comp_id.0, layer.id.0, prop.uid, ctx.time.0);
|
||||
if let Some(value) = self.values.lock().unwrap_or_else(PoisonError::into_inner).get(&key).cloned() {
|
||||
return value;
|
||||
}
|
||||
// Never hold the lock while evaluating: expressions may query other properties.
|
||||
let result = self.inner.eval(ctx, layer, prop, value);
|
||||
let mut values = self.values.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if values.len() < 8192 {
|
||||
values.insert(key, result.clone());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn eval_text_selector(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, index: usize, total: usize, selector: [f64; 3]) -> Result<[f64; 3], String> {
|
||||
self.inner.eval_text_selector(ctx, layer, prop, index, total, selector)
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap targets of a comp at the context time: other visible layers' features and vertices and
|
||||
/// the comp's edges and centre (with `layers`, as the Snapping options allow), and guides and
|
||||
/// grid lines (when on). Layers in `exclude` (the dragged ones) are skipped.
|
||||
pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
|
||||
let memo = ctx.expr.map(|inner| SnapExpressions { inner, values: Mutex::default() });
|
||||
let ctx = EvalCtx { expr: memo.as_ref().map(|h| h as &dyn ExprHost), ..*ctx };
|
||||
targets_in(&ctx, exclude, opts)
|
||||
}
|
||||
|
||||
fn targets_in(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
|
||||
let comp = ctx.comp;
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
let f = opts.features;
|
||||
|
|
@ -1063,3 +1100,45 @@ pub struct Snapshot {
|
|||
pub scale: f64,
|
||||
pub image: Arc<Image>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod snap_expression_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
struct PositionHost(AtomicUsize);
|
||||
impl ExprHost for PositionHost {
|
||||
fn eval(&self, ctx: &EvalCtx, _: &Layer, _: &Property, value: &Value) -> Result<Value, String> {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
let v = value.components();
|
||||
Ok(Value::Vec3([v[0] + 10. * ctx.time.seconds(), v[1], 0.]))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_reuses_expressions_within_one_query_but_refreshes_time_and_edits() {
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("comp.new", json!({"width":320,"height":180,"duration":5})).unwrap();
|
||||
let lid = s.execute("layer.newSolid", json!({"width":40,"height":20})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer":lid,"path":"transform/position","expression":"value"})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let host = PositionHost(AtomicUsize::new(0));
|
||||
let opts = SnapOptions { layers: true, ..Default::default() };
|
||||
let query = |s: &crate::Session, time, cached| {
|
||||
let ctx = EvalCtx { expr: Some(&host), ..EvalCtx::new(&s.project, cid, s.project.comp(cid).unwrap(), time) };
|
||||
if cached { targets(&ctx, &[], opts) } else { targets_in(&ctx, &[], opts) }
|
||||
};
|
||||
let expected = query(&s, Tick::ZERO, false);
|
||||
assert!(host.0.swap(0, Ordering::Relaxed) > 1, "fixture must visit the same expression for multiple geometry features");
|
||||
assert_eq!(query(&s, Tick::ZERO, true), expected);
|
||||
assert_eq!(host.0.swap(0, Ordering::Relaxed), 1);
|
||||
let moved = query(&s, Tick::from_seconds_f64(1.), true);
|
||||
assert_ne!(moved, expected, "a new time must not reuse stale targets");
|
||||
assert_eq!(host.0.swap(0, Ordering::Relaxed), 1);
|
||||
s.execute("prop.set", json!({"layer":lid,"path":"transform/position","value":[200,100,0]})).unwrap();
|
||||
let edited = query(&s, Tick::ZERO, true);
|
||||
assert_ne!(edited, expected, "a new project revision must not reuse stale targets");
|
||||
assert_eq!(host.0.swap(0, Ordering::Relaxed), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ use serde_json::{Map, Value};
|
|||
|
||||
const ROOT: &str = "EffectCraftProject";
|
||||
|
||||
/// How deep `arr`/`obj` elements may nest: the JSON reader's own limit (`serde_json` stops at
|
||||
/// 128), so every project the JSON form can hold reads from XML too. Without a limit a deeply
|
||||
/// nested file overflowed the stack and aborted the app instead of failing to open (#779).
|
||||
const MAX_DEPTH: usize = 128;
|
||||
|
||||
/// Whether `text` looks like an XML project.
|
||||
pub fn is_xml(text: &str) -> bool {
|
||||
let t = text.trim_start_matches('\u{feff}').trim_start();
|
||||
|
|
@ -158,7 +163,10 @@ impl<'a> P<'a> {
|
|||
self.i += end;
|
||||
t
|
||||
}
|
||||
fn value(&mut self) -> Result<Value, String> {
|
||||
fn value(&mut self, depth: usize) -> Result<Value, String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return self.err(&format!("elements nested deeper than {MAX_DEPTH} levels"));
|
||||
}
|
||||
let (name, _, closed) = self.open()?;
|
||||
let v = match (name.as_str(), closed) {
|
||||
("null", _) => Value::Null,
|
||||
|
|
@ -175,7 +183,7 @@ impl<'a> P<'a> {
|
|||
if self.rest().starts_with("</") {
|
||||
break;
|
||||
}
|
||||
a.push(self.value()?);
|
||||
a.push(self.value(depth + 1)?);
|
||||
}
|
||||
Value::Array(a)
|
||||
}
|
||||
|
|
@ -190,7 +198,7 @@ impl<'a> P<'a> {
|
|||
if tag != "m" {
|
||||
return self.err("expected <m n=\"…\">");
|
||||
}
|
||||
let v = self.value()?;
|
||||
let v = self.value(depth + 1)?;
|
||||
self.close("m")?;
|
||||
m.insert(key.unwrap_or_default(), v);
|
||||
}
|
||||
|
|
@ -243,7 +251,7 @@ pub fn from_xml(text: &str) -> Result<String, String> {
|
|||
if root != ROOT {
|
||||
return Err(format!("not an EffectCraft XML project (root <{root}>)"));
|
||||
}
|
||||
let v = p.value()?;
|
||||
let v = p.value(0)?;
|
||||
p.close(ROOT)?;
|
||||
serde_json::to_string(&v).map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
@ -262,4 +270,18 @@ mod tests {
|
|||
assert_eq!(back, j);
|
||||
assert!(from_xml("<Other/>").is_err());
|
||||
}
|
||||
|
||||
/// #779 (found by the project-file fuzzer): a file of deeply nested elements overflowed the
|
||||
/// stack, which aborts the app; it is now refused. Nesting the JSON reader takes still reads.
|
||||
#[test]
|
||||
fn deep_nesting_is_refused_not_a_stack_overflow() {
|
||||
let nested = |n: usize| format!("<{ROOT}>{}<null/>{}</{ROOT}>", "<arr>".repeat(n), "</arr>".repeat(n));
|
||||
let e = from_xml(&nested(1_000_000)).unwrap_err();
|
||||
assert!(e.contains("nested deeper"), "{e}");
|
||||
let ok = from_xml(&nested(100)).unwrap();
|
||||
assert!(serde_json::from_str::<Value>(&ok).is_ok());
|
||||
let json = format!("{}null{}", "[".repeat(MAX_DEPTH - 1), "]".repeat(MAX_DEPTH - 1));
|
||||
let v: Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&from_xml(&to_xml(&json).unwrap()).unwrap()).unwrap(), v);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@
|
|||
//! `seedRandom`), so frames render identically on every thread and platform.
|
||||
//! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read
|
||||
//! the project through memoized host requests (see [`host`]), so nothing borrowed ever enters
|
||||
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels.
|
||||
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels; one that needs
|
||||
//! its own value fails at once, and one evaluation (reads included) runs at most [`MAX_RUNS`]
|
||||
//! scripts for at most [`MAX_TIME`]. Text nested deeper than the parser's stack allows is refused
|
||||
//! ([`rewrite::MAX_NESTING`]); a seeded fuzzer (`tests/fuzz.rs`) keeps all of this honest.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
|
|
@ -38,12 +41,39 @@ use effectcraft_render::{EvalCtx, ExprHost};
|
|||
use effectcraft_time::Tick;
|
||||
|
||||
use host::{Own, Req, Resolver, prop_dims, value_resp};
|
||||
use runtime::{Begin, FRAMES, Frame, Run};
|
||||
use runtime::{Begin, FRAMES, Frame, Owner, RUNS, Run};
|
||||
|
||||
/// Maximum nesting of expressions that read other expressions (cycles stop here).
|
||||
pub const MAX_DEPTH: usize = 16;
|
||||
/// Maximum script re-runs while answering host requests.
|
||||
const MAX_PASSES: usize = 2000;
|
||||
/// Maximum script runs for one outermost evaluation, the expressions it reads included: reads
|
||||
/// that fan out (an expression reading two values whose expressions each read two more…) grow
|
||||
/// exponentially with [`MAX_DEPTH`] and would hang the frame.
|
||||
pub const MAX_RUNS: usize = 20_000;
|
||||
/// Longest one outermost evaluation may take (its script runs and the expressions it reads),
|
||||
/// checked between runs and while a script runs: endless or very long loops, and a script that
|
||||
/// loops a while and then reads many values (re-running the loop once per value), stop here.
|
||||
/// Native builds only (the web build has no clock here; loops stop at the loop limit there).
|
||||
pub const MAX_TIME: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Start the work budget of an outermost evaluation.
|
||||
fn budget_start() {
|
||||
RUNS.with(|r| r.set(0));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
runtime::STARTED.with(|s| s.set(Some(std::time::Instant::now())));
|
||||
}
|
||||
|
||||
/// An error once the outermost evaluation has used up its runs or its time.
|
||||
fn budget_left() -> Result<(), String> {
|
||||
if RUNS.with(|r| r.get()) >= MAX_RUNS {
|
||||
return Err("Error: expression reads too many other expressions' values (circular or fanning-out references?)".into());
|
||||
}
|
||||
if runtime::out_of_time() {
|
||||
return Err(runtime::TIMED_OUT.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The expression engine (stateless; runtimes are per thread).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
|
|
@ -111,10 +141,23 @@ fn evaluate_out(
|
|||
text_sel: [f64; 2],
|
||||
selector: [f64; 3],
|
||||
) -> Result<runtime::Out, String> {
|
||||
let depth = FRAMES.with(|f| f.borrow().len());
|
||||
let owner = Owner { comp: ctx.comp_id.0, layer: layer.id.0, uid: prop.uid, time: ctx.time, text: text.to_string() };
|
||||
let (depth, cycle) = FRAMES.with(|f| {
|
||||
let f = f.borrow();
|
||||
(f.len(), f.iter().any(|fr| fr.owner.as_ref() == Some(&owner)))
|
||||
});
|
||||
if depth == 0 {
|
||||
budget_start();
|
||||
}
|
||||
if cycle {
|
||||
// Needs its own result: fails at once (the reader falls back to the keyframed value)
|
||||
// instead of nesting to `MAX_DEPTH` once per read on the way.
|
||||
return Err("Error: circular expression reference (the expression needs its own value)".into());
|
||||
}
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
|
||||
}
|
||||
budget_left()?;
|
||||
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage };
|
||||
let comp = ctx.comp_id.0;
|
||||
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
|
||||
|
|
@ -133,7 +176,7 @@ fn evaluate_out(
|
|||
selector,
|
||||
};
|
||||
// Answer the requests nearly every expression makes up front.
|
||||
let mut frame = Frame::default();
|
||||
let mut frame = Frame { owner: Some(owner), ..Frame::default() };
|
||||
for req in [Req::CompInfo { comp }, Req::LayerInfo { comp, layer: layer.id.0 }, Req::PropInfo { comp, layer: layer.id.0, path: path.clone() }] {
|
||||
let r = resolver.resolve(&req);
|
||||
frame.memo.insert(req, r);
|
||||
|
|
@ -141,6 +184,8 @@ fn evaluate_out(
|
|||
FRAMES.with(|f| f.borrow_mut().push(frame));
|
||||
let _guard = FrameGuard;
|
||||
for _ in 0..MAX_PASSES {
|
||||
budget_left()?;
|
||||
RUNS.with(|r| r.set(r.get().saturating_add(1)));
|
||||
match runtime::run(text, &begin) {
|
||||
Run::Done(r) => return r,
|
||||
Run::Pending => {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ pub fn noise1(seed: u64, x: f64) -> f64 {
|
|||
let i = x.floor();
|
||||
let u = fade(x - i);
|
||||
let i = i as i64;
|
||||
let (a, b) = (lattice(seed, i), lattice(seed, i + 1));
|
||||
let (a, b) = (lattice(seed, i), lattice(seed, i.wrapping_add(1)));
|
||||
a + (b - a) * u
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ pub fn noise3(x: f64, y: f64, z: f64) -> f64 {
|
|||
let (fx, fy, fz) = (x.floor(), y.floor(), z.floor());
|
||||
let (u, v, w) = (fade(x - fx), fade(y - fy), fade(z - fz));
|
||||
let (ix, iy, iz) = (fx as i64, fy as i64, fz as i64);
|
||||
let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[(ix + dx) as u64, (iy + dy) as u64, (iz + dz) as u64])) * 2.0 - 1.0;
|
||||
let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[ix.wrapping_add(dx) as u64, iy.wrapping_add(dy) as u64, iz.wrapping_add(dz) as u64])) * 2.0 - 1.0;
|
||||
let l = |a: f64, b: f64, t: f64| a + (b - a) * t;
|
||||
let x00 = l(g(0, 0, 0), g(1, 0, 0), u);
|
||||
let x10 = l(g(0, 1, 0), g(1, 1, 0), u);
|
||||
|
|
@ -99,6 +99,18 @@ mod tests {
|
|||
assert!((-1.0..=1.0).contains(&noise3(0.3, 4.2, -7.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_inputs_do_not_overflow() {
|
||||
// Found by the expression fuzzer (#786): `wiggle(9007199254740993, 30)` at a late time
|
||||
// put the lattice index at `i64::MAX`, and its neighbour overflowed.
|
||||
let w = wiggle(1, 2, 9_007_199_254_740_993.0, 30.0, 1.0, 0.5, 1e6);
|
||||
assert!(w.iter().all(|v| v.is_finite() && v.abs() <= 30.0));
|
||||
for x in [1e300, -1e300, 9.3e18, -9.3e18] {
|
||||
assert!(noise1(5, x).is_finite());
|
||||
assert!(noise3(x, -x, x).is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_bounds() {
|
||||
for i in 0..2000 {
|
||||
|
|
|
|||
|
|
@ -548,55 +548,230 @@ fn push_piece(out: &mut String, p: Piece) {
|
|||
out.push_str(&p.body);
|
||||
}
|
||||
|
||||
/// The deepest nesting (brackets, plus chains of prefix operators) an expression may have,
|
||||
/// before and after the rewrite (which nests a call per arithmetic operator). The JavaScript
|
||||
/// parser recurses per level and a 2 MiB thread overflows its stack (aborting the app) at
|
||||
/// about 60 levels, so deeper expressions are rejected with an error instead.
|
||||
/// The deepest nesting an expression may have, in levels of brackets, before and after the
|
||||
/// rewrite (which nests a call per arithmetic operator). The JavaScript parser and compiler
|
||||
/// recurse per level and a 2 MiB thread overflows its stack (aborting the app) at about 75
|
||||
/// nested brackets, so deeper expressions are rejected with an error instead. Constructs that
|
||||
/// nest without brackets count as part of a level ([`too_nested`]).
|
||||
pub const MAX_NESTING: usize = 32;
|
||||
|
||||
fn nesting(toks: &[Tok]) -> usize {
|
||||
let (mut depth, mut prefix, mut max) = (0usize, 0usize, 0usize);
|
||||
for t in toks {
|
||||
if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") {
|
||||
depth += 1;
|
||||
} else if t.kind == Kind::Punct && matches!(t.text.as_str(), ")" | "]" | "}") {
|
||||
depth = depth.saturating_sub(1);
|
||||
/// Stack cost of one level of brackets (also of a template substitution, a regex group and a
|
||||
/// prefix operator, which cost the parser about as much or are never chained in real code).
|
||||
const LEVEL: usize = 256;
|
||||
/// Stack cost of a construct the parser recurses on without a bracket: `?:`, `=>`, assignments,
|
||||
/// `**`, `||`, labels, `if`/`else`/`while`/`for`/`with`/`do` without a block (about 1/8 of a
|
||||
/// bracket: 256 of them in a row are allowed; boa overflows from about 750).
|
||||
const HEAVY: usize = 32;
|
||||
/// Stack cost of a link in a left-deep chain (binary operators, member access, calls, indexing,
|
||||
/// the comma operator): the tree is built in a loop but compiled recursively (overflows from
|
||||
/// about 3 800 links).
|
||||
const LIGHT: usize = 6;
|
||||
|
||||
/// One bracket level of [`too_nested`]'s walk.
|
||||
#[derive(Default)]
|
||||
struct Level {
|
||||
/// `[`: commas separate elements (a loop in the parser), not operands.
|
||||
array: bool,
|
||||
/// Cost of the chains of the current statement or element.
|
||||
chain: usize,
|
||||
/// Comma operators so far in the current statement.
|
||||
commas: usize,
|
||||
/// `else` branches in a row: `if … else if … else if …` nests even across `;`.
|
||||
elses: usize,
|
||||
/// The next token starts a statement.
|
||||
fresh: bool,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
fn boundary(&mut self) {
|
||||
self.chain = 0;
|
||||
self.commas = 0;
|
||||
self.fresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
const ASSIGN: &[&str] = &["=", "+=", "-=", "*=", "/=", "%=", "**=", "<<=", ">>=", ">>>=", "&=", "|=", "^=", "&&=", "||=", "??="];
|
||||
|
||||
/// Can `t` end an expression statement (so a line break after it may end the statement)?
|
||||
fn ends_value(t: &Tok) -> bool {
|
||||
match t.kind {
|
||||
Kind::Num | Kind::Str | Kind::Template | Kind::Regex => true,
|
||||
Kind::Ident => !t.is_kw(DELIM_KW) && !t.is_kw(PREFIX_KW),
|
||||
Kind::Punct => matches!(t.text.as_str(), ")" | "]" | "}" | "++" | "--"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Can `t` only start a new statement after a value (automatic semicolon insertion)?
|
||||
fn starts_statement(t: &Tok) -> bool {
|
||||
match t.kind {
|
||||
Kind::Num | Kind::Str => true,
|
||||
Kind::Ident => !t.is_kw(&["in", "instanceof", "of"]),
|
||||
Kind::Punct => matches!(t.text.as_str(), "{" | "!" | "~" | "++" | "--"),
|
||||
Kind::Template | Kind::Regex => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bracket depth inside a template literal (its `${…}` substitutions) or a regex (its groups).
|
||||
fn inner_depth(text: &str) -> usize {
|
||||
let (mut d, mut max) = (0usize, 0usize);
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'(' | '[' | '{' => d += 1,
|
||||
')' | ']' | '}' => d = d.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
let is_prefix = (t.kind == Kind::Punct && matches!(t.text.as_str(), "!" | "~" | "-" | "+")) || t.is_kw(&["typeof", "void", "delete"]);
|
||||
prefix = if is_prefix { prefix + 1 } else { 0 };
|
||||
max = max.max(depth + prefix);
|
||||
max = max.max(d);
|
||||
}
|
||||
max
|
||||
}
|
||||
|
||||
/// The code of a template literal's `${…}` substitutions (the outermost ones).
|
||||
fn substitutions(template: &str) -> Vec<String> {
|
||||
let c: Vec<char> = template.chars().collect();
|
||||
let mut out = vec![];
|
||||
let mut i = 1;
|
||||
while i < c.len() {
|
||||
match c[i] {
|
||||
'\\' => i += 2,
|
||||
'$' if c.get(i + 1) == Some(&'{') => {
|
||||
let start = i + 2;
|
||||
let (mut j, mut depth) = (start, 0usize);
|
||||
while j < c.len() {
|
||||
match c[j] {
|
||||
'{' => depth += 1,
|
||||
'}' if depth == 0 => break,
|
||||
'}' => depth -= 1,
|
||||
'\'' | '"' => {
|
||||
j = skip_string(&c, j);
|
||||
continue;
|
||||
}
|
||||
'`' => {
|
||||
j = skip_template(&c, j);
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
out.push(c.get(start..j.min(c.len())).unwrap_or_default().iter().collect());
|
||||
i = j + 1;
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `toks` nest deeper than [`MAX_NESTING`] levels: an estimate of the parser's stack
|
||||
/// use, summing brackets, runs of prefix operators and, per bracket level, the chains of the
|
||||
/// current statement (or array element) weighted by what each link costs the parser.
|
||||
fn too_nested(toks: &[Tok]) -> bool {
|
||||
too_nested_from(toks, 0)
|
||||
}
|
||||
|
||||
/// [`too_nested`] for code that already sits `base` deep (a template substitution).
|
||||
fn too_nested_from(toks: &[Tok], base: usize) -> bool {
|
||||
let mut levels = vec![Level::default()];
|
||||
let mut prefix = 0usize;
|
||||
let mut prev: Option<&Tok> = None;
|
||||
for t in toks {
|
||||
let p = t.kind == Kind::Punct;
|
||||
if let Some(l) = levels.last_mut() {
|
||||
if t.nl && prev.is_some_and(ends_value) && starts_statement(t) {
|
||||
l.boundary();
|
||||
}
|
||||
if l.fresh {
|
||||
if !t.is_kw(&["else"]) {
|
||||
l.elses = 0;
|
||||
}
|
||||
l.fresh = false;
|
||||
}
|
||||
}
|
||||
let mut inner = 0;
|
||||
if p && matches!(t.text.as_str(), "(" | "[" | "{") {
|
||||
// A call or index is a link in its chain too (`f()()()…`).
|
||||
if t.text != "{"
|
||||
&& let Some(l) = levels.last_mut()
|
||||
{
|
||||
l.chain = l.chain.saturating_add(LIGHT);
|
||||
}
|
||||
levels.push(Level { array: t.text == "[", ..Level::default() });
|
||||
} else if p && matches!(t.text.as_str(), ")" | "]" | "}") {
|
||||
if levels.len() > 1 {
|
||||
levels.pop();
|
||||
}
|
||||
} else if let Some(l) = levels.last_mut() {
|
||||
if t.is(";") {
|
||||
l.boundary();
|
||||
} else if t.is(",") {
|
||||
l.commas += usize::from(!l.array);
|
||||
l.chain = l.commas.saturating_mul(LIGHT);
|
||||
} else if t.is_kw(&["else"]) {
|
||||
l.elses += 1;
|
||||
} else if (p && (matches!(t.text.as_str(), "?" | ":" | "=>" | "**" | "||") || ASSIGN.contains(&t.text.as_str())))
|
||||
|| t.is_kw(&["if", "while", "for", "with", "do"])
|
||||
{
|
||||
l.chain = l.chain.saturating_add(HEAVY);
|
||||
} else if p || t.is_kw(&["in", "instanceof"]) {
|
||||
l.chain = l.chain.saturating_add(LIGHT);
|
||||
} else if matches!(t.kind, Kind::Template | Kind::Regex) {
|
||||
// A tagged template is a link like a call.
|
||||
l.chain = l.chain.saturating_add(LIGHT);
|
||||
inner = inner_depth(&t.text);
|
||||
}
|
||||
}
|
||||
let is_prefix = (p && matches!(t.text.as_str(), "!" | "~" | "-" | "+" | "++" | "--")) || t.is_kw(&["typeof", "void", "delete", "new", "await"]);
|
||||
prefix = if is_prefix { prefix + 1 } else { 0 };
|
||||
let mut cost = (levels.len() - 1 + prefix + inner).saturating_mul(LEVEL).saturating_add(base);
|
||||
for l in &levels {
|
||||
cost = cost.saturating_add(l.chain).saturating_add(l.elses.saturating_mul(HEAVY));
|
||||
}
|
||||
if cost > MAX_NESTING * LEVEL {
|
||||
return true;
|
||||
}
|
||||
// The code inside `${…}` is parsed like any other, one level deeper (the depth check
|
||||
// above bounds this recursion).
|
||||
if t.kind == Kind::Template
|
||||
&& t.text.contains("${")
|
||||
&& substitutions(&t.text)
|
||||
.iter()
|
||||
.any(|sub| too_nested_from(&tokenize(sub).0, cost.saturating_sub(inner.saturating_mul(LEVEL)).saturating_add(LEVEL)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
prev = Some(t);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite).
|
||||
pub fn check_nesting(src: &str) -> Result<(), String> {
|
||||
let (toks, tail) = tokenize(src);
|
||||
if nesting(&toks) > MAX_NESTING {
|
||||
if too_nested(&toks) {
|
||||
return Err(too_deep());
|
||||
}
|
||||
let mut out = rewrite_seq(&build(toks));
|
||||
out.push_str(&tail);
|
||||
if nesting(&tokenize(&out).0) > MAX_NESTING {
|
||||
if too_nested(&tokenize(&out).0) {
|
||||
return Err(too_deep());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn too_deep() -> String {
|
||||
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets or chained operators)")
|
||||
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets, or too long a chain of operators)")
|
||||
}
|
||||
|
||||
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
|
||||
/// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error.
|
||||
pub fn rewrite(src: &str) -> String {
|
||||
let (toks, tail) = tokenize(src);
|
||||
if nesting(&toks) > MAX_NESTING {
|
||||
if too_nested(&toks) {
|
||||
return throw_too_deep();
|
||||
}
|
||||
let mut out = rewrite_seq(&build(toks));
|
||||
out.push_str(&tail);
|
||||
if nesting(&tokenize(&out).0) > MAX_NESTING {
|
||||
if too_nested(&tokenize(&out).0) {
|
||||
return throw_too_deep();
|
||||
}
|
||||
out
|
||||
|
|
@ -673,4 +848,60 @@ mod tests {
|
|||
assert!(crate::check_syntax(&ok).is_ok());
|
||||
assert!(check_nesting(&vec!["x"; 20].join(" + ")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_chains_without_brackets_are_an_error_not_a_stack_overflow() {
|
||||
// Found by the expression fuzzer (#786): the parser also recurses on chains that need no
|
||||
// bracket, so a few hundred `?:`, `=>` or `if`, or a few thousand `.`/`<`/`()` links,
|
||||
// aborted the app with a stack overflow; so did 75 nested template substitutions or
|
||||
// regex groups (both hidden inside one token), and 6 000 prefix `new`/`++` the rewriter.
|
||||
let chains: [(&str, &str); 19] = [
|
||||
("x?x:", ""),
|
||||
("()=>", ""),
|
||||
("a=>", ""),
|
||||
("if(x)", ""),
|
||||
("if(x)x;else ", ""),
|
||||
("x=", ""),
|
||||
("x**", ""),
|
||||
("x||", ""),
|
||||
("x<", ""),
|
||||
("x,", ""),
|
||||
("x.y", ""),
|
||||
("f()", ""),
|
||||
("a:", ""),
|
||||
("new ", ""),
|
||||
("++", ""),
|
||||
("`${", "}`"),
|
||||
("s.replace(/(", ")/, '')"),
|
||||
("`${x??", "}`"),
|
||||
("{a:", "}"),
|
||||
];
|
||||
for (open, close) in chains {
|
||||
for n in [2_000, 100_000] {
|
||||
let src = open.repeat(n) + "1" + &close.repeat(n);
|
||||
assert!(rewrite(&src).starts_with("throw new RangeError("), "{open} × {n}");
|
||||
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"), "{open} × {n}");
|
||||
}
|
||||
}
|
||||
// A chain inside one template substitution (the template is one token to the rewriter).
|
||||
let src = format!("`${{{}1}}`", "x??".repeat(100_000));
|
||||
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_real_expressions_still_parse() {
|
||||
let lines = |n: usize, line: &str| vec![line; n].join("\n");
|
||||
let else_ifs = (0..200).map(|i| format!("if (time < {i}) {i}; else ")).collect::<String>() + "0";
|
||||
let numbers = format!("[{}][0]", (0..5000).map(|i| i.to_string()).collect::<Vec<_>>().join(", "));
|
||||
let points = format!("createPath([{}], [], [], true)", (0..500).map(|i| format!("[{i}, {}]", i * 2)).collect::<Vec<_>>().join(", "));
|
||||
let object = format!("var o = {{{}}}; o.k0", (0..200).map(|i| format!("k{i}: time > {i} ? {i} : -{i}")).collect::<Vec<_>>().join(", "));
|
||||
let program = "var x = 0\n".to_string() + &lines(300, "x = x + thisComp.layer(1).transform.position[0] * 2 - 1") + "\nx";
|
||||
let ifs = "var y = 0\n".to_string() + &lines(300, "if (time > 1) y = y + 1") + "\ny";
|
||||
let chained = vec!["thisComp.layer(1).transform.position.valueAtTime(time - 0.1)[0]"; 30].join(" + ");
|
||||
let template = "`${thisLayer.name}: ${Math.floor(time * 10) / 10} s, ${`${index}`} {}`".to_string();
|
||||
for src in [else_ifs, numbers, points, object, program, ifs, chained, template] {
|
||||
assert!(check_nesting(&src).is_ok(), "{}", &src[..80]);
|
||||
assert!(crate::check_syntax(&src).is_ok(), "{}", &src[..80]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ const PRELUDE: &str = include_str!("prelude.js");
|
|||
/// Message of the error thrown when a request isn't answered yet (never shown to users).
|
||||
const PENDING: &str = "\u{1}effectcraft: pending host request";
|
||||
const MAX_SCRIPTS: usize = 4096;
|
||||
/// Loop iterations one script run may make (per function call): a run re-executes once per
|
||||
/// property it reads, so this bounds the time of every pass.
|
||||
pub const LOOP_LIMIT: u64 = 1_000_000;
|
||||
/// The error of an evaluation that ran out of time.
|
||||
pub const TIMED_OUT: &str = "Error: expression takes too long to evaluate";
|
||||
/// The part of boa's stack-size limit error the runtime recovers from (see [`run`]).
|
||||
const STACK_LIMIT: &str = "maximum stack size";
|
||||
|
||||
|
|
@ -24,6 +29,18 @@ const STACK_LIMIT: &str = "maximum stack size";
|
|||
pub struct Frame {
|
||||
pub memo: HashMap<Req, Resp>,
|
||||
pub misses: Vec<Req>,
|
||||
/// What this frame evaluates: an evaluation that needs itself is a cycle.
|
||||
pub owner: Option<Owner>,
|
||||
}
|
||||
|
||||
/// One expression evaluation: the property (comp, layer, uid), the comp time and the text.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Owner {
|
||||
pub comp: u64,
|
||||
pub layer: u64,
|
||||
pub uid: u64,
|
||||
pub time: effectcraft_time::Tick,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
|
|
@ -32,6 +49,14 @@ thread_local! {
|
|||
static RUNTIME: RefCell<Option<ManuallyDrop<Result<Runtime, String>>>> = const { RefCell::new(None) };
|
||||
/// Evaluation stack (nested when one expression reads another's value).
|
||||
pub static FRAMES: RefCell<Vec<Frame>> = const { RefCell::new(Vec::new()) };
|
||||
/// Script runs since the outermost evaluation on this thread began (nested ones included).
|
||||
pub static RUNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
thread_local! {
|
||||
/// When the outermost evaluation on this thread began.
|
||||
pub static STARTED: std::cell::Cell<Option<std::time::Instant>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
struct Runtime {
|
||||
|
|
@ -209,7 +234,7 @@ impl Runtime {
|
|||
fn new() -> Result<Runtime, String> {
|
||||
let mut ctx = Context::default();
|
||||
let limits = ctx.runtime_limits_mut();
|
||||
limits.set_loop_iteration_limit(10_000_000);
|
||||
limits.set_loop_iteration_limit(LOOP_LIMIT);
|
||||
limits.set_recursion_limit(400);
|
||||
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 4] = [
|
||||
(js_string!("__h"), 6, native_host),
|
||||
|
|
@ -270,7 +295,11 @@ impl Runtime {
|
|||
if let Err(e) = self.begin.call(&JsValue::undefined(), &args, ctx) {
|
||||
return Run::Done(Err(format_error(&e.to_string(), line_count(text))));
|
||||
}
|
||||
let result = script.evaluate(ctx).and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx));
|
||||
let result = match evaluate_in_time(&script, ctx) {
|
||||
Some(r) => r.and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx)),
|
||||
// Abandoned mid-run: the context is unusable now (see `run`).
|
||||
None => return Run::Done(Err(TIMED_OUT.into())),
|
||||
};
|
||||
let pending = FRAMES.with(|f| f.borrow().last().is_some_and(|fr| !fr.misses.is_empty()));
|
||||
if pending {
|
||||
return Run::Pending;
|
||||
|
|
@ -282,6 +311,54 @@ impl Runtime {
|
|||
}
|
||||
}
|
||||
|
||||
/// Run `script`, giving up (`None`) once the outermost evaluation is out of time
|
||||
/// ([`crate::MAX_TIME`]). boa's budgeted evaluation yields every few thousand instructions of
|
||||
/// the script and the functions it calls (not inside callbacks of native functions such as
|
||||
/// `Array.prototype.map`, which stay bounded by the loop limit per call).
|
||||
fn evaluate_in_time(script: &Script, ctx: &mut Context) -> Option<JsResult<JsValue>> {
|
||||
let mut run = std::pin::pin!(script.evaluate_async_with_budget(ctx, 20_000));
|
||||
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
|
||||
loop {
|
||||
if let std::task::Poll::Ready(r) = run.as_mut().poll(&mut cx) {
|
||||
return Some(r);
|
||||
}
|
||||
if out_of_time() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Test hook: this thread's time budget (tests of the run-count budget lift it so that a
|
||||
/// loaded machine cannot reach the clock first).
|
||||
pub static TIME_LIMIT: std::cell::Cell<std::time::Duration> = const { std::cell::Cell::new(crate::MAX_TIME) };
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn max_time() -> std::time::Duration {
|
||||
#[cfg(test)]
|
||||
{
|
||||
TIME_LIMIT.with(|t| t.get())
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
crate::MAX_TIME
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the outermost evaluation on this thread has used up [`crate::MAX_TIME`].
|
||||
pub fn out_of_time() -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
STARTED.with(|s| s.get()).is_some_and(|t| t.elapsed() > max_time())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a JS value out of the heap.
|
||||
fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
|
||||
if v.is_undefined() {
|
||||
|
|
@ -413,6 +490,13 @@ pub fn run(text: &str, b: &Begin) -> Run {
|
|||
Err(e) => return Run::Done(Err(e.clone())),
|
||||
};
|
||||
match out {
|
||||
Run::Done(Err(e)) if e == TIMED_OUT => {
|
||||
// Abandoned mid-run: start the next evaluation on a fresh context.
|
||||
if let Some(old) = r.take() {
|
||||
drop(ManuallyDrop::into_inner(old));
|
||||
}
|
||||
return Run::Done(Err(e));
|
||||
}
|
||||
Run::Done(Err(e)) if !fresh && e.contains(STACK_LIMIT) => {
|
||||
// Dropped here, mid-thread, where boa's heap is alive (unlike at thread exit).
|
||||
if let Some(old) = r.take() {
|
||||
|
|
@ -500,6 +584,11 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
|
|||
Value::Str(_) => Value::Str(text(out)),
|
||||
Value::Path(_) => match out {
|
||||
Out::Path { points, ins, outs, closed } => {
|
||||
// A vertex that isn't a number (`[[0, 0], [[1, 2], 0]]`) comes through as NaN, which
|
||||
// the rasterizer must never see.
|
||||
if points.iter().chain(ins).chain(outs).flatten().any(|c| !c.is_finite()) {
|
||||
return Err("Error: path points must be finite numbers".into());
|
||||
}
|
||||
let n = points.len();
|
||||
let fit = |v: &[[f64; 2]]| {
|
||||
let mut v = v.to_vec();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use std::time::Instant;
|
||||
|
||||
mod fuzz;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
|
|
@ -383,11 +385,87 @@ fn circular_references_terminate() {
|
|||
f.set_expr(f.b, OPA, "thisComp.layer('A').transform.opacity");
|
||||
let start = Instant::now();
|
||||
let r = eval_property(&f.p, f.cid, f.a, OPA, 0.0);
|
||||
assert!(start.elapsed().as_secs_f64() < 10.0);
|
||||
// The cycle bottoms out at the depth limit and falls back to keyframed values.
|
||||
// Only a hang guard (the budgets stop it far sooner); generous for a loaded machine.
|
||||
assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed());
|
||||
// The cycle is caught when A needs itself again, and falls back to keyframed values.
|
||||
assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expression_needing_its_own_value_fails_fast() {
|
||||
// Found by the expression fuzzer (#786): rotation's `toWorld` reads the layer's transform,
|
||||
// which evaluates rotation's expression again, twice per level (layer-to-comp and world
|
||||
// matrices), so the cycle nested to `MAX_DEPTH` 2^16 times and the frame took 20 s.
|
||||
let mut f = fx();
|
||||
f.set_expr(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]");
|
||||
let start = Instant::now();
|
||||
let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5);
|
||||
assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed());
|
||||
// The inner read falls back to the keyframed rotation, as in a standalone evaluation.
|
||||
assert_eq!(r, f.ev(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]", 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanning_out_reads_stop_at_a_budget() {
|
||||
// Two layers reading each other twice at other times: no cycle (the times differ), but
|
||||
// 2^16 evaluations at `MAX_DEPTH`; the evaluation stops after `MAX_RUNS` script runs.
|
||||
let mut f = fx();
|
||||
let text = |other: &str| format!("var r = thisComp.layer('{other}').transform.rotation; r.valueAtTime(time + 0.001) + r.valueAtTime(time + 0.002)");
|
||||
f.set_expr(f.a, ROT, &text("B"));
|
||||
f.set_expr(f.b, ROT, &text("A"));
|
||||
// Take the clock out of the race: on a loaded machine the time budget could otherwise stop
|
||||
// the evaluation before the run count does, with a different error.
|
||||
crate::runtime::TIME_LIMIT.with(|t| t.set(std::time::Duration::from_secs(3600)));
|
||||
let start = Instant::now();
|
||||
let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5);
|
||||
crate::runtime::TIME_LIMIT.with(|t| t.set(crate::MAX_TIME));
|
||||
assert!(start.elapsed().as_secs_f64() < 300.0, "{:?}", start.elapsed());
|
||||
assert!(r.unwrap_err().contains("too many"));
|
||||
// The budget is per outermost evaluation: the next one starts afresh.
|
||||
assert_close!(f.num("time * 2", 1.0), 2.0);
|
||||
assert_close!(f.ev(f.b, OPA, "thisComp.layer('A').transform.opacity", 0.0).unwrap().as_f64(), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loops_stop_at_a_million_iterations() {
|
||||
// Found by the expression fuzzer (#786): `for (…; i < 1e15; …)` ran ten million iterations
|
||||
// per run (seconds), once more for every property the script reads after it.
|
||||
let f = fx();
|
||||
assert_close!(f.num("var n = 0; while (n < 900000) n++; n", 0.0), 900_000.0);
|
||||
assert!(f.err("var n = 0; while (n < 2000000) n++; n").contains("iteration"));
|
||||
assert!(f.err("for (;;) {}").contains("iteration"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_running_script_stops_at_the_time_budget() {
|
||||
// Found by the expression fuzzer (#786): `smooth(NaN, 1e9)` made a million host reads in
|
||||
// one run (tens of seconds), and the loop limit counts per function call, so loops calling
|
||||
// functions that loop run for hours. The run is now abandoned after `MAX_TIME`.
|
||||
let f = fx();
|
||||
let start = Instant::now();
|
||||
let e = f.err("function g() { for (var i = 0; i < 900000; i++) {} }\nfor (var k = 0; k < 900000; k++) g()");
|
||||
assert!(e.contains("too long"), "{e}");
|
||||
assert!(start.elapsed() < crate::MAX_TIME * 12, "{:?}", start.elapsed());
|
||||
// The thread's runtime starts afresh.
|
||||
assert_close!(f.num("time * 2", 1.0), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_with_non_numeric_points_is_an_error() {
|
||||
// Found by the expression fuzzer (#786): an array in a vertex became NaN and tripped the
|
||||
// rasterizer's clipping assertion when the mask was drawn.
|
||||
let mut f = fx();
|
||||
let text = "createPath([[0, 0], [[1, 2] * 2, 0], [100, 100]], [], [], true)";
|
||||
assert!(f.ev(f.a, "masks/#1/path", text, 0.0).unwrap_err().contains("finite"));
|
||||
assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [1e400, 0], [100, 100]], [], [], true)", 0.0).is_err());
|
||||
f.set_expr(f.a, "masks/#1/path", text);
|
||||
let host = Expressions;
|
||||
let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
|
||||
r.expr = Some(&host);
|
||||
r.comp_frame(f.cid, Tick::ZERO);
|
||||
assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [10, 0], [10, 10]], [], [], true)", 0.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_and_property_reads_keep_the_runtime_usable() {
|
||||
// Errors thrown inside helpers, and the aborts every property read makes until the host
|
||||
|
|
@ -1002,7 +1080,7 @@ fn hundred_wiggles_are_fast() {
|
|||
let per_frame = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("100 wiggle properties: {per_frame:.3} ms/frame (checksum {acc})");
|
||||
// Generous bound so unoptimised CI builds pass; release is far below.
|
||||
assert!(per_frame < 100.0, "{per_frame} ms/frame");
|
||||
assert!(per_frame < 1000.0, "{per_frame} ms/frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,361 @@
|
|||
//! An expression fuzzer (gaps G3, #786): any expression text must evaluate to a value or an
|
||||
//! error, never a panic, a stack overflow (which aborts the app), a hang or a runaway allocation
|
||||
//! (AGENTS.md "Never crash").
|
||||
//!
|
||||
//! Dependency-free, deterministic and seeded: real After Effects-style expressions (`wiggle`,
|
||||
//! `loopOut`, `valueAtTime`, `thisComp.layer(…)`, effects, masks, markers, text styles, paths…)
|
||||
//! are mutated character-wise (flips, deletions, duplications, splices with other seeds,
|
||||
//! truncation) and token-wise (punctuation, keywords and object-model names inserted, numbers
|
||||
//! swapped for extreme ones, a token repeated into deep nesting or a long operator chain), then
|
||||
//! syntax-checked, evaluated on a scalar, a vector, a text and a path property of the test comp,
|
||||
//! read through another layer's expression and rendered in a small frame. Inputs run on a worker
|
||||
//! thread with the app's default 2 MiB stack under `catch_unwind` and a deadline: a panic or a
|
||||
//! hang is reported with the iteration that reproduces it instead of taking the run down, and a
|
||||
//! stack overflow aborts the run, which fails it all the same (`EFFECTCRAFT_FUZZ_VERBOSE=1`
|
||||
//! prints every input before it runs).
|
||||
//!
|
||||
//! The default run is bounded (iterations and seconds) to stay fast in `cargo test`; a longer run:
|
||||
//! `EFFECTCRAFT_FUZZ_ITERS=200000 EFFECTCRAFT_FUZZ_SECS=600 cargo test -p effectcraft-expr fuzz_`
|
||||
//! (`EFFECTCRAFT_FUZZ_SEED` picks another sequence, `EFFECTCRAFT_FUZZ_START` the first iteration).
|
||||
//! The `mutate` + `eval_input` pair is what a cargo-fuzz target would call.
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use effectcraft_render::{NoFootage, RenderOpts, Renderer};
|
||||
|
||||
use super::{Fx, OPA, POS, ROT, fx, secs};
|
||||
use crate::{Expressions, check_syntax, eval_property, eval_standalone};
|
||||
|
||||
/// splitmix64: tiny, good enough for picking mutations.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
/// `0..n` (`0` when `n` is 0).
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
if n == 0 { 0 } else { (self.next() % n as u64) as usize }
|
||||
}
|
||||
fn chance(&mut self, one_in: usize) -> bool {
|
||||
self.below(one_in) == 0
|
||||
}
|
||||
fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T {
|
||||
&xs[self.below(xs.len())]
|
||||
}
|
||||
}
|
||||
|
||||
/// Real expressions, in the shapes people write them (and the test comp can answer them).
|
||||
#[rustfmt::skip]
|
||||
const SEEDS: &[&str] = &[
|
||||
"time * 360",
|
||||
"value",
|
||||
"wiggle(2, 50)",
|
||||
"wiggle(5, 20, 3, 0.5, time * 2)",
|
||||
"temporalWiggle(2, 0.1)",
|
||||
"loopOut('cycle')",
|
||||
"loopOut('pingpong', 1)",
|
||||
"loopIn('offset')",
|
||||
"loopOutDuration('continue', 0.5)",
|
||||
"valueAtTime(time - 0.5)",
|
||||
"thisProperty.valueAtTime(time / 2) + velocityAtTime(time)[0]",
|
||||
"speedAtTime(0.5)",
|
||||
"smooth(0.2, 5)",
|
||||
"nearestKey(time).index",
|
||||
"numKeys > 1 ? key(numKeys).value : value",
|
||||
"thisComp.layer('A').transform.rotation.valueAtTime(0.25)",
|
||||
"thisComp.layer(\"B\").transform.position[0] + 10",
|
||||
"thisComp.layer(1).effect('Slider Control')('Slider') * 2",
|
||||
"effect(1).param(1) + effect(1)(1)",
|
||||
"comp('Other').layer(1).name",
|
||||
"comp('Other').width / thisComp.height",
|
||||
"thisLayer.index + thisComp.numLayers",
|
||||
"mask('Mask 1').maskOpacity",
|
||||
"mask('Mask 1').maskPath.points()[0]",
|
||||
"content('Rectangle 1').content('Rectangle Path 1').size[0]",
|
||||
"marker.numKeys > 0 ? marker.key(1).time : 0",
|
||||
"marker.nearestKey(time).comment",
|
||||
"thisComp.marker.key(1).time",
|
||||
"var r = sourceRectAtTime(time, false); [r.left + r.width / 2, r.top + r.height / 2]",
|
||||
"toComp([0, 0])",
|
||||
"fromWorld(toWorld(anchorPoint))",
|
||||
"lookAt([0, 0, 0], [100, 0, 100])",
|
||||
"value + [10, -5]",
|
||||
"[1, 2] * 2 - [0.5, 0.5] / 4",
|
||||
"x = value; x += [1, 0]; x",
|
||||
"linear(time, 0, 1, 0, 100)",
|
||||
"ease(time, 0, 2, [0, 0], [100, 50])",
|
||||
"easeIn(time, 0, 1, 0, 360) + easeOut(time, 1, 2, 0, 10)",
|
||||
"clamp(value * 2, 0, 100)",
|
||||
"length([0, 0], [3, 4]) + dot([1, 0], [0, 1]) + cross([1, 0, 0], [0, 1, 0])[2]",
|
||||
"normalize([3, 4])[0] * degreesToRadians(180)",
|
||||
"seedRandom(5, true); random(100)",
|
||||
"seedRandom(index, false); gaussRandom([10, 20])",
|
||||
"noise(time) * 100",
|
||||
"posterizeTime(12); wiggle(3, 30)",
|
||||
"framesToTime(timeToFrames(time) + 1)",
|
||||
"rgbToHsl([1, 0, 0, 1])[0] + hslToRgb([0.5, 1, 0.5, 1])[1]",
|
||||
"Math.sin(time * Math.PI * 2) * 50",
|
||||
"Math.round(value / 10) * 10",
|
||||
"var s = 0; for (var i = 0; i < 10; i++) { s += i; } s",
|
||||
"function f(a) { return a * 2 } f(value)",
|
||||
"var g = (a, b) => a - b; g(5, 3)",
|
||||
"if (time > 1) { 100 } else { 0 }",
|
||||
"try { thisComp.layer('Missing').name } catch (e) { 0 }",
|
||||
"'Frame ' + timeToFrames(time) + ' of ' + thisComp.duration",
|
||||
"text.sourceText + ' world'",
|
||||
"text.sourceText.style.setText('Hi there').setFontSize(20, 3, 5).setFauxBold(true, 0, 2)",
|
||||
"thisComp.layer('Title').text.sourceText.style.fontSize",
|
||||
"`${thisLayer.name}: ${Math.floor(time)}`",
|
||||
"createPath([[0, 0], [100, 0], [100, 100]], [], [], true)",
|
||||
"var p = thisProperty; p.pointOnPath(0.5)",
|
||||
"[thisComp.width, thisComp.height] / 2",
|
||||
"thisComp.frameDuration * 10",
|
||||
"inPoint + (outPoint - inPoint) * 0.5",
|
||||
"transform.opacity * 0.5 + [0][0]",
|
||||
];
|
||||
|
||||
/// What gets inserted: syntax, keywords, object-model names and odd characters.
|
||||
#[rustfmt::skip]
|
||||
const TOKENS: &[&str] = &[
|
||||
"(", ")", "[", "]", "{", "}", ",", ";", ".", "?.", "?", ":", "=", "==", "=>", "+", "-", "*", "/", "%", "**", "++", "--",
|
||||
"+=", "-=", "*=", "/=", "!", "~", "&&", "||", "??", "...", "'", "\"", "`", "${", "\\", "/*", "*/", "//", "\n", "\r", "\t",
|
||||
"\u{0}", "\u{2028}", "\u{feff}", "é", "\u{1F600}", "\u{fffd}", " in ", " of ", " new ", " typeof ", " void ", " delete ",
|
||||
"function", "return ", "var ", "let ", "const ", "if", "else", "for", "while", "do", "switch", "case ", "break", "try",
|
||||
"catch", "finally", "throw ", "class ", "this", "null", "undefined", "NaN", "Infinity", "true", "-0", "0x", "1e", "1n",
|
||||
"/a+/g", "time", "value", "index", "thisComp", "thisLayer", "thisProperty", "layer(", "comp(", "effect(", "mask(",
|
||||
"content(", "key(", "marker", "valueAtTime(", "wiggle(", "loopOut(", "sourceRectAtTime(", "toComp(", "text.sourceText",
|
||||
".style", ".setText(", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments",
|
||||
];
|
||||
|
||||
/// Numbers swapped in for numeric literals.
|
||||
#[rustfmt::skip]
|
||||
const NUMBERS: &[&str] = &[
|
||||
"0", "-0", "1", "-1", "0.5", "2", "16", "17", "255", "256", "65536", "4294967295", "4294967296", "9007199254740993",
|
||||
"1e308", "-1e308", "1e-320", "1e400", "NaN", "Infinity", "-Infinity", "1e9", "1e15", "-2147483648", "0x7fffffff",
|
||||
];
|
||||
|
||||
/// A fragment that nests (or chains) once per repetition: what overflows a recursive parser.
|
||||
#[rustfmt::skip]
|
||||
const NESTS: &[(&str, &str)] = &[
|
||||
("(", ")"), ("[", "]"), ("{", "}"), ("-", ""), ("!", ""), ("~", ""), ("+", ""), ("++", ""), ("--", ""), ("typeof ", ""),
|
||||
("new ", ""), ("await ", ""), ("void ", ""), ("delete ", ""), ("x**", ""), ("x+", ""), ("x*", ""), ("x=", ""),
|
||||
("x?x:", ""), ("x&&", ""), ("x||", ""), ("x??", ""), ("x<", ""), ("x==", ""), ("x,", ""), ("()=>", ""), ("a=>", ""),
|
||||
("x.", "y"), ("f(", ")"), ("x[", "]"), ("`${", "}`"), ("function(){return ", "}"), ("{a:", "}"), ("if(x)", ""),
|
||||
("x.y", ""), ("f()", ""), ("x[0]", ""),
|
||||
];
|
||||
|
||||
/// One mutated expression from the seeds, decided by the iteration's own generator.
|
||||
fn mutate(r: &mut Rng) -> String {
|
||||
let mut s: Vec<char> = r.pick(SEEDS).chars().collect();
|
||||
if r.chance(4) {
|
||||
// Splice two expressions together.
|
||||
let other: Vec<char> = r.pick(SEEDS).chars().collect();
|
||||
let at = r.below(s.len() + 1);
|
||||
let glue: Vec<char> = r.pick(&[";\n", " + ", " * ", ", ", "(", "."]).chars().collect();
|
||||
s.splice(at..at, glue.into_iter().chain(other));
|
||||
}
|
||||
for _ in 0..1 + r.below(4) {
|
||||
let len = s.len();
|
||||
match r.below(10) {
|
||||
0 if len > 0 => {
|
||||
// A bit flip in the UTF-8 bytes (invalid sequences become U+FFFD).
|
||||
let mut b = s.iter().collect::<String>().into_bytes();
|
||||
let i = r.below(b.len());
|
||||
b[i] ^= 1 << r.below(8);
|
||||
s = String::from_utf8_lossy(&b).chars().collect();
|
||||
}
|
||||
1 if len > 0 => {
|
||||
let a = r.below(len);
|
||||
let e = (a + 1 + r.below(16)).min(len);
|
||||
s.drain(a..e);
|
||||
}
|
||||
2 if len > 0 => {
|
||||
let a = r.below(len);
|
||||
let e = (a + 1 + r.below(32)).min(len);
|
||||
let chunk = s[a..e].to_vec();
|
||||
let at = r.below(len + 1);
|
||||
s.splice(at..at, chunk);
|
||||
}
|
||||
3 | 4 => {
|
||||
let t: Vec<char> = r.pick(TOKENS).chars().collect();
|
||||
let at = r.below(len + 1);
|
||||
s.splice(at..at, t);
|
||||
}
|
||||
5 => {
|
||||
// An extreme number in place of a numeric literal (or anywhere).
|
||||
let starts: Vec<usize> = (0..len).filter(|&i| s[i].is_ascii_digit() && (i == 0 || !s[i - 1].is_ascii_alphanumeric())).collect();
|
||||
let n: Vec<char> = r.pick(NUMBERS).chars().collect();
|
||||
if starts.is_empty() {
|
||||
let at = r.below(len + 1);
|
||||
s.splice(at..at, n);
|
||||
} else {
|
||||
let a = *r.pick(&starts);
|
||||
let e = (a..len).find(|&i| !(s[i].is_ascii_alphanumeric() || s[i] == '.')).unwrap_or(len);
|
||||
s.splice(a..e, n);
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
// Deep nesting or a long chain, closed or not.
|
||||
let (open, close) = *r.pick(NESTS);
|
||||
let depth = *r.pick(&[8, 40, 300, 5_000, 100_000]);
|
||||
let at = r.below(len + 1);
|
||||
let mut nest: Vec<char> = open.repeat(depth).chars().collect();
|
||||
nest.push('1');
|
||||
if !r.chance(4) {
|
||||
nest.extend(close.repeat(depth).chars());
|
||||
}
|
||||
s.splice(at..at, nest);
|
||||
}
|
||||
7 if len > 0 => s.truncate(r.below(len)),
|
||||
8 => {
|
||||
// A whole statement in front or behind.
|
||||
let other: Vec<char> = r.pick(SEEDS).chars().collect();
|
||||
if r.chance(2) {
|
||||
s.extend(";\n".chars().chain(other));
|
||||
} else {
|
||||
s.splice(0..0, other.into_iter().chain(";\n".chars()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Where an input is evaluated: a scalar, a vector, a text and a path property.
|
||||
const TARGETS: &[(usize, &str)] = &[(0, ROT), (0, POS), (2, "text/sourceText"), (0, "masks/#1/path"), (3, ROT), (1, OPA)];
|
||||
const TIMES: &[f64] = &[0.0, 0.5, 1.25, 4.0, -1.0, 1e6];
|
||||
|
||||
/// Syntax-check `text`, evaluate it on one property, read it from another layer's expression
|
||||
/// and render a small frame with it. `Err` only for what must never happen: an `internal error`
|
||||
/// (a panic caught by a guard).
|
||||
fn eval_input(f: &mut Fx, text: &str, target: usize, t: f64) -> Result<(), String> {
|
||||
internal(&check_syntax(text))?;
|
||||
let layers = [f.a, f.b, f.title, f.shape];
|
||||
let (li, path) = TARGETS[target % TARGETS.len()];
|
||||
let layer = layers[li];
|
||||
internal(&eval_standalone(&f.p, f.cid, layer, path, text, t))?;
|
||||
// As the property's own expression, read by B's rotation and drawn by the renderer.
|
||||
f.set_expr(layer, path, text);
|
||||
f.set_expr(f.b, ROT, &format!("var v = thisComp.layer({}).transform.rotation; v + 1", li + 1));
|
||||
internal(&eval_property(&f.p, f.cid, layer, path, t))?;
|
||||
internal(&eval_property(&f.p, f.cid, f.b, ROT, t))?;
|
||||
let host = Expressions;
|
||||
let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, scale: 0.25, ..Default::default() });
|
||||
r.expr = Some(&host);
|
||||
r.comp_frame(f.cid, secs(t));
|
||||
// Leave the fixture as it was.
|
||||
let l = f.layer_mut(layer);
|
||||
if let Some(p) = l.props.prop_mut(path) {
|
||||
p.expr = None;
|
||||
}
|
||||
if let Some(p) = f.layer_mut(f.b).props.prop_mut(ROT) {
|
||||
p.expr = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn internal<T>(r: &Result<T, String>) -> Result<(), String> {
|
||||
match r {
|
||||
Err(e) if e.contains("internal error") => Err(e.clone()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
struct Job {
|
||||
text: String,
|
||||
target: usize,
|
||||
t: f64,
|
||||
}
|
||||
|
||||
/// A worker thread that keeps its expression runtime between inputs, like a render thread.
|
||||
struct Worker {
|
||||
jobs: Sender<Job>,
|
||||
results: Receiver<Result<(), String>>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn start() -> Result<Worker, String> {
|
||||
let (jobs, rx) = channel::<Job>();
|
||||
let (tx, results) = channel();
|
||||
std::thread::Builder::new()
|
||||
.name("fuzz-expr".into())
|
||||
.stack_size(2 << 20)
|
||||
.spawn(move || {
|
||||
let mut f = fx();
|
||||
for job in rx {
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| eval_input(&mut f, &job.text, job.target, job.t)));
|
||||
let r = r.unwrap_or_else(|p| {
|
||||
Err(format!(
|
||||
"panic: {}",
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default()
|
||||
))
|
||||
});
|
||||
let failed = r.is_err();
|
||||
if tx.send(r).is_err() || failed {
|
||||
break; // after a panic the fixture and runtime may be half-updated: start over
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("couldn't start the fuzz thread: {e}"))?;
|
||||
Ok(Worker { jobs, results })
|
||||
}
|
||||
|
||||
/// Run one input: a panic becomes its message, a hang a timeout (the worker is then dropped).
|
||||
fn run(&self, job: Job, deadline: Duration) -> Result<(), String> {
|
||||
self.jobs.send(job).map_err(|_| "the fuzz worker exited".to_string())?;
|
||||
self.results.recv_timeout(deadline).unwrap_or_else(|_| Err(format!("hang: no result within {deadline:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn env_u64(name: &str, default: u64) -> u64 {
|
||||
std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_expressions_never_crash() {
|
||||
let seed = env_u64("EFFECTCRAFT_FUZZ_SEED", 0x00EC_E8E2);
|
||||
let start = env_u64("EFFECTCRAFT_FUZZ_START", 0);
|
||||
let iters = env_u64("EFFECTCRAFT_FUZZ_ITERS", 1_500);
|
||||
let budget = Duration::from_secs(env_u64("EFFECTCRAFT_FUZZ_SECS", 5));
|
||||
let verbose = env_u64("EFFECTCRAFT_FUZZ_VERBOSE", 0) != 0;
|
||||
// Every evaluation gives up after `MAX_TIME`, and an input is evaluated a handful of times.
|
||||
let deadline = crate::MAX_TIME * 24;
|
||||
let mut worker = Worker::start().unwrap();
|
||||
// The unmutated seeds evaluate everywhere without crashing.
|
||||
for (i, s) in SEEDS.iter().enumerate() {
|
||||
assert_eq!(worker.run(Job { text: s.to_string(), target: i, t: 0.5 }, deadline), Ok(()), "{s}");
|
||||
}
|
||||
let began = Instant::now();
|
||||
let mut failures = vec![];
|
||||
let mut ran = 0;
|
||||
for i in start..start + iters {
|
||||
if began.elapsed() > budget {
|
||||
break;
|
||||
}
|
||||
let mut r = Rng(seed ^ i.wrapping_mul(0xD6E8_FEB8_6659_FD93));
|
||||
let text = mutate(&mut r);
|
||||
let target = r.below(TARGETS.len());
|
||||
let t = *r.pick(TIMES);
|
||||
if verbose {
|
||||
eprintln!("iteration {i}: target {target}, t {t}, {} chars: {:?}", text.len(), text.chars().take(300).collect::<String>());
|
||||
}
|
||||
ran += 1;
|
||||
if let Err(e) = worker.run(Job { text: text.clone(), target, t }, deadline) {
|
||||
let head: String = text.chars().take(200).collect();
|
||||
failures.push(format!("iteration {i} (seed {seed:#x}, target {target}, t {t}, {} chars): {e}\n input starts: {head:?}", text.len()));
|
||||
if failures.len() >= 5 {
|
||||
break;
|
||||
}
|
||||
worker = Worker::start().unwrap();
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "expressions crashed on {} of {ran} fuzz inputs:\n{}", failures.len(), failures.join("\n"));
|
||||
eprintln!("fuzz_expressions: {ran} inputs in {:?}, no crashes", began.elapsed());
|
||||
}
|
||||
|
|
@ -145,6 +145,21 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> {
|
|||
let out = f(Buf { img, offset: b.offset, scale: b.scale });
|
||||
self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale });
|
||||
}
|
||||
|
||||
/// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no
|
||||
/// readback. A region the device can't allocate leaves the buffer as it is.
|
||||
fn cover(&mut self, size: [f64; 2]) {
|
||||
let Some(b) = self.b.take() else { return };
|
||||
let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) {
|
||||
Some((dx, dy, w, h)) if self.e.g.fits(w, h) => {
|
||||
let img = self.e.image(w, h);
|
||||
self.e.copy_into(&b.img, &img, dx, dy);
|
||||
GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale }
|
||||
}
|
||||
_ => b,
|
||||
};
|
||||
self.b = Some(out);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
|
|
|
|||
|
|
@ -45,11 +45,17 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
|
|||
let fd = 1.0 / ctx.fps();
|
||||
mean_of(e, ctx, b, &(-back..=fwd).map(|k| ctx.time + k as f64 * fd).collect::<Vec<_>>())
|
||||
}
|
||||
// Pixel Motion Blur synthesizes along measured motion vectors (#535); a plan that
|
||||
// cannot be built (no host) declines to the CPU effect.
|
||||
// Pixel Motion Blur synthesizes along measured motion vectors (#535); without a plan (no
|
||||
// host to read neighbouring frames) it averages the shutter's frames, as the CPU effect does.
|
||||
"ec.time.pixelmotionblur" => match effectcraft_effects::pmb_plan(ctx) {
|
||||
Some(plan) => render_plan(e, &plan),
|
||||
None => None,
|
||||
None => {
|
||||
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
|
||||
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
|
||||
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
|
||||
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
|
||||
mean_of(e, ctx, b, ×)
|
||||
}
|
||||
},
|
||||
"ec.time.timewarp" => timewarp(e, ctx, b),
|
||||
_ => None,
|
||||
|
|
|
|||
|
|
@ -665,7 +665,7 @@ fn fxn_fractal(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
r = blend_pixel(P.u[2].x, orig, s, 0.5);
|
||||
}
|
||||
let bo = P.f[1].w;
|
||||
textureStore(out, p, orig * bo + r * (1.0 - bo));
|
||||
textureStore(out, p, orig * bo + r * orig.a * (1.0 - bo));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Noise Alpha / Noise HLS (noise.rs)
|
||||
|
|
|
|||
|
|
@ -865,13 +865,13 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
val = 1.0 - val;
|
||||
}
|
||||
let op = P.f[2].z;
|
||||
var fx = vec4<f32>(vec3<f32>(val) * op, op);
|
||||
if (P.u[1].z == 1u) {
|
||||
// Normal: the noise over the layer at Opacity, then Blend With Original.
|
||||
let over = vec4<f32>(vec3<f32>(val) * op, op) + px * (1.0 - op);
|
||||
px = px * P.f[2].y + over * (1.0 - P.f[2].y);
|
||||
} else {
|
||||
px = put(px, vec3<f32>(val), op, P.f[2].y);
|
||||
// Normal: the noise over the layer at Opacity.
|
||||
fx = fx + px * (1.0 - op);
|
||||
}
|
||||
// Inside the layer's alpha (its masks), then Blend With Original.
|
||||
px = px * P.f[2].y + fx * px.w * (1.0 - P.f[2].y);
|
||||
}
|
||||
default: {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -287,7 +287,9 @@ impl SpatialSeg {
|
|||
/// along curved paths instead of piecewise linear per cell (#456).
|
||||
fn at_length(&self, s: f64) -> [f64; 3] {
|
||||
let len = self.length();
|
||||
if len <= 0.0 {
|
||||
// A path whose length overflowed (huge coordinates) or is NaN has no arc-length table to
|
||||
// walk: `clamp` would panic on a NaN bound.
|
||||
if !(len > 0.0 && len.is_finite()) {
|
||||
return self.p[0];
|
||||
}
|
||||
let s = s.clamp(0.0, len);
|
||||
|
|
@ -878,6 +880,30 @@ mod tests {
|
|||
assert_eq!(segment_value(&keys, 0, s(1.0), true), Value::Vec2([80.0, 50.0]));
|
||||
}
|
||||
|
||||
/// #880: a motion path whose arc length overflows (huge coordinates, as the Position field
|
||||
/// accepts) or is NaN evaluates without panicking: scrubbing over it crashed the app.
|
||||
#[test]
|
||||
fn overflowing_or_nan_motion_paths_evaluate() {
|
||||
let paths = [
|
||||
[[-1.7e308, 0.0], [1.7e308, 0.0], [-1.7e308, 5.0]],
|
||||
[[0.0, 0.0], [f64::NAN, 10.0], [20.0, 0.0]],
|
||||
[[f64::INFINITY, 0.0], [0.0, f64::NEG_INFINITY], [20.0, 0.0]],
|
||||
];
|
||||
for path in paths {
|
||||
let mut keys: Vec<Keyframe> = path.iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64 * 2.0), Value::Vec2(v))).collect();
|
||||
for eased in [false, true] {
|
||||
if eased {
|
||||
keys = keys.into_iter().map(Keyframe::eased).collect();
|
||||
}
|
||||
for f in 0..=40 {
|
||||
let t = s(f as f64 * 0.1);
|
||||
assert!(evaluate(&keys, t, true).is_some());
|
||||
let _ = speed(&keys, t, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_auto_bezier_tangents_follow_after_effects() {
|
||||
// Measured on After Effects 26.3 with original paths (docs/fidelity/README.md): each
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ effectcraft-pdf = { workspace = true }
|
|||
effectcraft-model = { workspace = true }
|
||||
filmcraft-codecs = { workspace = true }
|
||||
filmcraft-media = { workspace = true }
|
||||
filmcraft-isobmff = { workspace = true }
|
||||
filmcraft-frame = { workspace = true }
|
||||
filmcraft-color = { workspace = true }
|
||||
filmcraft-time = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -114,6 +114,32 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec<Px>) -
|
|||
img
|
||||
}
|
||||
|
||||
/// A movie frame turned the way its file says it is shown ([`crate::probe::video_turn`]:
|
||||
/// clockwise quarter turns; anything else leaves it as decoded).
|
||||
pub(crate) fn turned(img: Image, turn: image::metadata::Orientation) -> Image {
|
||||
use image::metadata::Orientation;
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if img.data.len() != w * h || w == 0 || h == 0 {
|
||||
return img;
|
||||
}
|
||||
// The source pixel shown at (x, y) of the turned picture.
|
||||
let (tw, th, from): (usize, usize, fn(usize, usize, usize, usize) -> usize) = match turn {
|
||||
Orientation::Rotate90 => (h, w, |x, y, w, h| (h - 1 - x) * w + y),
|
||||
Orientation::Rotate180 => (w, h, |x, y, w, h| (h - 1 - y) * w + (w - 1 - x)),
|
||||
Orientation::Rotate270 => (h, w, |x, y, w, _| x * w + (w - 1 - y)),
|
||||
_ => return img,
|
||||
};
|
||||
let mut data = vec![[0.0; 4]; tw * th];
|
||||
data.par_chunks_mut(tw).enumerate().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
if let Some(s) = img.data.get(from(x, y, w, h)) {
|
||||
*px = *s;
|
||||
}
|
||||
}
|
||||
});
|
||||
Image { width: tw as u32, height: th as u32, data }
|
||||
}
|
||||
|
||||
/// A FilmCraft frame for a render that shows it at `w` × `h`, at most half its size each way:
|
||||
/// what `resample(&frame_to_image(f, op), w, h)` makes, converting a quarter of the pixels.
|
||||
/// Opaque 4:2:0 Y'CbCr is box-filtered to half size before conversion (each 2×2 block of luma,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
//! Stereo downmix of multichannel audio, shared by preview, waveform and export mixdown (they
|
||||
//! all read footage audio through `MediaPool::audio_samples`).
|
||||
//!
|
||||
//! The matrix is the usual ITU-R BS.775 one: front L/R at unity, the centre and every surround
|
||||
//! or back channel at −3 dB (1/√2) into both sides' own side (centre into both), the low
|
||||
//! frequency effects channel left out (as ffmpeg's and swresample's default, `lfe_mix_level` 0;
|
||||
//! not verified against Adobe's documentation, which does not state it). A back-centre channel
|
||||
//! reaches both sides at −6 dB. The whole matrix is then scaled by one fixed gain, the reciprocal
|
||||
//! of the largest row sum (ffmpeg's default normalisation), so that full-scale input can never
|
||||
//! exceed full scale: 5.1 is scaled by 1/(1 + 0.707 + 0.707). Mono stays duplicated at unity and
|
||||
//! stereo is untouched.
|
||||
//!
|
||||
//! Channel order follows the WAVE / SMPTE convention by channel count (what FilmCraft's AC-3, PCM,
|
||||
//! FLAC and Opus readers deliver): 3 = L R C, 4 = quad L R Ls Rs, 5 = L R C Ls Rs,
|
||||
//! 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. FilmCraft's AAC-LC
|
||||
//! decoder instead delivers AAC's own order (C L R first, LFE last), so AAC gets its own tables.
|
||||
//! More than 8 channels carry no known layout: only the first two (front L/R) are used.
|
||||
|
||||
use std::f32::consts::FRAC_1_SQRT_2;
|
||||
|
||||
/// A source channel's contribution to the left and right outputs, before normalisation.
|
||||
type Pair = [f32; 2];
|
||||
|
||||
const FL: Pair = [1.0, 0.0];
|
||||
const FR: Pair = [0.0, 1.0];
|
||||
const FC: Pair = [FRAC_1_SQRT_2, FRAC_1_SQRT_2];
|
||||
const LFE: Pair = [0.0, 0.0];
|
||||
const SL: Pair = [FRAC_1_SQRT_2, 0.0];
|
||||
const SR: Pair = [0.0, FRAC_1_SQRT_2];
|
||||
const CS: Pair = [0.5, 0.5];
|
||||
|
||||
/// The per-channel `[left, right]` gains for `count` channels in the given order, normalised.
|
||||
/// `aac` selects AAC's native channel order. Empty for zero channels.
|
||||
pub fn stereo_gains(count: usize, aac: bool) -> Vec<Pair> {
|
||||
let raw: &[Pair] = match (count, aac) {
|
||||
(0, _) => &[],
|
||||
(1, _) => &[[1.0, 1.0]],
|
||||
(2, _) => &[FL, FR],
|
||||
(3, false) => &[FL, FR, FC],
|
||||
(3, true) => &[FC, FL, FR],
|
||||
(4, false) => &[FL, FR, SL, SR],
|
||||
(4, true) => &[FC, FL, FR, CS],
|
||||
(5, false) => &[FL, FR, FC, SL, SR],
|
||||
(5, true) => &[FC, FL, FR, SL, SR],
|
||||
(6, false) => &[FL, FR, FC, LFE, SL, SR],
|
||||
(6, true) => &[FC, FL, FR, SL, SR, LFE],
|
||||
(7, false) => &[FL, FR, FC, LFE, CS, SL, SR],
|
||||
(7, true) => &[FC, FL, FR, SL, SR, CS, LFE],
|
||||
(8, false) => &[FL, FR, FC, LFE, SL, SR, SL, SR],
|
||||
(8, true) => &[FC, FL, FR, SL, SR, SL, SR, LFE],
|
||||
_ => &[FL, FR],
|
||||
};
|
||||
let mut out = raw.to_vec();
|
||||
// Channels beyond a known layout (more than 8) are silent.
|
||||
out.resize(count, [0.0, 0.0]);
|
||||
let peak = out.iter().map(|g| g[0]).sum::<f32>().max(out.iter().map(|g| g[1]).sum::<f32>());
|
||||
// Mono is deliberately left at unity on both sides; so is anything already within range.
|
||||
if count > 2 && peak > 1.0 {
|
||||
for g in &mut out {
|
||||
g[0] /= peak;
|
||||
g[1] /= peak;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Downmix planar `channels` to interleaved stereo in `out` (`out.len() / 2` frames); frames a
|
||||
/// channel is too short for count as silence, extra frames in `out` stay as they are.
|
||||
pub fn downmix_into(channels: &[Vec<f32>], aac: bool, out: &mut [f32]) {
|
||||
let gains = stereo_gains(channels.len(), aac);
|
||||
for (ch, g) in channels.iter().zip(&gains) {
|
||||
if g[0] == 0.0 && g[1] == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for (o, v) in out.as_chunks_mut::<2>().0.iter_mut().zip(ch) {
|
||||
o[0] += v * g[0];
|
||||
o[1] += v * g[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_channel_count_has_a_bounded_defined_matrix() {
|
||||
for aac in [false, true] {
|
||||
for n in 0..40usize {
|
||||
let g = stereo_gains(n, aac);
|
||||
assert_eq!(g.len(), n);
|
||||
let (l, r) = (g.iter().map(|p| p[0]).sum::<f32>(), g.iter().map(|p| p[1]).sum::<f32>());
|
||||
if n > 2 {
|
||||
assert!(l <= 1.0001 && r <= 1.0001, "{n} channels (aac {aac}): {l} {r}");
|
||||
assert!(l > 0.0 && r > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_one_matches_the_bs775_matrix() {
|
||||
let k = 1.0 / (1.0 + 2.0 * FRAC_1_SQRT_2);
|
||||
let g = stereo_gains(6, false);
|
||||
assert!((g[0][0] - k).abs() < 1e-6 && g[0][1] == 0.0);
|
||||
assert!((g[2][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && (g[2][1] - k * FRAC_1_SQRT_2).abs() < 1e-6);
|
||||
assert_eq!(g[3], [0.0, 0.0], "LFE is left out");
|
||||
assert!((g[4][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[4][1] == 0.0);
|
||||
assert!((g[5][1] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[5][0] == 0.0);
|
||||
// AAC order: the centre is first, LFE last
|
||||
let a = stereo_gains(6, true);
|
||||
assert_eq!((a[0], a[5]), (g[2], g[3]));
|
||||
assert_eq!((a[1], a[2]), (g[0], g[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mono_and_stereo_are_untouched_and_wide_layouts_use_the_front_pair() {
|
||||
assert_eq!(stereo_gains(1, false), vec![[1.0, 1.0]]);
|
||||
assert_eq!(stereo_gains(2, true), vec![FL, FR]);
|
||||
let g = stereo_gains(12, false);
|
||||
assert_eq!((g[0], g[1], g[2]), (FL, FR, [0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ragged_and_empty_input_never_panics() {
|
||||
let mut out = vec![0.0; 8];
|
||||
downmix_into(&[], false, &mut out);
|
||||
downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], true, &mut out);
|
||||
assert!(out.iter().all(|v| v.is_finite()));
|
||||
let mut none: [f32; 0] = [];
|
||||
downmix_into(&vec![vec![1.0; 3]; 7], false, &mut none);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,12 @@
|
|||
//! A GIF gives every frame its own delay, in hundredths of a second. The footage gets the frame
|
||||
//! rate that every delay is a whole number of frames at (100 / the greatest common divisor of the
|
||||
//! delays), so uneven timing is kept exactly; [`Anim::frame_at`] looks the frame up by time.
|
||||
//! Animated WebP ([`crate::webp_anim`]) shares [`Anim`] and this decode path.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind};
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder};
|
||||
|
||||
use crate::{MediaError, Result};
|
||||
|
|
@ -15,19 +16,20 @@ use crate::{MediaError, Result};
|
|||
/// The `Footage::codec` of an animated GIF.
|
||||
pub(crate) const CODEC: &str = "GIF";
|
||||
|
||||
/// Decoded frames of one GIF are kept as 8-bit RGBA, up to this many bytes (less in a browser tab,
|
||||
/// whose wasm32 heap is 4 GiB at most and shared with everything else).
|
||||
/// Decoded frames of one GIF or animated WebP are kept as 8-bit RGBA, up to this many bytes (less
|
||||
/// in a browser tab, whose wasm32 heap is 4 GiB at most and shared with everything else).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_BYTES: usize = 1 << 30;
|
||||
pub(crate) const MAX_BYTES: usize = 1 << 30;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const MAX_BYTES: usize = 256 << 20;
|
||||
pub(crate) const MAX_BYTES: usize = 256 << 20;
|
||||
|
||||
/// Frames read from a GIF's headers when probing.
|
||||
const MAX_FRAMES: usize = 100_000;
|
||||
/// Frames read from a GIF's or animated WebP's headers when probing.
|
||||
pub(crate) const MAX_FRAMES: usize = 100_000;
|
||||
|
||||
/// Whether `f` is an animated GIF imported by [`probe`].
|
||||
pub(crate) fn is_gif(f: &Footage) -> bool {
|
||||
f.kind == FootageKind::Video && f.codec == CODEC
|
||||
/// Whether `f` is an animated GIF or WebP imported by [`probe`] or [`crate::webp_anim::probe`]:
|
||||
/// its frames come from an [`Anim`].
|
||||
pub(crate) fn is_animated(f: &Footage) -> bool {
|
||||
f.kind == FootageKind::Video && (f.codec == CODEC || f.codec == crate::webp_anim::CODEC)
|
||||
}
|
||||
|
||||
/// The delay of a frame in hundredths of a second. Browsers show the 0 and 1 that many encoders
|
||||
|
|
@ -36,7 +38,7 @@ fn delay_cs(raw: u32) -> u32 {
|
|||
if raw < 2 { 10 } else { raw }
|
||||
}
|
||||
|
||||
fn gcd(a: u32, b: u32) -> u32 {
|
||||
pub(crate) fn gcd(a: u32, b: u32) -> u32 {
|
||||
if b == 0 { a } else { gcd(b, a % b) }
|
||||
}
|
||||
|
||||
|
|
@ -73,19 +75,27 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Option<Footage> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Every frame of a GIF, composited onto the full canvas.
|
||||
/// Every frame of a GIF or animated WebP, composited onto the full canvas.
|
||||
pub(crate) struct Anim {
|
||||
frames: Vec<DynamicImage>,
|
||||
/// When each frame starts, in hundredths of a second.
|
||||
starts: Vec<u64>,
|
||||
/// When each frame starts (ascending, one per frame).
|
||||
starts: Vec<Tick>,
|
||||
}
|
||||
|
||||
impl Anim {
|
||||
/// Decode all frames. A damaged tail keeps the frames before it.
|
||||
/// Frames and the times they start at (one each, ascending); `None` when there are none.
|
||||
pub(crate) fn new(frames: Vec<DynamicImage>, starts: Vec<Tick>) -> Option<Anim> {
|
||||
(!frames.is_empty() && frames.len() == starts.len()).then_some(Anim { frames, starts })
|
||||
}
|
||||
|
||||
/// Decode all frames of a GIF or animated WebP. A damaged tail keeps the frames before it.
|
||||
pub(crate) fn load(bytes: &[u8]) -> Result<Anim> {
|
||||
if crate::webp_anim::is_webp(bytes) {
|
||||
return crate::webp_anim::load(bytes);
|
||||
}
|
||||
let dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?;
|
||||
let (mut frames, mut starts) = (Vec::new(), Vec::new());
|
||||
let (mut at, mut size) = (0u64, 0usize);
|
||||
let (mut at, mut size) = (0i64, 0usize);
|
||||
for frame in dec.into_frames() {
|
||||
let frame = match frame {
|
||||
Ok(f) => f,
|
||||
|
|
@ -102,21 +112,17 @@ impl Anim {
|
|||
if size > MAX_BYTES {
|
||||
return Err(MediaError::Unsupported("GIF is too large to hold in memory".into()));
|
||||
}
|
||||
starts.push(at);
|
||||
at = at.saturating_add(u64::from(cs));
|
||||
starts.push(Tick::from_units(at, 100));
|
||||
at = at.saturating_add(i64::from(cs));
|
||||
frames.push(DynamicImage::ImageRgba8(buf));
|
||||
}
|
||||
if frames.is_empty() {
|
||||
return Err(MediaError::Decode("GIF: no frames".into()));
|
||||
}
|
||||
Ok(Anim { frames, starts })
|
||||
Anim::new(frames, starts).ok_or_else(|| MediaError::Decode("GIF: no frames".into()))
|
||||
}
|
||||
|
||||
/// The frame shown at source time `t` (the last one past the end).
|
||||
/// The frame shown at source time `t` (the first one before the start, the last one past the
|
||||
/// end).
|
||||
pub(crate) fn frame_at(&self, t: Tick) -> Option<&DynamicImage> {
|
||||
let cs = i128::from(t.0).max(0).saturating_mul(100) / TICKS_PER_SECOND as i128;
|
||||
let cs = u64::try_from(cs).unwrap_or(u64::MAX);
|
||||
let i = self.starts.partition_point(|&s| s <= cs).saturating_sub(1);
|
||||
let i = self.starts.partition_point(|&s| s <= t).saturating_sub(1);
|
||||
self.frames.get(i).or(self.frames.last())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ fn is_pdf_like(path: &str, bytes: &[u8]) -> bool {
|
|||
effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF"))
|
||||
}
|
||||
|
||||
/// Whether `bytes` (the first 64 KiB suffice) are a Photoshop or vector file [`probe`] handles.
|
||||
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
|
||||
pub(crate) fn sniff(path: &str, bytes: &[u8]) -> bool {
|
||||
effectcraft_psd::is_psd(bytes) || is_svg(path, bytes) || is_pdf_like(path, bytes)
|
||||
}
|
||||
|
||||
/// Footage for a Photoshop or vector file (`None` for other formats).
|
||||
pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result<Option<Footage>> {
|
||||
if effectcraft_psd::is_psd(bytes) {
|
||||
|
|
@ -81,7 +87,7 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
|
|||
let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
|
||||
inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?
|
||||
} else {
|
||||
let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
|
||||
let img = crate::probe::load_oriented(data, None).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
|
||||
let rgba = img.to_rgba32f();
|
||||
effectcraft_psd::Pixels { width: rgba.width(), height: rgba.height(), data: rgba.pixels().map(|p| p.0).collect() }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod convert;
|
||||
pub mod downmix;
|
||||
mod dpx;
|
||||
pub mod exr_channels;
|
||||
mod gif_anim;
|
||||
|
|
@ -23,6 +24,8 @@ mod layered;
|
|||
pub use layered::vector_doc;
|
||||
mod pool;
|
||||
mod probe;
|
||||
mod stream;
|
||||
mod webp_anim;
|
||||
|
||||
pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats};
|
||||
pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model};
|
||||
|
|
@ -36,6 +39,9 @@ pub enum MediaError {
|
|||
Unsupported(String),
|
||||
#[error("decode error: {0}")]
|
||||
Decode(String),
|
||||
/// The read was called off before it finished (see [`effectcraft_render::FootageSource::cancellable`]).
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, MediaError>;
|
||||
|
|
@ -46,6 +52,7 @@ impl From<filmcraft_media::MediaError> for MediaError {
|
|||
match e {
|
||||
F::Unsupported(s) => MediaError::Unsupported(s),
|
||||
F::Io(s) | F::Offline(s) => MediaError::Io(s),
|
||||
F::Cancelled => MediaError::Cancelled,
|
||||
other => MediaError::Decode(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ use effectcraft_raster::{Image, Px};
|
|||
use effectcraft_render::{FloatCurve, FootageSource};
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use filmcraft_media::{FrameRequest, SharedSource};
|
||||
use image::metadata::Orientation;
|
||||
|
||||
use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled};
|
||||
use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned};
|
||||
use crate::gif_anim::{self, Anim};
|
||||
use crate::{MediaError, Result};
|
||||
|
||||
|
|
@ -170,9 +171,10 @@ struct CacheState {
|
|||
last: HashMap<Arc<str>, i64>,
|
||||
}
|
||||
|
||||
/// A movie/audio source by path, opened by the first caller while the others wait for it
|
||||
/// A movie/audio source by path and the turn its picture is shown with
|
||||
/// ([`crate::probe::video_turn`]), opened by the first caller while the others wait for it
|
||||
/// (`None`: failed to open; not retried until `forget`).
|
||||
type SourceSlot = Arc<OnceLock<Option<SharedSource>>>;
|
||||
type SourceSlot = Arc<OnceLock<Option<(SharedSource, Orientation)>>>;
|
||||
|
||||
/// 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>>>;
|
||||
|
|
@ -186,7 +188,7 @@ struct Inner {
|
|||
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>>>>,
|
||||
/// Decoded animated GIFs by path, loaded by the first caller while the others wait (an
|
||||
/// 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>>,
|
||||
cache: Mutex<CacheState>,
|
||||
|
|
@ -206,6 +208,9 @@ struct Inner {
|
|||
/// audio is read from the decoder instead of trying again on every read.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
conform_failed: Mutex<HashSet<std::path::PathBuf>>,
|
||||
/// Movies that failed to open or decode (path to a readable reason; see
|
||||
/// [`FootageSource::problem`]). The first failure of a file is logged, later ones are not.
|
||||
problems: Mutex<HashMap<Arc<str>, String>>,
|
||||
}
|
||||
|
||||
/// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus
|
||||
|
|
@ -275,6 +280,7 @@ impl MediaPool {
|
|||
conform: Mutex::default(),
|
||||
conforming: Mutex::default(),
|
||||
conform_failed: Mutex::default(),
|
||||
problems: Mutex::default(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -322,6 +328,7 @@ impl MediaPool {
|
|||
pub fn clear(&self) {
|
||||
self.clear_frames();
|
||||
lock(&self.inner.sources).clear();
|
||||
lock(&self.inner.problems).clear();
|
||||
}
|
||||
|
||||
/// Forget a file (after it changed on disk, or to retry a file that failed to open).
|
||||
|
|
@ -329,6 +336,7 @@ impl MediaPool {
|
|||
lock(&self.inner.sources).remove(path);
|
||||
lock(&self.inner.models).remove(path);
|
||||
lock(&self.inner.gifs).remove(path);
|
||||
lock(&self.inner.problems).remove(path);
|
||||
let mut c = lock(&self.inner.cache);
|
||||
let keys: Vec<Key> = c.lru.map.keys().filter(|k| &*k.path == path).cloned().collect();
|
||||
for k in keys {
|
||||
|
|
@ -357,8 +365,13 @@ impl MediaPool {
|
|||
}
|
||||
match Inner::frame_at(&self.inner, footage, t, true, size, float) {
|
||||
Ok(img) => Some(img),
|
||||
// nobody wants the frame any more (`cancellable`): not an error
|
||||
Err(MediaError::Cancelled) => None,
|
||||
// (a movie's failure is logged once per file, when it is recorded)
|
||||
Err(e) => {
|
||||
log::warn!("media: frame of {} at {:?}: {e}", footage.path, t);
|
||||
if lock(&self.inner.problems).get(footage.path.as_str()).is_none() {
|
||||
log::warn!("media: frame of {} at {:?}: {e}", footage.path, t);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -450,8 +463,8 @@ impl MediaPool {
|
|||
}
|
||||
|
||||
/// `frames` stereo sample frames of `footage`'s audio starting at source time `start`, at
|
||||
/// `rate` Hz, interleaved (L R L R …). Mono is duplicated to both channels; channels beyond
|
||||
/// the first two are dropped. Silence where there is no audio (or before the media starts).
|
||||
/// `rate` Hz, interleaved (L R L R …). Mono is duplicated to both channels; multichannel
|
||||
/// sources are downmixed to stereo ([`crate::downmix`]). Silence where there is no audio (or before the media starts).
|
||||
/// With a conformed audio folder, reads come from the conformed file once it is written.
|
||||
pub fn audio_samples(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec<f32> {
|
||||
if !footage.has_audio || footage.missing || rate == 0 || frames == 0 {
|
||||
|
|
@ -574,17 +587,34 @@ impl Inner {
|
|||
/// it again: a preview's frame workers, audio feeder, conform and waveform threads each
|
||||
/// decoded a long FLAC in full at once (#408).
|
||||
fn source(&self, path: &Arc<str>) -> Option<SharedSource> {
|
||||
self.opened(path).map(|(src, _)| src)
|
||||
}
|
||||
|
||||
/// [`Self::source`] and the turn the movie's picture is shown with.
|
||||
fn opened(&self, path: &Arc<str>) -> Option<(SharedSource, Orientation)> {
|
||||
let slot = lock(&self.sources).entry(path.clone()).or_default().clone();
|
||||
slot.get_or_init(|| {
|
||||
self.opened.fetch_add(1, Ordering::Relaxed);
|
||||
let opened = 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();
|
||||
filmcraft_codecs::open_bytes(&name, bytes).map_err(MediaError::from)
|
||||
// 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 turn = crate::stream::movie_header(file).map_or(Orientation::NoTransforms, |h| crate::probe::video_turn(&h));
|
||||
src.map(|src| (src, turn))
|
||||
});
|
||||
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 turn = crate::probe::video_turn(&bytes);
|
||||
filmcraft_codecs::open_bytes(&name, bytes).map(|src| (src, turn)).map_err(MediaError::from)
|
||||
})
|
||||
});
|
||||
match opened {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
log::warn!("media: cannot open {path}: {e}");
|
||||
self.note_problem(path, "", &e);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -592,7 +622,21 @@ impl Inner {
|
|||
.clone()
|
||||
}
|
||||
|
||||
/// The decoded frames of the animated GIF at `path`. The first caller decodes it, outside the
|
||||
/// Remember why the movie at `path` cannot be shown (`codec` is the footage's, when known)
|
||||
/// and log it the first time. I/O errors (missing or unreadable file) are not decoder
|
||||
/// problems and are left to the missing-footage handling.
|
||||
fn note_problem(&self, path: &Arc<str>, codec: &str, e: &MediaError) {
|
||||
// A decode called off for a newer frame (#329) says nothing about the file either.
|
||||
if matches!(e, MediaError::Io(_) | MediaError::Cancelled) {
|
||||
return;
|
||||
}
|
||||
let reason = describe_problem(codec, e);
|
||||
if lock(&self.problems).insert(path.clone(), reason.clone()).is_none() {
|
||||
log::warn!("media: {path}: {reason}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoded frames of the animated GIF or WebP at `path`. The first caller decodes it, outside the
|
||||
/// 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();
|
||||
|
|
@ -749,10 +793,7 @@ impl Inner {
|
|||
{
|
||||
return Err(MediaError::Decode(format!("{path}: {why}")));
|
||||
}
|
||||
let loaded = match crate::still_format(path, &bytes) {
|
||||
Some(fmt) => image::load_from_memory_with_format(&bytes, fmt),
|
||||
None => image::load_from_memory(&bytes),
|
||||
};
|
||||
let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes));
|
||||
let img = match loaded {
|
||||
Ok(img) => img,
|
||||
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
|
||||
|
|
@ -760,25 +801,31 @@ impl Inner {
|
|||
};
|
||||
Ok(dynamic_to_image(&img, op))
|
||||
}
|
||||
Some(mt) if gif_anim::is_gif(footage) => {
|
||||
Some(mt) if gif_anim::is_animated(footage) => {
|
||||
let anim = self.gif(path)?;
|
||||
let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?;
|
||||
Ok(dynamic_to_image(frame, op))
|
||||
}
|
||||
Some(mt) => {
|
||||
let src = self.source(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?;
|
||||
let vf = src.video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))).map_err(MediaError::from)?;
|
||||
let (src, turn) = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?;
|
||||
let vf = src
|
||||
.video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0)))
|
||||
.map_err(MediaError::from)
|
||||
.inspect_err(|e| self.note_problem(path, &footage.codec, e))?;
|
||||
// A movie shown turned a quarter (a phone held upright): the footage's size and
|
||||
// the render's are the turned picture's, the decoded frame's are the file's.
|
||||
let side = |(w, h): (u32, u32)| if crate::probe::swaps_sides(turn) { (h, w) } else { (w, h) };
|
||||
// A reduced-resolution render's frame, made at its size (a frame of the footage's
|
||||
// own size only, as the renderer resamples only those).
|
||||
let (w, h) = loc.key.size;
|
||||
let (w, h) = side(loc.key.size);
|
||||
if w > 0
|
||||
&& (vf.width, vf.height) == (footage.width, footage.height)
|
||||
&& (vf.width, vf.height) == side((footage.width, footage.height))
|
||||
&& let Some(img) = frame_to_image_scaled(&vf, w, h)
|
||||
{
|
||||
return Ok(img);
|
||||
return Ok(turned(img, turn));
|
||||
}
|
||||
let buf = lock(&self.cache).lru.take_spare(vf.width as usize * vf.height as usize);
|
||||
Ok(frame_to_image_in(&vf, op, buf))
|
||||
Ok(turned(frame_to_image_in(&vf, op, buf), turn))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -806,6 +853,12 @@ impl FootageSource for MediaPool {
|
|||
fn cache_budget(&self) -> Option<usize> {
|
||||
Some(self.budget())
|
||||
}
|
||||
/// A movie's decoder gives up on a seek (before it, and between samples while it decodes
|
||||
/// forward from the keyframe) once `cancel` is set: a scrub's superseded frames no longer
|
||||
/// hold the decoder the frame under the pointer waits for (#329).
|
||||
fn cancellable(&self, cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
|
||||
filmcraft_media::cancel::with_cancel(cancel, f)
|
||||
}
|
||||
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
self.frame_sized(item, footage, t, None, FloatCurve::Srgb)
|
||||
}
|
||||
|
|
@ -816,6 +869,13 @@ impl FootageSource for MediaPool {
|
|||
self.frame_sized(item, footage, t, size, float)
|
||||
}
|
||||
|
||||
fn problem(&self, footage: &Footage) -> Option<String> {
|
||||
if footage.missing || footage.kind != FootageKind::Video {
|
||||
return None;
|
||||
}
|
||||
lock(&self.inner.problems).get(footage.path.as_str()).cloned()
|
||||
}
|
||||
|
||||
fn vector_frame(&self, _item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
|
||||
if footage.missing || !effectcraft_render::is_vector_footage(footage) {
|
||||
return None;
|
||||
|
|
@ -846,7 +906,7 @@ impl FootageSource for MediaPool {
|
|||
|
||||
/// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which
|
||||
/// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both
|
||||
/// channels; silence before the start. An error where decoding fails.
|
||||
/// channels, multichannel sources are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails.
|
||||
fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result<Vec<f32>, String> {
|
||||
let mut out = vec![0.0; frames * 2];
|
||||
let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0);
|
||||
|
|
@ -854,15 +914,28 @@ fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::re
|
|||
return Ok(out);
|
||||
}
|
||||
let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?;
|
||||
let (Some(l), Some(r)) = (buf.channels.first(), buf.channels.get(1).or(buf.channels.first())) else { return Ok(out) };
|
||||
let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) };
|
||||
for ([o0, o1], (a, b)) in dst.as_chunks_mut::<2>().0.iter_mut().zip(l.iter().zip(r)) {
|
||||
*o0 = *a;
|
||||
*o1 = *b;
|
||||
}
|
||||
let aac = src.info().audio.as_ref().is_some_and(|a| a.codec.starts_with("AAC"));
|
||||
crate::downmix::downmix_into(&buf.channels, aac, dst);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The one-line reason a movie shows no picture: its `codec` (the probe's, e.g. "H.264 (High
|
||||
/// 4:2:2)") and what the decoder said, without the error wrappers it passed through.
|
||||
fn describe_problem(codec: &str, e: &MediaError) -> String {
|
||||
let mut detail = match e {
|
||||
MediaError::Io(s) | MediaError::Unsupported(s) | MediaError::Decode(s) => s.as_str(),
|
||||
MediaError::Cancelled => "the decode was called off",
|
||||
};
|
||||
let mut unsupported = matches!(e, MediaError::Unsupported(_));
|
||||
while let Some(rest) = ["decode error: ", "unsupported media: ", "unsupported: ", "I/O error: "].iter().find_map(|p| detail.strip_prefix(p)) {
|
||||
unsupported |= detail.starts_with("unsupported");
|
||||
detail = rest;
|
||||
}
|
||||
let what = if codec.is_empty() { "This video" } else { codec };
|
||||
if unsupported { format!("{what} isn't supported by the built-in decoder: {detail}") } else { format!("{what} can't be decoded: {detail}") }
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use std::sync::Arc;
|
|||
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use image::metadata::Orientation;
|
||||
|
||||
use crate::{MediaError, Result, ext_of, rate_from_fc};
|
||||
|
||||
|
|
@ -15,6 +16,13 @@ pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30;
|
|||
/// (`effectcraft_engine::sequence`) and probes their first frame here.
|
||||
pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
|
||||
let path = path.as_ref();
|
||||
// A movie is read in place (its index), not loaded whole.
|
||||
if let Some(src) = crate::stream::probe_source(path) {
|
||||
let mut f = footage_from_info(&path.to_string_lossy(), src?.info());
|
||||
let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h));
|
||||
turn_footage(&mut f, turn);
|
||||
return Ok(f);
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
|
||||
if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) {
|
||||
let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
|
|
@ -77,11 +85,30 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
|
|||
{
|
||||
return Ok(f);
|
||||
}
|
||||
// So is an animated WebP; a still WebP stays a still.
|
||||
if fmt == image::ImageFormat::WebP
|
||||
&& let Some(f) = crate::webp_anim::probe(path, &bytes)
|
||||
{
|
||||
return Ok(f);
|
||||
}
|
||||
return probe_still_bytes(path, &bytes, fmt);
|
||||
}
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let turn = video_turn(&bytes);
|
||||
let src = filmcraft_codecs::open_bytes(&name, bytes)?;
|
||||
Ok(footage_from_info(path, src.info()))
|
||||
let mut f = footage_from_info(path, src.info());
|
||||
turn_footage(&mut f, turn);
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// The size as displayed: a movie shot with the camera on its side trades width and height.
|
||||
fn turn_footage(f: &mut Footage, turn: Orientation) {
|
||||
if swaps_sides(turn) {
|
||||
std::mem::swap(&mut f.width, &mut f.height);
|
||||
if f.pixel_aspect > 0.0 {
|
||||
f.pixel_aspect = 1.0 / f.pixel_aspect;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) -> Footage {
|
||||
|
|
@ -171,7 +198,12 @@ fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Resul
|
|||
}
|
||||
let reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt);
|
||||
let (w, h, has_alpha) = match reader.into_decoder() {
|
||||
Ok(dec) => (dec.dimensions().0, dec.dimensions().1, dec.color_type().has_alpha()),
|
||||
Ok(mut dec) => {
|
||||
// The size as displayed: an Exif orientation of a quarter turn swaps the sides.
|
||||
let (w, h) = dec.dimensions();
|
||||
let (w, h) = if swaps_sides(dec.orientation().unwrap_or(Orientation::NoTransforms)) { (h, w) } else { (w, h) };
|
||||
(w, h, dec.color_type().has_alpha())
|
||||
}
|
||||
Err(e) => {
|
||||
// A multi-layer OpenEXR file without an unnamed RGB layer.
|
||||
let img = (fmt == image::ImageFormat::OpenExr).then(|| crate::exr_channels::layered_image(bytes)).flatten();
|
||||
|
|
@ -181,3 +213,44 @@ fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Resul
|
|||
};
|
||||
Ok(still_footage(path, w, h, fmt, has_alpha))
|
||||
}
|
||||
|
||||
/// Whether an Exif orientation turns the picture a quarter turn (its width and height trade places).
|
||||
pub(crate) fn swaps_sides(o: Orientation) -> bool {
|
||||
matches!(o, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH)
|
||||
}
|
||||
|
||||
/// Decode a still the way it is meant to be seen: its Exif orientation (a camera held sideways, a
|
||||
/// mirrored scan) is applied to the pixels, as image viewers do. A file without
|
||||
/// the tag, or with a broken one, decodes as stored. `fmt`: the format when the file's extension
|
||||
/// decides it (Targa has no signature to guess from), else guessed from the bytes.
|
||||
pub(crate) fn load_oriented(bytes: &[u8], fmt: Option<image::ImageFormat>) -> image::ImageResult<image::DynamicImage> {
|
||||
use image::ImageDecoder;
|
||||
let reader = match fmt {
|
||||
Some(fmt) => image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt),
|
||||
None => image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?,
|
||||
};
|
||||
let mut dec = reader.into_decoder()?;
|
||||
let orientation = dec.orientation().unwrap_or(Orientation::NoTransforms);
|
||||
let mut img = image::DynamicImage::from_decoder(dec)?;
|
||||
img.apply_orientation(orientation);
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// The rotation an MP4/MOV movie is shown with (a phone held upright records sideways frames and
|
||||
/// says so in its video track's header): the quarter turn nearest the track's rotation. Only the
|
||||
/// rotation counts; any mirroring, scaling or offset in the header is ignored, and other
|
||||
/// containers have none.
|
||||
pub(crate) fn video_turn(bytes: &[u8]) -> Orientation {
|
||||
let Ok(file) = filmcraft_isobmff::open(bytes) else { return Orientation::NoTransforms };
|
||||
let Some(m) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video).map(|t| t.matrix) else {
|
||||
return Orientation::NoTransforms;
|
||||
};
|
||||
// [a b u; c d v; x y w]: (a, b) is where the picture's x axis points.
|
||||
let (a, b) = (m[0] as i64, m[1] as i64);
|
||||
match (a.abs() >= b.abs(), a >= 0, b >= 0) {
|
||||
(true, true, _) => Orientation::NoTransforms,
|
||||
(true, false, _) => Orientation::Rotate180,
|
||||
(false, _, true) => Orientation::Rotate90,
|
||||
(false, _, false) => Orientation::Rotate270,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
//! Movie and audio files read in place: the container's index now, single samples on demand.
|
||||
//!
|
||||
//! Reading a file whole before opening it made a clip cost its full size in memory (twice while
|
||||
//! probing: the file's bytes plus the demuxer's copy), so a 45-minute movie took gigabytes just to
|
||||
//! import. FilmCraft's reader openers (MP4/MOV, Matroska/WebM, MXF, Ogg, MPEG-PS/TS) open from a
|
||||
//! random-access [`ByteReader`]; [`FileReader`] serves them positional reads from the open file.
|
||||
//! Formats decoded in one go (stills, WAV, FLAC, MP3…) still read the whole file, as before.
|
||||
//! Builds without a native file system (wasm32) keep using in-memory bytes.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use filmcraft_media::SharedSource;
|
||||
|
||||
use crate::Result;
|
||||
|
||||
/// A file on disk read in place: it stays open and only the requested ranges are read.
|
||||
#[cfg(any(unix, windows))]
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct FileReader {
|
||||
file: std::fs::File,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
impl FileReader {
|
||||
pub(crate) fn open(path: &Path) -> std::io::Result<Self> {
|
||||
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() })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
impl filmcraft_media::ByteReader for FileReader {
|
||||
fn len(&self) -> u64 {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// Positional reads: threads decoding different parts of the file share no cursor.
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::FileExt::read_exact_at(&self.file, buf, offset)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let (mut done, mut at) = (0usize, offset);
|
||||
while let Some(rest) = buf.get_mut(done..).filter(|r| !r.is_empty()) {
|
||||
match std::os::windows::fs::FileExt::seek_read(&self.file, rest, at) {
|
||||
Ok(0) => return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of file")),
|
||||
Ok(n) => {
|
||||
done = done.saturating_add(n);
|
||||
at = at.saturating_add(n as u64);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
fn name_of(path: &Path) -> String {
|
||||
path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[cfg(any(unix, windows))]
|
||||
pub(crate) fn open_source(path: &Path) -> Option<Result<SharedSource>> {
|
||||
let reader = FileReader::open(path).ok()?;
|
||||
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))
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(crate) fn open_source(_path: &Path) -> Option<Result<SharedSource>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The leading `ftyp` and the `moov` box of an ISO-BMFF movie on disk (all a track's display
|
||||
/// matrix needs), without reading the samples: top-level box headers are walked with positional
|
||||
/// reads. `None` when the file isn't ISO-BMFF or its `moov` is missing or implausibly large.
|
||||
#[cfg(any(unix, windows))]
|
||||
pub(crate) fn movie_header(path: &Path) -> Option<Vec<u8>> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
const MAX_MOOV: u64 = 64 << 20;
|
||||
let mut f = std::fs::File::open(path).ok()?;
|
||||
let len = f.metadata().ok()?.len();
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0u64;
|
||||
for _ in 0..4096 {
|
||||
if pos.checked_add(8)? > len {
|
||||
return None;
|
||||
}
|
||||
f.seek(SeekFrom::Start(pos)).ok()?;
|
||||
let mut h = [0u8; 16];
|
||||
f.read_exact(&mut h[..8]).ok()?;
|
||||
let kind = [h[4], h[5], h[6], h[7]];
|
||||
let mut size = u64::from(u32::from_be_bytes([h[0], h[1], h[2], h[3]]));
|
||||
if size == 1 {
|
||||
f.read_exact(&mut h[8..16]).ok()?;
|
||||
size = u64::from_be_bytes([h[8], h[9], h[10], h[11], h[12], h[13], h[14], h[15]]);
|
||||
} else if size == 0 {
|
||||
size = len - pos;
|
||||
}
|
||||
if size < 8 || pos.checked_add(size)? > len {
|
||||
return None;
|
||||
}
|
||||
if kind == *b"ftyp" || kind == *b"moov" {
|
||||
if size > MAX_MOOV {
|
||||
return None;
|
||||
}
|
||||
f.seek(SeekFrom::Start(pos)).ok()?;
|
||||
let mut b = vec![0u8; usize::try_from(size).ok()?];
|
||||
f.read_exact(&mut b).ok()?;
|
||||
out.extend_from_slice(&b);
|
||||
if kind == *b"moov" {
|
||||
return Some(out);
|
||||
}
|
||||
}
|
||||
pos += size;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(crate) fn movie_header(_path: &Path) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Probing in place: the opened container when `path` is a movie/audio container a reader opener
|
||||
/// handles and nothing probed before containers (stills, layered and vector documents, 3D models)
|
||||
/// claims it. `None`: probe the whole file's bytes as before.
|
||||
#[cfg(any(unix, windows))]
|
||||
pub(crate) fn probe_source(path: &Path) -> Option<Result<SharedSource>> {
|
||||
let ext = crate::ext_of(path);
|
||||
if crate::STILL_EXTENSIONS.contains(&ext.as_str()) || crate::MODEL_EXTENSIONS.contains(&ext.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let reader = FileReader::open(path).ok()?;
|
||||
let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader);
|
||||
let head = filmcraft_media::reader::read_range(&*reader, 0, filmcraft_media::reader::HEAD_LEN).ok()?;
|
||||
let name = path.to_string_lossy();
|
||||
if crate::layered::sniff(&name, &head) || image::guess_format(&head).is_ok() {
|
||||
return None;
|
||||
}
|
||||
let name = name_of(path);
|
||||
filmcraft_codecs::reader_openers().into_iter().find_map(|open| open(&name, &head, &reader)).map(|r| r.map_err(Into::into))
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(crate) fn probe_source(_path: &Path) -> Option<Result<SharedSource>> {
|
||||
None
|
||||
}
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
//! Animated WebP: probed as movies and decoded whole into 8-bit frames, like animated GIF
|
||||
//! ([`crate::gif_anim`], whose [`Anim`] holds the frames).
|
||||
//!
|
||||
//! An animated WebP is a RIFF file with a `VP8X` header whose animation flag is set and one `ANMF`
|
||||
//! chunk per frame, each with its own duration in milliseconds. Evenly spaced frames get the rate
|
||||
//! they are spaced at. Encoders round the frame times of a rate like 24 or 29.97 frames a second to
|
||||
//! whole milliseconds (41, 42, 42, 41… ms), so delays that differ by at most one millisecond are
|
||||
//! read as the standard rate whose frame times they round to; any other timing gets the rate that
|
||||
//! every delay is a whole number of frames at (1000 / the greatest common divisor of the delays),
|
||||
//! so it is kept exactly. A still WebP (no animation flag, or a single frame) stays a still.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use image::{AnimationDecoder, DynamicImage, codecs::webp::WebPDecoder};
|
||||
|
||||
use crate::gif_anim::{Anim, MAX_BYTES, MAX_FRAMES, gcd};
|
||||
use crate::{MediaError, Result};
|
||||
|
||||
/// The `Footage::codec` of an animated WebP.
|
||||
pub(crate) const CODEC: &str = "WebP";
|
||||
|
||||
/// Whether `bytes` start like a WebP file.
|
||||
pub(crate) fn is_webp(bytes: &[u8]) -> bool {
|
||||
bytes.get(0..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP")
|
||||
}
|
||||
|
||||
/// What the chunk headers of an animated WebP say.
|
||||
struct Header {
|
||||
width: u32,
|
||||
height: u32,
|
||||
alpha: bool,
|
||||
/// Each frame's delay in milliseconds, as stored.
|
||||
delays: Vec<u32>,
|
||||
}
|
||||
|
||||
fn u24(b: &[u8]) -> Option<u32> {
|
||||
match b {
|
||||
[a, b, c, ..] => Some(u32::from(*a) | u32::from(*b) << 8 | u32::from(*c) << 16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the `VP8X` header and the `ANMF` frame headers. `None` when the file isn't an animated
|
||||
/// WebP. A truncated tail keeps the frames before it.
|
||||
fn header(bytes: &[u8]) -> Option<Header> {
|
||||
if !is_webp(bytes) {
|
||||
return None;
|
||||
}
|
||||
let riff = bytes.get(4..8).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes)?;
|
||||
let end = usize::try_from(riff).ok()?.saturating_add(8).min(bytes.len());
|
||||
let (mut at, mut canvas, mut delays) = (12usize, None, Vec::new());
|
||||
while delays.len() < MAX_FRAMES {
|
||||
let Some(head) = bytes.get(at..at.saturating_add(8)).filter(|_| at.saturating_add(8) <= end) else { break };
|
||||
let size = head.get(4..8).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes)?;
|
||||
let size = usize::try_from(size).ok()?;
|
||||
let body_end = at.saturating_add(8).saturating_add(size);
|
||||
let Some(body) = bytes.get(at.saturating_add(8)..body_end).filter(|_| body_end <= end) else { break };
|
||||
match head.get(0..4) {
|
||||
Some(b"VP8X") => {
|
||||
let flags = *body.first()?;
|
||||
if flags & 0x02 == 0 {
|
||||
// No animation flag: a still.
|
||||
return None;
|
||||
}
|
||||
let w = u24(body.get(4..)?)?.saturating_add(1);
|
||||
let h = u24(body.get(7..)?)?.saturating_add(1);
|
||||
canvas = Some((w, h, flags & 0x10 != 0));
|
||||
}
|
||||
Some(b"ANMF") if body.len() >= 16 => delays.push(u24(body.get(12..)?)?),
|
||||
_ => {}
|
||||
}
|
||||
at = body_end.saturating_add(size & 1);
|
||||
}
|
||||
let (width, height, alpha) = canvas?;
|
||||
Some(Header { width, height, alpha, delays })
|
||||
}
|
||||
|
||||
/// The delay of a frame in milliseconds. Browsers show delays of 10 ms or less (that encoders
|
||||
/// write for "as fast as possible") as 100 ms, and so do we, as for GIF.
|
||||
fn delay_ms(raw: u32) -> u32 {
|
||||
if raw <= 10 { 100 } else { raw }
|
||||
}
|
||||
|
||||
/// The footage rate, when each frame starts, and the duration, for frames of these delays (ms).
|
||||
struct Timing {
|
||||
rate: FrameRate,
|
||||
starts: Vec<Tick>,
|
||||
duration: Tick,
|
||||
}
|
||||
|
||||
fn timing(delays: &[u32]) -> Timing {
|
||||
let delays: Vec<u32> = delays.iter().map(|&d| delay_ms(d)).collect();
|
||||
let mut starts_ms = Vec::with_capacity(delays.len());
|
||||
let mut total = 0i64;
|
||||
for &d in &delays {
|
||||
starts_ms.push(total);
|
||||
total = total.saturating_add(i64::from(d));
|
||||
}
|
||||
let n = i64::try_from(delays.len()).unwrap_or(i64::MAX);
|
||||
let (min, max) = (delays.iter().copied().min().unwrap_or(0), delays.iter().copied().max().unwrap_or(0));
|
||||
// Frame times of a standard rate rounded to milliseconds: that rate if every frame starts (and
|
||||
// the last one ends) within a millisecond of it.
|
||||
let fps = if total > 0 { n.saturating_mul(1000).saturating_add(total / 2) / total } else { 0 };
|
||||
if max == min.saturating_add(1) && fps > 0 {
|
||||
for rate in [FrameRate::new(fps, 1), FrameRate::new(fps.saturating_mul(1000), 1001)] {
|
||||
// |start_ms - k * 1000 * den / num| <= 1, in integers.
|
||||
let (num, den) = (i128::from(rate.num), i128::from(rate.den));
|
||||
let fits = starts_ms.iter().copied().chain([total]).enumerate().all(|(k, ms)| {
|
||||
let k = i128::try_from(k).unwrap_or(i128::MAX);
|
||||
(i128::from(ms) * num - k * 1000 * den).abs() <= num
|
||||
});
|
||||
if fits {
|
||||
let starts = (0..n).map(|k| rate.tick_of(k)).collect();
|
||||
return Timing { rate, starts, duration: rate.tick_of(n) };
|
||||
}
|
||||
}
|
||||
}
|
||||
let step = delays.iter().fold(0, |g, &d| gcd(g, d)).max(1);
|
||||
Timing {
|
||||
rate: FrameRate::new(1000, i64::from(step)),
|
||||
starts: starts_ms.iter().map(|&ms| Tick::from_units(ms, 1000)).collect(),
|
||||
duration: Tick::from_units(total, 1000),
|
||||
}
|
||||
}
|
||||
|
||||
/// Describe an animated WebP as a movie. `None` for a still WebP (no animation, or one frame) or
|
||||
/// one whose headers can't be read.
|
||||
pub(crate) fn probe(path: &str, bytes: &[u8]) -> Option<Footage> {
|
||||
let h = header(bytes)?;
|
||||
if h.delays.len() < 2 || h.width == 0 || h.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let t = timing(&h.delays);
|
||||
Some(Footage {
|
||||
path: path.to_string(),
|
||||
kind: FootageKind::Video,
|
||||
width: h.width,
|
||||
height: h.height,
|
||||
frame_rate: t.rate,
|
||||
duration: t.duration,
|
||||
has_video: true,
|
||||
has_audio: false,
|
||||
alpha: if h.alpha { AlphaMode::Straight } else { AlphaMode::Ignore },
|
||||
codec: CODEC.to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode every frame of an animated WebP, composited onto the full canvas. A damaged tail keeps
|
||||
/// the frames before it; an animation too large to hold in memory is refused before decoding.
|
||||
pub(crate) fn load(bytes: &[u8]) -> Result<Anim> {
|
||||
let h = header(bytes).ok_or_else(|| MediaError::Decode("WebP: not an animated WebP".into()))?;
|
||||
let frame_bytes = usize::try_from(u64::from(h.width) * u64::from(h.height) * 4).unwrap_or(usize::MAX);
|
||||
if frame_bytes.saturating_mul(h.delays.len()) > MAX_BYTES {
|
||||
return Err(MediaError::Unsupported(format!("animated WebP of {} frames of {}×{} is too large to hold in memory", h.delays.len(), h.width, h.height)));
|
||||
}
|
||||
let dec = WebPDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("WebP: {e}")))?;
|
||||
let (mut frames, mut delays, mut size) = (Vec::new(), Vec::new(), 0usize);
|
||||
for frame in dec.into_frames() {
|
||||
let frame = match frame {
|
||||
Ok(f) => f,
|
||||
Err(e) if frames.is_empty() => return Err(MediaError::Decode(format!("WebP: {e}"))),
|
||||
Err(e) => {
|
||||
log::warn!("media: WebP stops after {} frames: {e}", frames.len());
|
||||
break;
|
||||
}
|
||||
};
|
||||
let (numer, denom) = frame.delay().numer_denom_ms();
|
||||
delays.push(numer.checked_div(denom).unwrap_or(0));
|
||||
let buf = frame.into_buffer();
|
||||
size = size.saturating_add(buf.as_raw().len());
|
||||
if size > MAX_BYTES {
|
||||
return Err(MediaError::Unsupported("animated WebP is too large to hold in memory".into()));
|
||||
}
|
||||
frames.push(DynamicImage::ImageRgba8(buf));
|
||||
}
|
||||
let starts = timing(&delays).starts;
|
||||
Anim::new(frames, starts).ok_or_else(|| MediaError::Decode("WebP: no frames".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use image::{ExtendedColorType, ImageEncoder, codecs::webp::WebPEncoder};
|
||||
|
||||
use super::timing;
|
||||
use crate::{MediaPool, probe_bytes};
|
||||
|
||||
fn chunk(fourcc: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
let mut out = fourcc.to_vec();
|
||||
out.extend_from_slice(&u32::try_from(body.len()).unwrap().to_le_bytes());
|
||||
out.extend_from_slice(body);
|
||||
if body.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn u24(v: u32) -> [u8; 3] {
|
||||
let b = v.to_le_bytes();
|
||||
[b[0], b[1], b[2]]
|
||||
}
|
||||
|
||||
/// The `VP8L` chunk of a lossless still WebP of a solid RGBA colour.
|
||||
fn vp8l(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
|
||||
let mut still = Vec::new();
|
||||
let px = rgba.repeat((w * h) as usize);
|
||||
WebPEncoder::new_lossless(&mut still).write_image(&px, w, h, ExtendedColorType::Rgba8).unwrap();
|
||||
let at = still.windows(4).position(|c| c == b"VP8L").unwrap();
|
||||
still[at..].to_vec()
|
||||
}
|
||||
|
||||
/// A `w`×`h` animated WebP with alpha, one solid full-canvas frame per `(rgba, delay ms)`.
|
||||
fn webp(w: u32, h: u32, frames: &[([u8; 4], u32)]) -> Vec<u8> {
|
||||
let mut vp8x = vec![0x12, 0, 0, 0];
|
||||
vp8x.extend_from_slice(&u24(w - 1));
|
||||
vp8x.extend_from_slice(&u24(h - 1));
|
||||
let mut body = b"WEBP".to_vec();
|
||||
body.extend(chunk(b"VP8X", &vp8x));
|
||||
body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]));
|
||||
for (rgba, delay) in frames {
|
||||
let mut anmf = Vec::new();
|
||||
anmf.extend_from_slice(&[0; 6]);
|
||||
anmf.extend_from_slice(&u24(w - 1));
|
||||
anmf.extend_from_slice(&u24(h - 1));
|
||||
anmf.extend_from_slice(&u24(*delay));
|
||||
anmf.push(0b10); // no blending, no disposal
|
||||
anmf.extend(vp8l(w, h, *rgba));
|
||||
body.extend(chunk(b"ANMF", &anmf));
|
||||
}
|
||||
chunk(b"RIFF", &body)
|
||||
}
|
||||
|
||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||
const GREEN: [u8; 4] = [0, 255, 0, 255];
|
||||
const BLUE: [u8; 4] = [0, 0, 255, 128];
|
||||
|
||||
fn pixel_at(pool: &MediaPool, f: &Footage, seconds: f64) -> [f32; 4] {
|
||||
pool.frame_at(f, Tick::from_seconds_f64(seconds)).unwrap().data[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_animated_webp_is_a_movie_with_its_frames() {
|
||||
let bytes = webp(4, 2, &[(RED, 50), (GREEN, 100), (BLUE, 50)]);
|
||||
let f = probe_bytes("a.webp", bytes.clone().into()).unwrap();
|
||||
assert_eq!(f.kind, FootageKind::Video);
|
||||
assert!(f.has_video && !f.has_audio);
|
||||
assert_eq!((f.width, f.height), (4, 2));
|
||||
assert_eq!(f.alpha, AlphaMode::Straight);
|
||||
// Delays of 50, 100 and 50 ms: 20 frames a second, 0.2 s in all.
|
||||
assert_eq!(f.frame_rate, FrameRate::new(20, 1));
|
||||
assert_eq!(f.duration, Tick::from_units(200, 1000));
|
||||
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes("a.webp", bytes.into());
|
||||
let first = pixel_at(&pool, &f, 0.01);
|
||||
assert_eq!(&first[..3], &[1.0, 0.0, 0.0]);
|
||||
let second = pixel_at(&pool, &f, 0.06);
|
||||
assert_ne!(first, second, "a later frame differs from the first");
|
||||
assert_eq!(&second[..3], &[0.0, 1.0, 0.0]);
|
||||
assert_eq!(&pixel_at(&pool, &f, 0.14)[..3], &[0.0, 1.0, 0.0]);
|
||||
// The last frame keeps its alpha, and holds past the end.
|
||||
let last = pixel_at(&pool, &f, 0.17);
|
||||
assert!((last[3] - 128.0 / 255.0).abs() < 1e-3, "{last:?}");
|
||||
assert_eq!(pixel_at(&pool, &f, 5.0), last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delays_rounded_to_milliseconds_read_as_the_standard_rate() {
|
||||
// 24 frames a second rounded to whole milliseconds, as ffmpeg writes them.
|
||||
let delays: Vec<u32> = (0..24).map(|k| (((k + 1) * 1000 + 12) / 24 - (k * 1000 + 12) / 24) as u32).collect();
|
||||
assert!(delays.iter().all(|&d| d == 41 || d == 42));
|
||||
let t = timing(&delays);
|
||||
assert_eq!(t.rate, FrameRate::new(24, 1));
|
||||
assert_eq!(t.duration, Tick::from_seconds_f64(1.0));
|
||||
assert_eq!(t.starts[5], FrameRate::new(24, 1).tick_of(5));
|
||||
// 29.97: 33 and 34 ms over a long run.
|
||||
let ntsc = FrameRate::new(30_000, 1001);
|
||||
let delays: Vec<u32> = (0..300).map(|k| (((k + 1) * 1_001_000 / 30_000) - (k * 1_001_000 / 30_000)) as u32).collect();
|
||||
assert_eq!(timing(&delays).rate, ntsc);
|
||||
// Uneven timing is kept exactly.
|
||||
assert_eq!(timing(&[40, 80, 40]).rate, FrameRate::new(25, 1));
|
||||
assert_eq!(timing(&[33, 34, 50]).rate, FrameRate::new(1000, 1));
|
||||
// As fast as possible is 100 ms, as in browsers.
|
||||
assert_eq!(timing(&[0, 10]).duration, Tick::from_units(200, 1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_still_webp_stays_a_still() {
|
||||
let mut still = Vec::new();
|
||||
WebPEncoder::new_lossless(&mut still).write_image(&RED.repeat(4), 2, 2, ExtendedColorType::Rgba8).unwrap();
|
||||
let f = probe_bytes("s.webp", still.into()).unwrap();
|
||||
assert_eq!(f.kind, FootageKind::Still);
|
||||
// So does an animation of one frame.
|
||||
let f = probe_bytes("one.webp", webp(2, 2, &[(RED, 100)]).into()).unwrap();
|
||||
assert_eq!(f.kind, FootageKind::Still);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_animated_webp_is_an_error_not_a_panic() {
|
||||
let full = webp(4, 2, &[(RED, 50), (GREEN, 50), (BLUE, 50)]);
|
||||
let f = probe_bytes("a.webp", full.clone().into()).unwrap();
|
||||
for len in [0, 11, 20, 40, full.len() / 2, full.len() - 3] {
|
||||
let bytes = &full[..len];
|
||||
let _ = probe_bytes("t.webp", bytes.to_vec().into());
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes("a.webp", bytes.to_vec().into());
|
||||
// Cut before the second frame's data: no frames at all, or the ones before the cut.
|
||||
let r = pool.frame_at(&f, Tick::ZERO);
|
||||
if len < full.len() / 2 {
|
||||
assert!(r.is_err(), "{len} bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_animation_is_refused_before_decoding() {
|
||||
// 16384×16384 canvas, 10 frames: 10 GiB of RGBA. The frame data is a tiny 1×1 picture;
|
||||
// the size check comes first.
|
||||
let mut bytes = webp(1, 1, &[(RED, 50); 10]);
|
||||
bytes[24..27].copy_from_slice(&u24(16383));
|
||||
bytes[27..30].copy_from_slice(&u24(16383));
|
||||
let e = super::load(&bytes).err().unwrap();
|
||||
assert!(e.to_string().contains("too large"), "{e}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
//! Multichannel audio downmix (#903) against ffmpeg as an external oracle. Fixtures: ffmpeg with
|
||||
//! one sine per channel, written to `target/fixtures/media`; tests skip when ffmpeg is missing.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_media::{MediaPool, probe};
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
const RATE: u32 = 48_000;
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media")
|
||||
}
|
||||
|
||||
/// A fixture with `layout`'s channels `names` (join order) each carrying its own sine, encoded
|
||||
/// with `codec_args`. `None` when ffmpeg is missing or cannot make it.
|
||||
fn fixture(name: &str, layout: &str, names: &[&str], freqs: &[u32], codec_args: &[&str]) -> Option<PathBuf> {
|
||||
if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) {
|
||||
eprintln!("ffmpeg not found: skipping");
|
||||
return None;
|
||||
}
|
||||
let out = fixtures().join(name);
|
||||
if out.exists() {
|
||||
return Some(out);
|
||||
}
|
||||
std::fs::create_dir_all(fixtures()).ok()?;
|
||||
let ext = out.extension()?.to_string_lossy().to_string();
|
||||
let tmp = out.with_file_name(format!("tmp-{}-{name}", std::process::id())).with_extension(ext);
|
||||
let mut cmd = Command::new("ffmpeg");
|
||||
cmd.args(["-hide_banner", "-loglevel", "error", "-y"]);
|
||||
let mut map = String::new();
|
||||
for (i, (n, f)) in names.iter().zip(freqs).enumerate() {
|
||||
cmd.args(["-f", "lavfi", "-i", &format!("sine=frequency={f}:sample_rate={RATE}:duration=2")]);
|
||||
map.push_str(&format!("[{i}]"));
|
||||
let _ = n;
|
||||
}
|
||||
let post = if name.contains("loud") { ",volume=8:precision=float,alimiter=limit=1:level=disabled" } else { "" };
|
||||
let joins: Vec<String> = names.iter().enumerate().map(|(i, n)| format!("{i}.0-{n}")).collect();
|
||||
cmd.args(["-filter_complex", &format!("{map}join=inputs={}:channel_layout={layout}:map={}{}[a]", names.len(), joins.join("|"), post), "-map", "[a]"]);
|
||||
cmd.args(codec_args).arg(&tmp);
|
||||
let ok = cmd.status().ok()?.success();
|
||||
if !ok {
|
||||
eprintln!("ffmpeg could not make {name}");
|
||||
return None;
|
||||
}
|
||||
std::fs::rename(&tmp, &out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Amplitude (peak) of the sine at `freq` in one channel of interleaved stereo.
|
||||
fn amp(s: &[f32], ch: usize, freq: u32) -> f64 {
|
||||
let (mut re, mut im, mut n) = (0.0, 0.0, 0usize);
|
||||
for (i, f) in s.as_chunks::<2>().0.iter().enumerate() {
|
||||
let w = 2.0 * PI * f64::from(freq) * i as f64 / f64::from(RATE);
|
||||
re += f64::from(f[ch]) * w.cos();
|
||||
im += f64::from(f[ch]) * w.sin();
|
||||
n += 1;
|
||||
}
|
||||
2.0 * (re * re + im * im).sqrt() / n.max(1) as f64
|
||||
}
|
||||
|
||||
const K: f64 = std::f64::consts::FRAC_1_SQRT_2;
|
||||
|
||||
struct Case {
|
||||
file: &'static str,
|
||||
layout: &'static str,
|
||||
names: &'static [&'static str],
|
||||
codec: &'static [&'static str],
|
||||
/// Expected `[left, right]` gain of each channel (the same order as `names`).
|
||||
gains: Vec<[f64; 2]>,
|
||||
/// Allowed deviation of a channel's amplitude (lossy codecs).
|
||||
tol: f64,
|
||||
}
|
||||
|
||||
const FREQS: [u32; 8] = [300, 500, 700, 900, 1100, 1300, 1500, 1700];
|
||||
|
||||
fn five_one() -> (&'static [&'static str], Vec<[f64; 2]>) {
|
||||
let k = 1.0 / (1.0 + 2.0 * K);
|
||||
(&["FL", "FR", "FC", "LFE", "BL", "BR"], vec![[k, 0.0], [0.0, k], [k * K, k * K], [0.0, 0.0], [k * K, 0.0], [0.0, k * K]])
|
||||
}
|
||||
|
||||
fn seven_one() -> (&'static [&'static str], Vec<[f64; 2]>) {
|
||||
let k = 1.0 / (1.0 + 3.0 * K);
|
||||
(
|
||||
&["FL", "FR", "FC", "LFE", "BL", "BR", "SL", "SR"],
|
||||
vec![[k, 0.0], [0.0, k], [k * K, k * K], [0.0, 0.0], [k * K, 0.0], [0.0, k * K], [k * K, 0.0], [0.0, k * K]],
|
||||
)
|
||||
}
|
||||
|
||||
fn cases() -> Vec<Case> {
|
||||
let (n51, g51) = five_one();
|
||||
let (n71, g71) = seven_one();
|
||||
let k5 = 1.0 / (1.0 + 2.0 * K);
|
||||
let kq = 1.0 / (1.0 + K);
|
||||
vec![
|
||||
Case { file: "dm51.m4a", layout: "5.1", names: n51, codec: &["-c:a", "aac", "-b:a", "384k"], gains: g51.clone(), tol: 0.15 },
|
||||
Case { file: "dm51.ac3.mp4", layout: "5.1", names: n51, codec: &["-c:a", "ac3", "-b:a", "640k"], gains: g51.clone(), tol: 0.15 },
|
||||
Case { file: "dm51.wav", layout: "5.1", names: n51, codec: &["-c:a", "pcm_s16le"], gains: g51.clone(), tol: 0.02 },
|
||||
Case { file: "dm51.flac", layout: "5.1", names: n51, codec: &["-c:a", "flac"], gains: g51, tol: 0.02 },
|
||||
Case { file: "dm71.wav", layout: "7.1", names: n71, codec: &["-c:a", "pcm_s16le"], gains: g71.clone(), tol: 0.02 },
|
||||
Case { file: "dm71.flac", layout: "7.1", names: n71, codec: &["-c:a", "flac"], gains: g71, tol: 0.02 },
|
||||
Case {
|
||||
file: "dm50.wav",
|
||||
layout: "5.0",
|
||||
names: &["FL", "FR", "FC", "BL", "BR"],
|
||||
codec: &["-c:a", "pcm_s16le"],
|
||||
gains: vec![[k5, 0.0], [0.0, k5], [k5 * K, k5 * K], [k5 * K, 0.0], [0.0, k5 * K]],
|
||||
tol: 0.02,
|
||||
},
|
||||
Case {
|
||||
file: "dmquad.wav",
|
||||
layout: "quad",
|
||||
names: &["FL", "FR", "BL", "BR"],
|
||||
codec: &["-c:a", "pcm_s16le"],
|
||||
gains: vec![[kq, 0.0], [0.0, kq], [kq * K, 0.0], [0.0, kq * K]],
|
||||
tol: 0.02,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Our stereo mix of `p` over one second, from 0.2 s in.
|
||||
fn ours(p: &Path) -> Vec<f32> {
|
||||
let f = probe(p).expect("probe");
|
||||
MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.2), RATE as usize, RATE)
|
||||
}
|
||||
|
||||
/// ffmpeg's own `-ac 2` downmix of the same second, interleaved. Written as 16-bit integers:
|
||||
/// swresample only normalises the matrix (to keep full scale from clipping) for integer output.
|
||||
fn ffmpeg_ac2(p: &Path) -> Vec<f32> {
|
||||
let out = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-ss", "0.2", "-t", "1", "-i"])
|
||||
.arg(p)
|
||||
.args(["-ac", "2", "-ar", "48000", "-f", "s16le", "-"])
|
||||
.output()
|
||||
.expect("run ffmpeg");
|
||||
out.stdout.as_chunks::<2>().0.iter().map(|b| f32::from(i16::from_le_bytes(*b)) / 32768.0).collect()
|
||||
}
|
||||
|
||||
fn rms(s: &[f32]) -> f64 {
|
||||
(s.iter().map(|v| f64::from(*v).powi(2)).sum::<f64>() / s.len().max(1) as f64).sqrt()
|
||||
}
|
||||
|
||||
fn peak(s: &[f32]) -> f64 {
|
||||
s.iter().fold(0.0f64, |m, v| m.max(f64::from(v.abs())))
|
||||
}
|
||||
|
||||
/// Each channel carries its own sine at amplitude 0.5·√... (ffmpeg's `sine` is −24 dBFS RMS,
|
||||
/// peak ≈ 0.125). Every channel must reach each side at the BS.775 gain, and the overall level
|
||||
/// must match ffmpeg's `-ac 2`.
|
||||
#[test]
|
||||
fn each_channel_reaches_stereo_at_its_bs775_gain() {
|
||||
for c in cases() {
|
||||
let freqs: Vec<u32> = FREQS.iter().copied().take(c.names.len()).collect();
|
||||
let Some(p) = fixture(c.file, c.layout, c.names, &freqs, c.codec) else { continue };
|
||||
let s = ours(&p);
|
||||
let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3)
|
||||
for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() {
|
||||
for (side, gain) in g.iter().enumerate() {
|
||||
let got = amp(&s, side, freqs[i]);
|
||||
let want = sine * gain;
|
||||
assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]);
|
||||
}
|
||||
}
|
||||
let theirs = ffmpeg_ac2(&p);
|
||||
let (a, b) = (rms(&s), rms(&theirs));
|
||||
let db = 20.0 * (a / b).log10();
|
||||
assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file);
|
||||
let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10();
|
||||
assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file);
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-scale in every channel must not clip.
|
||||
#[test]
|
||||
fn loud_surround_does_not_clip() {
|
||||
let Some(p) = fixture("dm51_loud.wav", "5.1", &["FL", "FR", "FC", "LFE", "BL", "BR"], &[300; 6], &["-c:a", "pcm_s16le"]) else { return };
|
||||
let s = ours(&p);
|
||||
assert!(peak(&s) <= 1.001, "peak {}", peak(&s));
|
||||
assert!(peak(&s) > 0.5, "audible, peak {}", peak(&s));
|
||||
}
|
||||
|
||||
/// Mono and stereo footage keep their level (mono on both sides).
|
||||
#[test]
|
||||
fn mono_and_stereo_keep_their_level() {
|
||||
let Some(p) = fixture("dm10.wav", "mono", &["FC"], &[440], &["-c:a", "pcm_s16le"]) else { return };
|
||||
let s = ours(&p);
|
||||
assert!((amp(&s, 0, 440) - 0.125).abs() < 0.003 && (amp(&s, 1, 440) - 0.125).abs() < 0.003);
|
||||
let Some(p) = fixture("dm20.wav", "stereo", &["FL", "FR"], &[440, 660], &["-c:a", "pcm_s16le"]) else { return };
|
||||
let s = ours(&p);
|
||||
assert!((amp(&s, 0, 440) - 0.125).abs() < 0.003 && amp(&s, 1, 440) < 0.003);
|
||||
assert!((amp(&s, 1, 660) - 0.125).abs() < 0.003 && amp(&s, 0, 660) < 0.003);
|
||||
}
|
||||
|
||||
/// Odd channel counts (3, 4, 7, 9, 12, 16) decode to finite stereo without panicking.
|
||||
#[test]
|
||||
fn hostile_channel_counts_do_not_panic() {
|
||||
for (n, layout) in [(3, "3.0"), (4, "quad"), (7, "6.1"), (9, "9 channels"), (12, "12 channels"), (16, "16 channels")] {
|
||||
let names: Vec<String> = (0..n).map(|i| format!("{i}")).collect();
|
||||
// channel names for unnamed layouts are DEFAULT-ordered by position
|
||||
let _ = names;
|
||||
let file = format!("dmn{n}.wav");
|
||||
let args = ["-f", "lavfi", "-i", &format!("sine=frequency=440:sample_rate={RATE}:duration=1")];
|
||||
if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) {
|
||||
return;
|
||||
}
|
||||
let out = fixtures().join(&file);
|
||||
std::fs::create_dir_all(fixtures()).expect("mkdir");
|
||||
if !out.exists() {
|
||||
let tmp = fixtures().join(format!("tmp-{}-{file}", std::process::id()));
|
||||
let af = format!("pan={layout}|{}", (0..n).map(|i| format!("c{i}=c0")).collect::<Vec<_>>().join("|"));
|
||||
let ok =
|
||||
Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).args(["-af", &af, "-c:a", "pcm_s16le"]).arg(&tmp).status();
|
||||
if !ok.is_ok_and(|s| s.success()) {
|
||||
eprintln!("ffmpeg cannot make {n} channels: skipping");
|
||||
continue;
|
||||
}
|
||||
std::fs::rename(&tmp, &out).expect("rename");
|
||||
}
|
||||
let Ok(f) = probe(&out) else { continue };
|
||||
let s = MediaPool::new().audio_samples(&f, Tick::ZERO, 4800, RATE);
|
||||
assert_eq!(s.len(), 9600, "{n} channels");
|
||||
assert!(s.iter().all(|v| v.is_finite() && v.abs() <= 1.001), "{n} channels");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
//! A still's Exif orientation (a camera held sideways, a mirrored scan) is applied on import: the
|
||||
//! footage has the size the picture is seen at, and its frames are the picture the right way up.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_media::{MediaPool, probe_bytes};
|
||||
use effectcraft_project::FootageKind;
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
const RED: [f32; 3] = [1.0, 0.0, 0.0];
|
||||
const GREEN: [f32; 3] = [0.0, 1.0, 0.0];
|
||||
const BLUE: [f32; 3] = [0.0, 0.0, 1.0];
|
||||
const WHITE: [f32; 3] = [1.0, 1.0, 1.0];
|
||||
|
||||
/// A 32×16 JPEG with red, green / blue, white quarters, tagged with Exif orientation `tag`
|
||||
/// (`None`: no Exif at all).
|
||||
fn quartered_jpeg(tag: Option<u8>) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_fn(32, 16, |x, y| {
|
||||
let c = match (x < 16, y < 8) {
|
||||
(true, true) => RED,
|
||||
(false, true) => GREEN,
|
||||
(true, false) => BLUE,
|
||||
(false, false) => WHITE,
|
||||
};
|
||||
image::Rgb(c.map(|v| (v * 255.0) as u8))
|
||||
});
|
||||
let mut jpeg = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100).encode_image(&img).expect("encode");
|
||||
let Some(tag) = tag else { return jpeg };
|
||||
// APP1 "Exif": a big-endian TIFF header and one IFD holding Orientation (0x0112, SHORT).
|
||||
let mut app1 = vec![0xFF, 0xE1, 0x00, 0x22];
|
||||
app1.extend_from_slice(b"Exif\0\0MM\0\x2A\0\0\0\x08");
|
||||
app1.extend_from_slice(&[0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, tag, 0x00, 0x00]);
|
||||
app1.extend_from_slice(&[0x00; 4]);
|
||||
// (Straight after the SOI marker.)
|
||||
jpeg.splice(2..2, app1);
|
||||
jpeg
|
||||
}
|
||||
|
||||
/// The footage size and the colours of its four corners (top-left, top-right, bottom-left,
|
||||
/// bottom-right), as imported.
|
||||
fn imported(tag: Option<u8>) -> ((u32, u32), [[f32; 3]; 4]) {
|
||||
let name = format!("oriented-{tag:?}.jpg");
|
||||
let bytes: Arc<[u8]> = quartered_jpeg(tag).into();
|
||||
let f = probe_bytes(&name, bytes.clone()).expect("probe");
|
||||
assert_eq!(f.kind, FootageKind::Still);
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes(&name, bytes);
|
||||
let img = pool.frame_at(&f, Tick::ZERO).expect("frame");
|
||||
assert_eq!((img.width, img.height), (f.width, f.height), "orientation {tag:?}: the frame has the probed size");
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let at = |x: usize, y: usize| {
|
||||
let p = img.data[y * w + x];
|
||||
[p[0], p[1], p[2]]
|
||||
};
|
||||
((f.width, f.height), [at(2, 2), at(w - 3, 2), at(2, h - 3), at(w - 3, h - 3)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_orientation_is_applied_to_stills() {
|
||||
let (landscape, portrait) = ((32, 16), (16, 32));
|
||||
let cases = [
|
||||
(None, landscape, [RED, GREEN, BLUE, WHITE]),
|
||||
(Some(1), landscape, [RED, GREEN, BLUE, WHITE]),
|
||||
(Some(2), landscape, [GREEN, RED, WHITE, BLUE]),
|
||||
(Some(3), landscape, [WHITE, BLUE, GREEN, RED]),
|
||||
(Some(4), landscape, [BLUE, WHITE, RED, GREEN]),
|
||||
(Some(5), portrait, [RED, BLUE, GREEN, WHITE]),
|
||||
(Some(6), portrait, [BLUE, RED, WHITE, GREEN]),
|
||||
(Some(7), portrait, [WHITE, GREEN, BLUE, RED]),
|
||||
(Some(8), portrait, [GREEN, WHITE, RED, BLUE]),
|
||||
// (Not an orientation: the picture as stored.)
|
||||
(Some(0), landscape, [RED, GREEN, BLUE, WHITE]),
|
||||
(Some(9), landscape, [RED, GREEN, BLUE, WHITE]),
|
||||
];
|
||||
for (tag, size, corners) in cases {
|
||||
let (got_size, got) = imported(tag);
|
||||
assert_eq!(got_size, size, "orientation {tag:?}: size");
|
||||
for (i, (g, want)) in got.iter().zip(corners).enumerate() {
|
||||
let near = g.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.2);
|
||||
assert!(near, "orientation {tag:?}: corner {i} is {g:?}, expected {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ use std::process::Command;
|
|||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use effectcraft_media::{MediaPool, probe};
|
||||
use effectcraft_media::{MediaPool, probe, probe_bytes};
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::FootageSource;
|
||||
|
|
@ -336,6 +336,59 @@ fn resampled_audio_read_in_frame_pieces_matches_ffmpeg() {
|
|||
}
|
||||
}
|
||||
|
||||
/// The H.264 fixture with the rotation part of its video track header's matrix (`[a b; c d]`,
|
||||
/// 16.16 fixed point) replaced: what a phone writes for a movie shot with the camera turned.
|
||||
fn turned_mp4(p: &Path, abcd: [i32; 4]) -> Arc<[u8]> {
|
||||
let mut bytes = std::fs::read(p).expect("read");
|
||||
// (The video track comes first; a version 0 `tkhd` has its matrix 44 bytes after the type.)
|
||||
let at = bytes.windows(4).position(|w| w == b"tkhd").expect("tkhd");
|
||||
assert_eq!(bytes[at + 4], 0, "a version 0 tkhd");
|
||||
for (slot, v) in [0, 1, 3, 4].into_iter().zip(abcd) {
|
||||
let o = at + 44 + slot * 4;
|
||||
bytes[o..o + 4].copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
bytes.into()
|
||||
}
|
||||
|
||||
/// A movie whose track header says it is shown rotated (a phone held upright) imports the way it
|
||||
/// is shown: the footage has the turned size and its frames are turned, at full and reduced size.
|
||||
#[test]
|
||||
fn rotated_mp4_imports_upright() {
|
||||
let Some(p) = h264_mp4() else { return };
|
||||
let plain_footage = probe(&p).expect("probe");
|
||||
let pool = MediaPool::new();
|
||||
let t = t_of(plain_footage.frame_rate, 3);
|
||||
let plain = pool.frame(ItemId(1), &plain_footage, t).expect("frame");
|
||||
let (w, h) = (plain.width as usize, plain.height as usize);
|
||||
let one = 0x1_0000;
|
||||
// The matrix, the turned size and the plain frame's pixel shown at (x, y).
|
||||
type From = fn(usize, usize, usize, usize) -> (usize, usize);
|
||||
let cases: [(&str, [i32; 4], (usize, usize), From); 3] = [
|
||||
("90", [0, one, -one, 0], (h, w), |x, y, _, h| (y, h - 1 - x)),
|
||||
("180", [-one, 0, 0, -one], (w, h), |x, y, w, h| (w - 1 - x, h - 1 - y)),
|
||||
("270", [0, -one, one, 0], (h, w), |x, y, w, _| (w - 1 - y, x)),
|
||||
];
|
||||
for (name, matrix, (tw, th), from) in cases {
|
||||
let path = format!("turned-{name}.mp4");
|
||||
let bytes = turned_mp4(&p, matrix);
|
||||
let f = probe_bytes(&path, bytes.clone()).expect("probe");
|
||||
assert_eq!((f.width as usize, f.height as usize), (tw, th), "{name}°: size");
|
||||
pool.add_bytes(&path, bytes);
|
||||
let img = pool.frame(ItemId(2), &f, t).expect("frame");
|
||||
assert_eq!((img.width as usize, img.height as usize), (tw, th), "{name}°: frame size");
|
||||
for (x, y) in [(0, 0), (tw - 1, 0), (0, th - 1), (tw - 1, th - 1), (tw / 3, th / 5), (tw / 2, th - 7)] {
|
||||
let (sx, sy) = from(x, y, w, h);
|
||||
assert_eq!(img.data[y * tw + x], plain.data[sy * w + sx], "{name}°: pixel ({x}, {y})");
|
||||
}
|
||||
// (A reduced-resolution render asks for the turned size.)
|
||||
let (hw, hh) = (f.width / 2, f.height / 2);
|
||||
let sized = pool.frame_at_size(ItemId(2), &f, t, hw, hh).expect("frame");
|
||||
assert_eq!((sized.width, sized.height), (hw, hh), "{name}°: reduced size");
|
||||
let db = psnr(&sized, &effectcraft_raster::resample(&img, hw, hh));
|
||||
assert!(db > 45.0, "{name}° at half size: {db:.2} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prores_4444_with_alpha() {
|
||||
let Some(p) = prores_mov() else { return };
|
||||
|
|
@ -778,9 +831,14 @@ fn flac_audio_of_any_shape_reads_in_device_blocks() {
|
|||
}
|
||||
at += 1024;
|
||||
}
|
||||
// ffmpeg's sine source has an amplitude of 1/8, on both channels (mono is duplicated).
|
||||
// ffmpeg's sine source has an amplitude of 1/8 (mono is duplicated). The fixture carries
|
||||
// channels 0 and 1 at full level and the others at half, so a multichannel file sounds as
|
||||
// its stereo downmix (tests/downmix.rs checks that matrix channel by channel).
|
||||
let gains = effectcraft_media::downmix::stereo_gains(channels as usize, false);
|
||||
let side = |s: usize| gains.iter().enumerate().map(|(c, g)| f64::from(g[s]) * if c < 2 { 1.0 } else { 0.5 }).sum::<f64>();
|
||||
let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt();
|
||||
let rms = (sum / n.max(1) as f64).sqrt();
|
||||
assert!((rms - 0.125 / 2f64.sqrt()).abs() < 0.003, "{}: rms {rms}", p.display());
|
||||
assert!((rms - want).abs() < 0.003, "{}: rms {rms}, expected {want}", p.display());
|
||||
eprintln!("{}: read {secs} s in {:?}, slowest block after the first {worst:?}", p.display(), t0.elapsed());
|
||||
assert_eq!(pool.stats().opened, 1, "opened once, not per block");
|
||||
}
|
||||
|
|
@ -890,7 +948,9 @@ fn dpx_stills_match_ffmpeg() {
|
|||
assert!(effectcraft_media::is_importable(&p));
|
||||
let f = probe(&p).expect(pf);
|
||||
assert_eq!(f.kind, FootageKind::Still);
|
||||
assert_eq!((f.width, f.height, f.codec.as_str()), (250, 100, codec), "{pf}");
|
||||
assert_eq!((f.width, f.height), (250, 100), "{pf}");
|
||||
// Some ffmpeg builds state the transfer (", linear"), which the codec name then carries.
|
||||
assert!(f.codec == codec || f.codec.starts_with(&format!("{codec}, ")), "{pf}: {}", f.codec);
|
||||
assert_eq!(f.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }, "{pf}");
|
||||
let img = MediaPool::new().frame_at(&f, Tick::ZERO).expect(pf);
|
||||
let want = ffmpeg_rgba(&p, !codec.contains("8-bit"));
|
||||
|
|
@ -967,3 +1027,54 @@ fn cineon_still_shows_code_values() {
|
|||
assert!(effectcraft_media::probe_bytes("cut.cin", cut).is_err(), "{n}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #329: a frame read that is called off (a scrub has moved on) gives up before the decoder
|
||||
/// seeks and decodes forward from the keyframe, so it no longer holds the decoder the newest
|
||||
/// frame waits for; the frame decodes as usual when it is wanted again.
|
||||
#[test]
|
||||
fn a_called_off_read_does_not_decode() {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
let Some(p) = h264_mp4() else { return };
|
||||
let f = probe(&p).expect("probe");
|
||||
let pool = MediaPool::new();
|
||||
let t = t_of(f.frame_rate, 45);
|
||||
let off = Arc::new(AtomicBool::new(true));
|
||||
let mut got = None;
|
||||
pool.cancellable(&off, &mut || got = Some(pool.frame(ItemId(1), &f, t)));
|
||||
assert!(got.expect("ran").is_none(), "a called-off read decoded its frame");
|
||||
assert_eq!(pool.stats().frames, 0, "nothing cached for it");
|
||||
let on = Arc::new(AtomicBool::new(false));
|
||||
let mut got = None;
|
||||
pool.cancellable(&on, &mut || got = pool.frame(ItemId(1), &f, t));
|
||||
let img = got.expect("decoded once wanted again");
|
||||
assert_eq!((img.width, img.height), (1920, 1080));
|
||||
}
|
||||
|
||||
/// H.264 High 4:2:2 10-bit: the software decoder lacks it; the pool says why, once, and the
|
||||
/// footage reports it (not a black frame without a word).
|
||||
#[test]
|
||||
fn an_unsupported_stream_reports_why_it_shows_no_picture() {
|
||||
let Some(p) = ffmpeg_fixture(
|
||||
"h264_high422_10bit.mp4",
|
||||
&["-f", "lavfi", "-i", "testsrc=size=320x180:rate=24:duration=1", "-c:v", "libx264", "-profile:v", "high422", "-pix_fmt", "yuv422p10le"],
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let f = probe(&p).expect("probe");
|
||||
let pool = MediaPool::new();
|
||||
assert_eq!(pool.problem(&f), None, "nothing is wrong before a frame was asked for");
|
||||
if pool.frame(ItemId(1), &f, Tick::ZERO).is_some() {
|
||||
// a decoder that handles 4:2:2 (FilmCraft's, or a hardware one): no notice
|
||||
assert_eq!(pool.problem(&f), None);
|
||||
return;
|
||||
}
|
||||
let why = pool.problem(&f).expect("a failed frame leaves a reason");
|
||||
assert!(why.contains("4:2:2"), "names the stream: {why}");
|
||||
assert!(why.contains("isn't supported"), "says it is unsupported: {why}");
|
||||
assert!(!why.contains("decode error"), "no error wrappers: {why}");
|
||||
// it stays for the other frames, and is dropped when the file is forgotten
|
||||
assert!(pool.frame(ItemId(1), &f, Tick::from_seconds_f64(0.5)).is_none());
|
||||
assert_eq!(pool.problem(&f).as_deref(), Some(why.as_str()));
|
||||
pool.forget(&f.path);
|
||||
assert_eq!(pool.problem(&f), None);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
//! Opening a long movie reads its index, not the whole file: probing it and decoding a frame and
|
||||
//! some audio must not grow peak memory by anything near the file's size.
|
||||
//!
|
||||
//! The large movie is a small ffmpeg-made MP4 followed by a 256 MiB `free` box (a sparse file, so
|
||||
//! it costs no disk writes). Reading it whole, as before, grew peak RSS by its size; read in place,
|
||||
//! the box is skipped by its header. The only test in this binary, so peak RSS is its own.
|
||||
//! Skips (passes with a note) without ffmpeg or off Linux (peak RSS from `/proc/self/status`).
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_media::{MediaPool, probe};
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
const PADDING: u64 = 256 << 20;
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media")
|
||||
}
|
||||
|
||||
/// A 2 s 320×240 H.264 + AAC movie (written to a temp name and renamed, so concurrent test
|
||||
/// processes never see a partial file). `None` without ffmpeg.
|
||||
fn small_mp4() -> Option<PathBuf> {
|
||||
let out = fixtures().join("in_place_small.mp4");
|
||||
if out.exists() {
|
||||
return Some(out);
|
||||
}
|
||||
if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) {
|
||||
return None;
|
||||
}
|
||||
std::fs::create_dir_all(fixtures()).unwrap();
|
||||
let tmp = fixtures().join(format!("in_place_tmp_{}.mp4", std::process::id()));
|
||||
let st = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=2"])
|
||||
.args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2"])
|
||||
.args(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"])
|
||||
.arg(&tmp)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(st.success(), "ffmpeg failed");
|
||||
std::fs::rename(&tmp, &out).unwrap();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `/proc/self/status` field `key` in KiB.
|
||||
fn status_kib(key: &str) -> Option<u64> {
|
||||
let s = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let line = s.lines().find(|l| l.starts_with(key))?;
|
||||
line.split_whitespace().nth(1)?.parse().ok()
|
||||
}
|
||||
|
||||
/// Peak RSS so far in bytes, after resetting it to the current RSS where the kernel allows that.
|
||||
fn reset_peak() -> Option<u64> {
|
||||
let _ = std::fs::write("/proc/self/clear_refs", "5");
|
||||
status_kib("VmHWM:").map(|k| k * 1024)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_movie_is_read_in_place_not_whole() {
|
||||
if !cfg!(target_os = "linux") {
|
||||
eprintln!("peak RSS is read from /proc: skipping");
|
||||
return;
|
||||
}
|
||||
let Some(small) = small_mp4() else {
|
||||
eprintln!("ffmpeg not found: skipping");
|
||||
return;
|
||||
};
|
||||
// The same movie with 256 MiB of padding after its boxes.
|
||||
let large = fixtures().join(format!("in_place_large_{}.mp4", std::process::id()));
|
||||
std::fs::copy(&small, &large).unwrap();
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&large).unwrap();
|
||||
f.write_all(&(8 + PADDING as u32).to_be_bytes()).unwrap();
|
||||
f.write_all(b"free").unwrap();
|
||||
f.set_len(std::fs::metadata(&small).unwrap().len() + 8 + PADDING).unwrap();
|
||||
drop(f);
|
||||
|
||||
// Warm up on the small file (code paths, allocator, decoder tables).
|
||||
let small_footage = probe(&small).unwrap();
|
||||
let pool = MediaPool::new();
|
||||
pool.set_read_ahead(false);
|
||||
let small_frame = pool.frame_at(&small_footage, Tick::ZERO).unwrap();
|
||||
let small_audio = pool.audio_samples(&small_footage, Tick::ZERO, 4800, 48_000);
|
||||
|
||||
let before = reset_peak().unwrap();
|
||||
let footage = probe(&large).unwrap();
|
||||
let frame = pool.frame_at(&footage, Tick::ZERO).unwrap();
|
||||
let audio = pool.audio_samples(&footage, Tick::ZERO, 4800, 48_000);
|
||||
let after = status_kib("VmHWM:").unwrap() * 1024;
|
||||
drop(pool);
|
||||
let _ = std::fs::remove_file(&large);
|
||||
|
||||
let growth = after.saturating_sub(before);
|
||||
eprintln!("peak RSS growth: {} KiB", growth >> 10);
|
||||
assert!(growth < 64 << 20, "probing and decoding a {} MiB movie grew peak RSS by {} MiB", PADDING >> 20, growth >> 20);
|
||||
// Same movie, same results.
|
||||
assert_eq!(footage, effectcraft_project::Footage { path: footage.path.clone(), ..small_footage });
|
||||
assert_eq!(frame, small_frame);
|
||||
assert_eq!(audio, small_audio);
|
||||
assert!(audio.iter().any(|s| s.abs() > 0.1), "the tone decodes");
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
pub mod build;
|
||||
pub mod essential;
|
||||
pub mod limits;
|
||||
pub mod props;
|
||||
pub mod render_queue;
|
||||
pub mod render_templates;
|
||||
|
|
@ -48,6 +49,42 @@ pub fn saved_by(text: &str) -> Option<String> {
|
|||
serde_json::from_str::<Meta>(text).ok()?.saved_by
|
||||
}
|
||||
|
||||
/// The compositions that were open when a project file was saved (`activeComp` and `openComps`
|
||||
/// next to `savedBy`), so it reopens where it was left (#748). Editor state rather than project
|
||||
/// content: opening a comp doesn't make the project unsaved, and it isn't part of
|
||||
/// [`Project`]. Older files have none; older versions ignore it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SavedView {
|
||||
/// The comp shown in the viewer and the Timeline.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub active_comp: Option<ItemId>,
|
||||
/// The open comps (Timeline tabs), in tab order.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub open_comps: Vec<ItemId>,
|
||||
}
|
||||
|
||||
/// The view a project file was saved with ([`SavedView`]); empty for older files. Values that
|
||||
/// aren't ids are ignored (whether an id still names a comp is up to the caller).
|
||||
pub fn saved_view(text: &str) -> SavedView {
|
||||
#[derive(Deserialize)]
|
||||
struct Meta {
|
||||
#[serde(rename = "activeComp", default)]
|
||||
active_comp: Option<serde_json::Value>,
|
||||
#[serde(rename = "openComps", default)]
|
||||
open_comps: Option<serde_json::Value>,
|
||||
}
|
||||
let Ok(m) = serde_json::from_str::<Meta>(text) else { return SavedView::default() };
|
||||
SavedView {
|
||||
active_comp: m.active_comp.as_ref().and_then(serde_json::Value::as_u64).map(ItemId),
|
||||
open_comps: m
|
||||
.open_comps
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map_or_else(Vec::new, |a| a.iter().filter_map(serde_json::Value::as_u64).map(ItemId).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether version `a` (`major.minor.patch`, pre-release suffixes ignored) is newer than `b`.
|
||||
pub fn is_newer_version(a: &str, b: &str) -> bool {
|
||||
let parts = |v: &str| -> [u64; 3] {
|
||||
|
|
@ -301,6 +338,71 @@ pub enum Renderer {
|
|||
Advanced3D,
|
||||
}
|
||||
|
||||
/// A composition's Resolution (the Composition panel's Resolution/Down Sample Factor popup, View ▸
|
||||
/// Resolution): how many of its pixels the viewer renders. Auto follows the magnification.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Resolution {
|
||||
#[default]
|
||||
Auto,
|
||||
Full,
|
||||
Half,
|
||||
Third,
|
||||
Quarter,
|
||||
/// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40).
|
||||
Custom(u8),
|
||||
}
|
||||
|
||||
impl Resolution {
|
||||
pub const ALL: [Resolution; 5] = [Resolution::Auto, Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Resolution::Auto => "Auto",
|
||||
Resolution::Full => "Full",
|
||||
Resolution::Half => "Half",
|
||||
Resolution::Third => "Third",
|
||||
Resolution::Quarter => "Quarter",
|
||||
Resolution::Custom(_) => "Custom",
|
||||
}
|
||||
}
|
||||
pub fn is_auto(&self) -> bool {
|
||||
*self == Resolution::Auto
|
||||
}
|
||||
/// The down-sample factor (render every n-th pixel); `None` for Auto.
|
||||
pub fn factor(self) -> Option<u8> {
|
||||
match self {
|
||||
Resolution::Auto => None,
|
||||
Resolution::Full => Some(1),
|
||||
Resolution::Half => Some(2),
|
||||
Resolution::Third => Some(3),
|
||||
Resolution::Quarter => Some(4),
|
||||
Resolution::Custom(n) => Some(n),
|
||||
}
|
||||
}
|
||||
/// The resolution that renders every `n`-th pixel (1 = Full … 4 = Quarter; up to 40).
|
||||
pub fn from_factor(n: u64) -> Resolution {
|
||||
match n.clamp(1, 40) {
|
||||
1 => Resolution::Full,
|
||||
2 => Resolution::Half,
|
||||
3 => Resolution::Third,
|
||||
4 => Resolution::Quarter,
|
||||
n => Resolution::Custom(n as u8),
|
||||
}
|
||||
}
|
||||
/// Render scale for a viewer magnification (and display pixel density).
|
||||
pub fn scale(self, zoom: f32, ppp: f32) -> f64 {
|
||||
match self.factor() {
|
||||
Some(n) => 1.0 / f64::from(n.clamp(1, 40)),
|
||||
// As in After Effects, Auto renders the pixels the magnification needs: the coarsest
|
||||
// whole factor that still gives every screen pixel a rendered one (Full above 50 %,
|
||||
// Half down to 33.3 %, Third down to 25 %, Quarter below), never fewer (#417).
|
||||
None => {
|
||||
let n = (1.0 / (zoom * ppp) as f64 + 1e-3).floor();
|
||||
if n >= 1.0 { 1.0 / n.min(4.0) } else { 1.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Comp {
|
||||
pub width: u32,
|
||||
|
|
@ -354,6 +456,11 @@ pub struct Comp {
|
|||
/// Essential Graphics: the controls this comp exposes to instances and templates.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub essential: Option<essential::EssentialGraphics>,
|
||||
/// The Composition panel's Resolution, saved with the project as in After Effects. It changes
|
||||
/// what the viewer renders, not the comp's pixels ([`Comp::same_pixels`]), and stays out of
|
||||
/// the undo history.
|
||||
#[serde(default, skip_serializing_if = "Resolution::is_auto")]
|
||||
pub resolution: Resolution,
|
||||
}
|
||||
|
||||
/// A viewer guide line: vertical guides sit at an x position, horizontal ones at a y position.
|
||||
|
|
@ -399,6 +506,7 @@ impl Comp {
|
|||
guides: vec![],
|
||||
global_light: styles::GlobalLight::default(),
|
||||
essential: None,
|
||||
resolution: Resolution::Auto,
|
||||
}
|
||||
}
|
||||
/// Whether the composition has a frame at its time `t`: nested, it shows nothing before it
|
||||
|
|
@ -698,8 +806,8 @@ impl Layer {
|
|||
}
|
||||
|
||||
impl Comp {
|
||||
/// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches and
|
||||
/// Hide Shy Layers.
|
||||
/// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide
|
||||
/// Shy Layers and the viewer's Resolution.
|
||||
pub fn same_pixels(&self, o: &Comp) -> bool {
|
||||
// Every field (no `..`: a new field must be considered here); the layers last.
|
||||
let Comp {
|
||||
|
|
@ -728,6 +836,7 @@ impl Comp {
|
|||
global_light,
|
||||
guides,
|
||||
essential,
|
||||
resolution: _,
|
||||
} = self;
|
||||
*width == o.width
|
||||
&& *height == o.height
|
||||
|
|
@ -756,14 +865,15 @@ impl Comp {
|
|||
&& layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b))
|
||||
}
|
||||
|
||||
/// The comp with those switches at their defaults (borrowed when they already are), for
|
||||
/// keys that hash the whole comp.
|
||||
/// The comp with those switches and the Resolution at their defaults (borrowed when they
|
||||
/// already are), for keys that hash the whole comp.
|
||||
pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> {
|
||||
if !self.hide_shy && self.layers.iter().all(|l| l.switches == l.switches.pixels()) {
|
||||
if !self.hide_shy && self.resolution.is_auto() && self.layers.iter().all(|l| l.switches == l.switches.pixels()) {
|
||||
return std::borrow::Cow::Borrowed(self);
|
||||
}
|
||||
let mut c = self.clone();
|
||||
c.hide_shy = false;
|
||||
c.resolution = Resolution::Auto;
|
||||
for l in &mut c.layers {
|
||||
l.switches = l.switches.pixels();
|
||||
}
|
||||
|
|
@ -1246,7 +1356,9 @@ impl Project {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn add_item(&mut self, name: &str, label: Label, parent: Option<ItemId>, kind: ItemKind) -> ItemId {
|
||||
/// Adds an item; its size, frame rate and the like are brought within [`limits`] first.
|
||||
pub fn add_item(&mut self, name: &str, label: Label, parent: Option<ItemId>, mut kind: ItemKind) -> ItemId {
|
||||
limits::repair_kind(name, &mut kind, &mut vec![]);
|
||||
let id = ItemId(self.alloc());
|
||||
self.items.insert(id, Item { id, name: name.into(), label, comment: String::new(), parent, kind, proxy: None });
|
||||
id
|
||||
|
|
@ -1315,14 +1427,20 @@ impl Project {
|
|||
/// to read back, so a save never writes a file that can't be opened (JSON has no NaN or
|
||||
/// infinity: such a value would be written as `null`).
|
||||
pub fn to_file_json(&self) -> Result<String, ProjectError> {
|
||||
self.to_file_json_with_view(&SavedView::default())
|
||||
}
|
||||
/// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]).
|
||||
pub fn to_file_json_with_view(&self, view: &SavedView) -> Result<String, ProjectError> {
|
||||
#[derive(Serialize)]
|
||||
struct File<'a> {
|
||||
#[serde(rename = "savedBy")]
|
||||
saved_by: &'a str,
|
||||
#[serde(flatten)]
|
||||
view: &'a SavedView,
|
||||
#[serde(flatten)]
|
||||
project: &'a Project,
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, project: self })
|
||||
let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, view, project: self })
|
||||
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?;
|
||||
Project::from_json(&text)
|
||||
.map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?;
|
||||
|
|
@ -1336,12 +1454,22 @@ impl Project {
|
|||
serde_json::to_string(self).unwrap_or_default()
|
||||
}
|
||||
pub fn from_json(s: &str) -> Result<Project, ProjectError> {
|
||||
Project::from_json_repaired(s).map(|(p, _)| p)
|
||||
}
|
||||
/// [`Project::from_json`], also returning one line per value that was out of [`limits`] and
|
||||
/// was corrected (a composition of a billion pixels, a zero frame rate…), so a corrupt or
|
||||
/// hostile file still opens, within bounds, and the user can be told what changed.
|
||||
pub fn from_json_repaired(s: &str) -> Result<(Project, Vec<String>), ProjectError> {
|
||||
let mut p: Project = serde_json::from_str(s).map_err(|e| ProjectError::Invalid(e.to_string()))?;
|
||||
if p.schema > SCHEMA_VERSION {
|
||||
return Err(ProjectError::Invalid(format!("project schema {} is newer than this version supports ({SCHEMA_VERSION})", p.schema)));
|
||||
}
|
||||
p.fix_next_id();
|
||||
Ok(p)
|
||||
let mut notes = vec![];
|
||||
for item in p.items.values_mut() {
|
||||
limits::repair_item(item, &mut notes);
|
||||
}
|
||||
Ok((p, notes))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
//! The bounds every project holds to (#788). After Effects limits compositions, solids and
|
||||
//! imported images to 30,000 × 30,000 pixels; the Composition Settings commands already used that
|
||||
//! limit, a frame-rate range and a motion-blur sample range. A project file is input like any
|
||||
//! other: [`Project::from_json_repaired`](crate::Project::from_json_repaired) brings what it reads
|
||||
//! into these bounds (and says what it changed) and [`Project::add_item`](crate::Project::add_item)
|
||||
//! does the same for every new item, so a corrupt or hostile file can't make the renderer allocate
|
||||
//! a frame of billions of pixels or step through zero-length frames.
|
||||
|
||||
use std::fmt::Display;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_time::FrameRate;
|
||||
|
||||
use crate::{Comp, Footage, Item, ItemKind, Solid};
|
||||
|
||||
/// The largest width or height of a composition, solid or footage frame, in pixels.
|
||||
pub const MAX_SIZE: u32 = 30_000;
|
||||
/// The slowest frame rate, in frames per second.
|
||||
pub const MIN_FPS: f64 = 0.001;
|
||||
/// The fastest frame rate, in frames per second.
|
||||
pub const MAX_FPS: f64 = 1000.0;
|
||||
/// The narrowest pixel aspect ratio.
|
||||
pub const MIN_PIXEL_ASPECT: f64 = 0.1;
|
||||
/// The widest pixel aspect ratio.
|
||||
pub const MAX_PIXEL_ASPECT: f64 = 10.0;
|
||||
/// Composition Settings ▸ Advanced ▸ Samples Per Frame.
|
||||
pub const MOTION_BLUR_SAMPLES: (u32, u32) = (2, 64);
|
||||
/// Composition Settings ▸ Advanced ▸ Adaptive Sample Limit.
|
||||
pub const ADAPTIVE_SAMPLE_LIMIT: (u32, u32) = (16, 256);
|
||||
|
||||
/// A width or height from 1 to [`MAX_SIZE`].
|
||||
pub fn size(v: u64) -> u32 {
|
||||
// In range after the clamp, so the cast can't truncate.
|
||||
v.clamp(1, u64::from(MAX_SIZE)) as u32
|
||||
}
|
||||
|
||||
/// [`size`] of a measured length (rounded; 1 for NaN).
|
||||
pub fn size_f64(v: f64) -> u32 {
|
||||
if v.is_finite() { v.round().clamp(1.0, f64::from(MAX_SIZE)) as u32 } else { 1 }
|
||||
}
|
||||
|
||||
/// A pixel aspect ratio from [`MIN_PIXEL_ASPECT`] to [`MAX_PIXEL_ASPECT`] (square for a value
|
||||
/// that isn't a positive number).
|
||||
pub fn pixel_aspect(v: f64) -> f64 {
|
||||
if v.is_finite() && v > 0.0 { v.clamp(MIN_PIXEL_ASPECT, MAX_PIXEL_ASPECT) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// A frame rate from [`MIN_FPS`] to [`MAX_FPS`]; `fallback` for a zero or negative rate.
|
||||
pub fn frame_rate(r: FrameRate, fallback: FrameRate) -> FrameRate {
|
||||
if r.num <= 0 || r.den <= 0 {
|
||||
return fallback;
|
||||
}
|
||||
let fps = r.as_f64();
|
||||
if (MIN_FPS..=MAX_FPS).contains(&fps) { r } else { FrameRate::from_f64(fps.clamp(MIN_FPS, MAX_FPS)) }
|
||||
}
|
||||
|
||||
/// Records one corrected value.
|
||||
fn note(notes: &mut Vec<String>, item: &str, what: &str, from: impl Display, to: impl Display) {
|
||||
notes.push(format!("{item}: {what} {from} → {to}"));
|
||||
}
|
||||
|
||||
fn fps(r: FrameRate) -> String {
|
||||
format!("{}/{} fps", r.num, r.den)
|
||||
}
|
||||
|
||||
/// Brings a read item (and its proxy) within the limits; one line per corrected value is added
|
||||
/// to `notes`.
|
||||
pub(crate) fn repair_item(item: &mut Item, notes: &mut Vec<String>) {
|
||||
repair_kind(&item.name, &mut item.kind, notes);
|
||||
if let Some(proxy) = &mut item.proxy {
|
||||
repair_footage(&format!("{} (proxy)", item.name), &mut proxy.footage, notes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings `kind` within the limits; one line per corrected value is added to `notes`.
|
||||
pub(crate) fn repair_kind(name: &str, kind: &mut ItemKind, notes: &mut Vec<String>) {
|
||||
match kind {
|
||||
ItemKind::Comp(c) => {
|
||||
if let Some(fixed) = repair_comp(name, c, notes) {
|
||||
*c = Arc::new(fixed);
|
||||
}
|
||||
}
|
||||
ItemKind::Solid(s) => repair_solid(name, s, notes),
|
||||
ItemKind::Footage(f) => repair_footage(name, f, notes),
|
||||
ItemKind::Folder => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The comp with its settings within the limits, or `None` when they already are (so a
|
||||
/// composition shared with undo snapshots is only copied when something changes).
|
||||
fn repair_comp(name: &str, c: &Comp, notes: &mut Vec<String>) -> Option<Comp> {
|
||||
let width = c.width.clamp(1, MAX_SIZE);
|
||||
let height = c.height.clamp(1, MAX_SIZE);
|
||||
let pa = pixel_aspect(c.pixel_aspect);
|
||||
let rate = frame_rate(c.frame_rate, FrameRate::FPS_29_97);
|
||||
// At least one frame long.
|
||||
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 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();
|
||||
if unchanged {
|
||||
return None;
|
||||
}
|
||||
let mut n = c.clone();
|
||||
if (width, height) != (c.width, c.height) {
|
||||
note(notes, name, "size", format_args!("{} × {} px", c.width, c.height), format_args!("{width} × {height} px"));
|
||||
}
|
||||
if pa.to_bits() != c.pixel_aspect.to_bits() {
|
||||
note(notes, name, "pixel aspect ratio", c.pixel_aspect, pa);
|
||||
}
|
||||
if rate != c.frame_rate {
|
||||
note(notes, name, "frame rate", fps(c.frame_rate), fps(rate));
|
||||
}
|
||||
if duration != c.duration {
|
||||
note(notes, name, "duration", format_args!("{:.3} s", c.duration.seconds()), format_args!("{:.3} s", duration.seconds()));
|
||||
n.work_area = (effectcraft_time::Tick::ZERO, duration);
|
||||
}
|
||||
if samples != c.motion_blur_samples {
|
||||
note(notes, name, "motion blur samples per frame", c.motion_blur_samples, samples);
|
||||
}
|
||||
if adaptive != c.motion_blur_adaptive_limit {
|
||||
note(notes, name, "adaptive sample limit", c.motion_blur_adaptive_limit, adaptive);
|
||||
}
|
||||
(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);
|
||||
Some(n)
|
||||
}
|
||||
|
||||
fn repair_solid(name: &str, s: &mut Solid, notes: &mut Vec<String>) {
|
||||
let (w, h) = (s.width.clamp(1, MAX_SIZE), s.height.clamp(1, MAX_SIZE));
|
||||
if (w, h) != (s.width, s.height) {
|
||||
note(notes, name, "size", format_args!("{} × {} px", s.width, s.height), format_args!("{w} × {h} px"));
|
||||
(s.width, s.height) = (w, h);
|
||||
}
|
||||
let pa = pixel_aspect(s.pixel_aspect);
|
||||
if pa.to_bits() != s.pixel_aspect.to_bits() {
|
||||
note(notes, name, "pixel aspect ratio", s.pixel_aspect, pa);
|
||||
s.pixel_aspect = pa;
|
||||
}
|
||||
}
|
||||
|
||||
fn repair_footage(name: &str, f: &mut Footage, notes: &mut Vec<String>) {
|
||||
// Audio-only footage has no frame (0 × 0).
|
||||
let (w, h) = (f.width.min(MAX_SIZE), f.height.min(MAX_SIZE));
|
||||
if (w, h) != (f.width, f.height) {
|
||||
note(notes, name, "size", format_args!("{} × {} px", f.width, f.height), format_args!("{w} × {h} px"));
|
||||
(f.width, f.height) = (w, h);
|
||||
}
|
||||
let pa = pixel_aspect(f.pixel_aspect);
|
||||
if pa.to_bits() != f.pixel_aspect.to_bits() {
|
||||
note(notes, name, "pixel aspect ratio", f.pixel_aspect, pa);
|
||||
f.pixel_aspect = pa;
|
||||
}
|
||||
let rate = frame_rate(f.frame_rate, FrameRate::FPS_30);
|
||||
if rate != f.frame_rate {
|
||||
note(notes, name, "frame rate", fps(f.frame_rate), fps(rate));
|
||||
f.frame_rate = rate;
|
||||
}
|
||||
if let Some(native) = f.native_rate {
|
||||
let rate = frame_rate(native, FrameRate::FPS_30);
|
||||
if rate != native {
|
||||
note(notes, name, "native frame rate", fps(native), fps(rate));
|
||||
f.native_rate = Some(rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
#[test]
|
||||
fn helpers_clamp_and_refuse_nonsense() {
|
||||
assert_eq!(size(0), 1);
|
||||
assert_eq!(size(u64::from(u32::MAX) + 5), MAX_SIZE);
|
||||
assert_eq!(size_f64(f64::NAN), 1);
|
||||
assert_eq!(size_f64(1e12), MAX_SIZE);
|
||||
assert_eq!(pixel_aspect(0.0), 1.0);
|
||||
assert_eq!(pixel_aspect(-2.0), 1.0);
|
||||
assert_eq!(pixel_aspect(1e9), MAX_PIXEL_ASPECT);
|
||||
assert_eq!(frame_rate(FrameRate { num: 0, den: 1 }, FrameRate::FPS_25), FrameRate::FPS_25);
|
||||
assert_eq!(frame_rate(FrameRate { num: 24, den: -1 }, FrameRate::FPS_25), FrameRate::FPS_25);
|
||||
assert_eq!(frame_rate(FrameRate::new(1_000_000_000_000_000_000, 1), FrameRate::FPS_25).as_f64(), MAX_FPS);
|
||||
assert_eq!(frame_rate(FrameRate::FPS_29_97, FrameRate::FPS_25), FrameRate::FPS_29_97);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_item_and_reading_a_file_hold_footage_to_the_limits() {
|
||||
let mut p = crate::Project::default();
|
||||
let wild = Footage { width: u32::MAX, height: 0, pixel_aspect: 0.0, frame_rate: FrameRate { num: 1, den: 0 }, ..Default::default() };
|
||||
let id = p.add_item("Clip", effectcraft_color::Label::None, None, ItemKind::Footage(wild));
|
||||
let ItemKind::Footage(f) = &p.items[&id].kind else { panic!("footage") };
|
||||
assert_eq!((f.width, f.height, f.pixel_aspect, f.frame_rate), (MAX_SIZE, 0, 1.0, FrameRate::FPS_30));
|
||||
// The same values in a file are corrected, and each correction is reported.
|
||||
let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap();
|
||||
let kind = &mut v["items"][id.0.to_string()]["kind"];
|
||||
kind["width"] = serde_json::json!(4_000_000_000u64);
|
||||
kind["native_rate"] = serde_json::json!({"num": -24, "den": 1});
|
||||
let mut proxy = kind.clone();
|
||||
proxy["frame_rate"] = serde_json::json!({"num": 0, "den": 0});
|
||||
v["items"][id.0.to_string()]["proxy"] = serde_json::json!({"footage": proxy});
|
||||
let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap();
|
||||
let ItemKind::Footage(b) = &back.items[&id].kind else { panic!("footage") };
|
||||
assert_eq!((b.width, b.native_rate), (MAX_SIZE, Some(FrameRate::FPS_30)));
|
||||
assert_eq!(
|
||||
notes,
|
||||
[
|
||||
"Clip: size 4000000000 × 0 px → 30000 × 0 px",
|
||||
"Clip: native frame rate -24/1 fps → 30/1 fps",
|
||||
"Clip (proxy): size 4000000000 × 0 px → 30000 × 0 px",
|
||||
"Clip (proxy): frame rate 0/0 fps → 30/1 fps",
|
||||
"Clip (proxy): native frame rate -24/1 fps → 30/1 fps",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comp_in_range_is_not_copied() {
|
||||
let c = Comp::new(1920, 1080, FrameRate::FPS_24, Tick::from_seconds_f64(2.0));
|
||||
let mut notes = vec![];
|
||||
assert!(repair_comp("Comp", &c, &mut notes).is_none());
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +65,54 @@ fn animate_and_roundtrip_json() {
|
|||
let _ = Keyframe::new(Tick::ZERO, Value::Scalar(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_resolution_is_saved_and_old_projects_load_with_auto() {
|
||||
use crate::Resolution;
|
||||
let (mut p, cid) = project_with_layer();
|
||||
// Auto (the default) isn't written, so older versions read the file as before.
|
||||
assert!(!p.to_file_json().unwrap().contains("\"resolution\""));
|
||||
p.comp_mut(cid).unwrap().resolution = Resolution::Custom(5);
|
||||
let q = Project::from_json(&p.to_file_json().unwrap()).unwrap();
|
||||
assert_eq!(q.comp(cid).unwrap().resolution, Resolution::Custom(5));
|
||||
p.comp_mut(cid).unwrap().resolution = Resolution::Half;
|
||||
let text = p.to_file_json().unwrap();
|
||||
assert_eq!(Project::from_json(&text).unwrap().comp(cid).unwrap().resolution, Resolution::Half);
|
||||
// A project saved before comps had a resolution opens with Auto.
|
||||
fn strip(v: &mut serde_json::Value) {
|
||||
match v {
|
||||
serde_json::Value::Object(m) => {
|
||||
m.remove("resolution");
|
||||
m.values_mut().for_each(strip);
|
||||
}
|
||||
serde_json::Value::Array(a) => a.iter_mut().for_each(strip),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut old: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
strip(&mut old);
|
||||
assert_eq!(Project::from_json(&old.to_string()).unwrap().comp(cid).unwrap().resolution, Resolution::Auto);
|
||||
// It's not part of the comp's pixels.
|
||||
let (a, b) = (p.comp(cid).unwrap(), q.comp(cid).unwrap());
|
||||
assert!(a != b && a.same_pixels(b));
|
||||
assert_eq!(format!("{:?}", a.pixel_form()), format!("{:?}", b.pixel_form()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_factors_and_scales() {
|
||||
use crate::Resolution;
|
||||
assert_eq!(Resolution::from_factor(1), Resolution::Full);
|
||||
assert_eq!(Resolution::from_factor(4), Resolution::Quarter);
|
||||
assert_eq!(Resolution::from_factor(0), Resolution::Full);
|
||||
assert_eq!(Resolution::from_factor(7), Resolution::Custom(7));
|
||||
assert_eq!(Resolution::from_factor(1000), Resolution::Custom(40));
|
||||
assert_eq!(Resolution::Custom(5).factor(), Some(5));
|
||||
assert_eq!(Resolution::Auto.factor(), None);
|
||||
// A hostile factor from a file renders within the Custom range.
|
||||
assert_eq!(Resolution::Custom(0).scale(1.0, 1.0), 1.0);
|
||||
assert_eq!(Resolution::Custom(255).scale(1.0, 1.0), 1.0 / 40.0);
|
||||
assert_eq!(Resolution::Third.scale(0.1, 1.0), 1.0 / 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_names_and_cycles() {
|
||||
let (p, cid) = project_with_layer();
|
||||
|
|
@ -121,3 +169,16 @@ fn project_files_name_their_version_and_always_read_back() {
|
|||
assert!(crate::is_newer_version("0.2.0", "0.1.9") && crate::is_newer_version("1.0.0-rc.1", "0.9.9"));
|
||||
assert!(!crate::is_newer_version("0.1.1", "0.1.1") && !crate::is_newer_version("0.1.0", "0.1.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_files_remember_the_open_comps() {
|
||||
let (p, cid) = project_with_layer();
|
||||
let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid] };
|
||||
let text = p.to_file_json_with_view(&view).unwrap();
|
||||
assert_eq!(crate::saved_view(&text), view);
|
||||
assert_eq!(Project::from_json(&text).unwrap(), p, "the view is not part of the project");
|
||||
// Files from before #748 have no view; a garbled one is ignored, not an error.
|
||||
assert_eq!(crate::saved_view(&p.to_file_json().unwrap()), crate::SavedView::default());
|
||||
let odd = text.replacen(&format!("\"activeComp\": {}", cid.0), "\"activeComp\": \"Main\"", 1);
|
||||
assert_eq!(crate::saved_view(&odd), crate::SavedView { active_comp: None, open_comps: vec![cid] });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,8 @@ pub fn audible(project: &Project, l: &Layer) -> bool {
|
|||
})
|
||||
}
|
||||
|
||||
fn db_to_gain(db: f64) -> f32 {
|
||||
/// Linear gain of an Audio Levels value in dB; -96 dB and below is silence.
|
||||
pub fn db_to_gain(db: f64) -> f32 {
|
||||
if db <= -96.0 { 0.0 } else { 10f64.powf(db / 20.0) as f32 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ pub mod text;
|
|||
pub mod three_d;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
pub use auto::{AutoKey, AutoPick};
|
||||
pub use cache::{CacheStats, LayerCache, LayerStore, PrefetchStore};
|
||||
|
|
@ -110,6 +111,93 @@ pub trait FootageSource: Send + Sync {
|
|||
fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
/// Run `f`, which reads frames from this source, as work that can be called off: once
|
||||
/// `cancel` is set, long decodes it starts (a movie seeking back to a keyframe and decoding
|
||||
/// forward) stop early and their frames come back `None`. Sources that can't stop a decode
|
||||
/// just run `f`. See [`CancellableFootage`].
|
||||
fn cancellable(&self, _cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
|
||||
f()
|
||||
}
|
||||
/// Reads through this source have been called off: frames may have come back `None` that
|
||||
/// exist, so what was made from them must not be kept.
|
||||
fn called_off(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Why `footage` shows no picture although its file is there: a read of it failed because
|
||||
/// no decoder handles its codec, profile, chroma format or bit depth, or the stream is damaged
|
||||
/// (e.g. "H.264 (High 4:2:2) isn't supported by the built-in decoder: ..."). `None` while it
|
||||
/// decodes, and for sources that don't track failures. Stays set until the file is forgotten.
|
||||
fn problem(&self, _footage: &Footage) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel`
|
||||
/// (a viewer frame nobody waits for any more, #329). Every read goes through
|
||||
/// [`FootageSource::cancellable`] on its own, so reads made for this render on another thread
|
||||
/// (a parallel loop) answer to this render's flag, and reads for other renders never do.
|
||||
pub struct CancellableFootage<'a> {
|
||||
pub inner: &'a dyn FootageSource,
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CancellableFootage<'_> {
|
||||
fn read<R>(&self, f: impl FnOnce() -> Option<R>) -> Option<R> {
|
||||
if self.cancel.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let (mut f, mut out) = (Some(f), None);
|
||||
self.inner.cancellable(&self.cancel, &mut || out = f.take().and_then(|f| f()));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl FootageSource for CancellableFootage<'_> {
|
||||
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
self.read(|| self.inner.frame(item, footage, t))
|
||||
}
|
||||
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
|
||||
self.read(|| self.inner.frame_at_size(item, footage, t, width, height))
|
||||
}
|
||||
fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
self.inner.audio(item, footage, t, frames, rate)
|
||||
}
|
||||
fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
|
||||
self.inner.aux(item, footage, t)
|
||||
}
|
||||
fn model(&self, item: ItemId, footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
|
||||
self.inner.model(item, footage)
|
||||
}
|
||||
fn set_cache_budget(&self, bytes: usize) {
|
||||
self.inner.set_cache_budget(bytes)
|
||||
}
|
||||
fn purge(&self) {
|
||||
self.inner.purge()
|
||||
}
|
||||
fn forget(&self, path: &str) {
|
||||
self.inner.forget(path)
|
||||
}
|
||||
fn set_conform_folder(&self, folder: Option<std::path::PathBuf>) {
|
||||
self.inner.set_conform_folder(folder)
|
||||
}
|
||||
fn cache_budget(&self) -> Option<usize> {
|
||||
self.inner.cache_budget()
|
||||
}
|
||||
fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
|
||||
self.inner.vector_frame(item, footage, scale)
|
||||
}
|
||||
fn cancellable(&self, cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
|
||||
self.inner.cancellable(cancel, f)
|
||||
}
|
||||
fn called_off(&self) -> bool {
|
||||
self.cancel.load(Ordering::Relaxed) || self.inner.called_off()
|
||||
}
|
||||
fn problem(&self, footage: &Footage) -> Option<String> {
|
||||
self.inner.problem(footage)
|
||||
}
|
||||
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
|
||||
self.read(|| self.inner.frame_with(item, footage, t, size, float))
|
||||
}
|
||||
}
|
||||
|
||||
/// Footage that can be rasterised at any scale (SVG).
|
||||
|
|
@ -402,6 +490,16 @@ pub trait FxTarget {
|
|||
fn gpu(&mut self, steps: &[FxStep]) -> bool;
|
||||
/// Run one CPU effect: `f` maps the buffer.
|
||||
fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf);
|
||||
/// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own
|
||||
/// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`].
|
||||
/// Authored buffers (text, shapes) only span their painted content; this gives the stack
|
||||
/// the room a sized source's buffer already has (#659).
|
||||
fn cover(&mut self, size: [f64; 2]) {
|
||||
self.cpu(&mut |mut buf| {
|
||||
buf.cover(size);
|
||||
buf
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback).
|
||||
|
|
@ -604,6 +702,12 @@ impl<'a> Renderer<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The layer cache to keep this render's buffers in: none once its footage reads were
|
||||
/// called off, as they may have come back empty ([`FootageSource::called_off`], #329).
|
||||
fn cache_to_keep(&self) -> Option<&'a LayerCache> {
|
||||
self.cache.filter(|_| !self.footage.called_off())
|
||||
}
|
||||
|
||||
/// A layer's Quality switch, limited by the precomp layers above it when
|
||||
/// [`RenderOpts::nested_switches`] is on.
|
||||
pub fn quality(&self, layer: &Layer) -> Quality {
|
||||
|
|
@ -757,10 +861,6 @@ impl<'a> Renderer<'a> {
|
|||
Region::Full
|
||||
}
|
||||
|
||||
/// Rasterisation scale of a layer's source: the output scale, or — for text layers, which are
|
||||
/// always continuously rasterised, and Continuously Rasterize shape layers and vector footage —
|
||||
/// the output scale times the layer's on-screen scale (quarter-octave steps, rounded up, so
|
||||
/// vector content is drawn at its final size and stays sharp when scaled up).
|
||||
/// Whether `layer`'s processed buffer (source → masks → effects) is cached and the same as
|
||||
/// one frame earlier: drawing it from the layer cache (an accelerator uploads it once) beats
|
||||
/// re-running its effects every frame.
|
||||
|
|
@ -774,11 +874,14 @@ impl<'a> Renderer<'a> {
|
|||
now.is_some() && now == cache::layer_key(&ctx.at(prev), layer, scale, draft, blur)
|
||||
}
|
||||
|
||||
/// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers,
|
||||
/// which are always continuously rasterised (#390, #751), and Continuously Rasterize vector
|
||||
/// footage — the output scale times the layer's on-screen scale (quarter-octave steps, rounded
|
||||
/// up, so vector content is drawn at its final size and stays sharp when scaled up).
|
||||
pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 {
|
||||
let s = self.opts.scale;
|
||||
let continuous = match layer.source {
|
||||
LayerSource::Text => true,
|
||||
LayerSource::Shape => layer.switches.collapse,
|
||||
LayerSource::Text | LayerSource::Shape => true,
|
||||
LayerSource::Footage { item } => {
|
||||
layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f))
|
||||
}
|
||||
|
|
@ -1029,6 +1132,15 @@ impl<'a> Renderer<'a> {
|
|||
let Some(fx) = layer.effects() else { return };
|
||||
let lt = layer.layer_time(ctx.time);
|
||||
let layer_size = ctx.effect_bounds(layer).0;
|
||||
// #659: an authored layer's buffer spans only its painted content while the stack works
|
||||
// on the layer's whole region (`layer_size`): give the buffer the room a sized source
|
||||
// already has, so tiling or displacement can place pixels outside the content instead
|
||||
// of losing them at its edge. Only when something will actually run, and not for an
|
||||
// adjustment layer, whose buffer is the comp below and already spans it.
|
||||
if !adjustment
|
||||
&& fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) {
|
||||
target.cover(layer_size);
|
||||
}
|
||||
let mut mask_shapes = masks::shapes(ctx, layer);
|
||||
if origin != [0.0; 2] {
|
||||
for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) {
|
||||
|
|
@ -1292,7 +1404,7 @@ impl<'a> Renderer<'a> {
|
|||
let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float);
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
let img = blend_frames(mode, &a, &b, w as f32);
|
||||
if let (Some(c), Some(k)) = (self.cache, key) {
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }));
|
||||
}
|
||||
return Some(Arc::new(img));
|
||||
|
|
@ -1340,7 +1452,7 @@ impl<'a> Renderer<'a> {
|
|||
let mut nb = (*b).clone();
|
||||
color::convert(&mut nb.img, &c);
|
||||
let nb = Arc::new(nb);
|
||||
if let (Some(cache), Some(k)) = (self.cache, key) {
|
||||
if let (Some(cache), Some(k)) = (self.cache_to_keep(), key) {
|
||||
cache.insert(k, nb.clone());
|
||||
}
|
||||
nb
|
||||
|
|
@ -1358,7 +1470,7 @@ impl<'a> Renderer<'a> {
|
|||
return Some((b, key));
|
||||
}
|
||||
let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten());
|
||||
if let (Some(c), Some(k)) = (self.cache, key) {
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, flat.clone());
|
||||
}
|
||||
Some((flat, key))
|
||||
|
|
@ -1389,7 +1501,7 @@ impl<'a> Renderer<'a> {
|
|||
}
|
||||
let buf = self.masked_source(ctx, layer)?;
|
||||
let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects)));
|
||||
if let (Some(c), Some(k)) = (self.cache, key) {
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, buf.clone());
|
||||
}
|
||||
Some((buf, key))
|
||||
|
|
@ -1414,7 +1526,7 @@ impl<'a> Renderer<'a> {
|
|||
buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin);
|
||||
}
|
||||
let buf = Arc::new(buf);
|
||||
if let (Some(c), Some(k)) = (self.cache, key) {
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, buf.clone());
|
||||
}
|
||||
Some(buf)
|
||||
|
|
@ -1446,7 +1558,7 @@ impl<'a> Renderer<'a> {
|
|||
}
|
||||
let body = Arc::new(st.body);
|
||||
let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect();
|
||||
if let (Some(c), Some(k)) = (self.cache, key) {
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, body.clone());
|
||||
for (i, (b, _)) in passes.iter().enumerate() {
|
||||
c.insert(cache::derive(k, i as u64 + 1), b.clone());
|
||||
|
|
@ -2070,7 +2182,7 @@ impl<'a> Renderer<'a> {
|
|||
let mut foot = self.source(ctx, layer)?;
|
||||
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
|
||||
let foot = Arc::new(foot);
|
||||
if let (Some(c), Some(k)) = (self.cache, key) {
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, foot.clone());
|
||||
}
|
||||
Some(foot)
|
||||
|
|
|
|||
|
|
@ -135,6 +135,32 @@ fn track_matte_alpha() {
|
|||
assert!(img.get(150, 50)[3] < 0.01);
|
||||
}
|
||||
|
||||
/// Every matte kind with an opaque white 50×50 matte at the left of a 200×100 comp: the plain
|
||||
/// kinds keep the fill only inside the matte, the inverted kinds only outside it (#864).
|
||||
#[test]
|
||||
fn track_matte_kinds_keep_and_invert() {
|
||||
for (kind, inside) in [(MatteKind::Alpha, true), (MatteKind::AlphaInverted, false), (MatteKind::Luma, true), (MatteKind::LumaInverted, false)] {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut matte = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50);
|
||||
matte.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
|
||||
matte.switches.video = false;
|
||||
let mut fill = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
|
||||
fill.track_matte = Some(TrackMatte { layer: matte.id, kind });
|
||||
let c = p.comp_mut(cid).unwrap();
|
||||
c.layers.push(matte);
|
||||
c.layers.push(fill);
|
||||
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
let (a_in, a_out) = (img.get(50, 50)[3], img.get(150, 50)[3]);
|
||||
if inside {
|
||||
assert!(a_in > 0.99 && a_out < 0.01, "{kind:?}: {a_in} inside, {a_out} outside");
|
||||
assert!(img.get(50, 50)[2] > 0.99, "{kind:?} keeps the fill's colour");
|
||||
} else {
|
||||
assert!(a_in < 0.01 && a_out > 0.99, "{kind:?}: {a_in} inside, {a_out} outside");
|
||||
assert!(img.get(150, 50)[2] > 0.99, "{kind:?} keeps the fill's colour");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_layer_draws_fill_and_stroke() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
|
|
@ -268,6 +294,23 @@ fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerC
|
|||
r.comp_frame(cid, t)
|
||||
}
|
||||
|
||||
/// #329: a render whose footage reads were called off keeps nothing in the layer cache: a layer
|
||||
/// made from a frame that came back empty (an effect's layer parameter) would be reused by the
|
||||
/// next render of that time.
|
||||
#[test]
|
||||
fn a_called_off_render_keeps_nothing_in_the_layer_cache() {
|
||||
let (p, cid) = cache_scene();
|
||||
let t = Tick::from_seconds_f64(0.5);
|
||||
let cache = crate::LayerCache::default();
|
||||
let off = crate::CancellableFootage { inner: &crate::NoFootage, cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)) };
|
||||
let mut r = crate::Renderer::new(&p, &off, crate::RenderOpts::default());
|
||||
r.cache = Some(&cache);
|
||||
r.comp_frame(cid, t);
|
||||
assert_eq!(cache.stats().entries, 0, "a called-off render kept layers");
|
||||
render_cached(&p, cid, t, Some(&cache));
|
||||
assert!(cache.stats().entries > 0, "a render that runs to the end keeps its layers");
|
||||
}
|
||||
|
||||
/// [`add_effect`] for a 200×100 layer.
|
||||
fn add_effect_200(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, vals: &[(&str, Value)]) {
|
||||
add_effect(p, l, id, [200.0, 100.0], vals);
|
||||
|
|
@ -597,6 +640,59 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() {
|
|||
}
|
||||
}
|
||||
|
||||
/// #659: a tiling effect on a text layer tiles across the layer's whole region. The glyphs
|
||||
/// were cut off at their own bounding box: the effect computed over the comp-sized layer rect
|
||||
/// but wrote into the painted (glyph-bounds) buffer alone. Tiling shrinks each copy, so the
|
||||
/// probe is the x-span of the painted pixels, not their count.
|
||||
#[test]
|
||||
fn effects_on_text_reach_past_the_text_bounds() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
|
||||
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() }));
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 60.0, 0.0]);
|
||||
p.comp_mut(cid).unwrap().layers.push(l.clone());
|
||||
let plain = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
let painted = plain.data.iter().filter(|px| px[3] > 0.5).count();
|
||||
add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]);
|
||||
p.comp_mut(cid).unwrap().layers = vec![l];
|
||||
let tiled = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
let (mut lo, mut hi) = (u32::MAX, 0u32);
|
||||
for y in 0..100 {
|
||||
for x in 0..200 {
|
||||
if tiled.get(x as i64, y as i64)[3] > 0.5 {
|
||||
lo = lo.min(x);
|
||||
hi = hi.max(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
painted > 50 && hi.saturating_sub(lo) > 100,
|
||||
"CC Tiler tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #658: CC Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past
|
||||
/// the painted rectangle used to fall off its edge, so the liquid trail never appeared.
|
||||
#[test]
|
||||
fn cc_smear_trails_text_past_its_bounds() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
|
||||
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() }));
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([60.0, 60.0, 0.0]);
|
||||
p.comp_mut(cid).unwrap().layers.push(l.clone());
|
||||
let right = |img: &effectcraft_raster::Image| (0..100).map(|y| (0..200).filter(|x| img.get(*x, y)[3] > 0.5).max().unwrap_or(0)).max().unwrap_or(0);
|
||||
let plain = right(&render_frame(&p, cid, Tick::ZERO, 1.0));
|
||||
add_effect_200(
|
||||
&mut p,
|
||||
&mut l,
|
||||
"ec.distort.ccsmear",
|
||||
&[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))],
|
||||
);
|
||||
p.comp_mut(cid).unwrap().layers = vec![l];
|
||||
let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0));
|
||||
assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}");
|
||||
}
|
||||
|
||||
/// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the
|
||||
/// effect's own masks are in effect space too: Set Matte with a centred shape layer as the
|
||||
/// matte keeps the centre of a full-comp solid (#227).
|
||||
|
|
|
|||
|
|
@ -145,36 +145,53 @@ fn edge_width(img: &Image, y: i64, x0: i64, x1: i64) -> usize {
|
|||
(x0..x1).filter(|&x| (0.02..0.98).contains(&img.get(x, y)[3])).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuously_rasterized_shape_stays_sharp_when_scaled_up() {
|
||||
let run = |cr: bool| {
|
||||
let mut p = project();
|
||||
let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None);
|
||||
let mut next = p.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0);
|
||||
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
|
||||
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
|
||||
p.next_id = next;
|
||||
l.props.sub_mut("contents").unwrap().children.push(g.into());
|
||||
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
|
||||
/// A 10 px white square in a 200×200 comp, scaled 800% either by its own Scale or by a parent
|
||||
/// null's, with the Continuously Rasterize switch `cr`.
|
||||
fn scaled_square(cr: bool, via_parent: bool) -> Image {
|
||||
let mut p = project();
|
||||
let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None);
|
||||
let mut next = p.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0);
|
||||
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
|
||||
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
|
||||
p.next_id = next;
|
||||
l.props.sub_mut("contents").unwrap().children.push(g.into());
|
||||
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
|
||||
l.switches.collapse = cr;
|
||||
let mut c = comp.clone();
|
||||
if via_parent {
|
||||
let mut null = build::layer(&mut p, &comp, "Null", LayerSource::Null, (100, 100), None);
|
||||
set(&mut null, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
|
||||
set(&mut null, "transform/position", Value::Vec3([100.3, 100.3, 0.0]));
|
||||
set(&mut null, "transform/scale", Value::Vec3([800.0, 800.0, 100.0]));
|
||||
set(&mut l, "transform/position", Value::Vec3([0.0, 0.0, 0.0]));
|
||||
l.parent = Some(null.id);
|
||||
c.layers = vec![l, null];
|
||||
} else {
|
||||
set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0]));
|
||||
set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0]));
|
||||
l.switches.collapse = cr;
|
||||
let mut c = comp;
|
||||
c.layers = vec![l];
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
|
||||
render_frame(&p, cid, Tick::ZERO, 1.0)
|
||||
};
|
||||
}
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
|
||||
render_frame(&p, cid, Tick::ZERO, 1.0)
|
||||
}
|
||||
|
||||
/// Shape layers are always continuously rasterised, as in After Effects (#751): a shape scaled up
|
||||
/// by its own transform or a parent's is drawn at its on-screen size, with a 1 px anti-aliased
|
||||
/// edge, whether or not the Continuously Rasterize switch is on.
|
||||
#[test]
|
||||
fn shape_scaled_up_stays_sharp_without_the_switch() {
|
||||
// The 10 px square scaled 800% spans 60.3..140.3.
|
||||
let soft = run(false);
|
||||
let sharp = run(true);
|
||||
let ws = edge_width(&soft, 100, 50, 75);
|
||||
let wc = edge_width(&sharp, 100, 50, 75);
|
||||
assert!(ws >= 4, "bitmap scaling blurs the edge: {ws}");
|
||||
assert!(wc <= 1, "continuously rasterized edge: {wc}");
|
||||
assert!(sharp.get(100, 100)[3] > 0.99 && sharp.get(55, 100)[3] < 0.01);
|
||||
for cr in [false, true] {
|
||||
for via_parent in [false, true] {
|
||||
let img = scaled_square(cr, via_parent);
|
||||
let w = edge_width(&img, 100, 50, 75);
|
||||
assert!(w <= 1, "switch {cr}, scaled by parent {via_parent}: edge {w} px wide");
|
||||
assert!(img.get(100, 100)[3] > 0.99 && img.get(55, 100)[3] < 0.01);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -285,3 +302,38 @@ fn unscaled_text_rasterises_at_the_output_scale() {
|
|||
assert_eq!(k, 1.0, "text at {pos:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Effects on a shape layer run on its continuously rasterised pixels, still in layer space as
|
||||
/// with the switch off in After Effects: a 10 px square scaled 800% with a 2 px blur looks like
|
||||
/// an 80 px square with a 16 px blur, not a blurred bitmap enlarged afterwards (#751).
|
||||
#[test]
|
||||
fn effects_on_a_scaled_shape_follow_its_scale() {
|
||||
let run = |size: f64, scale: f64, blur: f64| {
|
||||
let mut p = project();
|
||||
let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None);
|
||||
let mut next = p.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let rect = build::shape_rect(&mut ids, [size, size], [0.0, 0.0], 0.0);
|
||||
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
|
||||
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
|
||||
let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap();
|
||||
let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [200.0, 200.0]);
|
||||
p.next_id = next;
|
||||
fx.prop_mut("blurriness").unwrap().value = Value::Scalar(blur);
|
||||
l.props.sub_mut("contents").unwrap().children.push(g.into());
|
||||
l.props.sub_mut("effects").unwrap().children.push(fx.into());
|
||||
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
|
||||
set(&mut l, "transform/position", Value::Vec3([100.0, 100.0, 0.0]));
|
||||
set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0]));
|
||||
let mut c = comp;
|
||||
c.layers = vec![l];
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
|
||||
render_frame(&p, cid, Tick::ZERO, 1.0)
|
||||
};
|
||||
let scaled = run(10.0, 800.0, 2.0);
|
||||
let native = run(80.0, 100.0, 16.0);
|
||||
let d = max_diff(&scaled, &native);
|
||||
assert!(scaled.get(100, 100)[3] > 0.99 && scaled.get(70, 100)[3] > 0.05, "the blur spreads the edge");
|
||||
assert!(d < 0.05, "scaled blurred shape differs from the native one by {d}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,9 +167,10 @@ fn range_params(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, n: usize) -> Rang
|
|||
let units_index = adv.map(|a| ctx.e(layer, a, "units") == 1).unwrap_or(false);
|
||||
let div = if units_index { n.max(1) as f64 } else { 100.0 };
|
||||
Range {
|
||||
start: ctx.f(layer, sel, "start", 0.0) / div,
|
||||
end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }) / div,
|
||||
offset: ctx.f(layer, sel, "offset", 0.0) / div,
|
||||
// Start and End stay within 0..100% (0..n in Index units); Offset within -100..100%.
|
||||
start: ctx.f(layer, sel, "start", 0.0).clamp(0.0, div) / div,
|
||||
end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }).clamp(0.0, div) / div,
|
||||
offset: ctx.f(layer, sel, "offset", 0.0).clamp(-div, div) / div,
|
||||
amount: af("amount", 100.0) / 100.0,
|
||||
shape: Shape::from_index(adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0)),
|
||||
smoothness: af("smoothness", 100.0) / 100.0,
|
||||
|
|
|
|||
|
|
@ -99,6 +99,30 @@ fn range_based_on_words_and_lines() {
|
|||
assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_start_end_and_offset_are_capped() {
|
||||
let (mut p, cid, comp) = setup(400, 200);
|
||||
let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0));
|
||||
let a = animator(&mut p, &mut l, &["opacity"], &["range"]);
|
||||
set(&mut l, "text/animators/#1/selectors/#1/advanced/smoothness", Value::Scalar(0.0));
|
||||
// End 300% is capped at 100% (Offset -150% at -100%): the range is -100%..0% and selects nothing.
|
||||
// Uncapped it would be -150%..150% and select everything.
|
||||
set(&mut l, "text/animators/#1/selectors/#1/end", Value::Scalar(300.0));
|
||||
set(&mut l, "text/animators/#1/selectors/#1/offset", Value::Scalar(-150.0));
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]);
|
||||
// Start -200% is capped at 0%: with Offset 100% the range is 100%..150% and selects nothing.
|
||||
// Uncapped it would be -100%..150% and select everything.
|
||||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||||
set(l, "text/animators/#1/selectors/#1/start", Value::Scalar(-200.0));
|
||||
set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(50.0));
|
||||
set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(100.0));
|
||||
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]);
|
||||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||||
set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(0.0));
|
||||
assert_eq!(selection(&p, cid, 0.0, a), vec![1.0, 1.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_modes_and_randomize() {
|
||||
let (mut p, cid, comp) = setup(400, 200);
|
||||
|
|
|
|||
|
|
@ -457,9 +457,29 @@ pub struct Views3D {
|
|||
/// Edited view cameras (views not present use their defaults).
|
||||
#[serde(default)]
|
||||
pub cams: BTreeMap<View3D, ViewCam>,
|
||||
/// The views of the extra panes of a 2- or 4-view layout (pane 1 to 3, i.e. index 0 to 2);
|
||||
/// panes not present show their default ([`Views3D::DEFAULT_PANES`]).
|
||||
#[serde(default)]
|
||||
pub panes: Vec<View3D>,
|
||||
}
|
||||
|
||||
impl Views3D {
|
||||
/// Views the extra panes of a multi-view layout start with: Top, Front and Right.
|
||||
pub const DEFAULT_PANES: [View3D; 3] = [View3D::Top, View3D::Front, View3D::Right];
|
||||
/// The view of extra pane `i` (0 = the first beside the main view).
|
||||
pub fn pane(&self, i: usize) -> View3D {
|
||||
self.panes.get(i).or_else(|| Self::DEFAULT_PANES.get(i)).copied().unwrap_or_default()
|
||||
}
|
||||
/// Choose the view of extra pane `i` (ignored past the third).
|
||||
pub fn set_pane(&mut self, i: usize, v: View3D) {
|
||||
if i >= Self::DEFAULT_PANES.len() {
|
||||
return;
|
||||
}
|
||||
while self.panes.len() <= i {
|
||||
self.panes.push(Self::DEFAULT_PANES[self.panes.len()]);
|
||||
}
|
||||
self.panes[i] = v;
|
||||
}
|
||||
pub fn cam(&self, v: View3D, w: f64, h: f64) -> ViewCam {
|
||||
self.cams.get(&v).copied().unwrap_or_else(|| default_view_cam(v, w, h))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,3 +26,4 @@ boa_engine = { workspace = true, features = ["js"] }
|
|||
|
||||
[dev-dependencies]
|
||||
effectcraft-expr = { workspace = true }
|
||||
effectcraft-psd = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -61,8 +61,24 @@ const TEXT: &[(&str, &str)] = &[
|
|||
("animators", "ADBE Text Animators"),
|
||||
];
|
||||
|
||||
// Match names of the six properties below Text ▸ Path Options, as named by
|
||||
// After Effects' scripting API (rather than EffectCraft's internal ids).
|
||||
const TEXT_PATH_OPTIONS: &[(&str, &str)] = &[
|
||||
("path", "ADBE Text Path"),
|
||||
("reversePath", "ADBE Text Reverse Path"),
|
||||
("perpendicular", "ADBE Text Perpendicular To Path"),
|
||||
("forceAlignment", "ADBE Text Force Align Path"),
|
||||
("firstMargin", "ADBE Text First Margin"),
|
||||
("lastMargin", "ADBE Text Last Margin"),
|
||||
];
|
||||
|
||||
const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")];
|
||||
|
||||
const TEXT_SELECTORS: &[(&str, &str)] =
|
||||
&[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")];
|
||||
|
||||
const TEXT_RANGE_SELECTOR: &[(&str, &str)] = &[("start", "ADBE Text Percent Start"), ("end", "ADBE Text Percent End"), ("offset", "ADBE Text Percent Offset")];
|
||||
|
||||
const CAMERA: &[(&str, &str)] = &[
|
||||
("zoom", "ADBE Camera Zoom"),
|
||||
("dof", "ADBE Camera Depth of Field"),
|
||||
|
|
@ -411,7 +427,10 @@ pub fn node(chain: &[&Node]) -> String {
|
|||
}
|
||||
}
|
||||
"text" if chain.len() == 2 => find(TEXT, id).map(str::to_string).unwrap_or_else(own),
|
||||
"text" if pid == "pathOptions" => find(TEXT_PATH_OPTIONS, id).map(str::to_string).unwrap_or_else(own),
|
||||
"text" if pid == "animators" => "ADBE Text Animator".into(),
|
||||
"text" if pid == "selectors" => find(TEXT_SELECTORS, id).map(str::to_string).unwrap_or_else(own),
|
||||
"text" if pid == "rangeSelector" => find(TEXT_RANGE_SELECTOR, id).map(str::to_string).unwrap_or_else(own),
|
||||
"text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own),
|
||||
"cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own),
|
||||
"lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own),
|
||||
|
|
@ -458,4 +477,15 @@ mod tests {
|
|||
assert_eq!(shape_kind("ADBE Vector Graphic - Fill"), Some("fill"));
|
||||
assert_eq!(shape_kind("nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_range_selector_names() {
|
||||
let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m));
|
||||
let (text, animators, animator, selectors, range, start, end, offset) =
|
||||
(n("text"), n("animators"), n("animator"), n("selectors"), n("rangeSelector"), n("start"), n("end"), n("offset"));
|
||||
assert_eq!(node(&[&text, &animators, &animator, &selectors, &range]), "ADBE Text Selector");
|
||||
assert_eq!(node(&[&text, &animators, &animator, &selectors, &range, &start]), "ADBE Text Percent Start");
|
||||
assert_eq!(node(&[&text, &animators, &animator, &selectors, &range, &end]), "ADBE Text Percent End");
|
||||
assert_eq!(node(&[&text, &animators, &animator, &selectors, &range, &offset]), "ADBE Text Percent Offset");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,16 @@
|
|||
|
||||
var __ecGlobal = this;
|
||||
|
||||
// The scripting host keeps wrappers stable for each project item and comp/layer id.
|
||||
// Clear them when loading a different document, whose IDs may be reused.
|
||||
var __itemCache = Object.create(null), __layerCache = Object.create(null);
|
||||
function __clearIdentityCache() {
|
||||
__itemCache = Object.create(null);
|
||||
__layerCache = Object.create(null);
|
||||
}
|
||||
function __call(id, p) {
|
||||
var r = __exec(id, JSON.stringify(p || {}));
|
||||
if (id === "file.newProject" || id === "file.open" || id === "file.revert") __clearIdentityCache();
|
||||
return r === undefined || r === "" ? null : JSON.parse(r);
|
||||
}
|
||||
function __get(kind, a) {
|
||||
|
|
@ -27,6 +35,25 @@ function __str(v) {
|
|||
return String(v);
|
||||
}
|
||||
|
||||
// ExtendScript includes the ES3 String.substr(start, length) method, which Boa
|
||||
// does not currently provide. Follow its legacy length (not end-index) semantics.
|
||||
if (typeof String.prototype.substr !== "function") {
|
||||
Object.defineProperty(String.prototype, "substr", {
|
||||
value: function (start, length) {
|
||||
if (this === null || this === undefined) throw new TypeError("String.substr called on null or undefined");
|
||||
var text = String(this), n = text.length;
|
||||
var from = Number(start);
|
||||
if (isNaN(from)) from = 0;
|
||||
// ToIntegerOrInfinity, then clamp positive or negative offsets.
|
||||
from = from < 0 ? Math.max(n + Math.ceil(from), 0) : Math.min(Math.floor(from), n);
|
||||
var count = length === undefined ? n - from : Number(length);
|
||||
if (isNaN(count) || count <= 0) return "";
|
||||
return text.slice(from, from + count);
|
||||
},
|
||||
writable: true, configurable: true
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ enums
|
||||
|
||||
function __enum(name, keys, base) {
|
||||
|
|
@ -67,6 +94,9 @@ var MaskMode = __enum("MaskMode", ["NONE", "ADD", "SUBTRACT", "INTERSECT", "LIGH
|
|||
var RQItemStatus = __enum("RQItemStatus", ["WILL_CONTINUE", "NEEDS_OUTPUT", "UNQUEUED", "QUEUED", "RENDERING", "USER_STOPPED", "ERR_STOPPED", "DONE"], 3012);
|
||||
var CloseOptions = __enum("CloseOptions", ["DO_NOT_SAVE_CHANGES", "PROMPT_TO_SAVE_CHANGES", "SAVE_CHANGES"], 1212);
|
||||
var ImportAsType = __enum("ImportAsType", ["COMP_CROPPED_LAYERS", "FOOTAGE", "COMP", "PROJECT"], 3812);
|
||||
// Accept the longer names used by some ExtendScript hosts.
|
||||
ImportAsType.COMPOSITION_CROPPED_LAYERS = ImportAsType.COMP_CROPPED_LAYERS;
|
||||
ImportAsType.COMPOSITION = ImportAsType.COMP;
|
||||
var TimeDisplayType = __enum("TimeDisplayType", ["FRAMES", "TIMECODE"], 2012);
|
||||
var LogType = __enum("LogType", ["ERRORS_ONLY", "ERRORS_AND_SETTINGS", "ERRORS_AND_PER_FRAME_INFO"], 3212);
|
||||
var KeyframeLabel = __enum("KeyframeLabel", ["NONE"], 0);
|
||||
|
|
@ -179,7 +209,7 @@ File.prototype = {
|
|||
get path() { var p = this.__path.split(/[\/\\]/); p.pop(); return p.join("/"); },
|
||||
get parent() { return new Folder(this.path); },
|
||||
get exists() { return JSON.parse(__file("exists", this.__path)) === true; },
|
||||
get length() { return this.__buf !== null ? this.__buf.length : (this.exists ? JSON.parse(__file("read", this.__path)).length : 0); },
|
||||
get length() { return this.__buf !== null ? this.__buf.length : (this.exists ? JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path)).length : 0); },
|
||||
get eof() { return this.__buf === null || this.__pos >= this.__buf.length; },
|
||||
toString: function () { return this.__path; },
|
||||
open: function (mode) {
|
||||
|
|
@ -193,9 +223,9 @@ File.prototype = {
|
|||
this.__buf = "";
|
||||
return true;
|
||||
}
|
||||
this.__buf = JSON.parse(__file("read", this.__path));
|
||||
this.__buf = JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path));
|
||||
} else if (mode === "a") {
|
||||
this.__buf = this.exists ? JSON.parse(__file("read", this.__path)) : "";
|
||||
this.__buf = this.exists ? JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path)) : "";
|
||||
this.__pos = this.__buf.length;
|
||||
} else {
|
||||
// Check the gate now, like After Effects refusing to open a file for writing.
|
||||
|
|
@ -232,18 +262,14 @@ File.prototype = {
|
|||
tell: function () { return this.__pos; },
|
||||
close: function () {
|
||||
if ((this.__mode === "w" || this.__mode === "a" || this.__mode === "e") && this.__buf !== null) {
|
||||
__file("write", this.__path, this.__buf);
|
||||
__file(this.encoding.toUpperCase() === "BINARY" ? "writeBinary" : "write", this.__path, this.__buf);
|
||||
}
|
||||
this.__buf = null;
|
||||
this.__mode = null;
|
||||
return true;
|
||||
},
|
||||
remove: function () { return JSON.parse(__file("remove", this.__path)); },
|
||||
copy: function (to) {
|
||||
var data = JSON.parse(__file("read", this.__path));
|
||||
__file("write", __str(to), data);
|
||||
return true;
|
||||
},
|
||||
copy: function (to) { return JSON.parse(__file("copy", this.__path, __str(to))); },
|
||||
rename: function () { throw __err("File.rename is not supported"); },
|
||||
execute: function () { return JSON.parse(__file("execute", this.__path)); },
|
||||
openDlg: function () { return null; },
|
||||
|
|
@ -903,6 +929,19 @@ Property.prototype.property = function () { return null; };
|
|||
return __call(id, params);
|
||||
};
|
||||
P.removeKey = function (i) { this.__keyCmd(i, "keys.delete"); };
|
||||
// EffectCraft extension: remove an inclusive range with one engine edit instead of
|
||||
// cloning the project for every removed key. Indices are one-based as in removeKey.
|
||||
P.removeKeys = function (first, last) {
|
||||
var keys = this.__keys();
|
||||
first = __num(first, "first key");
|
||||
last = __num(last, "last key");
|
||||
if (!Number.isInteger(first) || !Number.isInteger(last) || first < 1 || last < first || last > keys.length)
|
||||
throw __err("removeKeys: key range is out of bounds");
|
||||
var targets = [];
|
||||
for (var i = first; i <= last; i++)
|
||||
targets.push({ layer: this.__layer.__id, prop: this.__uid, index: i - 1 });
|
||||
return __call("keys.delete", { comp: this.__layer.__comp, __keys: targets });
|
||||
};
|
||||
P.keyInInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).inInterp]; };
|
||||
P.keyOutInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).outInterp]; };
|
||||
// Values that don't interpolate (checkboxes, popups, text) only hold.
|
||||
|
|
@ -1009,7 +1048,7 @@ MarkerProperty.prototype = {
|
|||
t = __num(t, "time");
|
||||
if (!(mv instanceof MarkerValue)) throw __err("value must be a MarkerValue");
|
||||
var p = this.__p({ time: t, duration: mv.duration || 0, comment: mv.comment, chapter: mv.chapter, url: mv.url, frameTarget: mv.frameTarget, protected: !!mv.protectedRegion, label: mv.label || 0 });
|
||||
p.cuePoint = mv.cuePointName ? { name: mv.cuePointName, navigation: !mv.eventCuePoint, params: mv.__params } : null;
|
||||
p.cuePoint = (mv.cuePointName || mv.__params.length) ? { name: mv.cuePointName, navigation: !mv.eventCuePoint, params: mv.__params } : null;
|
||||
var l = this.__list(), at = -1;
|
||||
for (var i = 0; i < l.length; i++) if (Math.abs(l[i].time - t) < 1e-6) at = i;
|
||||
if (at >= 0) p.index = at;
|
||||
|
|
@ -1025,14 +1064,18 @@ MarkerProperty.prototype = {
|
|||
|
||||
function __layer(compId, id) {
|
||||
if (id === null || id === undefined) return null;
|
||||
var i = __get("layer", { comp: compId, layer: id });
|
||||
var key = compId + ":" + id;
|
||||
if (Object.prototype.hasOwnProperty.call(__layerCache, key)) return __layerCache[key];
|
||||
var i = __get("layer", { comp: compId, layer: id }), layer;
|
||||
switch (i.kind) {
|
||||
case "text": return new TextLayer(compId, id);
|
||||
case "shape": return new ShapeLayer(compId, id);
|
||||
case "camera": return new CameraLayer(compId, id);
|
||||
case "light": return new LightLayer(compId, id);
|
||||
default: return new AVLayer(compId, id);
|
||||
case "text": layer = new TextLayer(compId, id); break;
|
||||
case "shape": layer = new ShapeLayer(compId, id); break;
|
||||
case "camera": layer = new CameraLayer(compId, id); break;
|
||||
case "light": layer = new LightLayer(compId, id); break;
|
||||
default: layer = new AVLayer(compId, id);
|
||||
}
|
||||
__layerCache[key] = layer;
|
||||
return layer;
|
||||
}
|
||||
|
||||
function Layer() {}
|
||||
|
|
@ -1068,7 +1111,7 @@ Layer.prototype = {
|
|||
set name(v) { __call("layer.rename", this.__p1({ name: String(v) })); },
|
||||
get index() { return this.__info().index; },
|
||||
get matchName() { return this.__info().matchName; },
|
||||
get containingComp() { return new CompItem(this.__comp); },
|
||||
get containingComp() { return __item(this.__comp); },
|
||||
get propertyDepth() { return 0; },
|
||||
get propertyType() { return PropertyType.INDEXED_GROUP; },
|
||||
get parentProperty() { return null; },
|
||||
|
|
@ -1110,6 +1153,7 @@ Layer.prototype = {
|
|||
__call("edit.label", this.__p({ label: n }));
|
||||
},
|
||||
get comment() { return this.__info().comment; },
|
||||
set comment(v) { __call("layer.setComment", this.__p({ comment: String(v) })); },
|
||||
get selected() { return this.__info().selected; },
|
||||
set selected(v) {
|
||||
var sel = this.containingComp.__info().selectedLayers;
|
||||
|
|
@ -1177,6 +1221,11 @@ AVLayer.prototype.constructor = AVLayer;
|
|||
var P = AVLayer.prototype;
|
||||
function def(name, get, set) { Object.defineProperty(P, name, { get: get, set: set }); }
|
||||
def("source", function () { return __item(this.__info().source); });
|
||||
// After Effects exposes the most common Transform properties directly on AVLayer.
|
||||
// Return the original Property object, not its current value: scripts can keyframe it.
|
||||
["anchorPoint", "position", "scale", "rotation", "opacity"].forEach(function (name) {
|
||||
def(name, function () { return this.transform[name]; });
|
||||
});
|
||||
def("width", function () { return this.__info().width; });
|
||||
def("height", function () { return this.__info().height; });
|
||||
def("threeDLayer", function () { return this.__info().threeDLayer; }, function (v) { this.__switch("threeD", v); });
|
||||
|
|
@ -1333,7 +1382,7 @@ LayerCollection.prototype = {
|
|||
var ids = this.__ids(), sel = [];
|
||||
for (var i = 0; i < indices.length; i++) sel.push(ids[indices[i] - 1]);
|
||||
var r = __call("layer.precompose", { comp: this.__comp, layers: sel, name: String(name), mode: moveAllAttributes === false ? "leave" : "move" });
|
||||
return new CompItem(r.comp);
|
||||
return __item(r.comp);
|
||||
},
|
||||
byName: function (name) {
|
||||
var ids = this.__ids();
|
||||
|
|
@ -1381,13 +1430,20 @@ ItemCollection.prototype = {
|
|||
|
||||
function __item(id) {
|
||||
if (id === null || id === undefined) return null;
|
||||
if (id === 0) return new FolderItem(0);
|
||||
var i = __get("item", { id: id });
|
||||
switch (i.kind) {
|
||||
case "comp": return new CompItem(id);
|
||||
case "folder": return new FolderItem(id);
|
||||
default: return new FootageItem(id);
|
||||
if (Object.prototype.hasOwnProperty.call(__itemCache, id)) return __itemCache[id];
|
||||
var item;
|
||||
if (id === 0) {
|
||||
item = new FolderItem(0);
|
||||
} else {
|
||||
var i = __get("item", { id: id });
|
||||
switch (i.kind) {
|
||||
case "comp": item = new CompItem(id); break;
|
||||
case "folder": item = new FolderItem(id); break;
|
||||
default: item = new FootageItem(id);
|
||||
}
|
||||
}
|
||||
__itemCache[id] = item;
|
||||
return item;
|
||||
}
|
||||
|
||||
function Item() {}
|
||||
|
|
@ -1417,7 +1473,7 @@ Item.prototype = {
|
|||
__call("project.select", { items: selection.filter(function (item) { return item !== id; }) });
|
||||
}
|
||||
},
|
||||
get usedIn() { return this.__info().usedIn.map(function (c) { return new CompItem(c); }); },
|
||||
get usedIn() { return this.__info().usedIn.map(function (c) { return __item(c); }); },
|
||||
remove: function () { __call("project.delete", { items: [this.__id] }); },
|
||||
};
|
||||
|
||||
|
|
@ -1644,7 +1700,7 @@ RenderQueueItem.prototype = {
|
|||
for (var i = 0; i < l.length; i++) if (l[i].id === this.__id) return l[i];
|
||||
throw __err("the render queue item no longer exists");
|
||||
},
|
||||
get comp() { return new CompItem(this.__info().comp); },
|
||||
get comp() { return __item(this.__info().comp); },
|
||||
get status() {
|
||||
var s = this.__info().statusLabel;
|
||||
for (var k in __rqStatus) if (s.indexOf(k) === 0) return __rqStatus[k];
|
||||
|
|
@ -1734,7 +1790,11 @@ function ImportOptions(file) {
|
|||
this.rangeStart = 0;
|
||||
this.rangeEnd = 0;
|
||||
}
|
||||
ImportOptions.prototype.canImportAs = function (t) { return t === ImportAsType.FOOTAGE; };
|
||||
ImportOptions.prototype.canImportAs = function (t) {
|
||||
if (t === ImportAsType.FOOTAGE) return true;
|
||||
return (t === ImportAsType.COMP || t === ImportAsType.COMP_CROPPED_LAYERS) &&
|
||||
/\.(psd|psb|pdf|ai|eps)$/i.test(__str(this.file));
|
||||
};
|
||||
|
||||
function Project() {}
|
||||
Project.prototype = {
|
||||
|
|
@ -1742,7 +1802,7 @@ Project.prototype = {
|
|||
__info: function () { return __get("project"); },
|
||||
get items() { return new ItemCollection(0); },
|
||||
get numItems() { return this.__info().items.length; },
|
||||
get rootFolder() { return new FolderItem(0); },
|
||||
get rootFolder() { return __item(0); },
|
||||
get activeItem() { return __item(this.__info().active); },
|
||||
get selection() { return this.__info().selection.map(__item); },
|
||||
get file() { var p = this.__info().path; return p ? new File(p) : null; },
|
||||
|
|
@ -1788,10 +1848,19 @@ Project.prototype = {
|
|||
if (!f) throw __err("importFile(): no file");
|
||||
var args = { paths: [__str(f)] };
|
||||
// After Effects imports just the picked file unless ImportOptions.sequence is set.
|
||||
if (opts instanceof ImportOptions) args.sequence = !!opts.sequence;
|
||||
if (opts instanceof ImportOptions) {
|
||||
args.sequence = !!opts.sequence;
|
||||
switch (opts.importAs) {
|
||||
case ImportAsType.FOOTAGE: args.importAs = "footage"; break;
|
||||
case ImportAsType.COMP: args.importAs = "composition"; break;
|
||||
case ImportAsType.COMP_CROPPED_LAYERS: args.importAs = "compositionLayerSizes"; break;
|
||||
default: throw __err("importFile(): unsupported ImportOptions.importAs value " + opts.importAs);
|
||||
}
|
||||
}
|
||||
var r = __call("file.import", args);
|
||||
if (r.errors && r.errors.length) throw __err(r.errors.join("; "));
|
||||
return __item(r.items[0]);
|
||||
// Composition imports create several footage items, but callers need the comp.
|
||||
return __item(r.comps && r.comps.length ? r.comps[0] : r.items[0]);
|
||||
},
|
||||
importFileWithDialog: function () { return null; },
|
||||
importPlaceholder: function (name, w, h, rate, dur) {
|
||||
|
|
@ -1858,8 +1927,13 @@ var app = {
|
|||
beginSuppressDialogs: function () {},
|
||||
endSuppressDialogs: function () {},
|
||||
purge: function () { __call("edit.purge", { what: "all" }); },
|
||||
findMenuCommandId: function (name) { return __get("menuCommandId", { name: String(name) }); },
|
||||
findMenuCommandId: function (name) {
|
||||
// Well-known After Effects ids must not shift when EffectCraft registers a command.
|
||||
if (String(name) === "Undo") return 16;
|
||||
return __get("menuCommandId", { name: String(name) });
|
||||
},
|
||||
executeCommand: function (id) {
|
||||
if (id === 16) return __call("edit.undo", {});
|
||||
var cmd = typeof id === "number" ? __get("commandAt", { index: id }) : String(id);
|
||||
if (!cmd) throw __err("Unknown command id " + id);
|
||||
return __call(cmd, {});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use std::mem::ManuallyDrop;
|
|||
use std::sync::Arc;
|
||||
|
||||
use boa_engine::{Context, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Source, js_string};
|
||||
use effectcraft_engine::history::History;
|
||||
use effectcraft_engine::project::Project;
|
||||
use effectcraft_engine::{ScriptRequest, Session};
|
||||
use serde_json::{Value as J, json};
|
||||
|
|
@ -63,8 +64,9 @@ impl Outcome {
|
|||
pub(crate) struct Active {
|
||||
pub(crate) session: Session,
|
||||
pub(crate) output: Vec<String>,
|
||||
/// Open undo groups: (name, project snapshot when the outermost group began).
|
||||
pub(crate) groups: Vec<(String, Arc<Project>)>,
|
||||
/// Open groups: name, project snapshot, original bounded history and saved-project
|
||||
/// identity. The history snapshot preserves earlier steps if long groups hit the limit.
|
||||
pub(crate) groups: Vec<(String, Arc<Project>, History, Arc<Project>)>,
|
||||
pub(crate) name: String,
|
||||
/// The script context running (its `app.scheduleTask` tasks are kept under this id, see
|
||||
/// [`crate::tasks`]).
|
||||
|
|
@ -240,12 +242,18 @@ fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
|||
match op.as_str() {
|
||||
"begin" => {
|
||||
let snap = a.session.project.clone();
|
||||
a.groups.push((if name.is_empty() || name == "undefined" { "Script".into() } else { name }, snap));
|
||||
a.groups.push((
|
||||
if name.is_empty() || name == "undefined" { "Script".into() } else { name },
|
||||
snap,
|
||||
a.session.history.clone(),
|
||||
a.session.saved_project.clone(),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
if a.groups.len() == 1 {
|
||||
let (name, snap) = a.groups.pop().unwrap_or_default();
|
||||
collapse(&mut a.session, &name, &snap);
|
||||
if let Some((name, snap, prior, saved)) = a.groups.pop() {
|
||||
collapse(&mut a.session, &name, &snap, &prior, &saved);
|
||||
}
|
||||
} else {
|
||||
a.groups.pop();
|
||||
}
|
||||
|
|
@ -257,17 +265,23 @@ fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
|||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// Fold the undo steps recorded since the project was `snap` into one step called `name`.
|
||||
pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>) {
|
||||
/// Fold every command in a group into a single bounded-history entry, even if the
|
||||
/// oldest intermediate undo state was trimmed before the group ended.
|
||||
pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>, prior: &History, saved: &Arc<Project>) {
|
||||
let h = &mut s.history;
|
||||
let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else {
|
||||
// Nothing was recorded (or history was reset by New / Open Project).
|
||||
return;
|
||||
};
|
||||
let first = h.undo[start].1.clone();
|
||||
h.undo.truncate(start);
|
||||
h.undo.push((name.to_string(), first));
|
||||
h.merge_key = None;
|
||||
if let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) {
|
||||
// The group is short enough that its opening snapshot is still in history.
|
||||
let first = h.undo[start].1.clone();
|
||||
h.undo.truncate(start);
|
||||
h.undo.push((name.to_string(), first));
|
||||
h.merge_key = None;
|
||||
} else if !Arc::ptr_eq(&s.project, snap) && Arc::ptr_eq(&s.saved_project, saved) && !h.undo.is_empty() {
|
||||
// The group exceeded Levels of Undo. Restore the pre-group history (including
|
||||
// older steps and redo branches) and record one step for the *whole* group.
|
||||
// A New / Open Project changes saved_project; never resurrect the old history.
|
||||
*h = prior.clone();
|
||||
h.record(name, snap.clone(), s.prefs.general.undo_levels.max(1) as usize);
|
||||
}
|
||||
}
|
||||
|
||||
/// `__task("schedule", code, delayMs, repeat)` → id; `__task("cancel", id)` (see
|
||||
|
|
@ -346,8 +360,14 @@ fn check_access(s: &Session, path: &str, write: bool) -> Result<std::path::PathB
|
|||
Err(format!("Unable to access {path}: scripts can only read files in the project's folder unless {GATE} is on"))
|
||||
}
|
||||
|
||||
/// `__file(op, path, data)`: `read` → text, `write`/`append` → bytes, `exists` → bool, `list` →
|
||||
/// names, `mkdir`, `remove`, `isDir`.
|
||||
/// Encode a binary script string as bytes, without silently corrupting code points outside
|
||||
/// the one-byte range supported by ExtendScript's BINARY encoding.
|
||||
fn binary_bytes(data: &str) -> Result<Vec<u8>, String> {
|
||||
data.chars().map(|c| u8::try_from(c as u32).map_err(|_| format!("BINARY encoding cannot represent U+{:04X}", c as u32))).collect()
|
||||
}
|
||||
|
||||
/// `__file(op, path, data)`: text and one-byte-per-character BINARY reads/writes,
|
||||
/// byte-for-byte copy, plus file and folder operations.
|
||||
fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let op = arg_string(args, 0, ctx)?;
|
||||
let path = arg_string(args, 1, ctx)?;
|
||||
|
|
@ -366,19 +386,36 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
|||
let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) };
|
||||
Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() }))
|
||||
}
|
||||
"read" => {
|
||||
"read" | "readBinary" => {
|
||||
let p = check_access(s, &path, false)?;
|
||||
let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?;
|
||||
Ok(json!(String::from_utf8_lossy(&bytes)))
|
||||
if op == "readBinary" {
|
||||
// Each byte becomes a single JS character (U+0000..U+00FF).
|
||||
Ok(json!(bytes.into_iter().map(char::from).collect::<String>()))
|
||||
} else {
|
||||
Ok(json!(String::from_utf8_lossy(&bytes)))
|
||||
}
|
||||
}
|
||||
"write" | "append" => {
|
||||
"write" | "writeBinary" | "append" => {
|
||||
let p = check_access(s, &path, true)?;
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
let mut bytes = if op == "append" { s.services.read_file(&ps).unwrap_or_default() } else { vec![] };
|
||||
bytes.extend_from_slice(data.as_bytes());
|
||||
if op == "writeBinary" {
|
||||
bytes.extend(binary_bytes(&data)?);
|
||||
} else {
|
||||
bytes.extend_from_slice(data.as_bytes());
|
||||
}
|
||||
s.services.write_file(&ps, &bytes).map_err(|e| format!("Unable to write {path}: {e}"))?;
|
||||
Ok(json!(bytes.len()))
|
||||
}
|
||||
"copy" => {
|
||||
let src = check_access(s, &path, false)?;
|
||||
let dst = check_access(s, &data, true)?;
|
||||
// Copy the original bytes, not UTF-8-decoded script text.
|
||||
let bytes = s.services.read_file(&src.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?;
|
||||
s.services.write_file(&dst.to_string_lossy(), &bytes).map_err(|e| format!("Unable to write {data}: {e}"))?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
"list" => {
|
||||
let p = check_access(s, &path, false)?;
|
||||
let mut names: Vec<String> = std::fs::read_dir(&p)
|
||||
|
|
@ -591,8 +628,8 @@ impl Drop for Restore<'_> {
|
|||
/// and take the output so far.
|
||||
pub(crate) fn settle() -> Vec<String> {
|
||||
with_active(|a| {
|
||||
if let Some((name, snap)) = a.groups.first().cloned() {
|
||||
collapse(&mut a.session, &name, &snap);
|
||||
if let Some((name, snap, prior, saved)) = a.groups.first().cloned() {
|
||||
collapse(&mut a.session, &name, &snap, &prior, &saved);
|
||||
a.groups.clear();
|
||||
}
|
||||
a.session.sanitize_state();
|
||||
|
|
|
|||
|
|
@ -730,3 +730,283 @@ fn import_options_sequence_picks_one_still_or_the_run() {
|
|||
assert_eq!(kind_of(&s, "shot_[0001-0003].png"), FootageKind::Sequence);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_file_io_preserves_all_byte_values() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-script-binary-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let src = dir.join("sig.bin");
|
||||
let dst = dir.join("written.bin");
|
||||
let copied = dir.join("copied.bin");
|
||||
let original = [0x89u8, 0x50, 0x4e, 0x47, 0x00, 0x7f, 0x80, 0xff, 0x0a];
|
||||
std::fs::write(&src, original).unwrap();
|
||||
let mut s = session();
|
||||
s.path = Some(dir.join("project.ecproj").to_string_lossy().to_string());
|
||||
s.prefs.scripting.allow_scripts_write_files = true;
|
||||
let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap();
|
||||
|
||||
let read = ok(
|
||||
&mut s,
|
||||
&format!(
|
||||
"var f = new File({}); f.encoding = 'BINARY'; f.open('r'); var data = f.read(); f.close(); [data.length, data.charCodeAt(0), data.charCodeAt(6), data.charCodeAt(7)]",
|
||||
q(&src)
|
||||
),
|
||||
);
|
||||
assert_eq!(read.result, json!([9, 0x89, 0x80, 0xff]));
|
||||
|
||||
ok(
|
||||
&mut s,
|
||||
&format!(
|
||||
"var f = new File({}); f.encoding = 'BINARY'; f.open('w'); f.write(String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0, 127, 128, 255, 10)); f.close();",
|
||||
q(&dst)
|
||||
),
|
||||
);
|
||||
assert_eq!(std::fs::read(&dst).unwrap(), original);
|
||||
|
||||
let copy = ok(&mut s, &format!("new File({}).copy({})", q(&src), q(&copied)));
|
||||
assert_eq!(copy.result, json!(true));
|
||||
assert_eq!(std::fs::read(&copied).unwrap(), original);
|
||||
|
||||
ok(&mut s, &format!("var f = new File({}); f.encoding = 'BINARY'; f.open('a'); f.write(String.fromCharCode(0xfe)); f.close();", q(&dst)));
|
||||
assert_eq!(std::fs::read(&dst).unwrap(), [original.as_slice(), &[0xfe]].concat());
|
||||
|
||||
let invalid = run_code(
|
||||
&mut s,
|
||||
&format!("var f = new File({}); f.encoding = 'BINARY'; f.open('w'); f.write(String.fromCharCode(0x100)); f.close();", q(&dst)),
|
||||
"invalid-binary.jsx",
|
||||
);
|
||||
assert!(invalid.error.is_some(), "code points above 255 must not be truncated");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_undo_uses_stable_after_effects_command_id() {
|
||||
let mut s = session();
|
||||
let lookup = ok(&mut s, "app.findMenuCommandId('Undo')");
|
||||
assert_eq!(lookup.result, json!(16));
|
||||
// ID 16 used to index an unrelated command as the registry grew. It must
|
||||
// undo the preceding layer creation rather than dispatching by position.
|
||||
let r = ok(&mut s, "var c = app.project.items.addComp('Undo ID', 100, 100, 1, 1, 24); c.layers.addNull(); app.executeCommand(16); c.numLayers");
|
||||
assert_eq!(r.result, json!(0));
|
||||
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 0);
|
||||
// Lookup of other command names still uses the existing registry.
|
||||
assert!(ok(&mut s, "app.findMenuCommandId('New Project')").result.as_u64().unwrap_or(0) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_script_undo_group_counts_as_one_level() {
|
||||
let mut s = session();
|
||||
s.prefs.general.undo_levels = 3;
|
||||
ok(&mut s, "var c = app.project.items.addComp('Long Undo', 100, 100, 1, 1, 24); c.layers.addNull();");
|
||||
assert_eq!(s.history.undo.len(), 2);
|
||||
// More edits than the history cap must still be one undoable group.
|
||||
ok(&mut s, "var c = app.project.item(1); app.beginUndoGroup('Forty layers'); for (var i = 0; i < 40; i++) c.layers.addNull(); app.endUndoGroup();");
|
||||
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 41);
|
||||
assert_eq!(s.history.undo.len(), 3);
|
||||
assert_eq!(s.history.undo[2].0, "Forty layers");
|
||||
s.undo();
|
||||
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 1);
|
||||
// Entries preceding the group remain available instead of being trimmed by
|
||||
// the forty intermediate operations.
|
||||
s.undo();
|
||||
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_remove_keys_batches_a_range_as_one_edit() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
"var c = app.project.items.addComp('Batch Keys', 100, 100, 1, 10, 25); var p = c.layers.addNull().transform.rotation; for (var i = 0; i < 5; i++) p.setValueAtTime(i, i * 10); p.removeKeys(2, 4); [p.numKeys, p.keyTime(1), p.keyTime(2)]",
|
||||
);
|
||||
assert_eq!(o.result, json!([2, 0, 4]));
|
||||
// The entire deletion is one undo step; keys outside the range survive.
|
||||
s.undo();
|
||||
let restored = ok(&mut s, "app.project.item(1).layer(1).transform.rotation.numKeys");
|
||||
assert_eq!(restored.result, json!(5));
|
||||
let invalid = run_code(&mut s, "app.project.item(1).layer(1).transform.rotation.removeKeys(0, 2)", "remove-keys-range.jsx");
|
||||
assert!(invalid.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_layer_comment_and_unnamed_marker_params_round_trip() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("Notes", 100, 100, 1, 5, 25);
|
||||
var l = c.layers.addSolid([1, 0, 0], "Solid", 10, 10, 1);
|
||||
l.comment = "Saved layer note";
|
||||
var markers = l.property("ADBE Marker");
|
||||
var plain = new MarkerValue("Plain");
|
||||
plain.setParameters({ lml: "shot", index: "1" });
|
||||
markers.setValueAtTime(1, plain);
|
||||
var named = new MarkerValue("Named");
|
||||
named.cuePointName = "cue";
|
||||
named.setParameters({ lml: "shot", index: "2" });
|
||||
markers.setValueAtTime(2, named);
|
||||
[l.comment, markers.keyValue(1).getParameters().index,
|
||||
markers.keyValue(1).cuePointName, markers.keyValue(2).getParameters().index]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!(["Saved layer note", "1", "", "2"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_av_layer_transform_shortcuts_return_live_properties() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("Shortcuts", 100, 100, 1, 5, 25);
|
||||
var l = c.layers.addSolid([1, 0, 0], "S", 10, 10, 1);
|
||||
l.position.setValue([30, 40]);
|
||||
l.rotation.setValueAtTime(1, 45);
|
||||
[l.position.value[0], l.transform.position.value[1], l.rotation.numKeys,
|
||||
l.opacity === undefined, l.scale === undefined, l.anchorPoint === undefined]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!([30, 40, 1, false, false, false]));
|
||||
s.undo();
|
||||
let restored = ok(&mut s, "app.project.item(1).layer(1).rotation.numKeys");
|
||||
assert_eq!(restored.result, json!(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extendscript_substr_keeps_start_and_length_semantics() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"[
|
||||
"abcdef".substr(0, 2),
|
||||
"abcdef".substr(2, 3),
|
||||
"abcdef".substr(-2),
|
||||
"abcdef".substr(-20, 2),
|
||||
"abcdef".substr(2, 0),
|
||||
"abcdef".substr(2, -3),
|
||||
"abcdef".substr(2.9, 2.9),
|
||||
"abcdef".substr(3),
|
||||
"abcdef".substr(30, 2),
|
||||
"abcdef".substr(NaN, 2),
|
||||
"abcdef".substr(2, NaN)
|
||||
]"#,
|
||||
);
|
||||
assert_eq!(o.result, json!(["ab", "cde", "ef", "ab", "", "", "cd", "def", "", "ab", ""]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_options_preserve_layered_psd_compositions() {
|
||||
use effectcraft_psd::Rect;
|
||||
use effectcraft_psd::write::{WDoc, WLayer, write};
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("ec-script-import-as-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let source = dir.join("layers.psd");
|
||||
let mut doc = WDoc::new(12, 12);
|
||||
doc.layers.push(WLayer::solid("Background", Rect::new(0, 0, 12, 12), [1.0, 1.0, 1.0, 1.0]));
|
||||
doc.layers.push(WLayer::solid("Accent", Rect::new(2, 2, 4, 4), [0.0, 0.4, 1.0, 1.0]));
|
||||
std::fs::write(&source, write(&doc)).unwrap();
|
||||
let file = serde_json::to_string(&source.to_string_lossy()).unwrap();
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
&format!(
|
||||
"var io = new ImportOptions(new File({file})); io.importAs = ImportAsType.COMPOSITION_CROPPED_LAYERS; \
|
||||
[io.canImportAs(io.importAs), app.project.importFile(io).typeName]"
|
||||
),
|
||||
);
|
||||
assert_eq!(o.result, json!([true, "Composition"]));
|
||||
let imported = s.project.comps().next().unwrap().1;
|
||||
assert_eq!(imported.layers.len(), 2);
|
||||
let bad = ok(&mut s, &format!("new ImportOptions(new File({file})).canImportAs(ImportAsType.PROJECT)"));
|
||||
assert_eq!(bad.result, json!(false));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_item_and_layer_identity_survives_repeated_access() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("Identity", 120, 80, 1, 3, 25);
|
||||
var parent = c.layers.addNull(), child = c.layers.addNull();
|
||||
child.parent = parent;
|
||||
[
|
||||
parent.containingComp === c,
|
||||
c.layer(1) === c.layer(1),
|
||||
child.parent === parent,
|
||||
app.project.item(1) === app.project.item(1),
|
||||
app.project.item(1) === c,
|
||||
app.project.itemByID(c.id) === c
|
||||
]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!([true, true, true, true, true, true]));
|
||||
// Reusing item and layer IDs in a new project must not alias old wrappers.
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var old = app.project.item(1);
|
||||
app.newProject();
|
||||
var next = app.project.items.addComp("New", 120, 80, 1, 3, 25);
|
||||
[old !== next, app.project.item(1) === next]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!([true, true]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_text_path_options_expose_after_effects_match_names() {
|
||||
let mut s = session();
|
||||
let r = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("Text Path", 160, 90, 1, 2, 24);
|
||||
var t = c.layers.addText("On a path");
|
||||
var options = t.property("ADBE Text Properties").property("ADBE Text Path Options");
|
||||
var names = [];
|
||||
for (var i = 1; i <= options.numProperties; i++)
|
||||
names.push(options.property(i).matchName);
|
||||
[names, options.property("ADBE Text Path").matchName,
|
||||
options.property("ADBE Text Perpendicular To Path").matchName]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
r.result,
|
||||
json!([
|
||||
[
|
||||
"ADBE Text Path",
|
||||
"ADBE Text Reverse Path",
|
||||
"ADBE Text Perpendicular To Path",
|
||||
"ADBE Text Force Align Path",
|
||||
"ADBE Text First Margin",
|
||||
"ADBE Text Last Margin"
|
||||
],
|
||||
"ADBE Text Path",
|
||||
"ADBE Text Perpendicular To Path"
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// A scripted text animator has only the selectors the script adds, and the Range Selector and
|
||||
/// its Start/End/Offset resolve by After Effects match name (#837).
|
||||
#[test]
|
||||
fn scripted_text_range_selector_resolves_and_is_the_only_selector() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("Sel", 960, 300, 1, 3, 30);
|
||||
var t = c.layers.addText("MOVARA");
|
||||
var a = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator");
|
||||
a.property("ADBE Text Animator Properties").addProperty("ADBE Text Opacity").setValue(0);
|
||||
var sels = a.property("ADBE Text Selectors");
|
||||
var sel = sels.addProperty("ADBE Text Selector");
|
||||
sel.property("ADBE Text Percent Start").setValue(50);
|
||||
var end = sel.property("ADBE Text Percent End").value;
|
||||
[sels.numProperties, sel.matchName, sel.property("start").value, end, sel.property("ADBE Text Percent Offset").matchName]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -794,10 +794,10 @@ impl<'a, 'i> Parser<'a, 'i> {
|
|||
let vb = viewbox_transform(n, w, h);
|
||||
let mut children = vec![];
|
||||
let saved = (self.vw, self.vh);
|
||||
if let Some((_, vw, vh)) = viewbox(n) {
|
||||
self.vw = vw;
|
||||
self.vh = vh;
|
||||
}
|
||||
// Percentages use this viewport's user space, even without a viewBox.
|
||||
let (vw, vh) = viewbox(n).map_or((w, h), |(_, vw, vh)| (vw, vh));
|
||||
self.vw = vw;
|
||||
self.vh = vh;
|
||||
for c in n.children() {
|
||||
self.node(c, &st, &mut children, depth + 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,39 @@ fn paths_gradients_css_use() {
|
|||
assert!(c[0] > 0.9 && c[3] > 0.9, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_svg_percentages_use_the_nearest_viewport() {
|
||||
let d = parse(
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="80" viewBox="0 0 100 80">
|
||||
<svg x="10" y="10" width="40" height="20">
|
||||
<rect id="inner" width="50%" height="50%" fill="red"/>
|
||||
<svg x="50%" width="50%" height="50%">
|
||||
<rect id="deep" width="50%" height="50%" fill="blue"/>
|
||||
</svg>
|
||||
</svg>
|
||||
<rect id="outer" y="60" width="50%" height="10" fill="green"/>
|
||||
<svg x="60" width="40" height="20" viewBox="0 0 200 100">
|
||||
<rect id="viewbox" width="50%" height="50%"/>
|
||||
</svg>
|
||||
</svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let shapes = d.flatten();
|
||||
assert_eq!(shapes.len(), 4);
|
||||
for ((_, _, shape), (name, width, height)) in
|
||||
shapes.iter().zip([("inner", 20.0, 10.0), ("deep", 10.0, 5.0), ("outer", 50.0, 10.0), ("viewbox", 100.0, 50.0)])
|
||||
{
|
||||
assert_eq!(shape.name, name);
|
||||
assert!(matches!(shape.geom, Geom::Rect { w, h, .. } if w == width && h == height), "{name}: {:?}", shape.geom);
|
||||
}
|
||||
let img = rasterize(&d, 100, 80, 1.0);
|
||||
assert_eq!(px(&img, 15, 15), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(px(&img, 35, 12), [0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(px(&img, 35, 17)[3], 0.0);
|
||||
assert_eq!(px(&img, 45, 65)[3], 1.0);
|
||||
assert_eq!(px(&img, 55, 65)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_input() {
|
||||
assert!(parse(b"<html/>").is_err());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,374 @@
|
|||
//! The font folders of a Linux/BSD system, found the way the desktop finds them: the usual
|
||||
//! folders, the XDG data folders (`$XDG_DATA_HOME/fonts`, `$XDG_DATA_DIRS/*/fonts`), the host font
|
||||
//! folders a Flatpak sandbox exposes under `/run/host`, and the `<dir>` entries of the fontconfig
|
||||
//! configuration (`/etc/fonts/fonts.conf` with its includes, `~/.config/fontconfig/fonts.conf`).
|
||||
//!
|
||||
//! The fontconfig files are read with a small tag scanner (no fontconfig library, no XML crate):
|
||||
//! only `<dir>`, `<remap-dir>` and `<include>` matter here. The work is bounded: include depth,
|
||||
//! number and size of the files read, and every file is read at most once.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The environment the folders depend on, passed in so tests don't touch the process environment.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct FontEnv {
|
||||
pub home: Option<PathBuf>,
|
||||
pub xdg_data_home: Option<PathBuf>,
|
||||
pub xdg_data_dirs: Option<std::ffi::OsString>,
|
||||
pub xdg_config_home: Option<PathBuf>,
|
||||
/// `FONTCONFIG_FILE`: the main configuration file instead of the standard ones.
|
||||
pub fontconfig_file: Option<PathBuf>,
|
||||
/// The main configuration files tried when `FONTCONFIG_FILE` is unset (the first that exists).
|
||||
pub fontconfig_roots: Vec<PathBuf>,
|
||||
/// Folders scanned if they exist (the usual system folders and the Flatpak host folders).
|
||||
pub fixed: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl FontEnv {
|
||||
pub fn from_process() -> Self {
|
||||
let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty());
|
||||
Self {
|
||||
home: var("HOME").map(PathBuf::from),
|
||||
xdg_data_home: var("XDG_DATA_HOME").map(PathBuf::from),
|
||||
xdg_data_dirs: var("XDG_DATA_DIRS"),
|
||||
xdg_config_home: var("XDG_CONFIG_HOME").map(PathBuf::from),
|
||||
fontconfig_file: var("FONTCONFIG_FILE").map(PathBuf::from),
|
||||
fontconfig_roots: ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(PathBuf::from).to_vec(),
|
||||
fixed: [
|
||||
"/usr/share/fonts",
|
||||
"/usr/local/share/fonts",
|
||||
// Flatpak: the host's system, local and user fonts (no filesystem permission needed).
|
||||
"/run/host/fonts",
|
||||
"/run/host/local-fonts",
|
||||
"/run/host/user-fonts",
|
||||
]
|
||||
.map(PathBuf::from)
|
||||
.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
fn data_home(&self) -> Option<PathBuf> {
|
||||
self.xdg_data_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".local/share")))
|
||||
}
|
||||
|
||||
fn config_home(&self) -> Option<PathBuf> {
|
||||
self.xdg_config_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".config")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every font folder for `env`, without duplicates, in a stable order. Folders that don't exist
|
||||
/// are kept (the scan skips them).
|
||||
pub(crate) fn unix_font_dirs(env: &FontEnv) -> Vec<PathBuf> {
|
||||
let mut dirs = env.fixed.clone();
|
||||
if let Some(h) = &env.home {
|
||||
dirs.push(h.join(".local/share/fonts"));
|
||||
dirs.push(h.join(".fonts"));
|
||||
}
|
||||
if let Some(d) = env.data_home() {
|
||||
dirs.push(d.join("fonts"));
|
||||
}
|
||||
let data_dirs = env.xdg_data_dirs.clone().unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
dirs.extend(std::env::split_paths(&data_dirs).filter(|p| p.is_absolute()).map(|p| p.join("fonts")));
|
||||
dirs.extend(fontconfig_dirs(env));
|
||||
dedup_dirs(dirs)
|
||||
}
|
||||
|
||||
/// Drop repeated folders (also the same folder reached through a symlink), keeping the first.
|
||||
fn dedup_dirs(dirs: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut seen = HashSet::new();
|
||||
dirs.into_iter().filter(|d| seen.insert(std::fs::canonicalize(d).unwrap_or_else(|_| d.clone()))).collect()
|
||||
}
|
||||
|
||||
const MAX_INCLUDE_DEPTH: u32 = 8;
|
||||
const MAX_FILES: usize = 512;
|
||||
const MAX_FILE_BYTES: u64 = 1 << 20;
|
||||
|
||||
/// The font folders named by the fontconfig configuration.
|
||||
pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec<PathBuf> {
|
||||
let mut st = Reader { env, out: Vec::new(), seen: HashSet::new() };
|
||||
let root = match &env.fontconfig_file {
|
||||
Some(f) => Some(f.clone()),
|
||||
None => env.fontconfig_roots.iter().find(|p| p.is_file()).cloned(),
|
||||
};
|
||||
if let Some(root) = root {
|
||||
st.file(&root, 0);
|
||||
}
|
||||
// The user's own configuration, in case the system one doesn't include it.
|
||||
if let Some(c) = env.config_home() {
|
||||
st.include(&c.join("fontconfig/fonts.conf"), 0);
|
||||
st.include(&c.join("fontconfig/conf.d"), 0);
|
||||
}
|
||||
st.out
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
env: &'a FontEnv,
|
||||
out: Vec<PathBuf>,
|
||||
seen: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Read a configuration file or, for a folder, its `NN-name.conf` files in name order.
|
||||
fn include(&mut self, path: &Path, depth: u32) {
|
||||
if path.is_dir() {
|
||||
let Ok(rd) = std::fs::read_dir(path) else { return };
|
||||
let mut files: Vec<PathBuf> = rd
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit())))
|
||||
.collect();
|
||||
files.sort();
|
||||
for f in files {
|
||||
self.file(&f, depth);
|
||||
}
|
||||
} else {
|
||||
self.file(path, depth);
|
||||
}
|
||||
}
|
||||
|
||||
fn file(&mut self, path: &Path, depth: u32) {
|
||||
if depth > MAX_INCLUDE_DEPTH || self.seen.len() >= MAX_FILES || !path.is_file() {
|
||||
return;
|
||||
}
|
||||
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
if !self.seen.insert(key) {
|
||||
return;
|
||||
}
|
||||
if std::fs::metadata(path).map(|m| m.len() > MAX_FILE_BYTES).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
let base = path.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
for el in elements(&text) {
|
||||
match el.name {
|
||||
"dir" | "remap-dir" => {
|
||||
if let Some(p) = self.resolve(&el, &base, false) {
|
||||
self.out.push(p);
|
||||
}
|
||||
}
|
||||
"include" => {
|
||||
if let Some(p) = self.resolve(&el, &base, true) {
|
||||
self.include(&p, depth.saturating_add(1));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The path an element names: `~` is the home folder, `prefix="xdg"` the XDG data (folders)
|
||||
/// or config (includes) home, `prefix="relative"` and plain relative paths the including
|
||||
/// file's folder, `prefix="default"`/`"cwd"` the working folder.
|
||||
fn resolve(&self, el: &Element, base: &Path, include: bool) -> Option<PathBuf> {
|
||||
let v = el.text.trim();
|
||||
if v.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(rest) = v.strip_prefix('~') {
|
||||
return Some(self.env.home.as_ref()?.join(rest.trim_start_matches('/')));
|
||||
}
|
||||
let p = Path::new(v);
|
||||
if p.is_absolute() {
|
||||
return Some(p.to_path_buf());
|
||||
}
|
||||
match el.attr("prefix") {
|
||||
Some("xdg") => Some(if include { self.env.config_home()? } else { self.env.data_home()? }.join(p)),
|
||||
Some("default" | "cwd") => std::env::current_dir().ok().map(|c| c.join(p)),
|
||||
_ => Some(base.join(p)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Element<'a> {
|
||||
name: &'a str,
|
||||
attrs: &'a str,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl Element<'_> {
|
||||
fn attr(&self, key: &str) -> Option<&str> {
|
||||
let mut rest = self.attrs;
|
||||
while let Some(eq) = rest.find('=') {
|
||||
let k = rest.get(..eq)?.trim();
|
||||
let after = rest.get(eq + 1..)?.trim_start();
|
||||
let q = after.chars().next()?;
|
||||
if q != '"' && q != '\'' {
|
||||
return None;
|
||||
}
|
||||
let body = after.get(1..)?;
|
||||
let end = body.find(q)?;
|
||||
if k == key {
|
||||
return body.get(..end);
|
||||
}
|
||||
rest = body.get(end + 1..)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The `<dir>`, `<remap-dir>` and `<include>` elements of a fontconfig file, comments skipped.
|
||||
fn elements(src: &str) -> Vec<Element<'_>> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(off) = src.get(i..).and_then(|s| s.find('<')) {
|
||||
let start = i + off;
|
||||
let Some(tail) = src.get(start..) else { break };
|
||||
if tail.starts_with("<!--") {
|
||||
match tail.find("-->") {
|
||||
Some(e) => {
|
||||
i = start + e + 3;
|
||||
continue;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let Some(close) = tail.find('>') else { break };
|
||||
let inner = tail.get(1..close).unwrap_or_default();
|
||||
i = start + close + 1;
|
||||
let name_len = inner.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(inner.len());
|
||||
let name = inner.get(..name_len).unwrap_or_default();
|
||||
if !matches!(name, "dir" | "remap-dir" | "include") || inner.ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
let attrs = inner.get(name_len..).unwrap_or_default();
|
||||
let end_tag = format!("</{name}>");
|
||||
let Some(body_len) = src.get(i..).and_then(|s| s.find(&end_tag)) else { break };
|
||||
let body = src.get(i..i + body_len).unwrap_or_default();
|
||||
i += body_len + end_tag.len();
|
||||
out.push(Element { name, attrs, text: decode_entities(body) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_entities(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("ec-font-dirs-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn env_in(root: &Path) -> FontEnv {
|
||||
FontEnv {
|
||||
home: Some(root.join("home")),
|
||||
fontconfig_roots: vec![root.join("etc/fonts/fonts.conf")],
|
||||
xdg_data_dirs: Some("/nonexistent-share".into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn write(p: &Path, s: &str) {
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, s).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fontconfig_dirs_follow_includes_prefixes_and_skip_the_rest() {
|
||||
let root = temp("conf");
|
||||
let etc = root.join("etc/fonts");
|
||||
write(
|
||||
&etc.join("fonts.conf"),
|
||||
r#"<?xml version="1.0"?>
|
||||
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
|
||||
<fontconfig>
|
||||
<!-- <dir>/commented/out</dir> -->
|
||||
<dir>/sys/fonts</dir>
|
||||
<dir prefix="xdg">fonts</dir>
|
||||
<dir>~/.fonts</dir>
|
||||
<cachedir>/var/cache/fontconfig</cachedir>
|
||||
<cachedir prefix="xdg">fontconfig</cachedir>
|
||||
<dir prefix="relative">local-fonts</dir>
|
||||
<dir salt="x">/a&b/fonts</dir>
|
||||
<include ignore_missing="yes">conf.d</include>
|
||||
<include ignore_missing="yes">missing.conf</include>
|
||||
<include ignore_missing="yes" prefix="xdg">fontconfig/fonts.conf</include>
|
||||
<include>fonts.conf</include>
|
||||
</fontconfig>"#,
|
||||
);
|
||||
write(
|
||||
&etc.join("conf.d/50-flatpak.conf"),
|
||||
"<fontconfig><reset-dirs /><dir>/run/host/fonts</dir><include ignore_missing=\"yes\">/run/host/font-dirs.xml</include></fontconfig>",
|
||||
);
|
||||
write(&etc.join("conf.d/60-remap.conf"), "<fontconfig><remap-dir as-path=\"/usr/share/fonts\">/remapped/fonts</remap-dir></fontconfig>");
|
||||
write(&etc.join("conf.d/README"), "<dir>/not/a/conf/file</dir>");
|
||||
write(&etc.join("conf.d/nodigit.conf"), "<fontconfig><dir>/no/digit</dir></fontconfig>");
|
||||
write(&root.join("home/.config/fontconfig/fonts.conf"), "<fontconfig><dir>/user/conf/fonts</dir></fontconfig>");
|
||||
let dirs = fontconfig_dirs(&env_in(&root));
|
||||
let home = root.join("home");
|
||||
let want = vec![
|
||||
PathBuf::from("/sys/fonts"),
|
||||
home.join(".local/share/fonts"),
|
||||
home.join(".fonts"),
|
||||
etc.join("local-fonts"),
|
||||
PathBuf::from("/a&b/fonts"),
|
||||
PathBuf::from("/run/host/fonts"),
|
||||
PathBuf::from("/remapped/fonts"),
|
||||
PathBuf::from("/user/conf/fonts"),
|
||||
];
|
||||
assert_eq!(dirs, want);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_cycles_and_unclosed_tags_terminate() {
|
||||
let root = temp("cycle");
|
||||
let etc = root.join("etc/fonts");
|
||||
write(&etc.join("fonts.conf"), "<fontconfig><dir>/one</dir><include>b.conf</include><include>fonts.conf</include></fontconfig>");
|
||||
write(&etc.join("b.conf"), "<fontconfig><include>fonts.conf</include><dir>/two</dir><dir>/unclosed");
|
||||
let dirs = fontconfig_dirs(&env_in(&root));
|
||||
assert_eq!(dirs, vec![PathBuf::from("/one"), PathBuf::from("/two")]);
|
||||
assert!(fontconfig_dirs(&FontEnv::default()).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_and_flatpak_folders_are_listed_once() {
|
||||
let root = temp("xdg");
|
||||
let env = FontEnv {
|
||||
home: Some(root.join("home")),
|
||||
xdg_data_home: Some(root.join("data")),
|
||||
xdg_data_dirs: Some(std::env::join_paths([root.join("share"), "relative/ignored".into()]).unwrap()),
|
||||
fixed: vec![PathBuf::from("/run/host/fonts")],
|
||||
fontconfig_file: Some(root.join("fonts.conf")),
|
||||
..Default::default()
|
||||
};
|
||||
write(&root.join("fonts.conf"), "<fontconfig><dir>/run/host/fonts</dir><dir prefix=\"xdg\">fonts</dir></fontconfig>");
|
||||
let dirs = unix_font_dirs(&env);
|
||||
assert_eq!(
|
||||
dirs,
|
||||
vec![
|
||||
PathBuf::from("/run/host/fonts"),
|
||||
root.join("home/.local/share/fonts"),
|
||||
root.join("home/.fonts"),
|
||||
root.join("data/fonts"),
|
||||
root.join("share/fonts"),
|
||||
]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A font in a folder that only the fontconfig configuration names (as in a Flatpak sandbox,
|
||||
/// where the host's fonts are under /run/host) is found by the scan.
|
||||
#[test]
|
||||
fn a_font_named_only_by_fontconfig_is_scanned() {
|
||||
use crate::fonts::FontSource;
|
||||
let root = temp("e2e");
|
||||
let host = root.join("run/host/fonts/TTF");
|
||||
std::fs::create_dir_all(&host).unwrap();
|
||||
std::fs::write(host.join("NOTOSERIF.TTF"), crate::fonts::NOTO_SERIF_REGULAR).unwrap();
|
||||
write(&root.join("etc/fonts/conf.d/50-flatpak.conf"), &format!("<fontconfig><dir>{}</dir></fontconfig>", root.join("run/host/fonts").display()));
|
||||
write(&root.join("etc/fonts/fonts.conf"), "<fontconfig><include ignore_missing=\"yes\">conf.d</include></fontconfig>");
|
||||
let src = crate::fonts::DirectorySource { dirs: unix_font_dirs(&env_in(&root)) };
|
||||
let faces = src.faces();
|
||||
assert!(faces.iter().any(|f| f.family == "Noto Serif" && f.origin == "system"), "{:?}", faces.iter().map(|f| &f.family).collect::<Vec<_>>());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
|
@ -89,13 +89,11 @@ impl DirectorySource {
|
|||
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
||||
dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
|
||||
}
|
||||
} else if cfg!(unix) && !cfg!(target_arch = "wasm32") {
|
||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".local/share/fonts"));
|
||||
dirs.push(h.join(".fonts"));
|
||||
}
|
||||
}
|
||||
// Linux/BSD: the usual folders, the XDG data folders, a Flatpak sandbox's host font
|
||||
// folders (/run/host/…) and the folders the fontconfig configuration names.
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
dirs.extend(crate::font_dirs::unix_font_dirs(&crate::font_dirs::FontEnv::from_process()));
|
||||
dirs.extend(extra_dirs(std::env::var_os("EFFECTCRAFT_FONT_DIRS").as_deref()));
|
||||
Self { dirs }
|
||||
}
|
||||
|
|
@ -112,7 +110,8 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>) {
|
|||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
if depth < 4 {
|
||||
// Bounded, so a symlink loop ends.
|
||||
if depth < 8 {
|
||||
walk(&p, depth + 1, out);
|
||||
}
|
||||
} else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
mod font_dirs;
|
||||
pub mod fonts;
|
||||
pub mod layout;
|
||||
pub mod path_text;
|
||||
|
|
|
|||
|
|
@ -223,8 +223,16 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||
if let Some(z) = v.get("zoom") {
|
||||
app.ui.viewer.zoom = z.as_f64().map(|z| z as f32);
|
||||
}
|
||||
// The active comp's Resolution (`view.res.*`), saved with the project.
|
||||
if let Some(r) = v.get("res").and_then(Value::as_str) {
|
||||
app.ui.viewer.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).unwrap_or(app.ui.viewer.res);
|
||||
let Some(res) = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)) else {
|
||||
return err(format!("unknown resolution `{r}` (auto, full, half, third, quarter)"));
|
||||
};
|
||||
if let Some(cid) = app.session.active_comp_id()
|
||||
&& let Err(e) = app.session.set_resolution(cid, res)
|
||||
{
|
||||
return err(e);
|
||||
}
|
||||
}
|
||||
if let Some(Value::Array(a)) = v.get("pan") {
|
||||
app.ui.viewer.pan = [a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32];
|
||||
|
|
@ -308,6 +316,9 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||
Err(e) => return err(e),
|
||||
};
|
||||
let m = modifiers(p);
|
||||
// egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event)
|
||||
// and the synthetic events arrive one per frame: hold them for the whole gesture.
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(m));
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
if req.method == "ui.click" {
|
||||
let button = match s("button") {
|
||||
|
|
@ -321,6 +332,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
|
||||
}
|
||||
}
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
Outcome::AfterInput
|
||||
}
|
||||
"ui.drag" => {
|
||||
|
|
@ -332,6 +344,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||
};
|
||||
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2);
|
||||
let m = modifiers(p);
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(m));
|
||||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
|
||||
for i in 1..=steps {
|
||||
|
|
@ -339,6 +352,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||
app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f));
|
||||
}
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
Outcome::AfterInput
|
||||
}
|
||||
"ui.scroll" => {
|
||||
|
|
@ -522,11 +536,16 @@ pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value {
|
|||
let automation_id = app.auto.elements.iter().chain(app.auto.previous.iter()).find(|e| e.response_id == Some(id)).map(|e| e.id.as_str());
|
||||
json!({"id": format!("{:016x}", id.value()), "rect": rect, "automationId": automation_id, "registryCandidates": candidates})
|
||||
});
|
||||
let mut ui = json!(app.ui);
|
||||
// The viewer's Resolution is the active comp's (kept in the project).
|
||||
if let Some(v) = ui.get_mut("viewer").and_then(Value::as_object_mut) {
|
||||
v.insert("res".into(), json!(app.viewer_res()));
|
||||
}
|
||||
json!({
|
||||
"window": [size.x, size.y],
|
||||
"pixelsPerPoint": ctx.pixels_per_point(),
|
||||
"fps": app.fps,
|
||||
"ui": app.ui,
|
||||
"ui": ui,
|
||||
"playing": app.playback.playing,
|
||||
"time": app.session.time().seconds(),
|
||||
"activeComp": app.session.state.active_comp.map(|i| i.0),
|
||||
|
|
|
|||
|
|
@ -670,6 +670,37 @@ pub struct Floating {
|
|||
pub rect: [f32; 4],
|
||||
}
|
||||
|
||||
/// A floating group moved out of the main window into its own native window (which can sit on
|
||||
/// another monitor): the group holding `panel` shows there.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PanelWindow {
|
||||
pub panel: PanelKind,
|
||||
/// The window's [x, y] outer position and [w, h] inner size, in desktop points.
|
||||
pub rect: [f32; 4],
|
||||
}
|
||||
|
||||
/// The smallest and largest native panel window, in points.
|
||||
pub const PANEL_WINDOW_MIN: [f32; 2] = [160.0, 100.0];
|
||||
pub const PANEL_WINDOW_MAX: f32 = 16_384.0;
|
||||
/// The farthest a native panel window may sit from the desktop origin, in points.
|
||||
pub const PANEL_WINDOW_MAX_POS: f32 = 100_000.0;
|
||||
|
||||
impl PanelWindow {
|
||||
/// The rect made safe to hand to the windowing system: finite, with a size within
|
||||
/// [`PANEL_WINDOW_MIN`]..=[`PANEL_WINDOW_MAX`] and a position within
|
||||
/// ±[`PANEL_WINDOW_MAX_POS`] (hostile workspace files and bad monitor reports included).
|
||||
pub fn sanitized(rect: [f32; 4]) -> [f32; 4] {
|
||||
let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback };
|
||||
let [x, y, w, h] = rect;
|
||||
[
|
||||
finite(x, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS),
|
||||
finite(y, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS),
|
||||
finite(w, 420.0).clamp(PANEL_WINDOW_MIN[0], PANEL_WINDOW_MAX),
|
||||
finite(h, 320.0).clamp(PANEL_WINDOW_MIN[1], PANEL_WINDOW_MAX),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl DockNode {
|
||||
/// Insert panel `p` relative to the group that holds `anchor`: as a tab next to it
|
||||
/// (Center) or in a new group split off to one side. In a stack, the group is `anchor`'s
|
||||
|
|
@ -1305,10 +1336,13 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
|
|||
}
|
||||
}
|
||||
}
|
||||
// clicking anywhere in the panel focuses it
|
||||
// clicking anywhere in the panel focuses it; a press on another of its tabs waits for that
|
||||
// tab's click, which focuses the panel actually chosen (a text edit stays open in Character)
|
||||
let at = ui.input(|i| i.pointer.interact_pos());
|
||||
if let Some(p) = active_panel
|
||||
&& ui.rect_contains_pointer(g.rect)
|
||||
&& ui.input(|i| i.pointer.any_pressed())
|
||||
&& !out.tabs.iter().any(|(q, r)| *q != p && at.is_some_and(|a| r.contains(a)))
|
||||
{
|
||||
out.actions.push(DockAction::Focus(p));
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue