New node: 'Color Lookup' (#4552)

* New node: 'Color Lookup' for applying .cube, .3dl, .look, .csp, and .icc LUT files

* Add LUT ingest to the UI

* Add caching to the parsed LUT

* More code review fixes
This commit is contained in:
Keavon Chambers
2026-09-19 02:56:14 -07:00
committed by GitHub
parent ab32a60476
commit 97d8452a1d
12 changed files with 1370 additions and 20 deletions
@@ -1470,6 +1470,13 @@ fn static_input_properties() -> InputProperties {
Ok(vec![LayoutGroup::row(widgets)])
}),
);
map.insert(
"lut_file".to_string(),
Box::new(|node_id, index, context| {
let widgets = node_properties::resource_widget(ParameterWidgetsInfo::at_index(node_id, index, true, context), vec![TypeFilter::lut()]);
Ok(vec![LayoutGroup::row(widgets)])
}),
);
map.insert(
"artboard_background".to_string(),
Box::new(|node_id, index, context| {
@@ -1314,8 +1314,8 @@ pub fn resource_widget(parameter_widgets_info: ParameterWidgetsInfo, filters: Ve
} = parameter_widgets_info;
let use_counts = network_interface.collect_resources_use_counts();
// This is a heuristic to filter for image resources and will break once other data types are loaded.
// TODO: Add a proper way to filter for image resources.
// Fonts are the only resources the registry tells apart, so a picker also lists the files of other types.
// TODO: Record each resource's data type so a picker lists only the files its input accepts.
let mut files: Vec<(ResourceId, String, String)> = resources
.registry
.resolved()
@@ -4,6 +4,7 @@ use crate::messages::prelude::*;
use glam::IVec2;
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue;
use graphene_std::raster_nodes::color_lookup_table::{Lut, LutParseError};
#[derive(ExtractField)]
pub struct IngestMessageContext {
@@ -54,10 +55,10 @@ impl MessageHandler<IngestMessage, IngestMessageContext> for IngestMessageHandle
input_index,
accepted_types,
} => {
if !is_accepted(&data, data_type, &accepted_types) {
if let Some(description) = rejection(&data, data_type, &accepted_types) {
responses.add(DialogMessage::DisplayDialogError {
title: "Unsupported file".into(),
description: "This file is not a type that this input accepts.".into(),
description: description.into(),
});
return;
}
@@ -134,7 +135,8 @@ impl MessageHandler<IngestMessage, IngestMessageContext> for IngestMessageHandle
};
(insert, None)
}
DataType::Unknown => return unsupported(responses),
// A LUT is only ever the file of a node input
DataType::Lut | DataType::Unknown => return unsupported(responses),
};
if !place_at_origin {
@@ -171,13 +173,32 @@ fn unsupported(responses: &mut VecDeque<Message>) {
});
}
/// Whether a node input takes the file, where an image must also fully decode.
fn is_accepted(data: &[u8], data_type: DataType, accepted_types: &[DataType]) -> bool {
/// The reason a node input refuses the file, or `None` if it accepts it. An input that lists its accepted types takes an image or LUT only if it fully parses.
fn rejection(data: &[u8], data_type: DataType, accepted_types: &[DataType]) -> Option<&'static str> {
const WRONG_TYPE: &str = "This input does not accept the format of the chosen file.";
if accepted_types.is_empty() {
return true;
return None;
}
if !accepted_types.contains(&data_type) {
return Some(WRONG_TYPE);
}
accepted_types.contains(&data_type) && (!matches!(data_type, DataType::Raster(_)) || decoded_image_size(data).is_some())
match data_type {
DataType::Raster(_) => decoded_image_size(data).is_none().then_some("This file could not be read as an image."),
DataType::Lut => Lut::parse(data).err().map(|error| match error {
LutParseError::IccProfileClass => {
"This ICC profile describes the colors of a device (like a monitor or printer) instead\n\
of remapping colors. Only \"abstract\" and \"device link\" profiles work as LUTs."
}
LutParseError::IccColorSpaces => {
"This ICC profile remaps within color spaces that are currently unsupported, such as CMYK.\n\
A \"device link\" profile must map RGB to RGB. An \"abstract\" profile must map Lab to Lab."
}
LutParseError::Unreadable => "This file could not be read as a LUT. It may be corrupted or an unsupported format variant.",
}),
_ => None,
}
}
// The viewBox preserves the full canvas rather than the tighter bounding box of the rendered content
@@ -208,14 +229,19 @@ mod tests {
use graphene_std::raster::Image;
const REQUESTING_DOCUMENT: DocumentId = DocumentId(3);
const IDENTITY_CUBE: &[u8] = b"LUT_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";
fn ingest(data: &[u8], action: IngestAction, document_open: bool) -> VecDeque<Message> {
ingest_named(data, None, action, document_open)
}
fn ingest_named(data: &[u8], file_name: Option<&str>, action: IngestAction, document_open: bool) -> VecDeque<Message> {
let mut responses = VecDeque::new();
let message = IngestMessage::Ingest {
data: data.into(),
action,
mime_type: String::new(),
path: None,
path: file_name.map(Into::into),
};
IngestMessageHandler::default().process_message(message, &mut responses, IngestMessageContext { document_open });
responses
@@ -291,6 +317,64 @@ mod tests {
assert!(matches!(&responses[0], Message::Portfolio(PortfolioMessage::Ingest(IngestMessage::Browse { action, .. })) if *action == dropped));
}
#[test]
fn resource_input_says_why_an_icc_profile_of_the_wrong_kind_is_refused() {
let mut monitor_profile = vec![0; 128];
monitor_profile[12..16].copy_from_slice(b"mntr");
monitor_profile[36..40].copy_from_slice(b"acsp");
let refusal = |data: &[u8], file_name: &str| {
let responses = ingest_named(data, Some(file_name), resource_input(TypeFilter::lut().types), true);
assert_eq!(responses.len(), 1, "a refused file should only show a dialog");
match &responses[0] {
Message::Dialog(DialogMessage::DisplayDialogError { description, .. }) => description.clone(),
_ => panic!("the user should be told why"),
}
};
assert!(refusal(&monitor_profile, "display.icc").contains("monitor"));
assert!(refusal(b"not a table", "grade.cube").contains("could not be read"));
assert!(refusal(IDENTITY_CUBE, "grade.png").contains("does not accept"));
}
#[test]
fn resource_input_tells_a_corrupt_image_apart_from_a_wrong_type() {
let png = Image::new(8, 8, Color::WHITE).to_png();
let refusal = |data: &[u8]| {
let responses = ingest(data, resource_input(TypeFilter::raster().types), true);
match &responses[0] {
Message::Dialog(DialogMessage::DisplayDialogError { description, .. }) => description.clone(),
_ => panic!("the user should be told why"),
}
};
assert!(refusal(&png[..40]).contains("could not be read as an image"));
assert!(refusal(b"not an image").contains("does not accept"));
}
#[test]
fn resource_input_takes_a_lookup_table_only_when_it_parses() {
let png = Image::new(8, 8, Color::WHITE).to_png();
let stores = |data: &[u8], filter: TypeFilter| {
let responses = ingest_named(data, Some("grade.cube"), resource_input(filter.types), true);
responses.iter().any(|message| stored_resource(message).is_some())
};
assert!(stores(IDENTITY_CUBE, TypeFilter::lut()));
// A table cut off partway, an image named as a table, and a table offered to an image input
assert!(!stores(&IDENTITY_CUBE[..30], TypeFilter::lut()));
assert!(!stores(&png, TypeFilter::lut()));
assert!(!stores(IDENTITY_CUBE, TypeFilter::raster()));
}
#[test]
fn lookup_table_outside_a_node_input_only_shows_a_dialog() {
let responses = ingest_named(IDENTITY_CUBE, Some("grade.cube"), IngestAction::Import, true);
assert_eq!(responses.len(), 1);
assert!(matches!(responses[0], Message::Dialog(DialogMessage::DisplayDialogError { .. })));
}
#[test]
fn truncated_image_only_shows_a_dialog() {
let png = Image::new(8, 8, Color::WHITE).to_png();
@@ -4,6 +4,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye
use crate::messages::prelude::DocumentId;
use document_container::archive::ArchiveFormat;
use graph_craft::document::NodeId;
use graphene_std::raster_nodes::color_lookup_table::LUT_FILE_EXTENSIONS;
use image::ImageFormat;
use std::ffi::OsStr;
use std::path::Path;
@@ -57,6 +58,7 @@ pub enum DataType {
Gdd,
Svg,
Raster(ImageFormat),
Lut,
#[default]
Unknown,
}
@@ -87,6 +89,7 @@ impl DataType {
FILE_EXTENSION => Self::GraphiteLegacy,
GDD_FILE_EXTENSION => Self::Gdd,
"svg" => Self::Svg,
extension if LUT_FILE_EXTENSIONS.contains(&extension) => Self::Lut,
extension => ImageFormat::from_extension(extension).map_or(Self::Unknown, Self::Raster),
}
}
@@ -127,7 +130,8 @@ impl DataType {
Self::Gdd => "application/vnd.graphite.document",
Self::Svg => "image/svg+xml",
Self::Raster(format) => format.to_mime_type(),
Self::Unknown => return None,
// The LUT formats share no MIME type
Self::Lut | Self::Unknown => return None,
})
}
@@ -137,6 +141,7 @@ impl DataType {
Self::Gdd => &[GDD_FILE_EXTENSION],
Self::Svg => &["svg"],
Self::Raster(format) => format.extensions_str(),
Self::Lut => LUT_FILE_EXTENSIONS,
Self::Unknown => &[],
}
}
@@ -168,6 +173,13 @@ impl TypeFilter {
images.types.push(DataType::Svg);
images
}
pub fn lut() -> Self {
Self {
name: "LUT".into(),
types: vec![DataType::Lut],
}
}
}
impl From<TypeFilter> for FileFilter {
@@ -216,6 +228,8 @@ mod tests {
assert_eq!(detect("image/svg+xml", ""), DataType::Svg);
assert_eq!(detect("application/graphite+json", ""), DataType::GraphiteLegacy);
assert_eq!(detect("image/png", "document.gdd"), DataType::Raster(ImageFormat::Png));
assert_eq!(detect("", "grade.CUBE"), DataType::Lut);
assert_eq!(detect("application/vnd.iccprofile", "profile.icm"), DataType::Lut);
assert_eq!(detect("text/csv", "table.csv"), DataType::Unknown);
assert_eq!(DataType::detect(&[], "", None), DataType::Unknown);
@@ -229,5 +243,9 @@ mod tests {
assert!(filter.extensions.iter().any(|extension| extension == "jpeg") && filter.extensions.iter().any(|extension| extension == "png"));
assert!(filter.extensions.last().is_some_and(|extension| extension == "svg"));
assert!(filter.mime_types.contains(&"image/jpeg".to_string()) && filter.mime_types.contains(&"image/svg+xml".to_string()));
// LUTs are picked by extension alone
let filter = FileFilter::from(TypeFilter::lut());
assert!(filter.extensions.iter().any(|extension| extension == "cube") && filter.mime_types.is_empty());
}
}