Effects: EXtractoR picks layers and channels of multi-layer EXRs (#295)

EXtractoR's Red, Green, Blue and Alpha are text parameters naming
channels, and Effect Controls drew nothing for text parameters: there
was no way to choose a layer or channel. And a multi-layer OpenEXR file
whose colour is only in named layers (`diffuse.R`, Blender's
`ViewLayer.Combined.R`) didn't import at all: the image decoder wants an
unnamed RGB layer ("does not contain non-deep rgb channels").

- Effect Controls: EXtractoR gets a Layer popup listing the footage's
  OpenEXR layers. Picking one sets red, green, blue and alpha to its
  channels in one undo step, as After Effects' EXtractoR dialog does (a
  one-channel layer such as depth goes in all three colours). Red /
  Green / Blue / Alpha are popups of the layer's own R, G, B, A, every
  channel of the file, and (none). The lists are read only while open,
  so playing an EXR sequence doesn't parse files on the UI thread.
- `layer.channels {layer}` lists a layer's EXR layers and channels and
  what each layer shows in red, green, blue and alpha, for agents.
- Media: an EXR without an unnamed RGB layer imports and shows its
  colour layer (one named combined, beauty or rgba first); its channels
  stay available to EXtractoR and the 3D Channel effects.
- Raster: AuxChannels::layers / layer_rgba and split_channel.

Test: extractor_shows_the_chosen_layer_and_channels_of_a_layered_exr
(host: a layered EXR written with the exr crate imports, layer.channels,
EXtractoR renders spec and depth; it failed to import before),
extractor_offers_the_exr_layers_and_channels (UI: Layer popup, one undo
step, channel popup), layered_exr_without_an_unnamed_rgb_layer_imports
(media).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-09 17:32:09 +07:00
1 parent a6c72acf94
commit 27cabcb36b
14 files changed
+406 -9

No files matched your search

Generated
+2
View File
@@ -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",
+27
View File
@@ -415,9 +415,36 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
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<std::sync::Arc<effectcraft_raster::AuxChannels>> {
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<Value> {
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<Value> = 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<CommandSpec> {
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",
+1
View File
@@ -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 }
+67
View File
@@ -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);
}
+64
View File
@@ -94,6 +94,46 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option<AuxChannels> {
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<image::DynamicImage> {
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<Vec<(String, Option<Arc<AuxChannels>>)>>;
/// 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));
}
}
+5 -1
View File
@@ -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) => {
+9 -3
View File
@@ -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<Footage> {
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))
}
+35
View File
@@ -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"])
}
+1
View File
@@ -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 }
+7 -2
View File
@@ -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),
+116 -2
View File
@@ -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<String> = 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<String> = ["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::<Vec<_>>());
}
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();
@@ -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<String> {
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);
}
+4
View File
@@ -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,
+1 -1
View File
@@ -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. |