diff --git a/Cargo.lock b/Cargo.lock index c60cedafd3..5d3156368d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4339,6 +4339,7 @@ name = "photocraft-engine" version = "0.6.0" dependencies = [ "blake3", + "exr", "flate2", "photocraft-affinity", "photocraft-algo", diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index 57197ee7a9..a4014db0b3 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -181,6 +181,77 @@ pub(crate) fn decode_part(bytes: &[u8], index: usize, limits: &Limits) -> Result layer_to_image(&read_layer(bytes, index)?) } +/// Decodes the named channels of one flat part (exact names as the file stores them, e.g. +/// `diffuse.R`) into an image: R/G/B (plus A when named) give Rgba, a lone Y gives Gray. This +/// is how a channel group renders without the part's other channels in the way. +pub(crate) fn decode_channels(bytes: &[u8], index: usize, names: &[&str], limits: &Limits) -> Result { + let meta = read_meta(bytes, limits)?; + if meta.requirements.has_deep_data { + return Err(CodecError::unsupported(F, "this file has deep parts; the deep decoder composites those (mixing flat and deep parts is not supported)")); + } + if index >= meta.headers.len() { + return Err(err(format!("part index {index} is out of range ({} parts)", meta.headers.len()))); + } + let layer = read_layer(bytes, index)?; + let (w, h) = (layer.size.0, layer.size.1); + let channels = &layer.channel_data.list; + let npx = w * h; + let mut picked: Vec<&AnyChannel> = Vec::with_capacity(names.len()); + for name in names { + let c = channels.iter().find(|c| c.name.to_string() == *name).ok_or_else(|| err(format!("the part has no channel {name}")))?; + if c.sample_data.len() != npx { + return Err(err("channel sample count mismatch (subsampled channels are unsupported)")); + } + picked.push(c); + } + let base = |c: &AnyChannel| -> String { base_channel_name(&c.name.to_string()).to_string() }; + let has = |n: &str| picked.iter().any(|c| base(c) == n); + let (layout, order): (ChannelLayout, Vec>) = if has("R") && has("G") && has("B") { + let at = |n: &str| picked.iter().position(|c| base(c) == n); + match has("A").then(|| at("A")) { + Some(a) => (ChannelLayout::Rgba, vec![at("R"), at("G"), at("B"), a]), + None => (ChannelLayout::Rgb, vec![at("R"), at("G"), at("B"), None]), + } + } else if has("Y") { + let y = picked.iter().position(|c| base(c) == "Y"); + match picked.iter().position(|c| base(c) == "A") { + Some(a) => (ChannelLayout::GrayA, vec![y, Some(a)]), + None => (ChannelLayout::Gray, vec![y]), + } + } else if picked.len() == 1 { + (ChannelLayout::Gray, vec![Some(0)]) + } else { + return Err(CodecError::unsupported(F, "the named channels are no R/G/B or Y set")); + }; + let all_f16 = picked.iter().all(|c| matches!(c.sample_data, FlatSamples::F16(_))); + let nc = layout.channels(); + let (w32, h32) = (w as u32, h as u32); + // The None slots sit past the layout's own channels (the absent A of an RGB pick) and + // are skipped, so the buffer keeps its pixel stride. + let img = if all_f16 { + let mut out = vec![f16::ZERO; npx * nc]; + for (ci, pick) in order.iter().enumerate() { + let Some(idx) = pick else { continue }; + let Some(FlatSamples::F16(v)) = picked.get(*idx).map(|c| &c.sample_data) else { continue }; + for (i, s) in v.iter().enumerate() { + out[i * nc + ci] = *s; + } + } + Image::from_f16(w32, h32, layout, &out) + } else { + let mut out = vec![0f32; npx * nc]; + for (ci, pick) in order.iter().enumerate() { + let Some(idx) = pick else { continue }; + let Some(c) = picked.get(*idx) else { continue }; + for (i, s) in c.sample_data.values_as_f32().enumerate() { + out[i * nc + ci] = s; + } + } + Image::from_f32(w32, h32, layout, &out) + }; + img.map_err(|e| CodecError::encode(F, e)) +} + pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); let (w, h) = (img.width() as usize, img.height() as usize); diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 4ad8a0ef3e..cc8d0558b2 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -163,6 +163,14 @@ pub fn decode_cryptomatte(bytes: &[u8], layer_name: &str, limits: &Limits) -> Re codecs::exr_cryptomatte::decode(bytes, layer_name, limits) } +/// Decodes the named channels of one part (exact names as the file stores them, e.g. +/// `diffuse.R`) into an image: R/G/B (plus A when named) give Rgba, a lone Y gives Gray. +/// This renders a channel group without the part's other channels in the way. +pub fn decode_exr_channels(bytes: &[u8], part: usize, names: &[&str], opts: &DecodeOptions) -> Result { + let img = exr::decode_channels(bytes, part, names, &opts.limits)?; + finish_decode(img, 1, opts) +} + /// Deep OpenEXR samples (`deepscanline`/`deeptile`): the structured per-pixel sample lists /// (colour, alpha and Z per sample), for callers that composite deep data themselves — /// [`decode_as_with`] instead flattens a deep file into a normal [`Image`] with a diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 97b8a1f4cd..fa15c30986 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -44,3 +44,5 @@ photocraft-affinity = { workspace = true, features = ["synth"] } photocraft-raw = { workspace = true, features = ["testgen"] } # WebAssembly text -> binary for plug-in test modules (Apache-2.0 WITH LLVM-exception). wat = "1.228" +# Synthetic multi-part EXR fixtures for the parts-commands tests. +exr = { version = "1.73", default-features = false } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index fad98f923e..848cdffd3b 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1143,6 +1143,7 @@ fn build() -> Vec { v.extend(crate::paint_cmds::specs()); v.extend(crate::extra_cmds::specs()); v.extend(crate::file_cmds::specs()); + v.extend(crate::exr_cmds::specs()); v.extend(crate::type_extra_cmds::specs()); v.extend(crate::type_styles_cmds::specs()); v.extend(crate::type_spell_cmds::specs()); diff --git a/crates/engine/src/exr_cmds.rs b/crates/engine/src/exr_cmds.rs new file mode 100644 index 0000000000..c0a3310f9f --- /dev/null +++ b/crates/engine/src/exr_cmds.rs @@ -0,0 +1,265 @@ +//! EXR parts: what a multi-part or channel-grouped EXR file holds, and opening the chosen +//! parts/groups as the layers of one document (a Maya/Arnold render writes one part per +//! AOV). The query command feeds the File › Open EXR Parts… dialog, the CLI, MCP and the +//! control channel alike. + +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::file_cmds::{native, read_file, stem}; +use crate::{EngineError, Result, Session}; + +fn entries_json(path: &str) -> Result { + let bytes = read_file(path)?; + let entries = photocraft_io::exr_parts::entries(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!(entries + .iter() + .map(|e| json!({ + "kind": match e.kind { photocraft_io::exr_parts::EntryKind::Part => "part", photocraft_io::exr_parts::EntryKind::Group => "group", photocraft_io::exr_parts::EntryKind::Cryptomatte => "cryptomatte" }, + "name": e.name, + "view": e.view, + "width": e.width, + "height": e.height, + "channels": e.channels, + "objects": e.objects, + })) + .collect::>())) +} + +fn exr_parts(s: &mut Session, p: &Value) -> Result { + let _ = s; + let path = crate::file_cmds::str_param(p, "path", "file.exrParts")?.to_string(); + entries_json(&path) +} + +/// Collects `part/` and `group/` boolean params into the selection names. +fn selected(p: &Value, prefix: &str) -> Vec { + p.as_object() + .map(|m| m.iter().filter(|(k, v)| k.starts_with(prefix) && v.as_bool() == Some(true)).map(|(k, _)| k[prefix.len()..].to_string()).collect()) + .unwrap_or_default() +} + +fn open_exr_parts(s: &mut Session, p: &Value) -> Result { + let path = crate::file_cmds::str_param(p, "path", "file.openExrParts")?.to_string(); + let view = p.get("view").and_then(Value::as_str).unwrap_or("both"); + if !matches!(view, "both" | "left" | "right") { + return Err(EngineError::BadParams { cmd: "file.openExrParts".into(), msg: format!("view is \"{view}\"; it is \"both\", \"left\" or \"right\"") }); + } + let sel = photocraft_io::exr_parts::Selection { + parts: selected(p, "part/"), + groups: selected(p, "group/"), + view: view.to_string(), + precomp: p.get("precomp").and_then(Value::as_bool).unwrap_or(false), + }; + let bytes = read_file(&path)?; + let r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let (i, color) = s.open_document(r.document, Some(path.clone())); + Ok(json!({"document": i, "color": color, "warnings": r.warnings})) +} + +pub fn specs() -> Vec { + macro_rules! spec { + ($id:expr, $label:expr, $menu:expr, $sc:expr, $params:expr, $enabled:expr, $run:expr) => { + CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } + }; + } + vec![ + spec!("file.exrParts", "EXR Parts Info", &[], None, r##"{"path":str (the EXR file)}"##, native, exr_parts), + spec!( + "file.openExrParts", + "Open EXR Parts…", + &["File"], + None, + r##"{"path":str (the EXR file),"view":"both|left|right"="both","part/":bool (import this part as a layer),"group/":bool (import this channel group as a layer),"precomp":bool=false (add the additive beauty precomp from the AOVs)}"##, + native, + open_exr_parts + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_doc::ColorMode; + + fn session() -> Session { + Session::new() + } + + // The fixture writer mirrors the codecs tests: a small multi-part EXR via the `exr` + // crate, one part per AOV plus a single-part file with prefixed channel groups. + + fn write_multipart(path: &std::path::Path) { + use exr::prelude::*; + let (w, h) = (4usize, 4usize); + let mk = |n: String, v: f32| AnyChannel::new(n.as_str(), FlatSamples::F32(vec![v; w * h])); + let beauty = AnyChannels::sort(vec![mk("R".into(), 0.8), mk("G".into(), 0.4), mk("B".into(), 0.2), mk("A".into(), 1.0)].into_iter().collect()); + let z = AnyChannels::sort(vec![mk("Z".into(), 5.0)].into_iter().collect()); + let layer = |name: &str, chans: AnyChannels| { + Layer::new( + (w, h), + LayerAttributes::named(name), + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + chans, + ) + }; + let image = Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer("Z", z), layer("beauty", beauty)]); + image.write().to_buffered(&mut std::io::BufWriter::new(std::fs::File::create(path).expect("create fixture"))).expect("write fixture"); + } + + fn write_grouped(path: &std::path::Path) { + use exr::prelude::*; + let (w, h) = (4usize, 4usize); + let mk = |n: String, v: f32| AnyChannel::new(n.as_str(), FlatSamples::F32(vec![v; w * h])); + // Two AOV groups (each with alpha) plus the beauty RGBA, in ONE part. + let list = vec![ + mk("diffuse.R".into(), 0.3), + mk("diffuse.G".into(), 0.3), + mk("diffuse.B".into(), 0.3), + mk("diffuse.A".into(), 0.5), + mk("specular.R".into(), 0.6), + mk("specular.G".into(), 0.6), + mk("specular.B".into(), 0.6), + mk("specular.A".into(), 0.25), + mk("R".into(), 0.9), + mk("G".into(), 0.9), + mk("B".into(), 0.9), + mk("A".into(), 1.0), + ]; + let layer = Layer::new( + (w, h), + LayerAttributes::named("main"), + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + image.write().to_buffered(&mut std::io::BufWriter::new(std::fs::File::create(path).expect("create fixture"))).expect("write fixture"); + } + + #[test] + fn exr_parts_lists_parts_groups_and_views() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-parts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("aov.exr"); + write_multipart(&path); + let info = entries_json(path.to_str().unwrap()).unwrap(); + let kinds: Vec<&str> = info.as_array().unwrap().iter().map(|e| e["kind"].as_str().unwrap()).collect(); + assert_eq!(kinds, ["part", "part"], "{info}"); + assert_eq!(info[0]["name"], "Z"); + assert_eq!(info[1]["name"], "beauty"); + assert_eq!(info[1]["channels"], 4); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn open_exr_parts_imports_the_chosen_part_as_a_layer() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-open-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("aov.exr"); + write_multipart(&path); + let mut s = session(); + let r = s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "part/beauty": true, "part/Z": false})).unwrap(); + assert_eq!(r["warnings"].as_array().unwrap().len(), 0, "{r}"); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.layers.len(), 1); + assert_eq!(doc.layers[0].name, "beauty"); + // The beauty pixels survived (R 0.8 at pixel 0). + if let photocraft_doc::LayerContent::Raster(surface) = &doc.layers[0].content { + let px = surface.pixel(0, 0); + assert!((px[0] - 0.8).abs() < 1e-4, "{:?}", px); + } else { + panic!("expected a raster layer"); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn open_exr_parts_refuses_an_empty_selection_and_unknown_names() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-empty-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("aov.exr"); + write_multipart(&path); + let mut s = session(); + let e = s.execute("file.openExrParts", json!({"path": path.to_str().unwrap()})).unwrap_err(); + assert!(e.to_string().contains("no parts or groups selected"), "{e}"); + let e = s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "part/nope": true})).unwrap_err(); + assert!(e.to_string().contains("no flat part named nope"), "{e}"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn channel_groups_import_from_their_own_channels() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-groups-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("grouped.exr"); + write_grouped(&path); + let info = entries_json(path.to_str().unwrap()).unwrap(); + let names: Vec<&str> = info.as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["main", "diffuse", "specular"], "{info}"); + let mut s = session(); + s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "group/diffuse": true})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.layers.len(), 1); + assert_eq!(doc.layers[0].name, "diffuse"); + if let photocraft_doc::LayerContent::Raster(surface) = &doc.layers[0].content { + let px = surface.pixel(0, 0); + // The channels import as stored (straight here): values are untouched. + assert!((px[0] - 0.3).abs() < 1e-4, "{:?}", px); + assert!((px[3] - 0.5).abs() < 1e-4, "{:?}", px); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn precomp_sums_the_light_path_set_and_refuses_an_incomplete_one() { + let dir = std::env::temp_dir().join(format!("pcraft-exr-precomp-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("grouped.exr"); + write_grouped(&path); + let mut s = session(); + // diffuse + specular make the set incomplete: emission is missing. + let e = s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "precomp": true, "group/diffuse": true})).unwrap_err(); + assert!(e.to_string().contains("missing") && e.to_string().contains("emission"), "{e}"); + + // A complete set: write a file holding direct/indirect diffuse+specular and emission. + let full = dir.join("full.exr"); + use exr::prelude::*; + let (w, h) = (4usize, 4usize); + let mk = |n: String, v: f32| AnyChannel::new(n.as_str(), FlatSamples::F32(vec![v; w * h])); + let aovs = [ + ("direct_diffuse", 0.10, 1.0), + ("indirect_diffuse", 0.20, 1.0), + ("direct_specular", 0.05, 1.0), + ("indirect_specular", 0.15, 1.0), + ("emission", 0.30, 1.0), + ]; + let mut list: Vec> = Vec::new(); + let mut expect = 0.0f32; + for (name, v, a) in aovs { + list.push(mk(format!("{name}.R"), v)); + list.push(mk(format!("{name}.G"), v)); + list.push(mk(format!("{name}.B"), v)); + list.push(mk(format!("{name}.A"), a)); + expect += v * a; + } + let layer = Layer::new( + (w, h), + LayerAttributes::named("main"), + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + image.write().to_buffered(&mut std::io::BufWriter::new(std::fs::File::create(&full).expect("create fixture"))).expect("write fixture"); + let mut s = session(); + let r = s.execute("file.openExrParts", json!({"path": full.to_str().unwrap(), "precomp": true})).unwrap(); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("precomp")), "{r}"); + let doc = &s.active().unwrap().doc; + let precomp = doc.layers.iter().find(|l| l.name == "beauty (precomp)").expect("the precomp layer"); + if let photocraft_doc::LayerContent::Raster(surface) = &precomp.content { + let px = surface.pixel(0, 0); + assert!((px[0] - expect).abs() < 1e-3, "{:?} vs {expect}", px); + } + assert_eq!(doc.mode, ColorMode::Rgb); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index df55281a4f..5245eed1c4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -32,6 +32,7 @@ pub mod document_preset_cmds; pub mod edit_cmds; pub mod edit_menu_cmds; pub mod eraser_cmds; +pub mod exr_cmds; pub mod extra_cmds; pub mod file_cmds; pub mod fill_cmds; diff --git a/crates/io/src/exr_parts.rs b/crates/io/src/exr_parts.rs new file mode 100644 index 0000000000..93b8e85f36 --- /dev/null +++ b/crates/io/src/exr_parts.rs @@ -0,0 +1,335 @@ +//! Multi-part EXR and channel groups as layers (a Maya/Arnold render writes one part per +//! AOV, or a single part with prefixed channel sets like `diffuse.R`): the entries of a +//! file listed for a chooser ([`entries`]), and the chosen ones imported as the layers of +//! one document ([`import`]), with an optional additive beauty precomp from the AOVs. + +use photocraft_cms::Builtin; +use photocraft_codecs::{self as codecs, ChannelLayout, DecodeOptions, Image, SampleType}; +use photocraft_color::{ColorMode, SampleType as DocSample}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; +use photocraft_raster::Surface; + +use crate::{ImportResult, IoError, flat::image_surface}; + +/// One selectable line of the chooser: a whole part, a prefixed channel group inside a +/// part, or a Cryptomatte layer (listed for information; its samples are not image layers). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Entry { + pub kind: EntryKind, + /// The name as shown and selected: the part name, the channel-group prefix, or the + /// Cryptomatte layer name. + pub name: String, + /// The part's `view` attribute (stereo files), when it has one. + pub view: Option, + pub width: u32, + pub height: u32, + /// Image channels that would import (4 for RGBA, 3 for RGB); Cryptomatte: stream count. + pub channels: usize, + /// Cryptomatte only: objects in the parsed manifest. + pub objects: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryKind { + Part, + Group, + Cryptomatte, +} + +/// One channel group: where it lives and which exact channels make up its RGB(A). +#[derive(Clone, Debug)] +struct Group { + /// The prefix before the last dot (`diffuse` of `diffuse.R`). + prefix: String, + part: usize, + channels: Vec, + alpha: Option, +} + +/// The name an AOV normalizes to for the precomp check (`Direct_Diffuse` and +/// `directDiffuse` are the same AOV). +fn normalize(name: &str) -> String { + name.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase() +} + +/// The additive AOVs a beauty precomp is built from: the light-path set Arnold writes. +const PRECOMP_REQUIRED: [&str; 5] = ["directdiffuse", "indirectdiffuse", "directspecular", "indirectspecular", "emission"]; +const PRECOMP_OPTIONAL: [&str; 3] = ["transmission", "sss", "volume"]; + +/// The base channel name (`R` of `diffuse.R`, `R` of `R`). +fn channel_base(name: &str) -> &str { + name.rsplit('.').next().unwrap_or(name) +} + +/// The prefixed RGB(A) channel sets of every flat part (`diffuse.R/G/B` make the group +/// `diffuse`). Unprefixed channels are no group, and a prefix ending in digits is a +/// Cryptomatte stream (`crypto_asset00`), not a group. Each prefix is listed once. +fn groups(bytes: &[u8]) -> Result, IoError> { + let mut out: Vec = Vec::new(); + for part in codecs::exr_info(bytes, &Default::default())? { + if part.deep { + continue; + } + let mut found: Vec = Vec::new(); + for c in &part.channels { + let Some((prefix, base)) = channel_base_split(&c.name) else { continue }; + if prefix.chars().last().is_some_and(|ch| ch.is_ascii_digit()) { + continue; + } + if found.iter_mut().all(|g| g.prefix != prefix) { + found.push(Group { prefix: prefix.to_string(), part: part.index, channels: Vec::new(), alpha: None }); + } + let Some(group) = found.iter_mut().find(|g| g.prefix == prefix) else { continue }; + match base { + "R" | "G" | "B" => group.channels.push(c.name.clone()), + "A" => group.alpha = Some(c.name.clone()), + _ => {} + } + } + found.retain(|g| g.channels.len() == 3); + out.extend(found); + } + Ok(out) +} + +/// `(prefix, base)` when the channel name carries a dot (`diffuse.R`); `None` unprefixed. +fn channel_base_split(name: &str) -> Option<(&str, &str)> { + let (prefix, base) = name.rsplit_once('.')?; + (!prefix.is_empty()).then_some((prefix, base)) +} + +/// Lists the entries of a multi-part or channel-grouped EXR for a chooser: flat parts in +/// file order, then the channel groups, then the Cryptomatte layers as information. A file +/// whose parts are all deep is refused (the deep decoder composites those). +pub fn entries(bytes: &[u8]) -> Result, IoError> { + let info = codecs::exr_info(bytes, &Default::default())?; + if info.iter().all(|p| p.deep) { + return Err(IoError::Unsupported("this EXR holds only deep parts; it opens through the deep decoder".into())); + } + let mut out = Vec::new(); + for part in &info { + if part.deep { + continue; + } + out.push(Entry { + kind: EntryKind::Part, + name: part.name.clone().unwrap_or_else(|| format!("part {}", part.index)), + view: part.view.clone(), + width: part.width, + height: part.height, + channels: part.channels.iter().filter(|c| ["R", "G", "B", "A", "Y"].contains(&channel_base(&c.name))).count(), + objects: None, + }); + } + for group in groups(bytes)? { + out.push(Entry { + kind: EntryKind::Group, + name: group.prefix.clone(), + view: None, + width: 0, + height: 0, + channels: 3 + usize::from(group.alpha.is_some()), + objects: None, + }); + } + for layer in codecs::cryptomatte_layers(bytes, &Default::default())? { + out.push(Entry { + kind: EntryKind::Cryptomatte, + name: layer.name.clone(), + view: None, + width: 0, + height: 0, + channels: layer.channels.len(), + objects: Some(layer.manifest.len()), + }); + } + Ok(out) +} + +/// What [`import`] turns into layers. +pub struct Selection { + /// Part names to import as whole layers. + pub parts: Vec, + /// Channel-group prefixes to import as layers. + pub groups: Vec, + /// `"both"`, `"left"` or `"right"`: which stereo views of the parts to consider. + pub view: String, + /// Add a `beauty (precomp)` layer summed from the file's AOV groups (see + /// [`PRECOMP_REQUIRED`]); fails naming the missing AOVs when the set is incomplete. + pub precomp: bool, +} + +/// An image as an importable layer: F16 becomes F32 (lossless, warned once by the caller), +/// the layout matches the document's, the surface is built a band of rows at a time. The +/// returned mode tells the caller whether this layer is grayscale. +fn layer_surface(img: &Image, max: &mut (u32, u32)) -> Result<(Surface, ColorMode), IoError> { + let (layout, mode) = if matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA) { + (ChannelLayout::GrayA, ColorMode::Grayscale) + } else { + (ChannelLayout::Rgba, ColorMode::Rgb) + }; + max.0 = max.0.max(img.width()); + max.1 = max.1.max(img.height()); + let converted = img.convert(layout, SampleType::F32); + image_surface(&converted, layout, SampleType::F32).map(|s| (s, mode)) +} + +/// Imports the chosen entries as the layers of one document: parts whole, groups from their +/// own channels, F16 kept losslessly as F32, the linear-light EXR values tagged linear sRGB. +/// `precomp` adds the additive sum of the file's AOV groups as a top layer and fails, +/// naming the missing AOVs, when the light-path set is incomplete. +pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result { + let opts = DecodeOptions::default(); + let info = codecs::exr_info(bytes, &opts.limits)?; + if info.is_empty() { + return Err(IoError::Unsupported("the EXR has no parts".into())); + } + let all_groups = groups(bytes)?; + let view_passes = |view: &Option| view.as_ref().is_none_or(|v| sel.view.eq_ignore_ascii_case("both") || v.eq_ignore_ascii_case(&sel.view)); + let part_by_name = + |want: &str| info.iter().find(|p| !p.deep && p.name.as_deref() == Some(want)).ok_or_else(|| IoError::Unsupported(format!("no flat part named {want}"))); + let mut chosen_parts: Vec<&codecs::ExrPartInfo> = Vec::new(); + for want in &sel.parts { + let p = part_by_name(want)?; + if !view_passes(&p.view) { + return Err(IoError::Unsupported(format!("the part {want} is of the {:?} view, the filter is {}", p.view, sel.view))); + } + if !chosen_parts.iter().any(|c| c.index == p.index) { + chosen_parts.push(p); + } + } + let mut chosen_groups: Vec<&Group> = Vec::new(); + for want in &sel.groups { + let g = all_groups.iter().find(|g| g.prefix == *want).ok_or_else(|| IoError::Unsupported(format!("no channel group named {want}")))?; + let part_view = info.get(g.part).and_then(|p| p.view.clone()); + if !view_passes(&part_view) { + return Err(IoError::Unsupported(format!("the group {} is of the {:?} view, the filter is {}", want, part_view, sel.view))); + } + if !chosen_groups.iter().any(|c| c.prefix == g.prefix) { + chosen_groups.push(g); + } + } + if chosen_parts.is_empty() && chosen_groups.is_empty() && !sel.precomp { + return Err(IoError::Unsupported("no parts or groups selected".into())); + } + + // Decode each needed whole part once; groups come from their part's named channels. + let mut part_pixels: Vec> = vec![None; info.len()]; + let mut layers: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + let mut gray_only = true; + let mut max = (1u32, 1u32); + let mut f16_seen = false; + + for part in &chosen_parts { + let img = match &part_pixels[part.index] { + Some(img) => img.clone(), + None => { + let img = codecs::decode_exr_part(bytes, part.index, &opts)?; + part_pixels[part.index] = Some(img.clone()); + img + } + }; + if img.sample_type() == SampleType::F16 { + f16_seen = true; + } + let (surface, mode) = layer_surface(&img, &mut max)?; + gray_only &= mode == ColorMode::Grayscale; + layers.push(Layer::new(part.name.clone().unwrap_or_else(|| format!("part {}", part.index)), LayerContent::Raster(surface))); + } + for group in &chosen_groups { + let mut names = group.channels.clone(); + names.extend(group.alpha.clone()); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + let img = codecs::decode_exr_channels(bytes, group.part, &refs, &opts)?; + if img.sample_type() == SampleType::F16 { + f16_seen = true; + } + let (surface, mode) = layer_surface(&img, &mut max)?; + gray_only &= mode == ColorMode::Grayscale; + layers.push(Layer::new(group.prefix.clone(), LayerContent::Raster(surface))); + } + + if sel.precomp { + let layer = precomp_layer(bytes, &all_groups, &opts, &mut max, &mut warnings)?; + gray_only = false; // the precomp is an RGB layer by construction + layers.push(layer); + } + + if layers.is_empty() { + return Err(IoError::Unsupported("nothing imported".into())); + } + if f16_seen { + warnings.push("16-bit float samples are stored as 32-bit float".to_string()); + } + let mode = if gray_only { ColorMode::Grayscale } else { ColorMode::Rgb }; + let mut doc = Document::new(name, Size::new(max.0, max.1), mode, DocSample::F32); + doc.layers = layers; + if mode == ColorMode::Rgb { + // OpenEXR holds linear, scene-referred values: tag them linear sRGB, as File › Open does. + doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes()); + } + Ok(ImportResult { document: doc, warnings, source_read_only: false, preview_only: false }) +} + +/// The additive beauty from the file's AOV groups: premultiplied RGB summed over the +/// light-path set, alpha the maximum coverage, straightened back. Fails naming the missing +/// AOVs when the required set is incomplete; AOVs beyond the set are ignored. +fn precomp_layer(bytes: &[u8], all_groups: &[Group], opts: &DecodeOptions, max: &mut (u32, u32), warnings: &mut Vec) -> Result { + let have: Vec = all_groups.iter().map(|g| normalize(&g.prefix)).collect(); + let missing: Vec<&str> = PRECOMP_REQUIRED.iter().copied().filter(|r| !have.iter().any(|h| h == *r)).collect(); + if !missing.is_empty() { + return Err(IoError::Unsupported(format!("the AOV set is incomplete for a beauty precomp; missing: {}", missing.join(", ")))); + } + let mut sum_rgb: Vec = Vec::new(); + let mut alpha: Vec = Vec::new(); + let mut size = (0u32, 0u32); + for group in all_groups { + let n = normalize(&group.prefix); + if !PRECOMP_REQUIRED.contains(&n.as_str()) && !PRECOMP_OPTIONAL.contains(&n.as_str()) { + continue; + } + let mut names = group.channels.clone(); + names.extend(group.alpha.clone()); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + let img = codecs::decode_exr_channels(bytes, group.part, &refs, opts)?; + let rgba = img.convert(ChannelLayout::Rgba, SampleType::F32); + let (w, h) = rgba.dimensions(); + if size == (0, 0) { + size = (w, h); + sum_rgb = vec![0.0; w as usize * h as usize * 3]; + alpha = vec![0.0; w as usize * h as usize]; + } else if size != (w, h) { + return Err(IoError::Unsupported("the AOV groups differ in size; a beauty precomp needs one common extent".into())); + } + let px = rgba.to_f32_samples().unwrap_or_default(); + for p in 0..(w as usize * h as usize) { + // AOV channels are premultiplied by their coverage, as renderers write them: + // the sum adds the stored RGB as-is, and alpha is the maximum coverage. + let (r, g, b, a) = (px[p * 4], px[p * 4 + 1], px[p * 4 + 2], px[p * 4 + 3]); + sum_rgb[p * 3] += r; + sum_rgb[p * 3 + 1] += g; + sum_rgb[p * 3 + 2] += b; + alpha[p] = alpha[p].max(a); + } + } + let (w, h) = size; + if w == 0 || h == 0 { + return Err(IoError::Unsupported("the AOV groups are empty".into())); + } + let mut out = vec![0f32; w as usize * h as usize * 4]; + for p in 0..(w as usize * h as usize) { + let a = alpha[p]; + let (r, g, b) = if a > 0.0 { (sum_rgb[p * 3] / a, sum_rgb[p * 3 + 1] / a, sum_rgb[p * 3 + 2] / a) } else { (0.0, 0.0, 0.0) }; + out[p * 4] = r; + out[p * 4 + 1] = g; + out[p * 4 + 2] = b; + out[p * 4 + 3] = a; + } + let img = Image::from_f32(w, h, ChannelLayout::Rgba, &out)?; + max.0 = max.0.max(w); + max.1 = max.1.max(h); + let (surface, _) = layer_surface(&img, max)?; + warnings.push("beauty (precomp) is the additive sum of the file's AOVs; alpha is the maximum coverage".to_string()); + Ok(Layer::new("beauty (precomp)", LayerContent::Raster(surface))) +} diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index cbd4c44c9f..a64af9c71d 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -146,21 +146,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result Result Result { + let mode = match target_layout { + ChannelLayout::Gray | ChannelLayout::GrayA => ColorMode::Grayscale, + ChannelLayout::Cmyk | ChannelLayout::CmykA => ColorMode::Cmyk, + _ => ColorMode::Rgb, + }; + let depth = match csample { + CSample::U8 => SampleType::U8, + CSample::U16 => SampleType::U16, + _ => SampleType::F32, + }; + let fmt = PixelFormat::new(mode, depth, true); + let (w, h) = img.dimensions(); + let mut s = Surface::new(fmt); + if img.layout() == target_layout && img.sample_type() == csample { + s.write_interleaved(Rect::new(0, 0, w as i32, h as i32), img.data()); + } else { + let row = img.data().len() / (h.max(1) as usize); + let band = (BAND_BYTES / row.max(1)).max(1); + for (i, rows) in img.data().chunks(row.max(1) * band).enumerate() { + let n = (rows.len() / row.max(1)) as u32; + let part = Image::from_raw(w, n, img.layout(), img.sample_type(), rows.to_vec())?.convert(target_layout, csample); + let y0 = (i * band) as i32; + s.write_interleaved(Rect::new(0, y0, w as i32, y0 + n as i32), part.data()); + } + } + Ok(s) +} + /// `Some(surface)` when the document is exactly one visible, unmasked, /// normal, fully opaque raster layer: its pixels can be written natively /// (keeping CMYK / depth exactly) instead of going through the compositor. diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 3d6b766af1..2379edb55b 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -38,6 +38,7 @@ pub mod blocks; mod channel_map; pub mod comps_map; pub mod effects_map; +pub mod exr_parts; mod flat; mod gradient_bake; pub mod linked; diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 9440b291ca..5115867c89 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -1200,6 +1200,7 @@ mod tests { "file.new", "file.open", "file.openAs", + "file.openExrParts", "file.closeAll", "file.revert", "file.export.exportAs", diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index be44f4468c..db59bd8154 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -40,6 +40,11 @@ New from Clipboard Nový ze schránky Open… Otevřít… Open As… Otevřít jako… + Open EXR Parts… Otevřít části EXR… + Add beauty precomp from the AOVs Přidat předběžnou kompozici krásy z AOV + Both Obě + Left Vlevo + Right Vpravo Open Recent Otevřít nedávné Clear Recent Files Vymazat nedávné soubory Close Zavřít @@ -825,7 +830,6 @@ Blending Options Možnosti prolnutí Blue Modrá Blue, Red, Yellow Modrá, červená, žlutá - Both Obojí Both Axes Obě osy Bottom Dole Brightness Jas @@ -1144,7 +1148,6 @@ Leading Proklad Leading (set to the font size × auto-leading when Auto) Proklad (při volbě Automaticky velikost písma × automatický proklad) Leave as is (don't color manage) Ponechat beze změny (bez správy barev) - Left Vlevo Left align text Zarovnat text vlevo Length: Délka: Level Úroveň @@ -1354,7 +1357,6 @@ Restore All Obnovit vše Restore Defaults Obnovit výchozí Restrictive (Web) Omezená (web) - Right Vpravo Right align text Zarovnat text vpravo Rigid Tuhý Rotate: Otočení: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9af7a83264..29afc7cfd6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -36,6 +36,11 @@ New from Clipboard Neu aus Zwischenablage Open… Öffnen… Open As… Öffnen als… + Open EXR Parts… EXR-Teile öffnen… + Add beauty precomp from the AOVs Schönheits-Vorkomposition aus den AOVs hinzufügen + Both Beide + Left Links + Right Rechts Open Recent Zuletzt geöffnete Dateien Clear Recent Files Zuletzt geöffnete Dateien leeren Close Schließen @@ -806,7 +811,6 @@ Blending Options Fusionsoptionen Blue Blau Blue, Red, Yellow Blau, Rot, Gelb - Both Beide Both Axes Beide Achsen Bottom Unten Brightness Helligkeit @@ -1128,7 +1132,6 @@ Leading Zeilenabstand Leading (set to the font size × auto-leading when Auto) Zeilenabstand (bei „Automatisch“ auf Schriftgröße × automatischer Zeilenabstand gesetzt) Leave as is (don't color manage) Unverändert lassen (keine Farbverwaltung) - Left Links Left align text Text links ausrichten Length: Länge: Level Stufe @@ -1338,7 +1341,6 @@ Restore All Alle wiederherstellen Restore Defaults Standardwerte wiederherstellen Restrictive (Web) Restriktiv (Web) - Right Rechts Right align text Text rechts ausrichten Rigid Starr Rotate: Drehen: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 59d769047e..3bd6157fff 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -18,6 +18,11 @@ New from Clipboard Νέο από το πρόχειρο Open… Άνοιγμα… Open As… Άνοιγμα ως… + Open EXR Parts… Άνοιγμα τμημάτων EXR… + Add beauty precomp from the AOVs Προσθήκη προ-σύνθεσης ομορφιάς από τα AOV + Both Και τα δύο + Left Αριστερά + Right Δεξιά Open Recent Πρόσφατα αρχεία Clear Recent Files Εκκαθάριση πρόσφατων αρχείων Close Κλείσιμο @@ -438,8 +443,6 @@ Alignment Στοίχιση Align Στοίχιση Distribute Κατανομή - Left Αριστερά - Right Δεξιά Center Κέντρο Top Πάνω Bottom Κάτω @@ -2017,7 +2020,6 @@ Bold Italic Έντονα πλάγια Border Περίγραμμα Border… Περίγραμμα… - Both Και τα δύο Both Axes Και οι δύο άξονες Box Blur Θόλωση πλαισίου Box Blur… Θόλωση πλαισίου… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9ebb9b9453..3028a2ba6d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -32,6 +32,11 @@ New from Clipboard Nuevo desde el portapapeles Open… Abrir… Open As… Abrir como… + Open EXR Parts… Abrir partes EXR… + Add beauty precomp from the AOVs Añadir la precomposición de belleza a partir de los AOV + Both Ambas + Left Izquierda + Right Derecha Open Recent Archivos recientes Clear Recent Files Vaciar archivos recientes Close Cerrar @@ -810,7 +815,6 @@ Blending Options Opciones de fusión Blue Azul Blue, Red, Yellow Azul, rojo, amarillo - Both Ambos Both Axes Ambos ejes Bottom Inferior Brightness Brillo @@ -1129,7 +1133,6 @@ Leading Interlínea Leading (set to the font size × auto-leading when Auto) Interlínea (al estar en Automático se iguala al tamaño de fuente × interlínea automática) Leave as is (don't color manage) Dejar como está (no gestionar el color) - Left Izquierda Left align text Alinear texto a la izquierda Length: Longitud: Level Nivel @@ -1339,7 +1342,6 @@ Restore All Restaurar todo Restore Defaults Restaurar valores predeterminados Restrictive (Web) Restrictivo (web) - Right Derecha Right align text Alinear texto a la derecha Rigid Rígido Rotate: Rotación: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 23d730a46b..55ddd1ba2a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -38,6 +38,11 @@ New… Nouveau… Open… Ouvrir… Open As… Ouvrir en tant que… + Open EXR Parts… Ouvrir les parties EXR… + Add beauty precomp from the AOVs Ajouter la précomposition de la beauté depuis les AOV + Both Les deux + Left Gauche + Right Droite Open Recent Ouvrir un fichier récent Clear Recent Files Effacer la liste des fichiers récents Close Fermer @@ -800,7 +805,6 @@ Blending Options Options de fusion Blue Bleu Blue, Red, Yellow Bleu, rouge, jaune - Both Les deux Both Axes Deux axes Bottom Bas Brightness Luminosité @@ -1113,7 +1117,6 @@ Leading Interlignage Leading (set to the font size × auto-leading when Auto) Interlignage (en Auto : taille de police × interlignage automatique) Leave as is (don't color manage) Laisser tel quel (sans gestion des couleurs) - Left Gauche Left align text Aligner le texte à gauche Length: Longueur : Level Niveau @@ -1323,7 +1326,6 @@ Restore All Tout restaurer Restore Defaults Rétablir les valeurs par défaut Restrictive (Web) Restrictive (Web) - Right Droite Right align text Aligner le texte à droite Rigid Rigide Rotate: Rotation : diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8ee55aa62f..fb21511d0e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -31,6 +31,11 @@ New… Baru… Open… Buka… Open As… Buka Sebagai… + Open EXR Parts… Buka bagian EXR… + Add beauty precomp from the AOVs Tambahkan prakomposisi beauty dari AOV + Both Keduanya + Left Kiri + Right Kanan Open Recent Terakhir Dibuka Clear Recent Files Hapus Daftar Berkas Terakhir Close Tutup @@ -794,7 +799,6 @@ Blending Options Opsi Blending Blue Biru Blue, Red, Yellow Biru, Merah, Kuning - Both Keduanya Both Axes Kedua Sumbu Bottom Bawah Brightness Kecerahan @@ -1107,7 +1111,6 @@ Leading Leading Leading (set to the font size × auto-leading when Auto) Leading (diatur ke ukuran font × auto-leading saat Auto) Leave as is (don't color manage) Biarkan apa adanya (tanpa manajemen warna) - Left Kiri Left align text Teks rata kiri Length: Panjang: Level Level @@ -1317,7 +1320,6 @@ Restore All Pulihkan Semua Restore Defaults Pulihkan Default Restrictive (Web) Restriktif (Web) - Right Kanan Right align text Teks rata kanan Rigid Kaku Rotate: Putar: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 24697c273b..1f1f5d87ab 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -32,6 +32,11 @@ New from Clipboard Nuovo dagli appunti Open… Apri… Open As… Apri come… + Open EXR Parts… Apri parti EXR… + Add beauty precomp from the AOVs Aggiungi la precomposizione della bellezza dagli AOV + Both Entrambe + Left Sinistra + Right Destra Open Recent Apri recenti Clear Recent Files Cancella file recenti Close Chiudi @@ -808,7 +813,6 @@ Blending Options Opzioni di fusione Blue Blu Blue, Red, Yellow Blu, rosso, giallo - Both Entrambi Both Axes Entrambi gli assi Bottom In basso Brightness Luminosità @@ -1128,7 +1132,6 @@ Leading Interlinea Leading (set to the font size × auto-leading when Auto) Interlinea (impostata a dimensione font × interlinea auto quando Auto) Leave as is (don't color manage) Lascia com'è (non gestire colore) - Left Sinistra Left align text Allinea testo a sinistra Length: Lunghezza: Level Livello @@ -1338,7 +1341,6 @@ Restore All Ripristina tutto Restore Defaults Ripristina predefiniti Restrictive (Web) Restrittivo (web) - Right Destra Right align text Allinea testo a destra Rigid Rigido Rotate: Rotazione: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 06da72f985..4cc38187af 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -36,6 +36,11 @@ New from Clipboard クリップボードから新規作成 Open… 開く… Open As… 形式を指定して開く… + Open EXR Parts… EXRパートを開く… + Add beauty precomp from the AOVs AOVからビューティーをプリコンポーズして追加 + Both 両方 + Left 左 + Right 右 Open Recent 最近使ったファイルを開く Clear Recent Files 最近使ったファイルを消去 Close 閉じる @@ -815,7 +820,6 @@ Blending Options 描画オプション Blue 青 Blue, Red, Yellow 青、赤、黄 - Both 両方 Both Axes 両方の軸 Bottom 下 Brightness 明るさ @@ -1134,7 +1138,6 @@ Leading 行送り Leading (set to the font size × auto-leading when Auto) 行送り(自動のときはフォントサイズ × 自動行送り) Leave as is (don't color manage) そのまま(カラーマネジメントしない) - Left 左 Left align text 左揃え Length: 長さ: Level レベル @@ -1343,7 +1346,6 @@ Restore All すべて復元 Restore Defaults 既定に戻す Restrictive (Web) 制限(Web) - Right 右 Right align text 右揃え Rigid 剛体 Rotate: 回転: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c990751b05..2030f0aa5a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -24,6 +24,11 @@ New from Clipboard 클립보드에서 새로 만들기 Open… 열기… Open As… 형식을 지정하여 열기… + Open EXR Parts… EXR 파트 열기… + Add beauty precomp from the AOVs AOV에서 뷰티 프리컴포지션 추가 + Both 양쪽 + Left 왼쪽 + Right 오른쪽 Open Recent 최근 파일 열기 Clear Recent Files 최근 파일 목록 지우기 Close 닫기 @@ -789,7 +794,6 @@ Blending Options 혼합 옵션 Blue 파랑 Blue, Red, Yellow 파랑, 빨강, 노랑 - Both 둘 다 Both Axes 양쪽 축 Bottom 아래쪽 Brightness 밝기 @@ -1105,7 +1109,6 @@ Leading 행간 Leading (set to the font size × auto-leading when Auto) 행간(자동일 때 글꼴 크기 × 자동 행간으로 설정) Leave as is (don't color manage) 그대로 두기(색상 관리 안 함) - Left 왼쪽 Left align text 텍스트 왼쪽 정렬 Length: 길이: Level 레벨 @@ -1314,7 +1317,6 @@ Restore All 모두 복원 Restore Defaults 기본값 복원 Restrictive (Web) 제한됨(웹) - Right 오른쪽 Right align text 텍스트 오른쪽 정렬 Rigid 고정 Rotate: 회전: diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 39e25894e0..2eafbf96fb 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -28,6 +28,11 @@ New from Clipboard Nieuw vanaf klembord Open… Openen… Open As… Openen als… + Open EXR Parts… EXR-delen openen… + Add beauty precomp from the AOVs Schoonheidsvoorcompositie toevoegen vanuit de AOV's + Both Beide + Left Links + Right Rechts Open Recent Recent geopend Clear Recent Files Recente bestanden wissen Close Sluiten @@ -800,7 +805,6 @@ Blending Options Overvloeiopties Blue Blauw Blue, Red, Yellow Blauw, rood, geel - Both Beide Both Axes Beide assen Bottom Onder Brightness Helderheid @@ -1122,7 +1126,6 @@ Leading Interlinie Leading (set to the font size × auto-leading when Auto) Interlinie (bij Automatisch: lettergrootte × automatische interlinie) Leave as is (don't color manage) Ongewijzigd laten (geen kleurbeheer) - Left Links Left align text Tekst links uitlijnen Length: Lengte: Level Niveau @@ -1331,7 +1334,6 @@ Restore All Alles herstellen Restore Defaults Standaardwaarden herstellen Restrictive (Web) Beperkt (web) - Right Rechts Right align text Tekst rechts uitlijnen Rigid Star Rotate: Roteren: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 70d16f46b0..8ffe96000b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -35,6 +35,11 @@ New from Clipboard Nowy ze schowka Open… Otwórz… Open As… Otwórz jako… + Open EXR Parts… Otwórz części EXR… + Add beauty precomp from the AOVs Dodaj wstępną kompozycję piękna z AOV-ów + Both Obie + Left Lewa + Right Prawa Open Recent Otwórz ostatnie Clear Recent Files Wyczyść listę ostatnich plików Close Zamknij @@ -812,7 +817,6 @@ Blending Options Opcje mieszania Blue Niebieski Blue, Red, Yellow Niebieski, czerwony, żółty - Both Oba Both Axes Obie osie Bottom Dół Brightness Jasność @@ -1129,7 +1133,6 @@ Leading Interlinia Leading (set to the font size × auto-leading when Auto) Interlinia (przy ustawieniu Automatycznie: rozmiar czcionki × automatyczna interlinia) Leave as is (don't color manage) Pozostaw bez zmian (bez zarządzania kolorami) - Left Lewo Left align text Wyrównaj tekst do lewej Length: Długość: Level Poziom @@ -1338,7 +1341,6 @@ Restore All Przywróć wszystko Restore Defaults Przywróć domyślne Restrictive (Web) Ograniczona (Internet) - Right Prawo Right align text Wyrównaj tekst do prawej Rigid Sztywne Rotate: Obrót: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1248becb8d..7c674eb98b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -35,6 +35,11 @@ New… Novo… Open… Abrir… Open As… Abrir como… + Open EXR Parts… Abrir partes EXR… + Add beauty precomp from the AOVs Adicionar a pré-composição da beleza a partir dos AOVs + Both Ambas + Left Esquerda + Right Direita Open Recent Abrir recentes Clear Recent Files Limpar arquivos recentes Close Fechar @@ -797,7 +802,6 @@ Blending Options Opções de mesclagem Blue Azul Blue, Red, Yellow Azul, vermelho, amarelo - Both Ambos Both Axes Ambos os eixos Bottom Inferior Brightness Brilho @@ -1112,7 +1116,6 @@ Leading Entrelinha Leading (set to the font size × auto-leading when Auto) Entrelinha (no modo Automático, é o tamanho da fonte × entrelinha automática) Leave as is (don't color manage) Deixar como está (não gerenciar cores) - Left Esquerda Left align text Alinhar texto à esquerda Length: Comprimento: Level Nível @@ -1322,7 +1325,6 @@ Restore All Restaurar tudo Restore Defaults Restaurar padrões Restrictive (Web) Restritiva (Web) - Right Direita Right align text Alinhar texto à direita Rigid Rígido Rotate: Girar: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 64229d0043..0b1964433e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -42,6 +42,11 @@ Open… Открыть… Open Открыть Open As… Открыть как… + Open EXR Parts… Открыть части EXR… + Add beauty precomp from the AOVs Добавить предкомпозит красоты из AOV + Both Оба + Left Левый + Right Правый Open Recent Открыть недавние Clear Recent Files Очистить список недавних файлов Close Закрыть @@ -1078,8 +1083,6 @@ Single Word Justification Выключка одного слова Alignment Выравнивание Indent First Отступ первой строки - Left По левому краю - Right По правому краю Center По центру Justify По ширине @@ -1930,7 +1933,6 @@ Bilinear Билинейная Black, White Чёрный, белый Blue, Red, Yellow Синий, красный, жёлтый - Both Оба Bottom Снизу Brightness Jitter Колебание яркости Brush Reconstruct Options Параметры восстановления кисти diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 36ba349024..4790bb760f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -33,6 +33,11 @@ Open… Відкрити… Open Відкрити Open As… Відкрити як… + Open EXR Parts… Відкрити частини EXR… + Add beauty precomp from the AOVs Додати попередню композицію краси з AOV + Both Обидва + Left Лівий + Right Правий Open Recent Нещодавні файли Clear Recent Files Очистити список нещодавніх файлів Close Закрити @@ -1058,8 +1063,6 @@ Single Word Justification Вирівнювання одного слова по ширині Alignment Вирівнювання Indent First Відступ першого рядка - Left Ліворуч - Right Праворуч Center По центру Justify По ширині @@ -1912,7 +1915,6 @@ Bilinear Білінійна Black, White Чорний, білий Blue, Red, Yellow Синій, червоний, жовтий - Both Обидва Bottom Низ Brightness Jitter Коливання яскравості Brush Reconstruct Options Параметри пензля реконструкції diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0e437e9ba2..9831561136 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -32,6 +32,11 @@ New from Clipboard 从剪贴板新建 Open… 打开… Open As… 打开为… + Open EXR Parts… 打开 EXR 分段… + Add beauty precomp from the AOVs 从 AOV 预合成美丽通道 + Both 两者 + Left 左 + Right 右 Open Recent 打开最近文件 Clear Recent Files 清除最近文件 Close 关闭 @@ -814,7 +819,6 @@ Blending Options 混合选项 Blue 蓝色 Blue, Red, Yellow 蓝色、红色、黄色 - Both 两者 Both Axes 双轴 Bottom 底部 Brightness 亮度 @@ -1136,7 +1140,6 @@ Leading 行距 Leading (set to the font size × auto-leading when Auto) 行距(自动时为字号 × 自动行距系数) Leave as is (don't color manage) 保持原样(不进行色彩管理) - Left 左 Left align text 文字左对齐 Length: 长度: Level 级别 @@ -1348,7 +1351,6 @@ Restore All 全部还原 Restore Defaults 还原默认值 Restrictive (Web) 限定(Web) - Right 右 Right align text 文字右对齐 Rigid 刚性 Rotate: 旋转: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8239f5d85f..38061c68bd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -40,6 +40,11 @@ New from Clipboard 從剪貼簿新增 Open… 開啟… Open As… 開啟為… + Open EXR Parts… 開啟 EXR 分段… + Add beauty precomp from the AOVs 從 AOV 預合成美麗通道 + Both 兩者 + Left 左 + Right 右 Open Recent 最近開啟的檔案 Clear Recent Files 清除最近檔案 Close 關閉 @@ -816,7 +821,6 @@ Blending Options 混合選項 Blue 藍色 Blue, Red, Yellow 藍色、紅色、黃色 - Both 兩者 Both Axes 兩軸 Bottom 下 Brightness 亮度 @@ -1135,7 +1139,6 @@ Leading 行距 Leading (set to the font size × auto-leading when Auto) 行距 (自動時設為字體大小 × 自動行距) Leave as is (don't color manage) 保持原樣 (不進行色彩管理) - Left 左 Left align text 文字靠左對齊 Length: 長度: Level 層級 @@ -1345,7 +1348,6 @@ Restore All 全部還原 Restore Defaults 還原預設值 Restrictive (Web) 限制 (網頁) - Right 右 Right align text 文字靠右對齊 Rigid 剛性 Rotate: 旋轉: diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 37032dbb5f..ad9261b7ec 100644 --- a/crates/ui-egui/src/menu_catalog.rs +++ b/crates/ui-egui/src/menu_catalog.rs @@ -7,6 +7,7 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["File"], "New…", Some("Cmd+N"), "file.new"), (&["File"], "Open…", Some("Cmd+O"), "file.open"), (&["File"], "Open As…", Some("Cmd+Alt+Shift+O"), "file.openAs"), + (&["File"], "Open EXR Parts…", None, "file.openExrParts"), (&["File"], "---", None, "---"), (&["File"], "Close", Some("Cmd+W"), "file.close"), (&["File"], "Close All", Some("Cmd+Alt+W"), "file.closeAll"), diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 3aa4a7957d..cfe31e540c 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -714,6 +714,59 @@ pub fn cells(layout: &str, rect: egui::Rect, n: usize) -> Option // ---------- dialogs and pickers in front of engine commands ---------- /// A generic form dialog that runs `command` with its fields on OK (rendered by [`form_body`]). +/// File › Open EXR Parts…: pick an EXR, then choose the parts and channel groups that become +/// the layers of one document (a Maya/Arnold render writes one part per AOV). The dialog is +/// a plain command form, so the control channel can drive the same selection headlessly. +fn open_exr_parts_dialog(app: &mut PhotocraftApp) -> Option> { + Some(app.pick_file_bytes_filtered(&["exr"], |app, name, _bytes| { + let info = app.run("file.exrParts", json!({"path": name})).map_err(|e| e.to_string())?; + let entries = info.as_array().cloned().unwrap_or_default(); + let mut fields = Map::new(); + fields.insert("path".into(), json!(name)); + fields.insert("view".into(), json!("both")); + fields.insert("precomp".into(), json!(false)); + let mut labels = Map::new(); + // The path field shows the picked file's name (data, verbatim); the rest translate. + if let Some(file) = name.rsplit(['/', '\\']).next() { + labels.insert("path".into(), json!(file)); + } + labels.insert("view".into(), json!(crate::i18n::t("View"))); + labels.insert("both".into(), json!(crate::i18n::t("Both"))); + labels.insert("left".into(), json!(crate::i18n::t("Left"))); + labels.insert("right".into(), json!(crate::i18n::t("Right"))); + labels.insert("precomp".into(), json!(crate::i18n::t("Add beauty precomp from the AOVs"))); + let mut any = false; + for e in &entries { + let kind = e["kind"].as_str().unwrap_or_default().to_string(); + let ename = e["name"].as_str().unwrap_or_default().to_string(); + let channels = e["channels"].as_u64().unwrap_or(0); + let key = format!("{kind}/{ename}"); + match kind.as_str() { + "part" => { + labels.insert(key.clone(), json!(format!("{ename} ({}×{}, {channels} ch)", e["width"], e["height"]))); + let on = !any && channels >= 3; + any |= on; + fields.insert(key, json!(on)); + } + "group" => { + labels.insert(key.clone(), json!(format!("{ename} ({channels} ch)"))); + fields.insert(key, json!(false)); + } + _ => {} + } + } + // Cryptomatte layers are information here (the picker comes separately); the array is + // not rendered but rides along for the control channel. + let crypto: Vec = entries.iter().filter(|e| e["kind"] == "cryptomatte").cloned().collect(); + if !crypto.is_empty() { + fields.insert("cryptomatte".into(), Value::Array(crypto)); + } + fields.insert("__labels".into(), Value::Object(labels)); + let choices = json!({"view": ["both", "left", "right"]}); + Ok(json!({"dialog": form(app, "file.openExrParts", "Open EXR Parts…", Value::Object(fields), choices)})) + })) +} + fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: Value, choices: Value) -> u64 { let mut f = Map::new(); f.insert("__command".into(), json!(command)); @@ -764,6 +817,10 @@ mod label_tests { /// Body of a `__form` dialog: text fields, number fields, checkboxes and `__choices` dropdowns. pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { let choices = f.get("__choices").cloned().unwrap_or(Value::Null); + // Data-driven labels (a per-part checkbox shows the part's name, not a catalogue entry) + // come from `__labels` verbatim; everything else goes through the catalogue. + let labels = f.get("__labels").cloned().unwrap_or(Value::Null); + let label = |k: &str| labels.get(k).and_then(Value::as_str).map_or_else(|| label_of(k), str::to_string); let keys: Vec = f.keys().filter(|k| !k.starts_with("__")).cloned().collect(); egui::Grid::new("form-dialog").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for k in keys { @@ -773,11 +830,11 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { Value::Bool(b) => { ui.label(""); let mut b = *b; - ui.checkbox(&mut b, label_of(&k)); + ui.checkbox(&mut b, label(&k)); json!(b) } Value::String(s) => { - ui.label(label_of(&k)); + ui.label(label(&k)); let mut s = s.clone(); match choices.get(&k).and_then(Value::as_array) { Some(opts) => { @@ -792,7 +849,7 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { json!(s) } Value::Number(n) => { - ui.label(label_of(&k)); + ui.label(label(&k)); if let Some(mut i) = n.as_i64() { ui.add(egui::DragValue::new(&mut i).custom_parser(crate::widgets::parse_num)); json!(i) @@ -849,6 +906,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option Some(app.open_dialog_file()), + "file.openExrParts" => open_exr_parts_dialog(app), "file.saveACopy" => Some(save_a_copy(app)), "file.placeEmbedded" | "file.placeLinked" => { let doc = match app.active_doc_id() { diff --git a/docs/parity.md b/docs/parity.md index 9394b6b6fc..9eb50a908c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,11 +2,11 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand. -**627 of 627 menu items live (100.0%).** +**628 of 628 menu items live (100.0%).** | Menu | Live | Total | % | |---|---:|---:|---:| -| File | 51 | 51 | 100% | +| File | 52 | 52 | 100% | | Edit | 75 | 75 | 100% | | Image | 61 | 61 | 100% | | Layer | 161 | 161 | 100% | diff --git a/docs/scorecard.md b/docs/scorecard.md index 5acc894359..324914c6ea 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 20 | 15 | 6 | | | [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 46 of 149 (target 0) | | [Type](#type) | 0 | 3 | 6 | | -| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) | +| [Automation](#automation) | 3 | 2 | 6 | 628 of 628 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 4 | 4 | 3 | | | [Open bugs](#open-bugs) | 22 | 2 | 8 | | @@ -220,7 +220,7 @@ Unread: `general.color_picker`, `general.beep_when_done`, `general.resize_image_ ## Automation -Menu wiring (`docs/parity.md`): 627 of 627 menu items live (100.0%). This counts dispatch, not behaviour. +Menu wiring (`docs/parity.md`): 628 of 628 menu items live (100.0%). This counts dispatch, not behaviour. | Id | Target | Status | Issue | Note | |---|---|---|---|---|