diff --git a/Cargo.lock b/Cargo.lock index 854a042c..ac4a03a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1346,6 +1346,7 @@ dependencies = [ "effectcraft-script", "effectcraft-svg", "effectcraft-time", + "exr", "image", "rayon", "serde_json", @@ -1617,6 +1618,7 @@ dependencies = [ "eframe", "egui", "egui_kittest", + "exr", "image", "kurbo", "log", diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 84f29901..58db3ba0 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -415,9 +415,36 @@ fn list(_: &mut Session, p: &Value) -> Result { Ok(json!(v)) } +impl Session { + /// The named channels of layer `lid` in the active comp at the current time: its + /// multi-layer OpenEXR footage's layers and channels (`diffuse.R`, `depth.Z`, …), which + /// EXtractoR and the 3D Channel effects read. `None` for other layers. + pub fn layer_channels(&self, lid: LayerId) -> Option> { + let layer = self.active_comp()?.layer(lid)?; + let effectcraft_project::LayerSource::Footage { item } = layer.source else { return None }; + let effectcraft_project::ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None }; + self.footage.aux(item, f, layer.layer_time(self.time())) + } +} + +/// `layer.channels`: the layers and channels of a layer's multi-layer OpenEXR footage, and +/// what each layer shows in red, green, blue and alpha (EXtractoR's popups). +fn channels(s: &mut Session, p: &Value) -> Result { + let (_, lid) = super::layer_p(s, p, "layer.channels")?; + let Some(aux) = s.layer_channels(lid) else { return Ok(json!({"channels": [], "layers": []})) }; + let layers: Vec = aux.layers().into_iter().map(|l| json!({"name": l, "rgba": aux.layer_rgba(l)})).collect(); + Ok(json!({"channels": aux.names(), "layers": layers})) +} + pub fn specs() -> Vec { vec![ cmd!("effect.apply", "Apply Effect", [], None, "{effect: id|name (e.g. Gaussian Blur), layers?}", has_layers, apply), + crate::query!( + "layer.channels", + "Layer Channels", + "{layer?} → {channels: [name], layers: [{name, rgba: [r, g, b, a channel]}]} — a multi-layer OpenEXR layer's channels (EXtractoR's red / green / blue / alpha take these names)", + channels + ), cmd!( "effect.pickColor", "Pick Effect Colour", diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 948b7829..13fcb87a 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -31,4 +31,5 @@ effectcraft-raster = { workspace = true } effectcraft-time = { workspace = true } effectcraft-interchange = { workspace = true } image = { workspace = true } +exr = { workspace = true } rayon = { workspace = true } diff --git a/crates/host/tests/exr_layers.rs b/crates/host/tests/exr_layers.rs new file mode 100644 index 00000000..39ea95e2 --- /dev/null +++ b/crates/host/tests/exr_layers.rs @@ -0,0 +1,67 @@ +//! Multi-layer OpenEXR footage and EXtractoR in a full session (#295): a file whose colour is +//! only in named layers (as Blender and Nuke write them) imports, `layer.channels` lists its +//! layers and channels, and EXtractoR shows the chosen ones. + +use effectcraft_engine::Session; +use effectcraft_engine::time::Tick; +use serde_json::{Value, json}; + +/// An 8×4 EXR with layers `diffuse` (orange), `spec` (blue) and `depth` (Z = 0.25), and no +/// unnamed RGB layer. +fn layered_exr() -> String { + use exr::prelude::*; + let (w, h) = (8usize, 4usize); + let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h])); + let channels = AnyChannels::sort( + vec![ch("diffuse.R", 1.0), ch("diffuse.G", 0.5), ch("diffuse.B", 0.0), ch("spec.R", 0.0), ch("spec.G", 0.0), ch("spec.B", 1.0), ch("depth.Z", 0.25)] + .into(), + ); + let image = Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels)); + let path = std::env::temp_dir().join(format!("ec-exr-layers-{}.exr", std::process::id())); + image.write().to_file(&path).unwrap(); + path.to_string_lossy().to_string() +} + +fn px(s: &Session) -> [f32; 4] { + let cid = s.active_comp_id().unwrap(); + s.render(cid, Tick::ZERO, Default::default()).get(4, 2) +} + +fn near(a: [f32; 4], b: [f32; 3]) -> bool { + (0..3).all(|k| (a[k] - b[k]).abs() < 0.02) +} + +#[test] +fn extractor_shows_the_chosen_layer_and_channels_of_a_layered_exr() { + let path = layered_exr(); + let mut s = effectcraft_host::session(); + let r = s.execute("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "the file imports: {r}"); + s.execute("comp.new", json!({"name": "EXR", "width": 8, "height": 4, "frameRate": 24, "duration": 1})).unwrap(); + let layer = s.execute("layer.addItem", json!({"item": r["items"][0]})).unwrap()["layer"].clone(); + // The picture is the colour layer (diffuse: orange). + assert!(near(px(&s), [1.0, 0.735, 0.0]), "{:?}", px(&s)); + + let ch = s.execute("layer.channels", json!({"layer": layer})).unwrap(); + let names: Vec<&str> = ch["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect(); + assert_eq!(names, ["depth", "diffuse", "spec"], "{ch}"); + assert_eq!(ch["layers"][2]["rgba"], json!(["spec.R", "spec.G", "spec.B", ""])); + assert_eq!(ch["layers"][0]["rgba"], json!(["depth.Z", "depth.Z", "depth.Z", ""]), "one channel fills red, green and blue"); + assert!(ch["channels"].as_array().unwrap().contains(&Value::from("depth.Z"))); + + // EXtractoR on the spec layer: blue. + s.execute("effect.apply", json!({"layers": [layer], "effect": "EXtractoR"})).unwrap(); + for (param, v) in ["red", "green", "blue", "alpha"].iter().zip(["spec.R", "spec.G", "spec.B", ""]) { + s.execute("prop.set", json!({"layer": layer, "path": format!("effects/#1/{param}"), "value": v})).unwrap(); + } + assert!(near(px(&s), [0.0, 0.0, 1.0]), "{:?}", px(&s)); + // Depth in all three: grey at its value. + for param in ["red", "green", "blue"] { + s.execute("prop.set", json!({"layer": layer, "path": format!("effects/#1/{param}"), "value": "depth.Z"})).unwrap(); + } + assert!(near(px(&s), [0.25, 0.25, 0.25]), "{:?}", px(&s)); + // A layer that isn't OpenEXR has no channels. + let solid = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].clone(); + assert_eq!(s.execute("layer.channels", json!({"layer": solid})).unwrap()["layers"], json!([])); + let _ = std::fs::remove_file(path); +} diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index ba0034c3..00c6fd70 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -94,6 +94,46 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option { Some(aux) } +/// The picture of a multi-layer OpenEXR file without an unnamed RGB layer (Blender's +/// `ViewLayer.Combined.R`, Nuke's `beauty.R`, …), which the image decoder rejects: its colour +/// layer (preferring one named combined, beauty or rgba), else its first channel as grey. +/// Linear, straight RGBA as the file stores it. `None` when it is not a readable EXR. +pub fn layered_image(bytes: &[u8]) -> Option { + let aux = read_exr_channels(bytes)?; + let layers = aux.layers(); + // Colour layers: three different channels (not one channel, such as depth, shown in all). + let rgb: Vec<&str> = layers + .iter() + .copied() + .filter(|l| { + let [r, g, b, _] = aux.layer_rgba(l); + !r.is_empty() && !g.is_empty() && !b.is_empty() && r != g + }) + .collect(); + let colour = rgb + .iter() + .find(|l| ["combined", "beauty", "rgba"].iter().any(|k| l.to_ascii_lowercase().contains(k))) + .or_else(|| rgb.first()) + .or_else(|| layers.first())?; + let names = aux.layer_rgba(colour); + let first = aux.channels.first().map(|(n, _)| n.as_str()).unwrap_or_default(); + let plane = |k: usize| -> Option<&[f32]> { aux.get(names.get(k).map(String::as_str).filter(|n| !n.is_empty()).unwrap_or(first)) }; + let (r, g, b) = (plane(0)?, plane(1)?, plane(2)?); + let a = names.get(3).filter(|n| !n.is_empty()).and_then(|n| aux.get(n)); + let n = (aux.width as usize).checked_mul(aux.height as usize)?; + let mut data = Vec::with_capacity(n.checked_mul(4)?); + for i in 0..n { + data.extend([r.get(i)?, g.get(i)?, b.get(i)?].map(|v| *v)); + if let Some(a) = a { + data.push(*a.get(i)?); + } + } + match a { + Some(_) => image::Rgba32FImage::from_raw(aux.width, aux.height, data).map(image::DynamicImage::ImageRgba32F), + None => image::Rgb32FImage::from_raw(aux.width, aux.height, data).map(image::DynamicImage::ImageRgb32F), + } +} + type Cache = Mutex>)>>; /// Read (and cache, a few files) the channels of the EXR at `path` via `read`. @@ -149,4 +189,28 @@ mod tests { assert_eq!(aux.manifests, vec![("CryptoObject".to_string(), vec![("bunny".to_string(), 0x1385_1a76), ("floor".to_string(), 0x0a1b_2c3d)])]); assert!(read_exr_channels(b"not an exr").is_none()); } + + /// #295: a multi-layer EXR whose colour is only in named layers (`diffuse.R`, as Blender and + /// Nuke write them) failed to import ("does not contain non-deep rgb channels"); it now + /// shows its colour layer and keeps every channel for EXtractoR. + #[test] + fn layered_exr_without_an_unnamed_rgb_layer_imports() { + use exr::prelude::*; + let (w, h) = (4usize, 2usize); + let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h])); + let channels = AnyChannels::sort(vec![ch("diffuse.R", 1.0), ch("diffuse.G", 0.2), ch("diffuse.B", 0.0), ch("depth.Z", 7.0)].into()); + let image = Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let b: Arc<[u8]> = bytes.into_inner().into(); + let f = crate::probe_bytes("/layers.exr", b.clone()).unwrap(); + assert_eq!((f.width, f.height, f.alpha), (4, 2, effectcraft_project::AlphaMode::Ignore)); + let pool = crate::MediaPool::new(); + pool.add_bytes("/layers.exr", b.clone()); + let px = pool.frame_at(&f, effectcraft_time::Tick::ZERO).unwrap().get(1, 1); + assert!((px[0] - 1.0).abs() < 1e-4 && px[1] > 0.3 && px[1] < 0.6 && px[2] < 1e-4 && px[3] == 1.0, "diffuse, sRGB-encoded: {px:?}"); + let aux = read_exr_channels(&b).unwrap(); + assert_eq!(aux.layers(), ["depth", "diffuse"]); + assert_eq!(aux.layer_rgba("diffuse"), ["diffuse.R", "diffuse.G", "diffuse.B", ""].map(String::from)); + } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index a13c081a..1ff740b2 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -594,7 +594,11 @@ impl Inner { if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { return Ok(img); } - let img = image::load_from_memory(&bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + let img = match image::load_from_memory(&bytes) { + Ok(img) => img, + // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. + Err(e) => crate::exr_channels::layered_image(&bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, + }; Ok(dynamic_to_image(&img, op)) } Some(mt) => { diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index dff77eb6..8ff0c05c 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -165,9 +165,15 @@ pub(crate) fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Result { use image::ImageDecoder; let reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt); - let dec = reader.into_decoder().map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; - let (w, h) = dec.dimensions(); - let has_alpha = dec.color_type().has_alpha(); + let (w, h, has_alpha) = match reader.into_decoder() { + Ok(dec) => (dec.dimensions().0, dec.dimensions().1, dec.color_type().has_alpha()), + Err(e) => { + // A multi-layer OpenEXR file without an unnamed RGB layer. + let img = (fmt == image::ImageFormat::OpenExr).then(|| crate::exr_channels::layered_image(bytes)).flatten(); + let img = img.ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?; + (img.width(), img.height(), img.color().has_alpha()) + } + }; Ok(still_footage(path, w, h, fmt, has_alpha)) } diff --git a/crates/raster/src/channels3d.rs b/crates/raster/src/channels3d.rs index 61ffbf18..010d16db 100644 --- a/crates/raster/src/channels3d.rs +++ b/crates/raster/src/channels3d.rs @@ -63,6 +63,41 @@ impl AuxChannels { self.channels.iter().map(|(n, _)| n.as_str()).collect() } + /// The layers of the channels (OpenEXR layers: the name before the last `.`; `""` for the + /// unnamed layer), in channel order. + pub fn layers(&self) -> Vec<&str> { + let mut out: Vec<&str> = vec![]; + for (n, _) in &self.channels { + let (layer, _) = split_channel(n); + if !out.contains(&layer) { + out.push(layer); + } + } + out + } + + /// A layer's channels as the names to show in red, green, blue and alpha (`""` = none): + /// its R, G, B, A channels (or red / green / blue / alpha, X / Y / Z, U / V); a layer of one + /// channel (depth, an ID) shows it in all three colours. + pub fn layer_rgba(&self, layer: &str) -> [String; 4] { + let chans: Vec<&str> = self.channels.iter().map(|(n, _)| n.as_str()).filter(|n| split_channel(n).0 == layer).collect(); + let pick = |names: &[&str]| -> String { + chans.iter().find(|c| names.iter().any(|n| split_channel(c).1.eq_ignore_ascii_case(n))).map_or(String::new(), |c| c.to_string()) + }; + if let [one] = chans.as_slice() { + return [one.to_string(), one.to_string(), one.to_string(), String::new()]; + } + [pick(&["R", "red", "X", "U"]), pick(&["G", "green", "Y", "V"]), pick(&["B", "blue", "Z"]), pick(&["A", "alpha"])] + } +} + +/// A channel name's layer and channel: `diffuse.R` → (`diffuse`, `R`), `ViewLayer.Combined.R` +/// → (`ViewLayer.Combined`, `R`), `Z` → (`""`, `Z`). +pub fn split_channel(name: &str) -> (&str, &str) { + name.rsplit_once('.').unwrap_or(("", name)) +} + +impl AuxChannels { pub fn depth(&self) -> Option<&[f32]> { self.find(&["Z", "depth", "Depth", "zDepth", "Z-Depth"]) } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 2d07038d..04253231 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -37,6 +37,7 @@ sys-locale = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } pollster = "0.4" effectcraft-host = { workspace = true } +exr = { workspace = true } [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 55a08632..34d89da9 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -380,6 +380,11 @@ fn prop_row( } } } + // EXtractoR's red / green / blue / alpha: a popup of the footage's OpenEXR channels (#295). + Value::Str(s) if super::fx_editors::is_extractor_channel(group, prop) => { + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); + super::fx_editors::channel_popup(app, ui, layer, prop, s, dr, actions); + } Value::Gradient(g) => { let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0)); let n = 48; @@ -652,7 +657,7 @@ fn roto_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l /// Height of the custom editor shown under an effect's header (0 when none). fn editor_height(effect: &str, g: &PropGroup, width: f32) -> f32 { match effect { - super::fx_editors::GLOW | super::fx_editors::RESHAPE => super::fx_editors::header_height(effect, g, width), + super::fx_editors::GLOW | super::fx_editors::RESHAPE | super::fx_editors::EXTRACTOR => super::fx_editors::header_height(effect, g, width), "ec.color.curves" => 34.0 + curves_size(width) + 26.0, effectcraft_engine::effects::warp_stab::ID => 50.0, effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::EDITOR_HEIGHT, @@ -1050,7 +1055,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::editor(app, ui, &bp, &layer, g, er, &mut actions), effectcraft_engine::effects::roto::ID => roto_editor(app, ui, &bp, &layer, g, er, &mut actions), "ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions), - super::fx_editors::GLOW | super::fx_editors::RESHAPE => { + super::fx_editors::GLOW | super::fx_editors::RESHAPE | super::fx_editors::EXTRACTOR => { super::fx_editors::header_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions) } _ => histogram_only(app, ui, &bp, g, er), diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index e47d0a8d..c9ff2b79 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -9,7 +9,9 @@ //! - Glow: the colour map (A & B Colors: a preview strip of the gradient over glow brightness; //! Arbitrary Map: red / green / blue curve graphs and the resulting strip); //! - Reshape: correspondence point list buttons here, and on the Composition viewer the source -//! and destination mask outlines with draggable correspondence handles ([`reshape_overlay`]). +//! and destination mask outlines with draggable correspondence handles ([`reshape_overlay`]); +//! - EXtractoR: a Layer popup of the footage's OpenEXR layers, and Red / Green / Blue / Alpha +//! popups of its channels ([`channel_popup`]). //! //! Every edit is a `prop.set` engine command (undoable; one drag = one undo step) and every //! handle registers an automation id. @@ -17,7 +19,7 @@ use effectcraft_engine::effects::{ColoramaPalette, OffsetCurve}; use effectcraft_engine::geom::Mat3; use effectcraft_engine::keyframe::Value; -use effectcraft_engine::project::{Layer, PropGroup}; +use effectcraft_engine::project::{GroupKind, Layer, PropGroup}; use effectcraft_engine::render::EvalCtx; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; @@ -33,6 +35,7 @@ pub const LUMETRI: &str = "ec.color.lumetri"; pub const COLORAMA: &str = "ec.color.colorama"; pub const GLOW: &str = "ec.stylize.glow"; pub const RESHAPE: &str = "ec.distort.reshape"; +pub const EXTRACTOR: &str = "ec.3d.extractor"; fn str_value(layer: &Layer, ectx: &EvalCtx, g: &PropGroup, id: &str) -> String { match g.get(id).map(|p| ectx.value(layer, p)) { @@ -609,6 +612,7 @@ pub fn header_height(effect: &str, g: &PropGroup, width: f32) -> f32 { _ => 0.0, }, RESHAPE => 50.0, + EXTRACTOR => 30.0, _ => 0.0, } } @@ -628,6 +632,7 @@ pub fn header_editor( match effect { GLOW => glow_map(app, ui, p, layer, g, ectx, r, actions), RESHAPE => reshape_buttons(app, ui, p, layer, g, ectx, r, actions), + EXTRACTOR => extractor_layers(app, ui, p, layer, g, ectx, r, actions), _ => {} } } @@ -795,6 +800,115 @@ fn reshape_buttons( p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim); } +// --------------------------------------------------------------------------------------------- +// EXtractoR: the layer's OpenEXR layers and channels + +/// EXtractoR's channel parameters, shown in red, green, blue and alpha. +const EXTRACTOR_CHANNELS: [&str; 4] = ["red", "green", "blue", "alpha"]; +/// What an empty channel name shows. +const NO_CHANNEL: &str = "(none)"; + +/// Whether `prop` (in group `g`) is one of EXtractoR's channel parameters. +pub fn is_extractor_channel(g: &PropGroup, prop: &effectcraft_engine::project::Property) -> bool { + matches!(&g.kind, GroupKind::Effect { effect } if effect == EXTRACTOR) && EXTRACTOR_CHANNELS.contains(&prop.match_id.as_str()) +} + +/// The OpenEXR layer EXtractoR's red, green and blue come from (`""`: the unnamed layer), or +/// `None` when they mix layers. +fn extractor_layer(vals: &[String; 4]) -> Option<&str> { + let mut layers = vals[..3].iter().filter(|v| !v.trim().is_empty()).map(|v| effectcraft_engine::raster::channels3d::split_channel(v.trim()).0); + let first = layers.next()?; + layers.all(|l| l == first).then_some(first) +} + +/// EXtractoR's Layer popup (above its channels): picking an OpenEXR layer of the footage +/// (`diffuse`, `ViewLayer.Combined`, …) shows its channels in red, green, blue and alpha in one +/// undo step, as After Effects' EXtractoR dialog does. +#[allow(clippy::too_many_arguments)] +fn extractor_layers( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + g: &PropGroup, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + let t = app.tokens; + let euid = g.uid; + let vals = EXTRACTOR_CHANNELS.map(|id| str_value(layer, ectx, g, id)); + let label = match extractor_layer(&vals) { + Some("") => "RGBA".to_string(), + Some(l) => l.to_string(), + None => "Custom".to_string(), + }; + // Lined up with the parameter names and value popups below (Effect Controls' row layout). + p.text(pos2(r.min.x + 37.0, r.center().y), Align2::LEFT_CENTER, "Layer", Tokens::ui(12.0), t.text); + let vx = (r.min.x + r.width() * 0.48).max(r.min.x + 161.0); + let dr = Rect::from_min_size(pos2(vx, r.center().y - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); + let pop = egui::Id::new(("extractor-layers", euid)); + if widgets::dropdown(ui, dr, &label, &t, egui::Id::new(("extractor-layer", euid))) + .on_hover_text("The OpenEXR layer shown in red, green, blue and alpha") + .clicked() + { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("effectControls.effect.{euid}.extractor.layer"), dr, "Layer"); + // The footage's layers, read only while the list is open (each frame of a sequence is a file). + if !widgets::popup_is_open(ui, pop) { + return; + } + let aux = app.session.layer_channels(layer.id); + let layers: Vec<&str> = aux.as_ref().map(|a| a.layers()).unwrap_or_default(); + let names: Vec = match layers.as_slice() { + [] => vec!["No layers: not a multi-layer OpenEXR file".into()], + _ => layers.iter().map(|l| if l.is_empty() { "RGBA".to_string() } else { l.to_string() }).collect(), + }; + let cur = names.iter().position(|n| *n == label); + if let (Some(i), Some(aux)) = (widgets::popup_menu(ui, pop, dr.left_bottom(), &names, cur), aux.as_ref()) { + let Some(l) = layers.get(i) else { return }; + let merge = format!("extractor-layer-{euid}-{}", ui.ctx().cumulative_pass_nr()); + for (id, v) in EXTRACTOR_CHANNELS.iter().zip(aux.layer_rgba(l)) { + set_str(actions, layer, g, id, v, Some(merge.clone())); + } + } +} + +/// An EXtractoR channel popup (in place of a text field): the layer's own R, G, B and A, then +/// every channel of its OpenEXR footage, and (none). +pub fn channel_popup( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + layer: &Layer, + prop: &effectcraft_engine::project::Property, + cur: &str, + r: Rect, + actions: &mut Actions, +) { + let t = app.tokens; + let uid = prop.uid; + let label = if cur.trim().is_empty() { NO_CHANNEL } else { cur }; + let pop = egui::Id::new(("ec-chpop", uid)); + if widgets::dropdown(ui, r, label, &t, egui::Id::new(("ec-ch", uid))).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), r, &prop.name); + if !widgets::popup_is_open(ui, pop) { + return; + } + let mut opts: Vec = ["R", "G", "B", "A"].map(String::from).to_vec(); + if let Some(aux) = app.session.layer_channels(layer.id) { + opts.extend(aux.names().into_iter().filter(|n| !opts.iter().any(|o| o.eq_ignore_ascii_case(n))).map(String::from).collect::>()); + } + opts.push(NO_CHANNEL.into()); + let sel = opts.iter().position(|o| o.eq_ignore_ascii_case(label)); + if let Some(i) = widgets::popup_menu(ui, pop, r.left_bottom(), &opts, sel) { + let v = opts.get(i).filter(|o| *o != NO_CHANNEL).cloned().unwrap_or_default(); + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v}))); + } +} + /// Arc-length fraction (0..1) of the point of a polyline nearest to `q`. pub fn nearest_fraction(pts: &[[f64; 2]], closed: bool, q: [f64; 2]) -> f64 { let n = pts.len(); diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index 5cf9f518..9347ebe2 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -357,3 +357,70 @@ fn right_click_in_effect_controls_shows_the_effect_menu() { assert_eq!(names(&h, a), ["Gaussian Blur"], "every selected layer gets it"); assert!(h.query_by_label(category).is_none(), "the menu closed"); } + +/// #295: EXtractoR on a multi-layer OpenEXR layer offers the file's layers (a Layer popup that +/// sets red, green, blue and alpha in one undo step) and its channels (a popup per colour); +/// there was no way to pick any. +#[test] +fn extractor_offers_the_exr_layers_and_channels() { + use exr::prelude::*; + let (w, hh) = (8usize, 4usize); + let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * hh])); + let channels = AnyChannels::sort( + vec![ch("diffuse.R", 1.0), ch("diffuse.G", 0.5), ch("diffuse.B", 0.0), ch("spec.R", 0.0), ch("spec.G", 0.0), ch("spec.B", 1.0), ch("depth.Z", 0.25)] + .into(), + ); + let path = std::env::temp_dir().join(format!("ec-ui-exr-layers-{}.exr", std::process::id())); + Image::from_layer(exr::prelude::Layer::new((w, hh), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels)).write().to_file(&path).unwrap(); + + let mut s = effectcraft_host::session(); + let item = s.execute("file.import", json!({"paths": [path.to_string_lossy()]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "EXR", "width": 8, "height": 4, "frameRate": 24, "duration": 1})).unwrap(); + let l = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layers": [l], "effect": "EXtractoR"})).unwrap()["effects"][0].as_u64().unwrap(); + let uid = |s: &Session, id: &str| layer(s, l).effects().and_then(|f| f.groups().find(|g| g.uid == fx)).and_then(|g| g.get(id)).map(|p| p.uid).unwrap(); + let red = uid(&s, "red"); + s.state.selected_layers = vec![LayerId(l)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let values = |h: &Harness<'_, EffectcraftApp>| -> Vec { + let l = layer(&h.state().session, l); + let g = l.effects().and_then(|f| f.groups().find(|g| g.uid == fx)).unwrap(); + ["red", "green", "blue", "alpha"] + .iter() + .map(|id| { + g.get(id) + .map(|p| match &p.value { + KV::Str(s) => s.clone(), + _ => String::new(), + }) + .unwrap_or_default() + }) + .collect() + }; + assert_eq!(values(&h), ["R", "G", "B", "A"]); + + // Layer ▸ spec: its channels in red, green and blue (no alpha channel: none). + let at = rect_of(&h, &format!("effectControls.effect.{fx}.extractor.layer")).center(); + click(&mut h, at); + for name in ["depth", "diffuse", "spec"] { + assert!(h.query_by_label(name).is_some(), "the file's layer {name}"); + } + h.get_by_label("spec").click(); + h.run_steps(3); + assert_eq!(values(&h), ["spec.R", "spec.G", "spec.B", ""]); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(values(&h), ["R", "G", "B", "A"], "one undo step"); + h.run_steps(2); + + // Red ▸ depth.Z: every channel of the file is listed. + let at = rect_of(&h, &format!("effectControls.prop.{red}.value")).center(); + click(&mut h, at); + assert!(h.query_by_label("diffuse.G").is_some() && h.query_by_label("(none)").is_some()); + h.get_by_label("depth.Z").click(); + h.run_steps(3); + assert_eq!(values(&h)[0], "depth.Z"); + let _ = std::fs::remove_file(path); +} diff --git a/docs/agents.md b/docs/agents.md index 373f1b7d..90878e10 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -207,6 +207,10 @@ build real projects through these interfaces; they are worked examples of everyt `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. * **Image sequences** import at 30 fps (Settings ▸ Import); `file.interpretFootage {"items":[id], "frameRate":12}` conforms them (the item and layers that ran to its end get the new length). +* **Multi-layer OpenEXR**: `layer.channels {layer}` lists a footage layer's EXR layers and + channels (`{"layers":[{"name":"diffuse","rgba":["diffuse.R","diffuse.G","diffuse.B",""]}]}`); + EXtractoR shows them when its `red` / `green` / `blue` / `alpha` are set to those names + (`prop.set {"path":"effects/#1/red","value":"depth.Z"}`). Effect Controls offers them as popups. * **Masks**: `layer.addMask {layer, shape: rect|ellipse, rect}` first, then `layer.mask.set {field: feather|expansion|opacity, value}`. * **Renders**: `renderQueue.add {comp, format: h264|prores|webm|png…, channels: rgba, diff --git a/docs/parity.md b/docs/parity.md index e9fb6c89..9a4f2ad0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -190,7 +190,7 @@ Mocha and Cineware are third-party and not counted. | Category | Effects | Notes | |---|---|---| -| 3D Channel | 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, Fog 3D, ID Matte, IDentifier | Read auxiliary channels: multi-layer OpenEXR (any `layer.channel`, Cryptomatte manifests from the header) or the compositor's own depth / layer-ID / normal / UV / Cryptomatte pass for precomps of 3D comps. | +| 3D Channel | 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, Fog 3D, ID Matte, IDentifier | Read auxiliary channels: multi-layer OpenEXR (any `layer.channel`, Cryptomatte manifests from the header; EXtractoR picks a layer and channels from popups of the footage's layers and channels, `layer.channels`, #295) or the compositor's own depth / layer-ID / normal / UV / Cryptomatte pass for precomps of 3D comps. | | Immersive Video | VR Blur, VR Chromatic Aberrations, VR Color Gradients, VR Converter, VR De-Noise, VR Digital Glitch, VR Fractal Noise, VR Glow, VR Plane to Sphere, VR Rotate Sphere, VR Sharpen, VR Sphere to Plane | Seam-aware equirectangular processing (wrap-around, pole continuation, latitude-scaled filters), mono / over-under / side-by-side layouts; VR Converter between equirectangular, cube maps (4:3, 3:2, 6:1), sphere map and 2D. | | Color Correction | OCIO CDL / Color Space / Display / File / Look Transform, Color Stabilizer | Built-in minimal OCIO-style config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published matrices and transfer functions; `.cube` / `.3dl` / `.csp` LUTs and ASC `.cc` / `.ccc` / `.cdl`. Custom `.ocio` config files are read (Configuration ▸ Custom: colorspaces, roles, displays/views; Matrix, File, Exponent, Log, LogAffine, CDL, Range and Group transforms). | | Distort | CC Flo Motion, Liquify, Rolling Shutter Repair | Liquify strokes are effect data replayed into a displacement mesh (`liquify.stroke` / `liquify.clear` commands; no interactive viewer brush yet). Rolling Shutter Repair uses the KLT tracker. |