Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
bflatastic
2026-10-30 03:39:42 +07:00
11 changed files with 163 additions and 17 deletions
+1 -1
View File
@@ -746,7 +746,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File...",
["File", "Import"],
Some("Cmd+I"),
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
always,
import_cmd
),
+3 -4
View File
@@ -45,14 +45,13 @@ impl SequenceOptions {
/// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After
/// Effects, while a folder's numbered stills import as sequences unless `sequence: false`
/// (Alt-drag of a folder). The Import dialog passes its `"<format> Sequence"` checkbox (off
/// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered
/// still brings its run, as it always has.
/// unless ticked) as `sequence`. Without `sequence` (scripts, agents) a numbered still imports
/// as itself, as `ImportOptions.sequence` defaults to false in After Effects (#2018).
pub fn for_import(p: &serde_json::Value) -> SequenceOptions {
let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool);
let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false));
let dropped = flag("drag").unwrap_or(false);
// Force Alphabetical Order is part of the Sequence option.
SequenceOptions { sequence: sequence.unwrap_or(alphabetical || !dropped), folders: sequence.unwrap_or(true), alphabetical }
SequenceOptions { sequence: sequence.unwrap_or(alphabetical), folders: sequence.unwrap_or(true), alphabetical }
}
}
+31 -1
View File
@@ -50,8 +50,20 @@ pub trait Services: Send + Sync {
pub struct FsServices;
impl Services for FsServices {
/// Refuses what isn't a regular file: a project can name `/dev/zero` or a FIFO, and reading
/// those never ends (#2134).
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
std::fs::read(path)
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let meta = file.metadata()?;
if !meta.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{path} is not a file")));
}
let mut bytes = Vec::new();
// A sparse file can claim far more than memory: an error, not an abort.
bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?;
file.read_to_end(&mut bytes)?;
Ok(bytes)
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
config::atomic_write(std::path::Path::new(path), data)
@@ -70,3 +82,21 @@ pub trait Importer: Send + Sync {
/// media layer reads from memory (the browser) register them; file-based ones need nothing.
fn add_frames(&self, _paths: &[String]) {}
}
#[cfg(test)]
mod tests {
use super::{FsServices, Services};
/// #2134: a project can name a folder, `/dev/zero` or a FIFO; reading those is an error.
#[test]
fn fs_services_read_only_regular_files() {
let dir = std::env::temp_dir();
assert!(FsServices.read_file(&dir.to_string_lossy()).is_err());
#[cfg(unix)]
assert!(FsServices.read_file("/dev/zero").is_err());
let f = dir.join(format!("ec-services-read-{}.txt", std::process::id()));
std::fs::write(&f, b"data").unwrap();
assert_eq!(FsServices.read_file(&f.to_string_lossy()).unwrap(), b"data");
let _ = std::fs::remove_file(f);
}
}
+5 -4
View File
@@ -104,10 +104,11 @@ fn numbered_stills_import_as_stills_by_default() {
let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap();
let (name, f) = footage(&s, &r["items"][0]);
assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence));
// Scripts and agents naming one frame without `sequence` still get its run.
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0003.png")]})).unwrap();
let (_, f) = footage(&s, &r["items"][0]);
assert_eq!(f.kind, FootageKind::Sequence);
// #2018: scripts and agents naming one frame without `sequence` get that still, as
// `ImportOptions.sequence` defaults to false in After Effects.
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0005.png")]})).unwrap();
let (name, f) = footage(&s, &r["items"][0]);
assert_eq!((name.as_str(), f.kind), ("shot_0005.png", FootageKind::Still));
}
#[test]
+19 -1
View File
@@ -13,6 +13,10 @@ use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_raster::AuxChannels;
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, split_channel};
/// The largest display window decoded (16384²; vector footage and Continuously Rasterize
/// stop at the same size).
const MAX_DISPLAY_PIXELS: usize = 1 << 28;
fn is_depth(name: &str) -> bool {
let last = name.rsplit('.').next().unwrap_or(name);
last.eq_ignore_ascii_case("z") || name.to_ascii_lowercase().contains("depth")
@@ -75,7 +79,9 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option<AuxChannels> {
}
};
collect_meta(&img.attributes.other);
let n = w.checked_mul(h)?;
// Each channel is a display-window plane: a 60000² window over a 1×1 data window was a
// multi-gigabyte allocation per channel (#2141).
let n = w.checked_mul(h).filter(|n| *n <= MAX_DISPLAY_PIXELS)?;
// The decoded image is consumed channel by channel, so each channel's samples are freed (or
// moved) as its plane is made instead of the whole file being held twice (#474).
for layer in img.layer_data {
@@ -337,6 +343,18 @@ mod tests {
assert_eq!(aux.get("GlossDir.R").and_then(|p| p.first()).copied(), Some(0.25));
}
/// #2141: one pixel of data in a 60000² display window is refused, not allocated per channel.
#[test]
fn a_huge_display_window_is_refused_not_allocated() {
use exr::prelude::*;
let channels = AnyChannels::sort(vec![AnyChannel::new("R", FlatSamples::F32(vec![0.5]))].into());
let mut image = Image::from_layer(Layer::new((1, 1), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels));
image.attributes.display_window = IntegerBounds::new((0, 0), (60000, 60000));
let mut bytes = std::io::Cursor::new(Vec::new());
image.write().to_buffered(&mut bytes).unwrap();
assert!(read_exr_channels(&bytes.into_inner()).is_none());
}
#[test]
fn layered_exr_without_an_unnamed_rgb_layer_imports() {
use exr::prelude::*;
+28 -1
View File
@@ -113,6 +113,11 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
if is_svg(path, bytes) || is_pdf_like(path, bytes) {
let doc = vector_doc(path, bytes, footage.layer.as_ref(), footage.page).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?;
let (w, h) = doc.pixel_size();
// A declared size up to 30000² would be a 14 GB allocation that aborts the app (#2136):
// past what Continuously Rasterize can draw, the footage reports an error instead.
if u64::from(w) * u64::from(h) > u64::from(MAX_VECTOR_SIDE) * u64::from(MAX_VECTOR_SIDE) {
return Err(MediaError::Decode(format!("{path}: {w}×{h} pixels is larger than vector footage can be drawn ({MAX_VECTOR_SIDE}×{MAX_VECTOR_SIDE})")));
}
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
// The rasteriser produces premultiplied pixels already.
let _ = op;
@@ -121,10 +126,32 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
Ok(None)
}
/// The largest side vector footage is drawn at, decoded or continuously rasterised.
const MAX_VECTOR_SIDE: u32 = 16384;
/// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize).
pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, scale: f64) -> Option<Image> {
let doc = vector_doc(path, bytes, layer, page)?.ok()?;
let (w, h) = doc.pixel_size();
let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, 16384), ((h as f64 * scale).ceil() as u32).clamp(1, 16384));
let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE), ((h as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE));
Some(effectcraft_svg::rasterize(&doc, sw, sh, scale))
}
#[cfg(test)]
mod tests {
use super::*;
/// #2136: an SVG that declares 30000×30000 asked for a 14 GB image and aborted the app; it
/// is now an error, and a drawable size still decodes.
#[test]
fn vector_footage_past_the_drawable_size_is_an_error_not_an_abort() {
let huge = br#"<svg xmlns="http://www.w3.org/2000/svg" width="30000" height="30000"/>"#;
let footage = probe("huge.svg", huge).unwrap().unwrap();
let e = decode("huge.svg", huge, &footage, AlphaOp::new(footage.alpha, footage.premul_color)).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("30000×30000"), "{e}");
let small = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20"/></svg>"#;
let footage = probe("small.svg", small).unwrap().unwrap();
let img = decode("small.svg", small, &footage, AlphaOp::new(footage.alpha, footage.premul_color)).unwrap().unwrap();
assert_eq!((img.width, img.height), (40, 20));
}
}
+33
View File
@@ -79,6 +79,22 @@ pub fn is_importable(path: &std::path::Path) -> bool {
|| dpx::EXTENSIONS.contains(&ext.as_str())
}
/// A whole file's bytes, refusing anything that isn't a regular file: a project can name
/// `/dev/zero` or a FIFO as footage, and reading those never ends (#2134).
pub(crate) fn read_regular_file(path: &std::path::Path) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let meta = file.metadata()?;
if !meta.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a file", path.display())));
}
let mut bytes = Vec::new();
// A sparse file can claim far more than memory: an error, not an abort.
bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?;
file.read_to_end(&mut bytes)?;
Ok(bytes)
}
pub(crate) fn ext_of(path: &std::path::Path) -> String {
path.extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase()
}
@@ -99,3 +115,20 @@ pub(crate) fn still_format(path: &str, bytes: &[u8]) -> Option<image::ImageForma
pub(crate) fn rate_from_fc(r: filmcraft_time::FrameRate) -> effectcraft_time::FrameRate {
if r.num > 0 && r.den > 0 { effectcraft_time::FrameRate::new(r.num, r.den) } else { effectcraft_time::FrameRate::FPS_30 }
}
#[cfg(test)]
mod read_tests {
/// #2134: a folder (and on Unix a device) named as footage is an error, not an endless read.
#[test]
fn only_regular_files_are_read_whole() {
let dir = std::env::temp_dir();
assert!(super::read_regular_file(&dir).is_err());
assert!(crate::probe::probe(&dir).is_err(), "probing a folder fails instead of reading it");
#[cfg(unix)]
assert!(super::read_regular_file(std::path::Path::new("/dev/zero")).is_err());
let f = dir.join(format!("ec-read-regular-{}.bin", std::process::id()));
std::fs::write(&f, b"abc").unwrap();
assert_eq!(super::read_regular_file(&f).unwrap(), b"abc");
let _ = std::fs::remove_file(f);
}
}
+10 -1
View File
@@ -67,6 +67,10 @@ const CONFORM_VERSION: u32 = 3;
/// conform thread allocate all of it at once (#408).
const MAX_CONFORM_SECONDS: i64 = 24 * 3600;
/// The sample rates audio is read at; a header outside them is damaged or hostile (#2141).
const MIN_SAMPLE_RATE: u32 = 1_000;
const MAX_SAMPLE_RATE: u32 = 768_000;
/// The sample frames of `footage`'s audio at `rate` Hz that a conformed file holds, `None` past
/// [`MAX_CONFORM_SECONDS`].
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
@@ -513,6 +517,11 @@ impl MediaPool {
let src = self.inner.source(&path).ok_or("the file can't be opened")?;
let s0 = start.to_units_floor(rate as i64);
let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate);
// The header's rate scales the samples read: 0xFFFFFFFF Hz made one conform chunk about
// 47 GB (#2141). Real audio is 1–768 kHz.
if native != 0 && !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&native) {
return Err(format!("a sample rate of {native} Hz is outside {MIN_SAMPLE_RATE}–{MAX_SAMPLE_RATE} Hz"));
}
if native == 0 || rate == 0 || native == rate {
return read_stereo(&src, s0, frames, rate);
}
@@ -581,7 +590,7 @@ impl Inner {
if let Some(b) = lock(&self.files).get(path) {
return Ok(b.clone());
}
std::fs::read(path).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}")))
crate::read_regular_file(std::path::Path::new(path)).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}")))
}
/// The opened movie/audio source for `path`. The first caller opens it, outside the map's
+2 -2
View File
@@ -25,7 +25,7 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
correct_rate(&mut f, header.as_deref().and_then(video_rate));
return Ok(f);
}
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
let bytes = crate::read_regular_file(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) {
return probe_model(&path.to_string_lossy(), &bytes, &side_file_reader(path));
}
@@ -38,7 +38,7 @@ fn side_file_reader(model: &Path) -> impl Fn(&str) -> Option<Vec<u8>> + use<> {
let model = std::path::absolute(model).unwrap_or_else(|_| model.to_path_buf());
move |uri| {
let p = craft_docpath::resolve_absolute(uri, Some(&model)).ok()?;
std::fs::read(p).ok()
crate::read_regular_file(&p).ok()
}
}
+3 -2
View File
@@ -1352,8 +1352,9 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp:
}
}
Gesture::Vertices { start, src, applied, inv, layer } => {
// The grabbed vertex snaps (to other layers' vertices and features, guides, grid).
let want = [cpt[0] - start[0], cpt[1] - start[1]];
// The grabbed vertex snaps (to other layers' vertices and features, guides, grid);
// Shift keeps it to one axis, as a layer move does (#1964).
let want = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
let c = vt::snap(app, &ctx, ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
let total = [want[0] + c[0], want[1] + c[1]];
let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[1]));
+28
View File
@@ -802,6 +802,34 @@ fn hidden_shape_layer_keeps_its_controls_and_can_still_scale() {
assert_ne!(scale(&h), before, "a hidden layer's handle still scales it");
}
/// #1964: Shift-dragging a selected mask vertex keeps it to the axis it moves along most, as a
/// Shift-dragged layer does; without Shift it moves freely.
#[test]
fn shift_drag_keeps_a_mask_vertex_to_one_axis() {
let mut h = harness();
let plate = layer_id(&h, "Plate");
let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]];
let mask = {
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [plate]})).unwrap();
let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap();
s.execute("mask.selectVertices", json!({"vertices": [{"layer": plate, "mask": m, "index": 0}]})).unwrap();
m
};
h.run_steps(2);
let vertex = format!("viewer.mask.{mask}.vertex.0");
let before = mask_path(&h, "Plate").vertices[0];
let from = rect(&h, &vertex).center();
drag_path(&mut h, &[from, from + vec2(30.0, 8.0)], egui::Modifiers::SHIFT);
let shifted = mask_path(&h, "Plate").vertices[0];
assert!(shifted[0] > before[0], "it moved along x: {before:?} -> {shifted:?}");
assert_eq!(shifted[1], before[1], "and not along y");
let from = rect(&h, &vertex).center();
drag_path(&mut h, &[from, from + vec2(30.0, 8.0)], egui::Modifiers::NONE);
let free = mask_path(&h, "Plate").vertices[0];
assert!(free[0] > shifted[0] && free[1] > shifted[1], "without Shift both axes move: {shifted:?} -> {free:?}");
}
/// #1769: a hidden masked layer keeps its mask path and vertices, and they remain editable.
#[test]
fn hidden_masked_layer_keeps_its_mask_path_editable() {