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