From d247b98b95634a82614413496d715de8f8e97384 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:29:44 +0000 Subject: [PATCH 1/4] Viewer: dragging a layer no longer piles up frame renders until memory runs out (#647) Every viewer request spawns a job on the frame pool. A pool thread blocked inside a render (waiting on a parallel loop's join) runs other pool work meanwhile, rayon's injected jobs included: the spawn of the next request started a whole frame render on top of the waiting one. Dragging makes a new revision, and a new viewer frame, every UI frame, so renders nested without bound on every thread, each holding its buffers (a large image's full-resolution copies), until all memory was used. Holding the layer still makes no new revisions, which is why only moving it did this. A spawn now drains the queue, and one that lands inside a render on its thread returns at once; the render below takes the queued jobs when it is done. Each pool thread renders one frame at a time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/frames.rs | 115 +++++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 33fd676d..ccffeb9f 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -768,10 +768,8 @@ impl Frames { #[cfg(not(target_arch = "wasm32"))] if self.remote.is_none() { let w = self.worker(); - // One spawn per job; each spawn renders whichever queued job matters most right now. - let job = move || { - w.run_next(); - }; + // One spawn per job; each spawn renders whichever queued jobs matter most right now. + let job = move || w.drain(); match &self.pool { Some(pool) => pool.spawn(job), None => rayon::spawn(job), @@ -924,7 +922,42 @@ struct Worker { gpu_retired: Arc, } +#[cfg(not(target_arch = "wasm32"))] +thread_local! { + /// This pool thread is inside [`Worker::drain`] (rendering a frame). + static DRAINING: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Clears [`DRAINING`] when the drain ends, even by a panic. +#[cfg(not(target_arch = "wasm32"))] +struct Draining; + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for Draining { + fn drop(&mut self) { + DRAINING.set(false); + } +} + impl Worker { + /// Render queued jobs, most important first, until the queue is empty. + /// + /// A pool thread waiting inside a render (on a parallel loop's join) runs other pool work + /// meanwhile, including the spawns of newer requests: each would start a whole frame render + /// on top of the one waiting, with all of its buffers. Dragging a layer asks for a new frame + /// every UI frame, so renders piled up on every thread until memory ran out (#647). A spawn + /// that lands inside a render returns at once instead; the render below it takes the queued + /// jobs when it is done, so each thread renders one frame at a time. + #[cfg(not(target_arch = "wasm32"))] + fn drain(&self) { + if DRAINING.get() { + return; + } + DRAINING.set(true); + let _reset = Draining; + while self.run_next() {} + } + /// Render the queued job that matters most right now. `false` when the queue was empty. fn run_next(&self) -> bool { let Worker { queue, content_keys, .. } = self; @@ -1234,6 +1267,80 @@ mod tests { assert!(!f.is_cached(&key)); } + /// #647: a pool thread waiting inside a render (on a parallel loop) never starts another + /// frame render on top of it. Dragging a layer queues a frame every UI frame; renders used to + /// nest on every thread, each holding its buffers, until memory ran out. + #[test] + fn a_waiting_render_never_starts_another_on_its_thread() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Footage, FootageKind}; + use effectcraft_engine::render::Image; + use effectcraft_engine::time::FrameRate; + use serde_json::json; + use std::cell::Cell; + use std::sync::atomic::AtomicUsize; + thread_local! { + static DEPTH: Cell = const { Cell::new(0) }; + } + /// A decoder that works in parallel, as the renderer does: its thread joins the loop and + /// may wait for the parts other threads took. + #[derive(Default)] + struct Parallel { + calls: AtomicUsize, + deepest: AtomicUsize, + } + impl FootageSource for Parallel { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + let d = DEPTH.get() + 1; + DEPTH.set(d); + self.deepest.fetch_max(d, Ordering::SeqCst); + self.calls.fetch_add(1, Ordering::SeqCst); + (0..64).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1))); + DEPTH.set(d - 1); + Some(Arc::new(Image::new(4, 4))) + } + } + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 10})).unwrap()["comp"].as_u64().unwrap()); + let clip = Footage { + kind: FootageKind::Video, + width: 4, + height: 4, + has_video: true, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let item = Arc::make_mut(&mut s.project).add_item("clip.mov", Default::default(), None, ItemKind::Footage(clip)); + s.execute("layer.addItem", json!({"item": item.0})).unwrap(); + let footage = Arc::new(Parallel::default()); + let src = RenderSource { + project: s.project.clone(), + footage: footage.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + let f = Frames::default(); + let content = f.content_of(&s.project, s.revision, cid); + let frames = 60; + for frame in 0..frames { + let key = FrameKey { revision: s.revision, content, comp: cid.0, frame, scale: 1000, view: 0, opts: 0 }; + f.request(&src, key, cid, FrameRate::FPS_30.tick_of(frame), RenderOpts::default()); + } + for _ in 0..4000 { + if f.inflight() == 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(f.inflight(), 0, "every queued frame rendered"); + assert!(footage.calls.load(Ordering::SeqCst) >= frames as usize); + assert_eq!(footage.deepest.load(Ordering::SeqCst), 1, "renders nested on a waiting thread"); + } + /// #284: scrubbing asks for a viewer frame at every step; a newer one drops the comp's viewer /// frames still waiting (they would all render first), but not prefetch frames or another /// comp's viewer frame. From 2aefe3e6445879a1d1856aecd43ee375c8fda57e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:35:47 +0000 Subject: [PATCH 2/4] Effects: CC Smear and CC Tiler on text and shape layers reach past the content's box (#659) A text or shape layer's effect bounds are comp-sized (as in After Effects), but its buffer surrounds only its content, and each effect grows the buffer itself when it needs room. CC Smear and CC Tiler never did, so a smear was cut off where the glyphs end and tiles filled only the text's box. CC Tiler now covers the whole layer rectangle; CC Smear grows by how far it moves pixels (Reach x From->To), at most to the layer bounds, so footage layers, whose buffer is their bounds already, are unchanged. CPU and GPU alike. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/effects/src/distort2.rs | 5 ++- crates/effects/src/distort3.rs | 57 +++++++++++++++++++++++++++++++++- crates/effects/src/lib.rs | 2 +- crates/effects/src/util.rs | 11 +++++++ crates/gpu/src/fx_stylize.rs | 12 +++++-- 5 files changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index f672d149..10ec5973 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -20,7 +20,7 @@ use effectcraft_raster::{Image, Px}; use rayon::prelude::*; use crate::generate::value_noise; -use crate::util::{SRC_NAMES, Src, fit_layer, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; +use crate::util::{SRC_NAMES, Src, fit_layer, layer_cover_pad, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { @@ -678,6 +678,9 @@ fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf { fn cc_tiler(ctx: &EffectCtx, mut b: Buf) -> Buf { let s = (ctx.params.f("scale") / 100.0).max(0.001); + if !ctx.adjustment { + b.pad(layer_cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale)); + } let c = b.to_px(ctx.params.v2("center")); let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32; let (x0, y0, w, h) = layer_rect(ctx, &b); diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index 27da970f..99139483 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -330,9 +330,12 @@ fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---------------------------------------------------------------- CC Smear fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { + let reach = ctx.params.f("reach") / 100.0; + if !ctx.adjustment { + b.pad(cc_smear_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale)); + } let from = b.to_px(ctx.params.v2("from")); let to = b.to_px(ctx.params.v2("to")); - let reach = ctx.params.f("reach") / 100.0; let radius = (ctx.params.f("radius") * b.scale).max(0.5); let (dx, dy) = (to.0 - from.0, to.1 - from.1); let l2 = dx * dx + dy * dy; @@ -351,6 +354,21 @@ fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// The transparent pixels to add on every side of a buffer `size` pixels big (with `offset` and +/// `scale`) for CC Smear: it moves pixels by up to Reach × From→To, and they may move out to the +/// layer bounds. A text or shape layer's buffer ends at its content while its bounds are +/// comp-sized, so the smear was cut off at the content's box (#659). +pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 { + let reach = ctx.params.f("reach") / 100.0; + let (from, to) = (ctx.params.v2("from"), ctx.params.v2("to")); + let len = ((to[0] - from[0]).powi(2) + (to[1] - from[1]).powi(2)).sqrt() * scale; + let moved = (reach.abs() * len).ceil(); + if !moved.is_finite() || moved <= 0.0 { + return 0; + } + (moved.min(4096.0) as u32).min(crate::util::layer_cover_pad(ctx, size, offset, scale)) +} + // ---------------------------------------------------------------- CC Split / CC Split 2 fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf { @@ -1109,6 +1127,43 @@ mod tests { } } + /// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized + /// (200 × 100) layer bounds, after `id` with `set`. + fn on_text(id: &str, set: &[(&str, Value)]) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let mut im = Image::new(20, 10); + im.data.iter_mut().for_each(|p| *p = [1.0; 4]); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 }) + } + + /// Alpha at layer point `p` (0 outside the buffer). + fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 { + let (x, y) = b.to_px(p); + b.img.get(x.floor() as i64, y.floor() as i64)[3] + } + + /// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds + /// (comp-sized, as in After Effects), not cut off where the glyphs end. + #[test] + fn effects_on_text_reach_past_its_box() { + let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]); + assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box"); + assert!(smear.img.width < 200, "it grows only as far as pixels move"); + let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]); + assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9); + assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds"); + assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1); + // A footage layer's buffer is its bounds already: no change. + let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]); + assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0])); + } + #[test] fn every_warp_style_runs_and_bends() { let im = img(40, 30); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 45e2be1c..7e968297 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -78,7 +78,7 @@ pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sampl pub use color3::OffsetCurve; pub use distort::transform_shutter; pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh}; -pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, reshape_setup, smear_setup, warp_inverse_map}; +pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map}; pub use distort4::liquify_mesh; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index c3416618..1527e221 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -433,6 +433,17 @@ pub fn layer_rect_at(ctx: &crate::EffectCtx, offset: [f64; 2], scale: f64) -> (f (offset[0], offset[1], ctx.layer_size[0] * scale, ctx.layer_size[1] * scale) } +/// Transparent pixels to add on every side ([`crate::Buf::pad`]) of a buffer `size` pixels big, +/// with `offset` and `scale`, so it covers the whole layer rectangle (at most 4096). A text or +/// shape layer's buffer only surrounds its content, but its layer bounds are comp-sized (see +/// the renderer's `effect_bounds`): an effect that fills the layer, such as CC Tiler, fills +/// those as in After Effects (#659). +pub fn layer_cover_pad(ctx: &crate::EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 { + let (x0, y0, w, h) = layer_rect_at(ctx, offset, scale); + let need = [-x0, -y0, x0 + w - f64::from(size[0]), y0 + h - f64::from(size[1])].into_iter().fold(0.0, f64::max); + need.ceil().min(4096.0) as u32 +} + /// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other /// layer is scaled to this layer's size ("Stretch to Fit"); otherwise it is centred. pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPixels, stretch: bool) -> Image { diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index 8aee71ed..f5b7708d 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -299,10 +299,13 @@ fn cc_slant(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { ) } -fn cc_smear(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { +fn cc_smear(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let reach = ctx.params.f("reach") / 100.0; + if !ctx.adjustment { + b.pad(e, effectcraft_effects::cc_smear_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale))?; + } let from = b.to_px(ctx.params.v2("from")); let to = b.to_px(ctx.params.v2("to")); - let reach = ctx.params.f("reach") / 100.0; let radius = (ctx.params.f("radius") * b.scale).max(0.5); let (dx, dy) = (to.0 - from.0, to.1 - from.1); let l2 = dx * dx + dy * dy; @@ -585,8 +588,11 @@ fn offset(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { table(e, b, &data, false, blend.max(0.0)) } -fn cc_tiler(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { +fn cc_tiler(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let s = (ctx.params.f("scale") / 100.0).max(0.001); + if !ctx.adjustment { + b.pad(e, effectcraft_effects::util::layer_cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale))?; + } let c = b.to_px(ctx.params.v2("center")); let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32; let (x0, y0, w, h) = layer_rect(ctx, &b); From 8e14c00eaf968e117a5244801aac4d4eef8e6e9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:41:43 +0000 Subject: [PATCH 3/4] Project: footage saved with a relative path is found next to the project file (#652) file.import stores a path as given, so footage imported by the CLI or an agent as `clip.mp4` was saved relative, and opening the project looked it up from the working directory. The CLI, started in the project's folder, found it; the desktop app, started from the Finder, Explorer or a launcher in another folder, showed and rendered neither its picture nor its sound, while the Project panel still listed the saved duration. On open, a relative footage path that doesn't exist from the working directory but does next to the project file is resolved there (After Effects also looks for footage relative to the project). Paths that exist as they are, and absolute ones, are kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/file.rs | 25 ++++++++++++++++++++++++- crates/engine/src/footage_check.rs | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 55b2c2b6..6f3d2e70 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -43,7 +43,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { 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 proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?; + let mut proj = Project::from_json(&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.note_project_path(path); // Written by a newer EffectCraft: what this version doesn't know would be lost on saving. @@ -62,6 +63,28 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "savedBy": saved_by})) } +/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written +/// by hand) is found next to the project file, as After Effects looks for footage relative to +/// the project. It used to be looked up from the working directory: the CLI started in the +/// project's folder found it, the desktop app (started from the Finder, Explorer or a launcher, +/// in another folder) showed and rendered nothing (#652). A path that exists from the working +/// directory is kept. +fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn crate::Services) { + use std::path::Path; + let Some(dir) = Path::new(project_path).parent().filter(|d| !d.as_os_str().is_empty()) else { return }; + let beside = |f: &str| dir.join(f).to_string_lossy().to_string(); + for item in p.items.values_mut() { + let ItemKind::Footage(f) = &mut item.kind else { continue }; + if f.path.is_empty() || !Path::new(&f.path).is_relative() || services.exists(&f.path) || !services.exists(&beside(&f.path)) { + continue; + } + f.path = beside(&f.path); + for frame in f.sequence.iter_mut().filter(|q| Path::new(q.as_str()).is_relative()) { + *frame = beside(frame); + } + } +} + /// The project file's text for `path`: `.ecproj` JSON, or XML for `.ecprojx` / `.xml` (a /// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project /// wouldn't read back. diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index c3765f1e..f5db284e 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -193,6 +193,30 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + /// #652: footage saved with a relative path is found next to the project, wherever the + /// app was started (the desktop app doesn't start in the project's folder, the CLI often + /// does). + #[test] + fn relative_footage_is_found_next_to_the_project() { + let dir = std::env::temp_dir().join(format!("ec-relative-{}", std::process::id())); + std::fs::create_dir_all(dir.join("media")).unwrap(); + let clip = dir.join("media").join("ec-relative-clip.png"); + std::fs::write(&clip, b"x").unwrap(); + let mut s = session_with(&["media/ec-relative-clip.png", "/nonexistent/ec/abs.png", "media/ec-relative-gone.png"]); + let path = dir.join("p.ecproj"); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut o = Session::default(); + o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + let mut paths: Vec = + o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect(); + paths.sort(); + assert_eq!(paths, ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]); + assert!(!o.is_dirty(), "finding the files is no edit"); + let r = o.execute("footage.check", json!({"wait": true})).unwrap(); + assert_eq!(r["missing"], 2); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn open_starts_the_check_when_asked() { let mut s = session_with(&["/nonexistent/ec/a.mov"]); From f3fe25fd67d3afcda29a0ec2541ce9bbcbdb3ce9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:50:08 +0000 Subject: [PATCH 4/4] Import: test that an animated GIF imported from disk plays (#651) Animated GIFs came in as one still frame in 0.7.0; bfbdadd3 (after that release) probes them as movies. This end-to-end test imports a GIF file through the wired session into a comp and checks the layer shows each of its frames at its time, the path the viewer and exports share. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- Cargo.lock | 1 + crates/host/Cargo.toml | 1 + crates/host/src/lib.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index cfab570d..4f6d9c87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1350,6 +1350,7 @@ dependencies = [ "effectcraft-svg", "effectcraft-time", "exr", + "gif", "image", "rayon", "serde_json", diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 13fcb87a..344d5433 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -31,5 +31,6 @@ effectcraft-raster = { workspace = true } effectcraft-time = { workspace = true } effectcraft-interchange = { workspace = true } image = { workspace = true } +gif = { workspace = true } exr = { workspace = true } rayon = { workspace = true } diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 7f2d1a5c..cf5661a1 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -257,6 +257,38 @@ mod tests { assert!(red * 20 > img.data.len(), "red cube pixels: {red} of {}", img.data.len()); } + /// #651: an animated GIF imported from disk plays: the layer shows each frame at its time, + /// in the viewer's renders and in exports alike. + #[test] + fn an_imported_animated_gif_plays() { + let dir = std::env::temp_dir().join(format!("ec-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("anim.gif"); + let mut bytes = Vec::new(); + { + let mut enc = gif::Encoder::new(&mut bytes, 4, 4, &[]).unwrap(); + for rgb in [[255u8, 0, 0], [0, 255, 0], [0, 0, 255]] { + let mut f = gif::Frame::from_rgb(4, 4, &rgb.repeat(16)); + f.delay = 10; + enc.write_frame(&f).unwrap(); + } + } + std::fs::write(&path, bytes).unwrap(); + let mut s = super::session(); + s.execute("comp.new", json!({"name": "G", "width": 4, "height": 4, "frameRate": 30, "duration": 1})).unwrap(); + s.execute("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let rgb = |t: f64| { + let img = s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), effectcraft_engine::render::RenderOpts::default()); + let p = img.data[5]; + [p[0].round(), p[1].round(), p[2].round()] + }; + assert_eq!(rgb(0.05), [1.0, 0.0, 0.0]); + assert_eq!(rgb(0.15), [0.0, 1.0, 0.0]); + assert_eq!(rgb(0.25), [0.0, 0.0, 1.0]); + let _ = std::fs::remove_dir_all(dir); + } + /// The web app's path: outputs go to a sink (downloads), never to the file system. #[test] fn render_queue_exports_to_a_sink() {