mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-25 04:38:11 +08:00
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user