diff --git a/Cargo.lock b/Cargo.lock index e3b6d87d..f48f09cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1560,6 +1560,7 @@ dependencies = [ "effectcraft-keyframe", "effectcraft-path", "effectcraft-project", + "effectcraft-psd", "effectcraft-render", "effectcraft-time", "serde_json", diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 7f36bf08..3e78d490 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -4,7 +4,8 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::{Justify, TextDoc, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ - Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte, limits, + Comp, 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}; diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 267de4a2..f2b3693d 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -23,8 +23,8 @@ mod layered; pub use layered::vector_doc; mod pool; mod probe; -mod webp_anim; mod stream; +mod webp_anim; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model}; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 90e19171..76c4e898 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -589,12 +589,17 @@ impl Inner { // 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 in_place = if in_memory { None } else { crate::stream::open_source(std::path::Path::new(&**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) + filmcraft_codecs::open_bytes(&name, bytes).map(|src| (src, turn)).map_err(MediaError::from) }) }); match opened { diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index a27f0209..e6be31c2 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -18,7 +18,10 @@ pub fn probe(path: impl AsRef) -> Result { 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) { - return Ok(footage_from_info(&path.to_string_lossy(), src?.info())); + 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()) { @@ -94,14 +97,18 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { let turn = video_turn(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; let mut f = footage_from_info(path, src.info()); - // The size as displayed: a movie shot with the camera on its side trades width and height. + 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; } } - Ok(f) } pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) -> Footage { diff --git a/crates/media/src/stream.rs b/crates/media/src/stream.rs index 0283036a..447e239d 100644 --- a/crates/media/src/stream.rs +++ b/crates/media/src/stream.rs @@ -85,6 +85,57 @@ pub(crate) fn open_source(_path: &Path) -> Option> { 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> { + 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> { + 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. diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 2683c25d..c7559856 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -6,9 +6,9 @@ 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 effectcraft_engine::history::History; use serde_json::{Value as J, json}; const PRELUDE: &str = include_str!("prelude.js"); @@ -267,13 +267,7 @@ fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult, - prior: &History, - saved: &Arc, -) { +pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc, prior: &History, saved: &Arc) { let h = &mut s.history; 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. @@ -369,9 +363,7 @@ fn check_access(s: &Session, path: &str, write: bool) -> Result Result, String> { - data.chars() - .map(|c| u8::try_from(c as u32).map_err(|_| format!("BINARY encoding cannot represent U+{:04X}", c as u32))) - .collect() + 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, diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 6d3a2a0c..38bf383a 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -731,7 +731,6 @@ fn import_options_sequence_picks_one_still_or_the_run() { 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())); @@ -746,26 +745,29 @@ fn binary_file_io_preserves_all_byte_values() { 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) - )); + 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) - )); + 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) - )); + 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( @@ -784,10 +786,7 @@ fn script_undo_uses_stable_after_effects_command_id() { 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", - ); + 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. @@ -801,10 +800,7 @@ fn long_script_undo_group_counts_as_one_level() { 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();", - ); + 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"); @@ -828,11 +824,7 @@ fn script_remove_keys_batches_a_range_as_one_edit() { 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", - ); + 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()); } @@ -983,8 +975,14 @@ fn script_text_path_options_expose_after_effects_match_names() { 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 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" ])