Browse / github.com/storytold/effectcraft / patch
Proxies: a composition proxy draws with Separate Fields, Invert Alpha, linear light and Frame Blending
Proxies: a composition proxy draws with Separate Fields, Invert Alpha, linear light and Frame Blending Composition proxies now apply Separate Fields, Invert Alpha, Interpret As Linear Light, and the precomp layer's Frame Blending through `footage_frame` in `crates/render/src/pipeline.rs`. Frame-blend cache keys include alpha mode and the premultiplication color for premultiplied footage. Changing those interpretation settings renders the proxy again. Unchanged settings reuse the cached frame. The regressions in `crates/engine/src/tests/proxy.rs` cover alpha inversion, linear-light interpretation, field separation, Frame Mix, and cache invalidation after alpha-mode and premultiplication-color
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #2352, open)
- Base
bc4b48f67deef2458e9dd3e3b0e592d5f75768a1- Size
- +129 −5 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
84a9ae19f0bd018fe20514ba
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/tests/proxy.rs crates/render/src/pipeline.rs
@@ -14,7 +14,8 @@ use crate::Session; use crate::render::{FootageSource, RenderOpts}; /// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced:-/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a+/// even rows red, odd rows blue; 4×4, 10 fps), `flash.mov` (10×10, 10 fps: red, then blue from+/// the second frame), `matte_black.png` / `matte_white.png` (a /// semi-transparent green premultiplied against black / white, read as straight), `half.png` /// (white at 25 % alpha), `wide.png` (10×10 white). struct Synthetic;@@ -52,9 +53,14 @@ fn size_of(path: &str) -> (u32, u32) { } impl FootageSource for Synthetic {- fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option<Arc<Image>> {+ fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> { let (w, h) = size_of(&f.path); let mut img = Image::new(w, h);+ // `flash.mov` is red for its first frame and blue from its second (0.1 s) on.+ if f.path.ends_with("flash.mov") {+ let c = if t.seconds() >= 0.1 - 1e-9 { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };+ return Some(Arc::new(Image::filled(w, h, c)));+ } for y in 0..h { for x in 0..w { // Decoders hand out premultiplied pixels (straight file colour × alpha).@@ -171,6 +177,118 @@ fn comp_proxy_and_create_proxy() { assert!(blue(px(&s, outer, 0.0, o, 8, 8))); } +/// Outer (4×4, 20 fps) holding a layer of Inner (4×4, 10 fps, a red solid): (inner, outer).+fn nested(s: &mut Session) -> (ItemId, ItemId) {+ s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 2})).unwrap();+ let inner = s.active_comp_id().unwrap();+ s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();+ s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap();+ let outer = s.active_comp_id().unwrap();+ s.execute("layer.addItem", json!({"item": inner.0})).unwrap();+ (inner, outer)+}++/// Interpret Footage ▸ Proxy's Invert Alpha and Interpret As Linear Light change what a+/// composition proxy draws, as they do for the same file used as footage.+#[test]+fn comp_proxy_draws_with_its_interpretation() {+ let mut s = session();+ let (inner, outer) = nested(&mut s);+ let o = RenderOpts::default();+ // Invert Alpha: the 25 % proxy covers 75 %.+ s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/half.png"})).unwrap();+ s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight", "invertAlpha": true})).unwrap();+ let p = px(&s, outer, 0.0, o, 1, 1);+ assert!((p[3] - 0.75).abs() < 0.01, "{p:?}");+ // Interpret As Linear Light: the proxy draws as the file does as footage with that setting.+ let f = import(&mut s, "/m/matte_black.png");+ s.execute("file.interpretFootage", json!({"item": f.0, "linearLight": true})).unwrap();+ s.execute("comp.new", json!({"name": "Ref", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap();+ let r = s.active_comp_id().unwrap();+ s.execute("layer.addItem", json!({"item": f.0})).unwrap();+ let want = px(&s, r, 0.0, o, 1, 1);+ assert!(want[1] / want[3] > 0.7, "linear light re-encodes the 0.5 green: {want:?}");+ s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/matte_black.png"})).unwrap();+ s.execute("file.interpretProxy", json!({"item": inner.0, "linearLight": true})).unwrap();+ let p = px(&s, outer, 0.0, o, 1, 1);+ assert!((0..4).all(|i| (p[i] - want[i]).abs() < 1e-3), "{p:?} != {want:?}");+}++/// Interpret Footage ▸ Proxy ▸ Separate Fields: a composition proxy shows one field per frame+/// at twice the rate, as footage does.+#[test]+fn comp_proxy_separates_fields() {+ let mut s = session();+ let (inner, outer) = nested(&mut s);+ let o = RenderOpts::default();+ s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/lines.mov"})).unwrap();+ assert!(red(px(&s, outer, 0.05, o, 1, 0)) && blue(px(&s, outer, 0.05, o, 1, 1)), "progressive");+ s.execute("file.interpretProxy", json!({"item": inner.0, "fields": "upper"})).unwrap();+ // At 0.05 s the lower field (odd rows, blue) is shown, with the even rows interpolated.+ for y in 0..3 {+ assert!(blue(px(&s, outer, 0.05, o, 1, y)), "lower field at row {y}");+ }+}++/// Frame Blending on a precomp layer blends the frames of the composition's proxy.+#[test]+fn comp_proxy_frames_blend() {+ let mut s = session();+ let (inner, outer) = nested(&mut s);+ let o = RenderOpts::default();+ s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/flash.mov"})).unwrap();+ assert!(red(px(&s, outer, 0.05, o, 1, 1)));+ s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap();+ s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap();+ // Halfway between the red first frame and the blue second one.+ let p = px(&s, outer, 0.05, o, 1, 1);+ assert!((p[0] - 0.5).abs() < 0.05 && (p[2] - 0.5).abs() < 0.05 && p[3] > 0.99, "{p:?}");+ assert!(blue(px(&s, outer, 0.1, o, 1, 1)));+}++#[test]+fn comp_proxy_frame_blend_cache_tracks_alpha_interpretation() {+ struct Interpreted(std::sync::atomic::AtomicUsize);+ impl FootageSource for Interpreted {+ fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {+ self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);+ let pixel = match f.alpha {+ AlphaMode::Straight => [0.4, 0.2, 0.1, 0.5],+ AlphaMode::Premultiplied => [f.premul_color[0] * 0.5, 0.1, 0.0, 0.5],+ AlphaMode::Ignore => [0.8, 0.4, 0.2, 1.0],+ };+ Some(Arc::new(Image::filled(4, 4, pixel)))+ }+ }+ for mode in ["frameMix", "pixelMotion"] {+ let mut s = session();+ let footage = Arc::new(Interpreted(std::sync::atomic::AtomicUsize::new(0)));+ s.footage = footage.clone();+ let (inner, outer) = nested(&mut s);+ s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/alpha.mov"})).unwrap();+ s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight"})).unwrap();+ s.execute("layer.frameBlending", json!({"mode": mode})).unwrap();+ s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap();+ let opts = RenderOpts::default();+ let first = px(&s, outer, 0.05, opts, 1, 1);+ let calls = footage.0.load(std::sync::atomic::Ordering::Relaxed);+ assert_eq!(px(&s, outer, 0.05, opts, 1, 1), first);+ assert_eq!(footage.0.load(std::sync::atomic::Ordering::Relaxed), calls);+ for params in [+ json!({"item": inner.0, "alpha": "ignore"}),+ json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [0.0, 0.0, 0.0]}),+ json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [1.0, 0.0, 0.0]}),+ ] {+ s.execute("file.interpretProxy", params.clone()).unwrap();+ let mut fresh = s.renderer(opts);+ fresh.cache = None;+ let want = fresh.comp_frame(outer, Tick::from_seconds_f64(0.05)).get(1, 1);+ let actual = px(&s, outer, 0.05, opts, 1, 1);+ assert_eq!(actual, want, "{mode} after {params}");+ }+ }+}+ #[test] fn interpret_proxy_settings_apply_to_the_proxy() { let mut s = session();@@ -595,8 +595,10 @@ impl<'a> Renderer<'a> { if self.project.comp_contains(*item, ctx.comp_id) { return None; }- // A composition proxy (a rendered still or movie) stands in for the comp.- // Nothing before the nested comp starts or after it ends.+ // A composition proxy (a rendered still or movie) stands in for the comp. It is+ // decoded like footage: its Separate Fields, Invert Alpha and Interpret As Linear+ // Light settings and the layer's Frame Blending apply. Nothing before the nested+ // comp starts or after it ends. let lt = ctx.nested_time(layer); if !self.project.comp(*item).is_some_and(|nc| nc.covers(lt)) { return None;@@ -604,7 +606,7 @@ impl<'a> Renderer<'a> { if let Some(pf) = self.proxy_for(*item) && let Some(nc) = self.project.comp(*item) {- let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?;+ let img = self.footage_frame(ctx, layer, *item, pf, lt, None)?; return Some(self.footage_buf(&img, pf, nc.width, nc.height)); } // Essential Properties overrides render the nested comp with this instance's@@ -738,6 +740,10 @@ impl<'a> Renderer<'a> { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h);+ (f.alpha as u8).hash(&mut h);+ if f.alpha == effectcraft_project::AlphaMode::Premultiplied {+ f.premul_color.map(f32::to_bits).hash(&mut h);+ } h.finish() }); if let (Some(c), Some(k)) = (self.cache, key)