mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Opening movies in place (#796) skipped the whole-file bytes #827 read the track matrix from, so the pool and the probe lost the turn for movies on disk. stream::movie_header reads just the ftyp and moov boxes (bounded, positional reads) and both in-place paths derive the orientation from it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f41fdf0680
commit
42ef79a2fd
8 files changed
+101
-46
No files matched your search
Generated
+1
@@ -1560,6 +1560,7 @@ dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-path",
|
||||
"effectcraft-project",
|
||||
"effectcraft-psd",
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"serde_json",
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,7 +18,10 @@ 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) {
|
||||
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<Footage> {
|
||||
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 {
|
||||
|
||||
@@ -85,6 +85,57 @@ 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.
|
||||
|
||||
@@ -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<JsV
|
||||
|
||||
/// 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>,
|
||||
) {
|
||||
pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>, prior: &History, saved: &Arc<Project>) {
|
||||
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<std::path::PathB
|
||||
/// 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()
|
||||
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,
|
||||
|
||||
+26
-28
@@ -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"
|
||||
])
|
||||
|
||||
Reference in new issue
Block a user