mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 parent
7c18c68e24
commit
ff9663bc9a
31 files changed
+864
-70
No files matched your search
Generated
+1
@@ -4339,6 +4339,7 @@ name = "photocraft-engine"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"exr",
|
||||
"flate2",
|
||||
"photocraft-affinity",
|
||||
"photocraft-algo",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1200,6 +1200,7 @@ mod tests {
|
||||
"file.new",
|
||||
"file.open",
|
||||
"file.openAs",
|
||||
"file.openExrParts",
|
||||
"file.closeAll",
|
||||
"file.revert",
|
||||
"file.export.exportAs",
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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:
|
||||
|
||||
|
@@ -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.
|
@@ -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: 회전:
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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: 旋转:
|
||||
|
||||
|
@@ -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.
|
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|---|---|---|---|---|
|
||||
|
||||
Reference in new issue
Block a user