Add WEBP, TIFF, ICO, TGA, HDR, and EXR image import and more raster export formats (#4554)

* Add WEBP, TIFF, ICO, TGA, HDR, and EXR image import and more raster export file types

* Try a file as TGA only when no image format is recognized
This commit is contained in:
Keavon Chambers
2026-09-19 18:14:37 -07:00
parent 23e6b0f11d
commit 2ed5e775e5
16 changed files with 373 additions and 122 deletions
@@ -94,19 +94,38 @@ impl DialogLayoutHolder for ExportDialogMessageHandler {
impl LayoutHolder for ExportDialogMessageHandler {
fn layout(&self) -> Layout {
let entries = [(FileType::Png, "PNG"), (FileType::Jpg, "JPG"), (FileType::Svg, "SVG")]
// The vector type, then the raster ones
let file_types = [
vec![(FileType::Svg, "SVG")],
vec![
(FileType::Png, "PNG"),
(FileType::Jpg, "JPG"),
(FileType::Webp, "WEBP"),
(FileType::Tiff, "TIFF"),
(FileType::Bmp, "BMP"),
(FileType::Tga, "TGA"),
(FileType::Ico, "ICO"),
],
];
let selected_index = file_types.iter().flatten().position(|(file_type, _)| *file_type == self.file_type);
let entries = file_types
.into_iter()
.map(|(file_type, name)| {
RadioEntryData::new(format!("{file_type:?}"))
.label(name)
.on_update(move |_| ExportDialogMessage::FileType { file_type }.into())
.map(|section| {
section
.into_iter()
.map(|(file_type, name)| {
MenuListEntry::new(format!("{file_type:?}"))
.label(name)
.on_commit(move |_| ExportDialogMessage::FileType { file_type }.into())
})
.collect()
})
.collect();
let export_type = vec![
TextLabel::new("File Type").table_align(true).min_width(100).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
RadioInput::new(entries).selected_index(Some(self.file_type as u32)).widget_instance(),
DropdownInput::new(entries).selected_index(selected_index.map(|index| index as u32)).min_width(200).widget_instance(),
];
let resolution = vec![
@@ -1,7 +1,7 @@
use super::IconName;
use super::utility_types::{MouseCursorIcon, PersistedState};
use crate::messages::app_window::app_window_message_handler::AppWindowPlatform;
use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FileDialogOptions, FileFilter, RasterizedImage};
use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FileDialogOptions, FileFilter, FileType, RasterizedImage};
use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::utility_types::{
@@ -109,7 +109,8 @@ pub enum FrontendMessage {
TriggerExportImage {
svg: String,
name: String,
mime: String,
#[serde(rename = "fileType")]
file_type: FileType,
size: (f64, f64),
},
TriggerFetchAndOpenDocument {
+97 -9
View File
@@ -47,28 +47,30 @@ pub enum MouseCursorIcon {
Rotate,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum FileType {
#[default]
Png,
Jpg,
Webp,
Tiff,
Bmp,
Tga,
Ico,
Svg,
}
impl FileType {
pub fn to_mime(self) -> &'static str {
match self {
FileType::Png => "image/png",
FileType::Jpg => "image/jpeg",
FileType::Svg => "image/svg+xml",
}
}
pub fn extension(self) -> &'static str {
match self {
FileType::Png => "png",
FileType::Jpg => "jpg",
FileType::Webp => "webp",
FileType::Tiff => "tiff",
FileType::Bmp => "bmp",
FileType::Tga => "tga",
FileType::Ico => "ico",
FileType::Svg => "svg",
}
}
@@ -77,6 +79,11 @@ impl FileType {
let name = match self {
FileType::Png => "PNG Image",
FileType::Jpg => "JPEG Image",
FileType::Webp => "WEBP Image",
FileType::Tiff => "TIFF Image",
FileType::Bmp => "BMP Image",
FileType::Tga => "TGA Image",
FileType::Ico => "ICO Image",
FileType::Svg => "SVG Image",
};
FileFilter {
@@ -85,6 +92,51 @@ impl FileType {
mime_types: Vec::new(),
}
}
/// Encodes 8-bit RGBA pixels with straight alpha as a file of this raster type.
pub fn encode(self, width: u32, height: u32, rgba: Vec<u8>) -> Result<Vec<u8>, String> {
use image::buffer::ConvertBuffer;
use image::{ImageFormat, RgbImage, RgbaImage};
let Some(mut image) = RgbaImage::from_raw(width, height, rgba) else {
return Err("Failed to create image buffer for export".to_string());
};
let format = match self {
FileType::Png => ImageFormat::Png,
FileType::Jpg => ImageFormat::Jpeg,
FileType::Webp => ImageFormat::WebP,
FileType::Tiff => ImageFormat::Tiff,
FileType::Bmp => ImageFormat::Bmp,
FileType::Tga => ImageFormat::Tga,
FileType::Ico => ImageFormat::Ico,
FileType::Svg => return Err("SVG cannot be exported from an image buffer".to_string()),
};
if self == FileType::Ico && (width > 256 || height > 256) {
return Err("An ICO image can be at most 256 pixels wide and tall. Lower the scale factor or choose a smaller export area.".to_string());
}
let mut encoded = Vec::new();
let mut cursor = std::io::Cursor::new(&mut encoded);
let result = if self == FileType::Jpg {
// Composite onto a white background since JPG doesn't support transparency
for pixel in image.pixels_mut() {
let [r, g, b, a] = pixel.0;
let alpha = a as f32 / 255.;
let blend = |channel: u8| (channel as f32 * alpha + 255. * (1. - alpha)).round() as u8;
*pixel = image::Rgba([blend(r), blend(g), blend(b), 255]);
}
let image: RgbImage = image.convert();
image.write_to(&mut cursor, format)
} else {
image.write_to(&mut cursor, format)
};
result.map_err(|error| format!("Failed to encode {self:?}: {error}"))?;
Ok(encoded)
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -128,3 +180,39 @@ pub struct FileDialogOptions {
pub filters: Vec<FileFilter>,
pub multiple: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use graphene_std::raster::Image;
#[test]
fn every_raster_file_type_encodes_an_image_that_reads_back() {
// Opaque red, then fully transparent
let pixels = vec![255, 0, 0, 255, 0, 0, 0, 0];
for file_type in [FileType::Png, FileType::Jpg, FileType::Webp, FileType::Tiff, FileType::Bmp, FileType::Tga, FileType::Ico] {
let encoded = file_type.encode(2, 1, pixels.clone()).unwrap_or_else(|error| panic!("{file_type:?}: {error}"));
let decoded = Image::from_encoded(&encoded).unwrap_or_else(|| panic!("{file_type:?} should read back"));
assert_eq!((decoded.width, decoded.height), (2, 1), "{file_type:?}");
// Only JPG lacks transparency, so it lands on white
let transparent = decoded.to_flat_u8().0[4..8].to_vec();
if file_type == FileType::Jpg {
assert!(transparent.iter().all(|&channel| channel > 250), "{transparent:?}");
} else {
assert_eq!(transparent[3], 0, "{file_type:?}");
}
}
}
#[test]
fn file_types_that_cannot_hold_the_image_say_so() {
assert!(FileType::Svg.encode(1, 1, vec![0; 4]).is_err());
assert!(FileType::Png.encode(2, 2, vec![0; 4]).is_err());
let too_large = FileType::Ico.encode(257, 1, vec![0; 257 * 4]).unwrap_err();
assert!(too_large.contains("256 pixels"), "{too_large}");
assert!(FileType::Ico.encode(256, 1, vec![0; 256 * 4]).is_ok());
}
}
@@ -1,9 +1,10 @@
use super::utility_types::{DataType, IngestAction, TypeFilter, decoded_image_size};
use super::utility_types::{DataType, IngestAction, TypeFilter};
use crate::messages::frontend::utility_types::{FileDialogOptions, FileFilter};
use crate::messages::prelude::*;
use glam::IVec2;
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue;
use graphene_std::raster::Image;
use graphene_std::raster_nodes::color_lookup_table::{Lut, LutParseError};
#[derive(ExtractField)]
@@ -124,7 +125,7 @@ impl MessageHandler<IngestMessage, IngestMessageContext> for IngestMessageHandle
(insert, artboard_canvas)
}
DataType::Raster(_) => {
let Some(size) = decoded_image_size(&data) else { return unsupported(responses) };
let Some(size) = Image::encoded_size(&data) else { return unsupported(responses) };
let insert = DocumentMessage::InsertImage {
name: name.clone(),
data: data.into(),
@@ -185,7 +186,7 @@ fn rejection(data: &[u8], data_type: DataType, accepted_types: &[DataType]) -> O
}
match data_type {
DataType::Raster(_) => decoded_image_size(data).is_none().then_some("This file could not be read as an image."),
DataType::Raster(_) => Image::encoded_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\
@@ -226,7 +227,6 @@ mod tests {
use super::*;
use graph_craft::document::NodeId;
use graphene_std::Color;
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";
@@ -337,6 +337,19 @@ mod tests {
assert!(refusal(IDENTITY_CUBE, "grade.png").contains("does not accept"));
}
#[test]
fn resource_input_takes_an_image_format_that_has_no_signature() {
// A TGA file is only told apart by its name
let tga = crate::messages::frontend::utility_types::FileType::Tga.encode(2, 1, vec![255; 8]).unwrap();
let stores = |file_name: &str| {
let responses = ingest_named(&tga, Some(file_name), resource_input(TypeFilter::raster().types), true);
responses.iter().any(|message| stored_resource(message).is_some())
};
assert!(stores("photo.tga"));
assert!(!stores("photo.unknown"));
}
#[test]
fn resource_input_tells_a_corrupt_image_apart_from_a_wrong_type() {
let png = Image::new(8, 8, Color::WHITE).to_png();
@@ -12,11 +12,6 @@ use std::path::Path;
/// How many leading bytes are inspected to recognize a text format.
const SNIFFED_TEXT_LENGTH: usize = 4096;
/// The pixel size of a file that fully decodes as a raster image.
pub fn decoded_image_size(data: &[u8]) -> Option<(u32, u32)> {
image::load_from_memory(data).ok().map(|image| (image.width(), image.height()))
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
pub enum IngestAction {
@@ -241,6 +236,9 @@ mod tests {
fn type_filter_to_file_filter() {
let filter = FileFilter::from(TypeFilter::image());
assert!(filter.extensions.iter().any(|extension| extension == "jpeg") && filter.extensions.iter().any(|extension| extension == "png"));
for enabled in ["webp", "tiff", "ico", "tga", "hdr", "exr"] {
assert!(filter.extensions.iter().any(|extension| extension == enabled), "{enabled} should be offered");
}
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()));
@@ -167,6 +167,13 @@ pub enum PortfolioMessage {
artboard_name: Option<String>,
artboard_count: usize,
},
SaveRasterizedExport {
name: String,
file_type: FileType,
width: u32,
height: u32,
data: Vec<u8>,
},
SubmitActiveGraphRender,
SubmitGraphRender {
document_id: DocumentId,
@@ -1266,6 +1266,18 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
});
}
}
PortfolioMessage::SaveRasterizedExport { name, file_type, width, height, data } => match file_type.encode(width, height, data) {
Ok(content) => responses.add(FrontendMessage::TriggerSaveFile {
name,
folder: None,
filters: vec![file_type.file_filter()],
content: content.into(),
}),
Err(description) => responses.add(DialogMessage::DisplayDialogError {
title: "Unable to export document".to_string(),
description,
}),
},
PortfolioMessage::SubmitActiveGraphRender => {
if let Some(document_id) = self.active_document_id {
responses.add(PortfolioMessage::SubmitGraphRender { document_id, ignore_hash: false });
+3 -45
View File
@@ -751,9 +751,8 @@ impl NodeGraphExecutor {
content: svg.into_bytes().into(),
});
} else {
let mime = file_type.to_mime().to_string();
let size = size.as_dvec2().into();
responses.add(FrontendMessage::TriggerExportImage { svg, name, mime, size });
responses.add(FrontendMessage::TriggerExportImage { svg, name, file_type, size });
}
}
#[cfg(feature = "gpu")]
@@ -761,49 +760,8 @@ impl NodeGraphExecutor {
data: RenderOutputType::Buffer { data, width, height },
..
}) if file_type != FileType::Svg => {
use image::buffer::ConvertBuffer;
use image::{ImageFormat, RgbImage, RgbaImage};
let Some(mut image) = RgbaImage::from_raw(width, height, data) else {
return Err("Failed to create image buffer for export".to_string());
};
let mut encoded = Vec::new();
let mut cursor = std::io::Cursor::new(&mut encoded);
match file_type {
FileType::Png => {
let result = image.write_to(&mut cursor, ImageFormat::Png);
if let Err(err) = result {
return Err(format!("Failed to encode PNG: {err}"));
}
}
FileType::Jpg => {
// Composite onto a white background since JPG doesn't support transparency
for pixel in image.pixels_mut() {
let [r, g, b, a] = pixel.0;
let alpha = a as f32 / 255.;
let blend = |channel: u8| (channel as f32 * alpha + 255. * (1. - alpha)).round() as u8;
*pixel = image::Rgba([blend(r), blend(g), blend(b), 255]);
}
let image: RgbImage = image.convert();
let result = image.write_to(&mut cursor, ImageFormat::Jpeg);
if let Err(err) = result {
return Err(format!("Failed to encode JPG: {err}"));
}
}
FileType::Svg => {
return Err("SVG cannot be exported from an image buffer".to_string());
}
}
responses.add(FrontendMessage::TriggerSaveFile {
name,
folder,
filters,
content: encoded.into(),
});
let content = file_type.encode(width, height, data)?.into();
responses.add(FrontendMessage::TriggerSaveFile { name, folder, filters, content });
}
_ => {
return Err(format!("Incorrect render type for exporting to an SVG ({file_type:?}, {node_graph_output})"));