From 750a91c8b2a852be2b37e89194d700e80d565cfd Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 16 Oct 2026 20:59:24 +0200 Subject: [PATCH] Color Lookup: PSD save keeps a .3dl/.look LUT file as loaded (#6544) A Color Lookup layer embeds its LUT file in the PSD (LUT3DFileData, LUTFormat, LUT3DFileName). Photoshop stores the file as loaded and tags its format; it never converts it. PhotoCraft kept only the parsed table, so every save re-encoded it as .cube with LUTFormatCUBE while keeping the original name: a layer loaded from "Film.3dl" was saved as .cube data under a .3dl name. The layer now keeps the file it was loaded from (its format and bytes, `ColorLookup::source`), set when a PSD is read and when the colorLookup command loads `data`/`file`, and cleared when a built-in look or "none" replaces the table. A PSD save writes those bytes and format back unchanged, but only while they still parse to the layer's table, so the stored file can never go stale. Otherwise (a table made in PhotoCraft, or one that no longer matches) it falls back to .cube as before and swaps a .3dl/.look extension in the name for .cube. The new field is `#[serde(default)]` and skipped when empty, so older .pcraft files load unchanged. lutfile gains Format::detect/parse_as so the recorded format is exactly the one the parser used. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/cms/src/lutfile.rs | 59 ++++++- crates/compose/src/adjust.rs | 9 +- crates/doc/src/adjust.rs | 23 +++ crates/engine/src/adjust_cmds.rs | 34 ++-- crates/engine/src/adjust_cmds/tests.rs | 49 +++++- crates/format/tests/roundtrip.rs | 19 +++ crates/gpu/src/plan.rs | 9 +- crates/gpu/tests/parity.rs | 2 +- crates/io/src/adjust_map.rs | 186 +++++++++++++++++++--- crates/io/tests/adjust_blend_roundtrip.rs | 18 ++- crates/io/tests/adjust_warnings.rs | 3 +- crates/ui-egui/src/adjust_ui.rs | 2 +- 12 files changed, 369 insertions(+), 44 deletions(-) diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index 0a68ef8bce..22dcca4dc0 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -87,16 +87,50 @@ impl LutFile { } } +/// A LUT file format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Cube, + ThreeDl, + Look, +} + +impl Format { + /// The format [`parse`] reads `bytes` as: from `file_name`'s extension, else sniffed + /// (XML is `.look`, anything else `.cube`). + pub fn detect(file_name: &str, bytes: &[u8]) -> Self { + let lower = file_name.to_ascii_lowercase(); + if lower.ends_with(".3dl") { + Format::ThreeDl + } else if lower.ends_with(".look") || String::from_utf8_lossy(bytes).trim_start().starts_with('<') { + Format::Look + } else { + Format::Cube + } + } + + /// The file extension, without the dot. + pub fn extension(self) -> &'static str { + match self { + Format::Cube => "cube", + Format::ThreeDl => "3dl", + Format::Look => "look", + } + } +} + /// Parses a LUT, picking the format from `file_name`'s extension (falls back to sniffing). pub fn parse(file_name: &str, bytes: &[u8]) -> Result { - let lower = file_name.to_ascii_lowercase(); + parse_as(Format::detect(file_name, bytes), bytes) +} + +/// Parses a LUT in a known format. +pub fn parse_as(format: Format, bytes: &[u8]) -> Result { let text = String::from_utf8_lossy(bytes); - if lower.ends_with(".3dl") { - parse_3dl(&text) - } else if lower.ends_with(".look") || text.trim_start().starts_with('<') { - parse_look(&text) - } else { - parse_cube(&text) + match format { + Format::Cube => parse_cube(&text), + Format::ThreeDl => parse_3dl(&text), + Format::Look => parse_look(&text), } } @@ -425,6 +459,17 @@ DOMAIN_MAX 0.5 1 1 assert!(parse_look(&text).is_err()); } + #[test] + fn format_detection_matches_parse() { + assert_eq!(Format::detect("a.3DL", b""), Format::ThreeDl); + assert_eq!(Format::detect("a.Look", b""), Format::Look); + assert_eq!(Format::detect("a.cube", b" "), Format::Look, "XML is sniffed as .look"); + assert_eq!(Format::detect("a.txt", b"LUT_3D_SIZE 2"), Format::Cube); + assert_eq!(Format::ThreeDl.extension(), "3dl"); + let text = write_cube(&LutFile::identity(2)); + assert_eq!(parse_as(Format::Cube, text.as_bytes()), parse("a.cube", text.as_bytes())); + } + #[test] fn malformed_rejected() { assert!(parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err()); diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index f91e7779d1..71de02af43 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -1590,7 +1590,14 @@ mod tone_tests { // A stored document's size is untrusted: (2²²)³ × 3 overflows, which once wrapped past // the table-length guard and indexed out of bounds. for size in [1 << 22, u32::MAX] { - let a = Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false }; + let a = Adjustment::ColorLookup { + name: "x".into(), + lut: Some(std::sync::Arc::new(vec![0.5; 24])), + size, + tetrahedral: false, + dither: false, + source: None, + }; let mut b = ramp(); apply(&a, &mut b); assert_eq!(b.px, ramp().px, "size {size}"); diff --git a/crates/doc/src/adjust.rs b/crates/doc/src/adjust.rs index 855cd1f399..98e61f72aa 100644 --- a/crates/doc/src/adjust.rs +++ b/crates/doc/src/adjust.rs @@ -17,6 +17,24 @@ pub fn is_identity_curve(points: &[CurvePoint]) -> bool { } } +/// A 3D LUT file format (Photoshop's `LUTFormat`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LutFormat { + #[serde(rename = "cube")] + Cube, + #[serde(rename = "3dl")] + ThreeDl, + #[serde(rename = "look")] + Look, +} + +/// A Color Lookup's LUT file as loaded: its format and bytes. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LutSource { + pub format: LutFormat, + pub bytes: Arc>, +} + /// Entries in a pencil-drawn Curves map (one per 8-bit level, as in PSD `curv` maps). pub const CURVE_MAP_LEN: usize = 256; @@ -260,6 +278,11 @@ pub enum Adjustment { /// Ordered dither of ±½ an 8-bit step to hide banding (Photoshop's "Dither"). #[serde(default)] dither: bool, + /// The LUT file `lut` was read from, embedded unchanged on PSD save as Photoshop does. + /// None for a table made in PhotoCraft (a built-in look); a save also ignores it when + /// it no longer parses to `lut`. + #[serde(default, skip_serializing_if = "Option::is_none")] + source: Option, }, Invert, Posterize { diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index c1393bb94c..a04bfb62a7 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -10,6 +10,7 @@ //! result through the selection like the other Image › Adjustments commands. use photocraft_algo::tone::{self, HdrToning, MatchColor, ShadowsHighlights}; +use photocraft_doc::adjust::{LutFormat, LutSource}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent, Rect}; use photocraft_raster::{Surface, to_rgba}; use serde_json::{Value, json}; @@ -104,34 +105,38 @@ pub fn selective_from_params(p: &Value, base: Option<&Adjustment>) -> Result) -> Result { const CMD: &str = "colorLookup"; - let (mut name, mut lut, mut size, mut tetrahedral, mut dither) = match base { - Some(Adjustment::ColorLookup { name, lut, size, tetrahedral, dither }) => (name.clone(), lut.clone(), *size, *tetrahedral, *dither), - _ => (String::new(), None, 0, false, false), + let (mut name, mut lut, mut size, mut tetrahedral, mut dither, mut source) = match base { + Some(Adjustment::ColorLookup { name, lut, size, tetrahedral, dither, source }) => { + (name.clone(), lut.clone(), *size, *tetrahedral, *dither, source.clone()) + } + _ => (String::new(), None, 0, false, false, None), }; - let mut loaded: Option<(photocraft_cms::lutfile::LutFile, String)> = None; + // The parsed table, its label and (for a file) the file as loaded, which a PSD save embeds. + let mut loaded: Option<(photocraft_cms::lutfile::LutFile, String, Option)> = None; if let Some(id) = opt_str(CMD, p, "lut")? { if id == "none" || id.is_empty() { name.clear(); lut = None; size = 0; + source = None; } else { let f = photocraft_cms::lutfile::builtin(id).ok_or_else(|| bad(CMD, format!("unknown look `{id}` (built-ins: {})", builtin_ids())))?; let label = f.title.clone(); - loaded = Some((f, label)); + loaded = Some((f, label, None)); } } let file_name = opt_str(CMD, p, "fileName")?; if let Some(text) = opt_str(CMD, p, "data")? { let file_name = file_name.unwrap_or("lut.cube"); let f = photocraft_cms::lutfile::parse(file_name, text.as_bytes()).map_err(|e| bad(CMD, e.0))?; - loaded = Some((f, base_name(file_name))); + loaded = Some((f, base_name(file_name), Some(lut_source(file_name, text.as_bytes().to_vec())))); } if let Some(path) = opt_str(CMD, p, "file")?.filter(|s| !s.is_empty()) { let bytes = read_file(path).ok_or_else(|| bad(CMD, format!("can't read {path}")))?; let f = photocraft_cms::lutfile::parse(path, &bytes).map_err(|e| bad(CMD, format!("{path}: {}", e.0)))?; - loaded = Some((f, base_name(path))); + loaded = Some((f, base_name(path), Some(lut_source(path, bytes)))); } - if let Some((f, label)) = loaded { + if let Some((f, label, src)) = loaded { if !f.domain_is_default() { return Err(bad( CMD, @@ -144,6 +149,7 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result {} @@ -157,7 +163,17 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result) -> LutSource { + let format = match photocraft_cms::lutfile::Format::detect(file_name, &bytes) { + photocraft_cms::lutfile::Format::Cube => LutFormat::Cube, + photocraft_cms::lutfile::Format::ThreeDl => LutFormat::ThreeDl, + photocraft_cms::lutfile::Format::Look => LutFormat::Look, + }; + LutSource { format, bytes: std::sync::Arc::new(bytes) } } fn builtin_ids() -> String { diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index d831fbf448..ca1fa99a63 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -170,7 +170,7 @@ fn color_lookup_builtin_data_and_errors() { // Switching interpolation keeps the table. s.execute("layer.setAdjustment", json!({"layer": id.0, "interpolation": "tetrahedral", "dither": true})).unwrap(); match &doc(&s).layer(id).unwrap().content { - LayerContent::Adjustment(Adjustment::ColorLookup { lut: Some(_), size: 33, tetrahedral: true, dither: true, name }) => { + LayerContent::Adjustment(Adjustment::ColorLookup { lut: Some(_), size: 33, tetrahedral: true, dither: true, name, .. }) => { assert_eq!(name, "Day for Night") } other => panic!("{other:?}"), @@ -220,6 +220,53 @@ LUT_3D_SIZE 2 } /// The kind's parameters, read back from the model (Color Lookup with its table name and size). +#[test] +fn color_lookup_file_round_trips_psd_in_its_own_format() { + use photocraft_doc::adjust::{LutFormat, LutSource}; + // #6544: a .3dl / .look file is kept as loaded and embedded unchanged in a PSD. + let mut threedl = String::from("0 1023\n"); + for r in 0..2 { + for g in 0..2 { + for b in 0..2 { + threedl.push_str(&format!("{} {} {}\n", (1 - r) * 4095, (1 - g) * 4095, (1 - b) * 4095)); + } + } + } + let hex: String = photocraft_cms::lutfile::LutFile::identity(2).data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + let look = format!("2{hex}"); + for (text, file_name, format, psd_format) in + [(threedl.as_str(), "Invert.3dl", LutFormat::ThreeDl, "LUTFormat3DL"), (look.as_str(), "Grade.look", LutFormat::Look, "LUTFormatLOOK")] + { + let mut s = session(8, "rgb"); + let r = s.execute("layer.newAdjustmentLayer.colorLookup", json!({"data": text, "fileName": file_name})).unwrap(); + let id = photocraft_doc::LayerId(r["layer"].as_u64().unwrap()); + let want = LutSource { format, bytes: std::sync::Arc::new(text.as_bytes().to_vec()) }; + let source_of = |s: &Session| match adjustment_of(s, id) { + Adjustment::ColorLookup { source, .. } => source, + other => panic!("{other:?}"), + }; + assert_eq!(source_of(&s), Some(want.clone()), "{file_name}"); + // Settings that don't touch the table keep the file. + s.execute("layer.setAdjustment", json!({"layer": id.0, "interpolation": "tetrahedral", "dither": true})).unwrap(); + assert_eq!(source_of(&s), Some(want.clone()), "{file_name}"); + // Saved as PSD and reopened: same format tag, same bytes, same name. + let psd = photocraft_io::export(doc(&s), "psd", &photocraft_io::ExportOptions::default()).unwrap().bytes; + let back = photocraft_io::import("x.psd", &psd).unwrap().document; + let layer = back.walk().into_iter().map(|(_, _, l)| l).find(|l| matches!(l.content, LayerContent::Adjustment(Adjustment::ColorLookup { .. }))).unwrap(); + let LayerContent::Adjustment(Adjustment::ColorLookup { name, source, .. }) = &layer.content else { panic!("not a Color Lookup") }; + assert_eq!((name.as_str(), source.as_ref()), (file_name, Some(&want)), "{psd_format}"); + // Choosing a built-in look replaces the table, so the file goes too. + s.execute("layer.setAdjustment", json!({"layer": id.0, "lut": "dayForNight"})).unwrap(); + assert_eq!(source_of(&s), None); + } + // "none" drops it as well. + let mut s = session(8, "rgb"); + let r = s.execute("layer.newAdjustmentLayer.colorLookup", json!({"data": threedl, "fileName": "Invert.3dl"})).unwrap(); + let id = photocraft_doc::LayerId(r["layer"].as_u64().unwrap()); + s.execute("layer.setAdjustment", json!({"layer": id.0, "lut": "none"})).unwrap(); + assert!(matches!(adjustment_of(&s, id), Adjustment::ColorLookup { source: None, lut: None, .. })); +} + fn adjustment_of(s: &Session, id: photocraft_doc::LayerId) -> Adjustment { match &doc(s).layer(id).unwrap().content { LayerContent::Adjustment(a) => a.clone(), diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index f78e93947e..3717678cb8 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -751,3 +751,22 @@ fn variables_follow_layer_id_remapping() { let gone = defs.iter().find(|d| d.name == "gone").unwrap().layer; assert!(back.walk().into_iter().all(|(_, _, l)| l.id != gone), "dangling target must not address a live layer"); } + +#[test] +fn color_lookup_lut_file_round_trips_and_old_files_load() { + use photocraft_doc::Adjustment; + use photocraft_doc::adjust::{LutFormat, LutSource}; + let lut = Some(std::sync::Arc::new(vec![0.5f32; 24])); + let source = Some(LutSource { format: LutFormat::ThreeDl, bytes: std::sync::Arc::new(b"0 1023\n0 0 0\n".to_vec()) }); + let adj = Adjustment::ColorLookup { name: "Film.3dl".into(), lut, size: 2, tetrahedral: false, dither: false, source }; + let mut doc = Document::new("d", photocraft_doc::Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + doc.insert_above(None, Layer::new("lookup", LayerContent::Adjustment(adj.clone()))); + let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); + assert_eq!(back, doc, "the LUT file survives a .pcraft round trip (#6544)"); + // A .pcraft written before the field existed has no `source`: it loads as None. + let mut v = serde_json::to_value(&adj).unwrap(); + let removed = v.as_object_mut().and_then(|o| o.values_mut().next()).and_then(|f| f.as_object_mut()).and_then(|f| f.remove("source")); + assert!(removed.is_some(), "{v}"); + let Adjustment::ColorLookup { source: None, name, .. } = serde_json::from_value(v).unwrap() else { panic!("old lookup must load") }; + assert_eq!(name, "Film.3dl"); +} diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 58ce6e4299..16e351b4e3 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -1903,7 +1903,14 @@ mod tests { #[test] fn color_lookup_with_an_overflowing_stored_size_uploads_no_table() { for size in [1 << 22, u32::MAX] { - let a = Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false }; + let a = Adjustment::ColorLookup { + name: "x".into(), + lut: Some(std::sync::Arc::new(vec![0.5; 24])), + size, + tetrahedral: false, + dither: false, + source: None, + }; let (kind, _, rows) = adjustment_program(&a, Transfer::Srgb, SampleType::U8); assert_eq!((kind, rows.is_none()), (0, true), "size {size}"); } diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..d430147292 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -649,7 +649,7 @@ fn lookup(n: usize, tetrahedral: bool, dither: bool) -> Adjustment { } } } - Adjustment::ColorLookup { name: "test".into(), lut: Some(std::sync::Arc::new(t)), size: n as u32, tetrahedral, dither } + Adjustment::ColorLookup { name: "test".into(), lut: Some(std::sync::Arc::new(t)), size: n as u32, tetrahedral, dither, source: None } } #[test] diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index d4bc312690..00ec0f5597 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -11,7 +11,7 @@ //! stay [`Adjustment::Unsupported`] and are written back verbatim. use photocraft_doc::Adjustment; -use photocraft_doc::adjust::{CURVE_MAP_LEN, CurvePoint, HueRange, LevelsChannel, ToneSpace, curve_map, curve_map_outputs}; +use photocraft_doc::adjust::{CURVE_MAP_LEN, CurvePoint, HueRange, LevelsChannel, LutFormat, LutSource, ToneSpace, curve_map, curve_map_outputs}; use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; /// All PSD adjustment keys recognized as adjustment layers. @@ -424,12 +424,12 @@ fn read_lookup(d: &[u8]) -> Result { Some(_) => return Err("its LUT data is empty".into()), None => return Err("it uses a colour profile (abstract or device link), not a LUT file, which isn't supported yet".into()), }; - let (ext, format) = match desc_enum(&desc, "LUTFormat") { - Some(b"LUTFormat3DL") => ("x.3dl", "3DL"), - Some(b"LUTFormatLOOK") => ("x.look", "LOOK"), - _ => ("x.cube", "CUBE"), + let format = match desc_enum(&desc, "LUTFormat") { + Some(b"LUTFormat3DL") => LutFormat::ThreeDl, + Some(b"LUTFormatLOOK") => LutFormat::Look, + _ => LutFormat::Cube, }; - let lut = photocraft_cms::lutfile::parse(ext, bytes).map_err(|e| format!("its {format} LUT can't be read ({e})"))?; + let lut = parse_lut_source(format, bytes).map_err(|e| format!("its {} LUT can't be read ({e})", psd_lut_format(format).trim_start_matches("LUTFormat")))?; let name = desc_text(&desc, "LUT3DFileName").or_else(|| desc_text(&desc, "NM ")).unwrap_or_default(); Ok(Adjustment::ColorLookup { name, @@ -437,9 +437,47 @@ fn read_lookup(d: &[u8]) -> Result { lut: Some(std::sync::Arc::new(lut.data)), tetrahedral: false, dither: desc_bool(&desc, "Dthr").unwrap_or(false), + // Kept so a save embeds the file as loaded (Photoshop never converts it). + source: Some(LutSource { format, bytes: std::sync::Arc::new(bytes.clone()) }), }) } +/// A LUT file's table, read the way a PSD read does (an XML file tagged `.cube` is sniffed as `.look`). +fn parse_lut_source(format: LutFormat, bytes: &[u8]) -> Result { + let format = match format { + LutFormat::Cube => photocraft_cms::lutfile::Format::Cube, + LutFormat::ThreeDl => photocraft_cms::lutfile::Format::ThreeDl, + LutFormat::Look => photocraft_cms::lutfile::Format::Look, + }; + photocraft_cms::lutfile::parse(&format!("x.{}", format.extension()), bytes) +} + +/// Photoshop's `LUTFormatType` value for a format. +fn psd_lut_format(format: LutFormat) -> &'static str { + match format { + LutFormat::Cube => "LUTFormatCUBE", + LutFormat::ThreeDl => "LUTFormat3DL", + LutFormat::Look => "LUTFormatLOOK", + } +} + +/// The source file of a Color Lookup to embed as is: only while it still parses to the +/// layer's table, so an edited table is never saved with stale bytes. +fn lut_source_for<'a>(source: Option<&'a LutSource>, table: &[f32], size: usize) -> Option<&'a LutSource> { + let source = source?; + let file = parse_lut_source(source.format, &source.bytes).ok()?; + (file.size == size && file.data == table).then_some(source) +} + +/// `name` with a `.3dl` / `.look` extension swapped for `.cube`, for a table saved as `.cube`. +fn cube_file_name(name: &str) -> String { + let lower = name.to_ascii_lowercase(); + match [".3dl", ".look"].iter().find(|ext| lower.ends_with(*ext)) { + Some(ext) => format!("{}.cube", name.get(..name.len() - ext.len()).unwrap_or(name)), + None => name.to_string(), + } +} + /// The descriptor of a `vibA` / `blwh` block: a 4-byte descriptor version (16), then the descriptor. fn versioned(d: &[u8]) -> Option { VersionedDescriptor::parse_prefix(d).ok().map(|(v, _)| v.descriptor) @@ -688,31 +726,40 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec)> { } return vec![(*b"selc", v)]; } - Adjustment::ColorLookup { name, lut, size, dither, .. } => { + Adjustment::ColorLookup { name, lut, size, dither, source, .. } => { // A lookup without a usable table (none chosen yet, or a bad size) renders as the // identity, so it is written as a 2³ identity cube rather than dropped. let n = *size as usize; let cube = lut.as_deref().zip(photocraft_doc::adjust::lut3d_len(*size)).and_then(|(table, len)| table.get(..len)).filter(|_| n <= 256); - let (file, dither) = match cube { - Some(cube) => ( - photocraft_cms::lutfile::LutFile { title: String::new(), size: n, data: cube.to_vec(), domain_min: [0.0; 3], domain_max: [1.0; 3] }, - *dither, - ), - _ => (photocraft_cms::lutfile::LutFile { title: String::new(), ..photocraft_cms::lutfile::LutFile::identity(2) }, false), + // Like Photoshop, embed the LUT file as loaded, in its own format; a table made or + // changed in PhotoCraft is written as `.cube`, named to match. + let (format, data, name, dither) = match cube { + Some(cube) => match lut_source_for(source.as_ref(), cube, n) { + Some(src) => (src.format, src.bytes.to_vec(), name.clone(), *dither), + None => { + let file = + photocraft_cms::lutfile::LutFile { title: String::new(), size: n, data: cube.to_vec(), domain_min: [0.0; 3], domain_max: [1.0; 3] }; + (LutFormat::Cube, photocraft_cms::lutfile::write_cube(&file).into_bytes(), cube_file_name(name), *dither) + } + }, + _ => { + let file = photocraft_cms::lutfile::LutFile { title: String::new(), ..photocraft_cms::lutfile::LutFile::identity(2) }; + (LutFormat::Cube, photocraft_cms::lutfile::write_cube(&file).into_bytes(), cube_file_name(name), false) + } }; let en = |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) }; let text = |t: &str| Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(t)); let d = Descriptor::new("null") .with("lookupType", en("colorLookupType", "3DLUT")) - .with("NM ", text(name)) + .with("NM ", text(&name)) .with("Dthr", Value::Boolean(dither)) .with("profile", Value::RawData(Vec::new())) - .with("LUTFormat", en("LUTFormatType", "LUTFormatCUBE")) + .with("LUTFormat", en("LUTFormatType", psd_lut_format(format))) .with("dataOrder", en("colorLookupOrder", "rgbOrder")) .with("tableOrder", en("colorLookupOrder", "bgrOrder")) - .with("LUT3DFileData", Value::RawData(photocraft_cms::lutfile::write_cube(&file).into_bytes())) - .with("LUT3DFileName", text(name)); + .with("LUT3DFileData", Value::RawData(data)) + .with("LUT3DFileName", text(&name)); put16(&mut v, 1); v.extend_from_slice(&VersionedDescriptor::new(d).to_bytes()); return vec![(*b"clrL", v)]; @@ -1007,7 +1054,9 @@ mod tests { rt(Adjustment::ChannelMixer { matrix: [[0.5, 0.3, 0.2, 0.0], [0.1, 0.8, 0.1, 0.05], [0.0, 0.2, 0.9, -0.05]], monochrome: false }); rt(Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.1], [0.0, 1.0, 0.0, 0.0], [-2.0, 0.0, 2.0, 0.0]], monochrome: true }); let id = photocraft_cms::lutfile::LutFile::identity(5); - rt(Adjustment::ColorLookup { name: "Look.cube".into(), lut: Some(std::sync::Arc::new(id.data)), size: 5, tetrahedral: false, dither: true }); + let cube = photocraft_cms::lutfile::write_cube(&id).into_bytes(); + let source = Some(LutSource { format: LutFormat::Cube, bytes: std::sync::Arc::new(cube) }); + rt(Adjustment::ColorLookup { name: "Look.cube".into(), lut: Some(std::sync::Arc::new(id.data)), size: 5, tetrahedral: false, dither: true, source }); } #[test] @@ -1034,6 +1083,105 @@ mod tests { assert_eq!(write(&a)[0].1, d); } + /// A 2³ Lustre `.3dl` (12-bit, blue fastest) that inverts every colour. + fn inverting_3dl() -> Vec { + let mut text = String::from("# made by hand\n0 1023\n"); + for r in 0..2 { + for g in 0..2 { + for b in 0..2 { + text.push_str(&format!("{} {} {}\n", (1 - r) * 4095, (1 - g) * 4095, (1 - b) * 4095)); + } + } + } + text.into_bytes() + } + + /// A 2³ SpeedGrade `.look` holding the identity. + fn identity_look() -> Vec { + let hex: String = photocraft_cms::lutfile::LutFile::identity(2).data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + format!("\n\"2\"\"{hex}\"\n").into_bytes() + } + + /// A `clrL` block as Photoshop writes one for a loaded LUT file. + fn clrl_block(format: &str, bytes: &[u8], name: &str) -> Vec { + let en = |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) }; + let text = |t: &str| Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(t)); + let desc = Descriptor::new("null") + .with("lookupType", en("colorLookupType", "3DLUT")) + .with("NM ", text(name)) + .with("LUTFormat", en("LUTFormatType", format)) + .with("LUT3DFileData", Value::RawData(bytes.to_vec())) + .with("LUT3DFileName", text(name)); + let mut d = vec![0, 1]; + d.extend(VersionedDescriptor::new(desc).to_bytes()); + d + } + + /// The descriptor of the `clrL` block `a` is written as. + fn written_clrl(a: &Adjustment) -> Descriptor { + let b = write(a); + assert_eq!(&b[0].0, b"clrL"); + VersionedDescriptor::parse_prefix(&b[0].1[2..]).unwrap().0.descriptor + } + + fn raw<'a>(d: &'a Descriptor, key: &str) -> &'a [u8] { + let Some(Value::RawData(b)) = d.get(key) else { panic!("{key} missing") }; + b + } + + #[test] + fn color_lookup_keeps_a_3dl_or_look_file_as_loaded() { + // #6544: Photoshop embeds the LUT file as loaded and tags its format; it never converts it. + for (format, kind, bytes, name) in + [("LUTFormat3DL", LutFormat::ThreeDl, inverting_3dl(), "Invert.3DL"), ("LUTFormatLOOK", LutFormat::Look, identity_look(), "Grade.look")] + { + let a = parse(b"clrL", &clrl_block(format, &bytes, name), None, Channels::Rgb); + let Adjustment::ColorLookup { name: n, lut: Some(_), size: 2, source: Some(src), .. } = &a else { panic!("{a:?}") }; + assert_eq!((n.as_str(), src.format, src.bytes.as_slice()), (name, kind, bytes.as_slice())); + let d = written_clrl(&a); + assert_eq!(desc_enum(&d, "LUTFormat"), Some(format.as_bytes()), "{name}"); + assert_eq!(raw(&d, "LUT3DFileData"), bytes.as_slice(), "{name}: the file's bytes, unchanged"); + assert_eq!(desc_text(&d, "LUT3DFileName").as_deref(), Some(name)); + assert_eq!(desc_text(&d, "NM ").as_deref(), Some(name)); + // And it reads back as the same layer. + let b = write(&a); + assert_eq!(parse(&b[0].0, &b[0].1, None, Channels::Rgb), a); + } + } + + #[test] + fn color_lookup_table_without_matching_file_is_saved_as_cube_named_to_match() { + let a = parse(b"clrL", &clrl_block("LUTFormat3DL", &inverting_3dl(), "Invert.3DL"), None, Channels::Rgb); + let Adjustment::ColorLookup { name, size, tetrahedral, dither, source, .. } = a else { panic!("{a:?}") }; + // The table no longer matches the stored file (changed in PhotoCraft): the bytes are stale. + let id = photocraft_cms::lutfile::LutFile::identity(2).data; + let edited = Adjustment::ColorLookup { name, lut: Some(std::sync::Arc::new(id.clone())), size, tetrahedral, dither, source }; + let d = written_clrl(&edited); + assert_eq!(desc_enum(&d, "LUTFormat"), Some(&b"LUTFormatCUBE"[..])); + assert_eq!(desc_text(&d, "LUT3DFileName").as_deref(), Some("Invert.cube")); + assert_eq!(photocraft_cms::lutfile::parse("x.cube", raw(&d, "LUT3DFileData")).unwrap().data, id); + // A table made in PhotoCraft (no file) named like another format is also renamed. + let made = + Adjustment::ColorLookup { name: "Grade.look".into(), lut: Some(std::sync::Arc::new(id)), size: 2, tetrahedral: false, dither: false, source: None }; + let d = written_clrl(&made); + assert_eq!(desc_enum(&d, "LUTFormat"), Some(&b"LUTFormatCUBE"[..])); + assert_eq!(desc_text(&d, "LUT3DFileName").as_deref(), Some("Grade.cube")); + assert_eq!(desc_text(&d, "NM ").as_deref(), Some("Grade.cube")); + // An unreadable stored file falls back the same way instead of failing the save. + let junk = Adjustment::ColorLookup { + name: "Junk.3dl".into(), + lut: Some(std::sync::Arc::new(photocraft_cms::lutfile::LutFile::identity(2).data)), + size: 2, + tetrahedral: false, + dither: false, + source: Some(LutSource { format: LutFormat::ThreeDl, bytes: std::sync::Arc::new(b"\xFF not a lut".to_vec()) }), + }; + assert_eq!(desc_text(&written_clrl(&junk), "LUT3DFileName").as_deref(), Some("Junk.cube")); + assert_eq!(cube_file_name("a.b.3dl"), "a.b.cube"); + assert_eq!(cube_file_name("Day for Night"), "Day for Night"); + assert_eq!(cube_file_name("x.cube"), "x.cube"); + } + #[test] fn color_lookup_synthetic_block() { let cube = b"TITLE \"t\"\nLUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; @@ -1297,7 +1445,7 @@ mod tests { } // A colour lookup without a (usable) table becomes an identity cube, without dither. for lut in [None, Some(std::sync::Arc::new(vec![0.5; 7]))] { - let b = write(&Adjustment::ColorLookup { name: "L".into(), lut, size: 3, tetrahedral: false, dither: true }); + let b = write(&Adjustment::ColorLookup { name: "L".into(), lut, size: 3, tetrahedral: false, dither: true, source: None }); let back = parse(&b[0].0, &b[0].1, None, Channels::Rgb); let Adjustment::ColorLookup { lut: Some(t), size: 2, dither: false, name, .. } = back else { panic!("{back:?}") }; assert_eq!(name, "L"); diff --git a/crates/io/tests/adjust_blend_roundtrip.rs b/crates/io/tests/adjust_blend_roundtrip.rs index 4e8b727af2..97160c6f32 100644 --- a/crates/io/tests/adjust_blend_roundtrip.rs +++ b/crates/io/tests/adjust_blend_roundtrip.rs @@ -80,9 +80,20 @@ fn adjustments() -> Vec<(Adjustment, bool)> { (Adjustment::PhotoFilter { color: [q16(0), q16(39321), q16(65535)], density: 0.3, preserve_luminosity: false }, true), (Adjustment::ChannelMixer { matrix: [[0.5, 0.3, 0.2, 0.0], [0.1, 0.8, 0.1, 0.05], [0.0, 0.2, 0.9, -0.05]], monochrome: false }, true), (Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], monochrome: true }, true), - (Adjustment::ColorLookup { name: "Look.cube".into(), lut: Some(Arc::new(lut.data)), size: 5, tetrahedral: false, dither: false }, true), + // The LUT file is embedded as loaded (here a .cube), so it reads back with its source. + ( + Adjustment::ColorLookup { + name: "Look.cube".into(), + source: Some(adjust::LutSource { format: adjust::LutFormat::Cube, bytes: Arc::new(photocraft_cms::lutfile::write_cube(&lut).into_bytes()) }), + lut: Some(Arc::new(lut.data)), + size: 5, + tetrahedral: false, + dither: false, + }, + true, + ), // No table chosen: written as an identity cube (renders the same, comes back with a table). - (Adjustment::ColorLookup { name: String::new(), lut: None, size: 0, tetrahedral: false, dither: false }, false), + (Adjustment::ColorLookup { name: String::new(), lut: None, size: 0, tetrahedral: false, dither: false, source: None }, false), (Adjustment::Invert, true), (Adjustment::Posterize { levels: 4 }, true), (Adjustment::Threshold { level: g(140) }, true), @@ -207,7 +218,8 @@ fn every_adjustment_kind_round_trips_16bit() { fn color_lookup_tetrahedral_warns_on_psd_save() { let lut = photocraft_cms::lutfile::LutFile::from_fn("t", 2, |c| if c == [1.0; 3] { [1.0; 3] } else { [0.0; 3] }); for tetrahedral in [true, false] { - let adj = Adjustment::ColorLookup { name: "test.cube".into(), lut: Some(Arc::new(lut.data.clone())), size: 2, tetrahedral, dither: false }; + let adj = + Adjustment::ColorLookup { name: "test.cube".into(), lut: Some(Arc::new(lut.data.clone())), size: 2, tetrahedral, dither: false, source: None }; let mut d = Document::new("lut", photocraft_geom::Size::new(8, 8), ColorMode::Rgb, SampleType::U8); let fmt = d.pixel_format(); d.layers.push(raster("Background", fmt, d.bounds(), 1, false)); diff --git a/crates/io/tests/adjust_warnings.rs b/crates/io/tests/adjust_warnings.rs index 9b91f40bcf..330f7ebb13 100644 --- a/crates/io/tests/adjust_warnings.rs +++ b/crates/io/tests/adjust_warnings.rs @@ -33,7 +33,8 @@ fn unreadable_adjustments_warn_on_open() { #[test] fn readable_adjustments_open_without_warnings() { let lut: Vec = (0..8).flat_map(|i| [(i & 1) as f32, ((i >> 1) & 1) as f32, ((i >> 2) & 1) as f32]).collect(); - let lookup = Adjustment::ColorLookup { name: "id.cube".into(), size: 2, lut: Some(std::sync::Arc::new(lut)), tetrahedral: false, dither: false }; + let lookup = + Adjustment::ColorLookup { name: "id.cube".into(), size: 2, lut: Some(std::sync::Arc::new(lut)), tetrahedral: false, dither: false, source: None }; for adj in [lookup, Adjustment::Invert] { let (doc, warnings) = reopen(adj, "x.psd", &ExportOptions::default()); assert!(!matches!(&doc.layers[0].content, LayerContent::Adjustment(Adjustment::Unsupported { .. }))); diff --git a/crates/ui-egui/src/adjust_ui.rs b/crates/ui-egui/src/adjust_ui.rs index b713e9104e..f4541599fb 100644 --- a/crates/ui-egui/src/adjust_ui.rs +++ b/crates/ui-egui/src/adjust_ui.rs @@ -92,7 +92,7 @@ fn browse_color_lookup(app: &mut PhotocraftApp, layer: LayerId) -> Result