EXR parts: import parts, channel groups and an AOV precomp as layers (#2338)

* EXR parts: import parts, channel groups and an AOV precomp as layers

file.exrParts lists what an EXR holds for a chooser - flat parts, prefixed
channel groups (diffuse.R/G/B), Cryptomatte layers as information;
file.openExrParts imports the chosen ones as the layers of one document
(F16 lossless as F32, linear sRGB tagged), with an optional additive
beauty precomp summed from the light-path AOV set (direct/indirect
diffuse+specular, emission; transmission/sss/volume when present) that
fails naming the missing AOVs. The codec gains decode_exr_channels for
exact named channels of one part; io shares the image_surface band writer.

File › Open EXR Parts… picks an EXR and opens a plain command form: a
checkbox per part and channel group (labels are the data's names with
size and channel count), a both/left/right view dropdown, and the
precomp checkbox. Cryptomatte layers ride along as non-rendered data for
the control channel. Form fields gain __labels for verbatim data-driven
labels; the new strings are catalogued in all sixteen complete languages.

* Regenerate parity and scorecard docs

---------

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
Elvis33LEandBrandon Thomas authored and GitHub committed 2026-10-10 13:51:06 -04:00
1 parent 7c18c68e24
commit ff9663bc9a
31 files changed
+864 -70

No files matched your search

Generated
+1
View File
@@ -4339,6 +4339,7 @@ name = "photocraft-engine"
version = "0.6.0"
dependencies = [
"blake3",
"exr",
"flate2",
"photocraft-affinity",
"photocraft-algo",
+71
View File
@@ -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<Image, CodecError> {
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<FlatSamples>> = 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<FlatSamples>| -> 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<Option<usize>>) = 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<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
let (w, h) = (img.width() as usize, img.height() as usize);
+8
View File
@@ -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<Image, CodecError> {
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
+2
View File
@@ -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 }
+1
View File
@@ -1143,6 +1143,7 @@ fn build() -> Vec<CommandSpec> {
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());
+265
View File
@@ -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<Value> {
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::<Vec<_>>()))
}
fn exr_parts(s: &mut Session, p: &Value) -> Result<Value> {
let _ = s;
let path = crate::file_cmds::str_param(p, "path", "file.exrParts")?.to_string();
entries_json(&path)
}
/// Collects `part/<name>` and `group/<name>` boolean params into the selection names.
fn selected(p: &Value, prefix: &str) -> Vec<String> {
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<Value> {
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<CommandSpec> {
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/<name>":bool (import this part as a layer),"group/<name>":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<FlatSamples>| {
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<AnyChannel<FlatSamples>> = 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();
}
}
+1
View File
@@ -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;
+335
View File
@@ -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<String>,
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<usize>,
}
#[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<String>,
alpha: Option<String>,
}
/// 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::<String>().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<Vec<Group>, IoError> {
let mut out: Vec<Group> = Vec::new();
for part in codecs::exr_info(bytes, &Default::default())? {
if part.deep {
continue;
}
let mut found: Vec<Group> = 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<Vec<Entry>, 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<String>,
/// Channel-group prefixes to import as layers.
pub groups: Vec<String>,
/// `"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<ImportResult, IoError> {
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<String>| 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<Option<Image>> = vec![None; info.len()];
let mut layers: Vec<Layer> = Vec::new();
let mut warnings: Vec<String> = 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<String>) -> Result<Layer, IoError> {
let have: Vec<String> = 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<f32> = Vec::new();
let mut alpha: Vec<f32> = 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)))
}
+32 -15
View File
@@ -146,21 +146,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
};
let (w, h) = img.dimensions();
let mut doc = Document::new(name, Size::new(w, h), mode, depth);
let fmt = PixelFormat::new(mode, depth, true);
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 {
// Converted a band of rows at a time: no second full-size copy of the image.
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());
}
}
let mut s = image_surface(img, target_layout, csample)?;
s.prune();
// As in Photoshop: an opaque image opens as the locked Background layer, one with
// transparency (an alpha channel, or a tRNS chunk the decoder expands to one) as a normal
@@ -185,6 +171,37 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
Ok(ImportResult { document: doc, warnings, source_read_only: false, preview_only: false })
}
/// The pixels of a decoded image as a surface of `fmt`, converting layout and sample type a
/// band of rows at a time so no second full-size copy of the image is made.
pub(crate) fn image_surface(img: &Image, target_layout: ChannelLayout, csample: CSample) -> Result<Surface, IoError> {
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.
+1
View File
@@ -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;
+1
View File
@@ -1200,6 +1200,7 @@ mod tests {
"file.new",
"file.open",
"file.openAs",
"file.openExrParts",
"file.closeAll",
"file.revert",
"file.export.exportAs",
+5 -3
View File
@@ -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í:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -3
View File
@@ -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:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -3
View File
@@ -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… Θόλωση πλαισίου…
Can't render this file because it contains an unexpected character in line 971 and column 18.
+5 -3
View File
@@ -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:
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5 -3
View File
@@ -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 :
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -3
View File
@@ -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:
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5 -3
View File
@@ -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:
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
32
33
34
35
36
37
38
39
40
41
42
813
814
815
816
817
818
1132
1133
1134
1135
1136
1137
1341
1342
1343
1344
1345
1346
+5 -3
View File
@@ -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: 回転:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -3
View File
@@ -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: 회전:
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
24
25
26
27
28
29
30
31
32
33
34
794
795
796
797
798
799
1109
1110
1111
1112
1113
1114
1317
1318
1319
1320
1321
1322
+5 -3
View File
@@ -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:
Can't render this file because it contains an unexpected character in line 8 and column 34.
+5 -3
View File
@@ -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:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -3
View File
@@ -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:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -3
View File
@@ -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 Параметры восстановления кисти
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -3
View File
@@ -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 Параметри пензля реконструкції
Can't render this file because it contains an unexpected character in line 1320 and column 18.
+5 -3
View File
@@ -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: 旋转:
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
32
33
34
35
36
37
38
39
40
41
42
819
820
821
822
823
824
1140
1141
1142
1143
1144
1145
1351
1352
1353
1354
1355
1356
+5 -3
View File
@@ -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: 旋轉:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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"),
+61 -3
View File
@@ -714,6 +714,59 @@ pub fn cells(layout: &str, rect: egui::Rect, n: usize) -> Option<Vec<egui::Rect>
// ---------- 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<Result<Value, String>> {
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<Value> = 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<String, Value>) {
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<String> = 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<String, Value>) {
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<String, Value>) {
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<Result<Val
let dialog = |app: &mut PhotocraftApp, fields: Value, choices: Value| Some(Ok(json!({"dialog": form(app, id, label, fields, choices)})));
match id {
"file.openAs" => 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() {
+2 -2
View File
@@ -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% |
+2 -2
View File
@@ -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 |
|---|---|---|---|---|