mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user