Merge pull request #387 from storytold/fix/295-extractor-layers

Effects: EXtractoR picks layers and channels of multi-layer EXRs (#295)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-09 17:58:37 +07:00
commit 4aaadd0ed3
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. |