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) <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-16 20:59:24 +02:00
co-authored by Claude Opus 5.5
parent 2a952f164c
commit 750a91c8b2
12 changed files with 369 additions and 44 deletions
+52 -7
View File
@@ -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<LutFile, LutError> {
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<LutFile, LutError> {
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"<look>"), Format::ThreeDl);
assert_eq!(Format::detect("a.Look", b""), Format::Look);
assert_eq!(Format::detect("a.cube", b" <look>"), 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());
+8 -1
View File
@@ -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}");
+23
View File
@@ -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<Vec<u8>>,
}
/// 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<LutSource>,
},
Invert,
Posterize {
+25 -9
View File
@@ -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<Adj
/// error.
pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result<Adjustment> {
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<LutSource>)> = 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<Adjust
name = label;
size = f.size as u32;
lut = Some(std::sync::Arc::new(f.data));
source = src;
}
match opt_str(CMD, p, "interpolation")? {
None => {}
@@ -157,7 +163,17 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result<Adjust
if let Some(b) = opt_bool(CMD, p, "dither")? {
dither = b;
}
Ok(Adjustment::ColorLookup { name, lut, size, tetrahedral, dither })
Ok(Adjustment::ColorLookup { name, lut, size, tetrahedral, dither, source })
}
/// A loaded LUT file, tagged with the format [`photocraft_cms::lutfile::parse`] read it as.
fn lut_source(file_name: &str, bytes: Vec<u8>) -> 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 {
+48 -1
View File
@@ -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!("<look><LUT><size>2</size><data>{hex}</data></LUT></look>");
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(),
+19
View File
@@ -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");
}
+8 -1
View File
@@ -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}");
}
+1 -1
View File
@@ -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]
+167 -19
View File
@@ -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<Adjustment, String> {
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<Adjustment, String> {
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<photocraft_cms::lutfile::LutFile, photocraft_cms::lutfile::LutError> {
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<Descriptor> {
VersionedDescriptor::parse_prefix(d).ok().map(|(v, _)| v.descriptor)
@@ -688,31 +726,40 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec<u8>)> {
}
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<u8> {
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<u8> {
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!("<?xml version=\"1.0\"?>\n<look><LUT><size>\"2\"</size><data>\"{hex}\"</data></LUT></look>\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<u8> {
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");
+15 -3
View File
@@ -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));
+2 -1
View File
@@ -33,7 +33,8 @@ fn unreadable_adjustments_warn_on_open() {
#[test]
fn readable_adjustments_open_without_warnings() {
let lut: Vec<f32> = (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 { .. })));
+1 -1
View File
@@ -92,7 +92,7 @@ fn browse_color_lookup(app: &mut PhotocraftApp, layer: LayerId) -> Result<Value,
pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &Adjustment) {
let t = Tokens::get(ui.ctx());
let Adjustment::ColorLookup { name, lut, size, tetrahedral, dither } = adj else {
let Adjustment::ColorLookup { name, lut, size, tetrahedral, dither, .. } = adj else {
return;
};
let builtins = photocraft_engine::adjust_cmds::LOOKS;