//! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and //! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light, Cineon Settings), //! rendered through a synthetic footage source. use std::sync::Arc; use effectcraft_project::render_queue::ProxyUse; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId, ItemKind}; use effectcraft_raster::Image; use effectcraft_time::{FrameRate, Tick}; use serde_json::json; 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), `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; fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] { let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(""); match name { "red16.png" => [1.0, 0.0, 0.0, 1.0], "blue8.png" => [0.0, 0.0, 1.0, 1.0], "lines.mov" => { if y.is_multiple_of(2) { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] } } // Straight interpretation of a file whose colour is green·a + matte·(1 − a), a = 0.5. "matte_black.png" => [0.0, 0.5, 0.0, 0.5], "matte_white.png" => [0.5, 1.0, 0.5, 0.5], "half.png" => [1.0, 1.0, 1.0, 0.25], // A log film scan: code values 95, 685, 400 and 1023 across, as the DPX reader hands // them out (code / 1023). "scan.dpx" | "plate.dpx" => { let v = [95.0, 685.0, 400.0, 1023.0][x.min(3) as usize] / 1023.0; [v, v, v, 1.0] } _ => { let _ = x; [1.0, 1.0, 1.0, 1.0] } } } fn size_of(path: &str) -> (u32, u32) { match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") { "red16.png" => (16, 16), "blue8.png" => (8, 8), "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" | "scan.dpx" | "plate.dpx" => (4, 4), _ => (10, 10), } } impl FootageSource for Synthetic { fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option> { 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). let mut p = file_px(&f.path, x, y); // A log scan sequence: code 95 on even frames, 685 on odd ones. if f.path.ends_with("frames.dpx") { let v = if f.frame_rate.frame_at(t) % 2 == 0 { 95.0 } else { 685.0 } / 1023.0; p = [v, v, v, 1.0]; } img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]); } } Some(Arc::new(img)) } } struct Probe; impl crate::Importer for Probe { fn probe(&self, path: &str) -> Result { let (w, h) = size_of(path); let video = path.ends_with(".mov") || path.ends_with("frames.dpx"); Ok(Footage { path: path.into(), kind: if video { FootageKind::Video } else { FootageKind::Still }, width: w, height: h, has_video: true, frame_rate: FrameRate::from_f64(10.0), duration: Tick::from_seconds_f64(if video { 2.0 } else { 0.0 }), ..Default::default() }) } } fn session() -> Session { Session { footage: Arc::new(Synthetic), importer: Some(Arc::new(Probe)), ..Default::default() } } fn import(s: &mut Session, path: &str) -> ItemId { ItemId(s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap()) } fn px(s: &Session, comp: ItemId, t: f64, opts: RenderOpts, x: i64, y: i64) -> [f32; 4] { s.render(comp, Tick::from_seconds_f64(t), opts).get(x, y) } fn red(p: [f32; 4]) -> bool { p[0] > 0.99 && p[2] < 0.01 && p[3] > 0.99 } fn blue(p: [f32; 4]) -> bool { p[2] > 0.99 && p[0] < 0.01 && p[3] > 0.99 } #[test] fn footage_proxy_follows_use_proxy_and_proxy_use() { let mut s = session(); let full = import(&mut s, "/m/red16.png"); s.execute("comp.new", json!({"name": "C", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap(); let c = s.active_comp_id().unwrap(); s.execute("layer.addItem", json!({"item": full.0})).unwrap(); let o = RenderOpts::default(); assert!(red(px(&s, c, 0.0, o, 8, 8))); s.execute("file.setProxy", json!({"item": full.0, "path": "/m/blue8.png"})).unwrap(); // The 8×8 proxy fills the 16×16 footage frame. for (x, y) in [(1, 1), (8, 8), (14, 14)] { assert!(blue(px(&s, c, 0.0, o, x, y)), "proxy at {x},{y}"); } assert!(s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled); // Render Settings ▸ Proxy Use. assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8))); assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8))); // Use Proxy off: the viewer shows the source, Use All Proxies still uses it. s.execute("file.useProxy", json!({"item": full.0})).unwrap(); assert!(!s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled); assert!(red(px(&s, c, 0.0, o, 8, 8))); assert!(blue(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8))); // Undo / Set Proxy ▸ None. s.execute("edit.undo", json!({})).unwrap(); assert!(blue(px(&s, c, 0.0, o, 8, 8))); s.execute("file.setProxyNone", json!({"item": full.0})).unwrap(); assert!(s.project.item(full).unwrap().proxy.is_none()); assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8))); // Render queue items carry Proxy Use (Best Settings: none). let r = s.execute("renderQueue.add", json!({"proxyUse": "all"})).unwrap(); assert_eq!(s.project.render_queue[0].settings.proxy_use, ProxyUse::UseAll, "{r}"); assert_eq!(effectcraft_project::render_queue::RenderSettings::default().proxy_use, ProxyUse::UseNone); } #[test] fn comp_proxy_and_create_proxy() { let mut s = session(); s.execute("comp.new", json!({"name": "Inner", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap(); let inner = s.active_comp_id().unwrap(); s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); s.execute("comp.new", json!({"name": "Outer", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap(); let outer = s.active_comp_id().unwrap(); s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); let o = RenderOpts::default(); assert!(red(px(&s, outer, 0.0, o, 8, 8))); s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/blue8.png"})).unwrap(); assert!(blue(px(&s, outer, 0.0, o, 14, 1))); assert!(blue(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8))); assert!(red(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8))); s.execute("file.setProxyNone", json!({"item": inner.0})).unwrap(); // Create Proxy queues a half-resolution render whose post-render action sets the proxy. s.state.project_selection = vec![inner]; let r = s.execute("file.createProxy", json!({"kind": "still"})).unwrap(); let it = s.project.render_queue.last().unwrap().clone(); assert_eq!(it.comp, inner); assert_eq!(it.post_render, effectcraft_project::render_queue::PostRenderAction::SetProxy); assert_eq!(it.settings.resolution, 0.5); assert!(r["output"].as_str().unwrap().contains("Inner_proxy_"), "{r}"); let m = s.execute("file.createProxy", json!({"kind": "movie", "comp": inner.0})).unwrap(); assert!(m["output"].as_str().unwrap().ends_with("Inner_proxy.mov"), "{m}"); // The post-render action (what a finished render runs) attaches the file. let id = it.id; s.post_render_action(id, "/m/blue8.png").unwrap(); assert_eq!(s.project.item(inner).unwrap().proxy.as_ref().unwrap().footage.path, "/m/blue8.png"); 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> { 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(); let full = import(&mut s, "/m/red16.png"); s.execute("file.setProxy", json!({"item": full.0, "path": "/m/half.png"})).unwrap(); s.execute("file.interpretProxy", json!({"item": full.0, "alpha": "ignore", "invertAlpha": true})).unwrap(); let px = s.project.item(full).unwrap().proxy.clone().unwrap(); assert_eq!(px.footage.alpha, AlphaMode::Ignore); assert!(px.footage.invert_alpha); let f = match &s.project.item(full).unwrap().kind { ItemKind::Footage(f) => f.clone(), _ => panic!("not footage"), }; assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation"); } /// Agents and the control channel run commands through `execute_checked`, which refuses keys a /// params doc does not list. The Interpret Footage commands list every key they read. #[test] fn interpret_commands_accept_their_keys_when_checked() { let mut s = session(); let mov = import(&mut s, "/m/lines.mov"); let b = import(&mut s, "/m/red16.png"); s.execute("file.setProxy", json!({"item": mov.0, "path": "/m/lines.mov"})).unwrap(); s.execute("file.setProxy", json!({"item": b.0, "path": "/m/matte_black.png"})).unwrap(); s.execute_checked("file.interpretProxy", json!({"item": mov.0, "preserveRgb": true, "overrideStart": true, "startTimecode": 25})).unwrap(); let px = s.project.item(mov).unwrap().proxy.clone().unwrap().footage; assert!(px.preserve_rgb); assert_eq!(px.start_timecode, Some(25)); s.execute_checked("file.interpretProxy", json!({"item": mov.0, "overrideStart": false})).unwrap(); assert_eq!(s.project.item(mov).unwrap().proxy.as_ref().unwrap().footage.start_timecode, None); s.execute_checked("file.interpretProxy", json!({"item": b.0, "guessAlpha": true})).unwrap(); assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.alpha, AlphaMode::Premultiplied); s.execute_checked("file.interpretProxy", json!({"item": b.0, "premulColor": [1.0, 0.0, 0.0]})).unwrap(); assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.premul_color, [1.0, 0.0, 0.0]); // `item` names one footage item, as `items` names several. s.execute_checked("file.interpretFootage", json!({"item": b.0, "invertAlpha": true, "premulColor": [0.0, 1.0, 0.0]})).unwrap(); s.execute_checked("file.rememberInterpretation", json!({"item": b.0})).unwrap(); s.execute_checked("file.applyInterpretation", json!({"item": mov.0})).unwrap(); let f = match &s.project.item(mov).unwrap().kind { ItemKind::Footage(f) => f.clone(), _ => panic!("not footage"), }; assert!(f.invert_alpha); assert_eq!(f.premul_color, [0.0, 1.0, 0.0]); assert_eq!(s.execute_checked("file.reloadFootage", json!({"item": mov.0})).unwrap()["reloaded"], 1); } /// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines /// interpolated; the dominant field comes first. #[test] fn separate_fields_make_frames_from_fields() { let mut s = session(); let mov = import(&mut s, "/m/lines.mov"); s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 20, "duration": 1})).unwrap(); let c = s.active_comp_id().unwrap(); s.execute("layer.addItem", json!({"item": mov.0})).unwrap(); let o = RenderOpts::default(); // Progressive: alternating rows. assert!(red(px(&s, c, 0.0, o, 1, 0)) && blue(px(&s, c, 0.0, o, 1, 1))); s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "upper"})).unwrap(); // Field 1 (upper = even rows, red) then field 2 (odd rows, blue), each 1/20 s. for y in 0..3 { assert!(red(px(&s, c, 0.0, o, 1, y)), "upper field at row {y}"); assert!(blue(px(&s, c, 0.05, o, 1, y + 1)), "lower field at row {}", y + 1); } s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "lower"})).unwrap(); assert!(blue(px(&s, c, 0.0, o, 1, 2))); assert!(red(px(&s, c, 0.05, o, 1, 1))); let r = s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "off"})).unwrap(); assert_eq!(r["items"][0]["fields"], "Off"); } /// Non-square pixels: a 10×10 footage with pixel aspect 2 covers 20×10 comp pixels. #[test] fn pixel_aspect_stretches_footage_in_the_comp() { let mut s = session(); let w = import(&mut s, "/m/wide.png"); s.execute("comp.new", json!({"name": "C", "width": 40, "height": 20, "frameRate": 10, "duration": 1})).unwrap(); let c = s.active_comp_id().unwrap(); s.execute("layer.addItem", json!({"item": w.0})).unwrap(); let o = RenderOpts::default(); let covered = |s: &Session| s.render(c, Tick::ZERO, o).data.iter().filter(|p| p[3] > 0.5).count(); assert_eq!(covered(&s), 100); s.execute("file.interpretFootage", json!({"item": w.0, "pixelAspect": 2.0})).unwrap(); assert_eq!(covered(&s), 200); // Centered on the comp: x 10..30 (edge pixels are filtered). assert!(px(&s, c, 0.0, o, 11, 10)[3] > 0.99 && px(&s, c, 0.0, o, 28, 10)[3] > 0.99); assert!(px(&s, c, 0.0, o, 8, 10)[3] < 0.01 && px(&s, c, 0.0, o, 31, 10)[3] < 0.01); // A comp with the same pixel aspect shows the footage 1:1. s.execute("comp.settings", json!({"pixelAspect": 2.0})).unwrap(); assert_eq!(covered(&s), 100); } /// Alpha ▸ Guess: premultiplied files are recognised with their matte colour; Invert Alpha /// and Interpret As Linear Light change the decoded pixels. #[test] fn guess_alpha_invert_and_linear_light() { let mut s = session(); let b = import(&mut s, "/m/matte_black.png"); let w = import(&mut s, "/m/matte_white.png"); let h = import(&mut s, "/m/half.png"); let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind { ItemKind::Footage(f) => f.clone(), _ => panic!("not footage"), }; s.execute("file.interpretFootage", json!({"items": [b.0, w.0, h.0], "alpha": "guess"})).unwrap(); assert_eq!(foot(&s, b).alpha, AlphaMode::Premultiplied); assert_eq!(foot(&s, b).premul_color, [0.0; 3]); assert_eq!(foot(&s, w).alpha, AlphaMode::Premultiplied); assert_eq!(foot(&s, w).premul_color, [1.0; 3]); // White at 25 %: not explained by a black matte (1 > 0.25) but it is by white… either way // the guess is consistent with the pixels. assert!(matches!(foot(&s, h).alpha, AlphaMode::Straight | AlphaMode::Premultiplied)); // An explicit matte colour. s.execute("file.interpretFootage", json!({"item": b.0, "alpha": "premultiplied", "matteColor": "#808080"})).unwrap(); assert!((foot(&s, b).premul_color[0] - 128.0 / 255.0).abs() < 1e-3); // Invert Alpha: 25 % → 75 %. s.execute("file.interpretFootage", json!({"item": h.0, "alpha": "straight", "invertAlpha": true})).unwrap(); s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); let c = s.active_comp_id().unwrap(); s.execute("layer.addItem", json!({"item": h.0})).unwrap(); let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1); assert!((p[3] - 0.75).abs() < 0.01 && p[0] > 0.7, "{p:?}"); // Interpret As Linear Light keeps alpha and re-encodes the colour. s.execute("file.interpretFootage", json!({"item": h.0, "invertAlpha": false, "linearLight": true})).unwrap(); let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1); assert!((p[3] - 0.25).abs() < 0.01, "{p:?}"); // Remember / Apply Interpretation carries the new settings. s.state.project_selection = vec![h]; s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap(); s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap(); assert!(foot(&s, w).linear_light); // Color ▸ Preserve RGB (#411) is set, rendered and carried by Apply Interpretation. let out = s.execute("file.interpretFootage", json!({"item": h.0, "preserveRgb": true})).unwrap(); assert_eq!(out["items"][0]["preserveRgb"], true); let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1); assert!((p[0] - 0.25).abs() < 0.01, "white at 25 %, unconverted: {p:?}"); s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap(); s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap(); assert!(foot(&s, w).preserve_rgb); } /// Color ▸ Cineon Settings: Logarithmic Conversion turns a DPX file's log code values into the /// Current Gamma between the converted points (off, they show as stored); the converted points /// are read and reported in the chosen Units; out-of-range values and footage that is not DPX /// or Cineon are refused; Remember / Apply Interpretation and undo carry the settings. #[test] fn cineon_settings_convert_dpx_code_values() { let mut s = session(); let d = import(&mut s, "/m/scan.dpx"); let other = import(&mut s, "/m/plate.dpx"); let png = import(&mut s, "/m/red16.png"); let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind { ItemKind::Footage(f) => f.clone(), _ => panic!("not footage"), }; s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); let c = s.active_comp_id().unwrap(); s.execute("layer.addItem", json!({"item": d.0})).unwrap(); let at = |s: &Session, x: i64| px(s, c, 0.0, RenderOpts::default(), x, 0)[0]; let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; // Off: the code values as stored. assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); // The checked path (control channel, MCP) accepts every Cineon parameter the doc lists. let out = s.execute_checked("file.interpretFootage", json!({"items": [d.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); assert_eq!(out["items"][0]["cineon"]["logConversion"], true, "{out}"); assert!(near(at(&s, 0), 0.0) && near(at(&s, 1), 1.0), "{} {}", at(&s, 0), at(&s, 1)); assert!(near(at(&s, 2), 0.26190), "code 400 at gamma 1.7: {}", at(&s, 2)); s.execute("file.interpretFootage", json!({"item": d.0, "currentGamma": 1})).unwrap(); assert!(near(at(&s, 2), 0.10251), "code 400 in linear light: {}", at(&s, 2)); // Converted points, in 8-bit units. let out = s .execute_checked("file.interpretFootage", json!({"items": [d.0], "cineonUnits": "8bit", "convertedBlack": 16, "convertedWhite": 235, "currentGamma": 1, "tenBitBlack": 95, "tenBitWhite": 685})) .unwrap(); assert_eq!(out["items"][0]["cineon"]["units"], "8bit"); assert!((out["items"][0]["cineon"]["convertedWhite"].as_f64().unwrap() - 235.0).abs() < 1e-3, "{out}"); assert!(near(at(&s, 0), 16.0 / 255.0) && near(at(&s, 1), 235.0 / 255.0), "{} {}", at(&s, 0), at(&s, 1)); // The form sends the points in the units it showed them in (`pointUnits`) with a new Units. s.execute_checked("file.interpretFootage", json!({"items": [d.0], "pointUnits": "8bit", "cineonUnits": "float", "convertedWhite": 255})).unwrap(); let f = foot(&s, d); assert_eq!(f.cineon.units, effectcraft_project::CineonUnits::Float); assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); // Refused: a black point at or above the white point, values out of range, and footage // that is not DPX or Cineon. Nothing changes. let before = foot(&s, d).cineon; for bad in [ json!({"item": d.0, "tenBitBlack": 700}), json!({"item": d.0, "tenBitWhite": 2000}), json!({"item": d.0, "currentGamma": 0}), json!({"item": d.0, "highlightRolloff": -1}), json!({"item": d.0, "convertedWhite": 1.5}), json!({"item": d.0, "cineonUnits": "12bit"}), json!({"item": png.0, "logConversion": true}), ] { assert!(s.execute("file.interpretFootage", bad.clone()).is_err(), "{bad}"); } assert_eq!(foot(&s, d).cineon, before); assert!(foot(&s, png).cineon == effectcraft_project::CineonSettings::default()); // With DPX and other footage selected, only the DPX file takes the settings. s.execute("file.interpretFootage", json!({"items": [png.0, other.0], "logConversion": true})).unwrap(); assert!(foot(&s, other).cineon.log_conversion && !foot(&s, png).cineon.log_conversion); s.execute("edit.undo", json!({})).unwrap(); assert!(!foot(&s, other).cineon.log_conversion); // Remember / Apply Interpretation. s.state.project_selection = vec![d]; s.execute("file.rememberInterpretation", json!({"item": d.0})).unwrap(); s.execute("file.applyInterpretation", json!({"items": [other.0, png.0]})).unwrap(); assert_eq!(foot(&s, other).cineon, before); assert_eq!(foot(&s, png).cineon, effectcraft_project::CineonSettings::default()); // Off again: as stored. s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": false})).unwrap(); assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); // Frame Mix blends converted frames: halfway between code 95 (black) and 685 (white) at // Current Gamma 1 is 0.5, not the conversion of the blended code values. let seq = import(&mut s, "/m/frames.dpx"); s.execute("file.interpretFootage", json!({"item": seq.0, "logConversion": true, "currentGamma": 1, "highlightRolloff": 0})).unwrap(); s.execute("comp.new", json!({"name": "Blend", "width": 10, "height": 10, "frameRate": 20, "duration": 1})).unwrap(); let bc = s.active_comp_id().unwrap(); s.execute("layer.addItem", json!({"item": seq.0})).unwrap(); s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap(); let mid = px(&s, bc, 0.05, RenderOpts::default(), 5, 5)[0]; assert!(near(mid, 0.5), "frame-mixed log frames: {mid}"); // A DPX proxy of a composition: Interpret Proxy's settings convert it where it stands in. s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); let inner = s.active_comp_id().unwrap(); s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); let outer = s.active_comp_id().unwrap(); s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/scan.dpx"})).unwrap(); let proxied = |s: &Session, x: i64| px(s, outer, 0.0, RenderOpts::default(), x, 0)[0]; assert!(near(proxied(&s, 1), 685.0 / 1023.0), "{}", proxied(&s, 1)); s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1)); } /// The Footage panel, the Project panel thumbnail and the tracker's source frames show DPX /// footage with its Cineon Settings, as a composition renders it. #[test] fn cineon_settings_apply_to_footage_panel_thumbnail_and_tracking() { let mut s = session(); let d = import(&mut s, "/m/scan.dpx"); s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); let c = s.active_comp_id().unwrap(); s.execute("layer.addItem", json!({"item": d.0})).unwrap(); s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": true, "highlightRolloff": 0})).unwrap(); let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; let rendered = px(&s, c, 0.0, RenderOpts::default(), 2, 0)[0]; assert!(near(rendered, 0.26190), "code 400 at gamma 1.7: {rendered}"); let f = match &s.project.item(d).unwrap().kind { ItemKind::Footage(f) => f.clone(), _ => panic!("not footage"), }; let panel = crate::commands::footage_panel::frame(&s, d, &f, Tick::ZERO).unwrap().get(2, 0)[0]; assert!(near(panel, rendered), "Footage panel: {panel}"); let (w, _, rgba) = (s.thumbnail_job(d, Tick::ZERO, 64).unwrap())().unwrap(); assert_eq!(w, 4); let thumb = rgba[8] as f32 / 255.0; assert!(near(thumb, rendered), "Project panel thumbnail: {thumb}"); let comp = s.project.comp(c).unwrap(); let r = crate::render::Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default()); let (img, _) = crate::tracking::source_frame(&r, &s.project, c, comp, &comp.layers[0], Tick::ZERO, None).unwrap(); assert!(near(img.get(2, 0)[0], rendered), "tracker source frame: {}", img.get(2, 0)[0]); }