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
Generated
+72
View File
@@ -276,6 +276,12 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -1497,6 +1503,21 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "exr"
version = "1.74.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide",
"rayon-core",
"smallvec",
"zune-inflate",
]
[[package]] [[package]]
name = "fancy-regex" name = "fancy-regex"
version = "0.18.0" version = "0.18.0"
@@ -2831,14 +2852,27 @@ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder-lite",
"color_quant", "color_quant",
"exr",
"gif", "gif",
"image-webp",
"num-traits", "num-traits",
"png 0.17.16", "png 0.17.16",
"serde", "serde",
"tiff",
"zune-core", "zune-core",
"zune-jpeg", "zune-jpeg",
] ]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]] [[package]]
name = "imagesize" name = "imagesize"
version = "0.14.0" version = "0.14.0"
@@ -3091,6 +3125,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "jpeg-decoder"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.98" version = "0.3.98"
@@ -3169,6 +3209,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]] [[package]]
name = "libbz2-rs-sys" name = "libbz2-rs-sys"
version = "0.2.2" version = "0.2.2"
@@ -4390,6 +4436,12 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.3" version = "0.38.3"
@@ -5904,6 +5956,17 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "tiff"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
dependencies = [
"flate2",
"jpeg-decoder",
"weezl",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.41" version = "0.3.41"
@@ -7993,6 +8056,15 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "zune-jpeg" name = "zune-jpeg"
version = "0.4.20" version = "0.4.20"
+6
View File
@@ -192,6 +192,12 @@ image = { version = "0.25", default-features = false, features = [
"jpeg", "jpeg",
"bmp", "bmp",
"gif", "gif",
"webp",
"tiff",
"ico",
"tga",
"hdr",
"exr",
"serde", "serde",
] } ] }
pretty_assertions = "1.4" pretty_assertions = "1.4"
@@ -94,19 +94,38 @@ impl DialogLayoutHolder for ExportDialogMessageHandler {
impl LayoutHolder for ExportDialogMessageHandler { impl LayoutHolder for ExportDialogMessageHandler {
fn layout(&self) -> Layout { 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() .into_iter()
.map(|(file_type, name)| { .map(|section| {
RadioEntryData::new(format!("{file_type:?}")) section
.label(name) .into_iter()
.on_update(move |_| ExportDialogMessage::FileType { file_type }.into()) .map(|(file_type, name)| {
MenuListEntry::new(format!("{file_type:?}"))
.label(name)
.on_commit(move |_| ExportDialogMessage::FileType { file_type }.into())
})
.collect()
}) })
.collect(); .collect();
let export_type = vec![ let export_type = vec![
TextLabel::new("File Type").table_align(true).min_width(100).widget_instance(), TextLabel::new("File Type").table_align(true).min_width(100).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).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![ let resolution = vec![
@@ -1,7 +1,7 @@
use super::IconName; use super::IconName;
use super::utility_types::{MouseCursorIcon, PersistedState}; use super::utility_types::{MouseCursorIcon, PersistedState};
use crate::messages::app_window::app_window_message_handler::AppWindowPlatform; 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::input_mapper::utility_types::misc::ActionShortcut;
use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::utility_types::{ use crate::messages::portfolio::document::node_graph::utility_types::{
@@ -109,7 +109,8 @@ pub enum FrontendMessage {
TriggerExportImage { TriggerExportImage {
svg: String, svg: String,
name: String, name: String,
mime: String, #[serde(rename = "fileType")]
file_type: FileType,
size: (f64, f64), size: (f64, f64),
}, },
TriggerFetchAndOpenDocument { TriggerFetchAndOpenDocument {
+97 -9
View File
@@ -47,28 +47,30 @@ pub enum MouseCursorIcon {
Rotate, 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)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum FileType { pub enum FileType {
#[default] #[default]
Png, Png,
Jpg, Jpg,
Webp,
Tiff,
Bmp,
Tga,
Ico,
Svg, Svg,
} }
impl FileType { 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 { pub fn extension(self) -> &'static str {
match self { match self {
FileType::Png => "png", FileType::Png => "png",
FileType::Jpg => "jpg", FileType::Jpg => "jpg",
FileType::Webp => "webp",
FileType::Tiff => "tiff",
FileType::Bmp => "bmp",
FileType::Tga => "tga",
FileType::Ico => "ico",
FileType::Svg => "svg", FileType::Svg => "svg",
} }
} }
@@ -77,6 +79,11 @@ impl FileType {
let name = match self { let name = match self {
FileType::Png => "PNG Image", FileType::Png => "PNG Image",
FileType::Jpg => "JPEG 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", FileType::Svg => "SVG Image",
}; };
FileFilter { FileFilter {
@@ -85,6 +92,51 @@ impl FileType {
mime_types: Vec::new(), 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))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -128,3 +180,39 @@ pub struct FileDialogOptions {
pub filters: Vec<FileFilter>, pub filters: Vec<FileFilter>,
pub multiple: bool, 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::frontend::utility_types::{FileDialogOptions, FileFilter};
use crate::messages::prelude::*; use crate::messages::prelude::*;
use glam::IVec2; use glam::IVec2;
use graph_craft::application_io::resource::ResourceId; use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue; use graph_craft::document::value::TaggedValue;
use graphene_std::raster::Image;
use graphene_std::raster_nodes::color_lookup_table::{Lut, LutParseError}; use graphene_std::raster_nodes::color_lookup_table::{Lut, LutParseError};
#[derive(ExtractField)] #[derive(ExtractField)]
@@ -124,7 +125,7 @@ impl MessageHandler<IngestMessage, IngestMessageContext> for IngestMessageHandle
(insert, artboard_canvas) (insert, artboard_canvas)
} }
DataType::Raster(_) => { 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 { let insert = DocumentMessage::InsertImage {
name: name.clone(), name: name.clone(),
data: data.into(), data: data.into(),
@@ -185,7 +186,7 @@ fn rejection(data: &[u8], data_type: DataType, accepted_types: &[DataType]) -> O
} }
match data_type { 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 { DataType::Lut => Lut::parse(data).err().map(|error| match error {
LutParseError::IccProfileClass => { LutParseError::IccProfileClass => {
"This ICC profile describes the colors of a device (like a monitor or printer) instead\n\ "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 super::*;
use graph_craft::document::NodeId; use graph_craft::document::NodeId;
use graphene_std::Color; use graphene_std::Color;
use graphene_std::raster::Image;
const REQUESTING_DOCUMENT: DocumentId = DocumentId(3); 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"; 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")); 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] #[test]
fn resource_input_tells_a_corrupt_image_apart_from_a_wrong_type() { fn resource_input_tells_a_corrupt_image_apart_from_a_wrong_type() {
let png = Image::new(8, 8, Color::WHITE).to_png(); 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. /// How many leading bytes are inspected to recognize a text format.
const SNIFFED_TEXT_LENGTH: usize = 4096; 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)] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
pub enum IngestAction { pub enum IngestAction {
@@ -241,6 +236,9 @@ mod tests {
fn type_filter_to_file_filter() { fn type_filter_to_file_filter() {
let filter = FileFilter::from(TypeFilter::image()); let filter = FileFilter::from(TypeFilter::image());
assert!(filter.extensions.iter().any(|extension| extension == "jpeg") && filter.extensions.iter().any(|extension| extension == "png")); 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.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())); 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_name: Option<String>,
artboard_count: usize, artboard_count: usize,
}, },
SaveRasterizedExport {
name: String,
file_type: FileType,
width: u32,
height: u32,
data: Vec<u8>,
},
SubmitActiveGraphRender, SubmitActiveGraphRender,
SubmitGraphRender { SubmitGraphRender {
document_id: DocumentId, 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 => { PortfolioMessage::SubmitActiveGraphRender => {
if let Some(document_id) = self.active_document_id { if let Some(document_id) = self.active_document_id {
responses.add(PortfolioMessage::SubmitGraphRender { document_id, ignore_hash: false }); responses.add(PortfolioMessage::SubmitGraphRender { document_id, ignore_hash: false });
+3 -45
View File
@@ -751,9 +751,8 @@ impl NodeGraphExecutor {
content: svg.into_bytes().into(), content: svg.into_bytes().into(),
}); });
} else { } else {
let mime = file_type.to_mime().to_string();
let size = size.as_dvec2().into(); 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")] #[cfg(feature = "gpu")]
@@ -761,49 +760,8 @@ impl NodeGraphExecutor {
data: RenderOutputType::Buffer { data, width, height }, data: RenderOutputType::Buffer { data, width, height },
.. ..
}) if file_type != FileType::Svg => { }) if file_type != FileType::Svg => {
use image::buffer::ConvertBuffer; let content = file_type.encode(width, height, data)?.into();
use image::{ImageFormat, RgbImage, RgbaImage}; responses.add(FrontendMessage::TriggerSaveFile { name, folder, filters, content });
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(),
});
} }
_ => { _ => {
return Err(format!("Incorrect render type for exporting to an SVG ({file_type:?}, {node_graph_output})")); return Err(format!("Incorrect render type for exporting to an SVG ({file_type:?}, {node_graph_output})"));
+8 -10
View File
@@ -3,8 +3,8 @@ import { SvelteMap } from "svelte/reactivity";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import type { SubscriptionsRouter } from "/src/subscriptions-router"; import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { acceptStringFromFilters, downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files"; import { acceptStringFromFilters, downloadFile, upload } from "/src/utility-functions/files";
import { rasterizeSVG } from "/src/utility-functions/rasterization"; import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization";
import { patchLayout } from "/src/utility-functions/widgets"; import { patchLayout } from "/src/utility-functions/widgets";
import type { EditorWrapper, DocumentInfo, LayerPanelEntry, LayerStructureEntry, Layout, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { EditorWrapper, DocumentInfo, LayerPanelEntry, LayerStructureEntry, Layout, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper";
@@ -108,17 +108,15 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
}); });
subscriptions.subscribeFrontendMessage("TriggerExportImage", async (data) => { subscriptions.subscribeFrontendMessage("TriggerExportImage", async (data) => {
const { svg, name, mime, size } = data; const { svg, name, fileType, size } = data;
// Fill the canvas with white if it'll be a JPEG (which does not support transparency and defaults to black) // Rasterize the SVG and hand its pixels back to the editor for encoding
const backgroundColor = mime.endsWith("jpeg") ? "white" : undefined;
// Rasterize the SVG to an image file
try { try {
const blob = await rasterizeSVG(svg, size[0], size[1], mime, backgroundColor); const canvas = await rasterizeSVGCanvas(svg, size[0], size[1]);
const pixels = canvas.getContext("2d")?.getImageData(0, 0, canvas.width, canvas.height);
if (!pixels) return;
// Have the browser download the file to the user's disk editor.saveRasterizedExport(name, fileType, pixels.width, pixels.height, new Uint8Array(pixels.data.buffer));
downloadFileBlob(name, blob);
} catch { } catch {
// Fail silently if there's an error rasterizing the SVG, such as a zero-sized image // Fail silently if there's an error rasterizing the SVG, such as a zero-sized image
} }
@@ -33,24 +33,6 @@ export async function rasterizeSVGCanvas(svg: string, width: number, height: num
return canvas; return canvas;
} }
// Rasterize the string of an SVG document at a given width and height and turn it into the blob data of an image file matching the given MIME type
export async function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise<Blob> {
if (!width || !height) throw new Error("Width and height must be nonzero when given to rasterizeSVG()");
const canvas = await rasterizeSVGCanvas(svg, width, height, backgroundColor);
// Convert the canvas to an image of the correct MIME type
const blob = await new Promise<Blob | undefined>((resolve) => {
canvas.toBlob((blob) => {
resolve(blob || undefined);
}, mime);
});
if (!blob) throw new Error("Converting canvas to blob data failed in rasterizeSVG()");
return blob;
}
export async function imageToCanvasContext(imageData: ImageBitmapSource): Promise<CanvasRenderingContext2D> { export async function imageToCanvasContext(imageData: ImageBitmapSource): Promise<CanvasRenderingContext2D> {
// Special handling to rasterize an SVG file // Special handling to rasterize an SVG file
let svgImageData; let svgImageData;
+6
View File
@@ -604,6 +604,11 @@ mod editor_commands {
ClipboardMessage::ReadSelection { content, cut }.into() ClipboardMessage::ReadSelection { content, cut }.into()
} }
/// The pixels of an export that `TriggerExportImage` had the frontend rasterize, which the editor encodes and saves as the chosen file type
fn save_rasterized_export(name: String, file_type: FileType, width: u32, height: u32, data: Vec<u8>) -> Message {
PortfolioMessage::SaveRasterizedExport { name, file_type, width, height, data }.into()
}
/// A file headed for a known action, picked in the dialog that `TriggerBrowse` opened /// A file headed for a known action, picked in the dialog that `TriggerBrowse` opened
fn ingest_picked(name: String, mime_type: String, data: Vec<u8>, action: IngestAction) -> Message { fn ingest_picked(name: String, mime_type: String, data: Vec<u8>, action: IngestAction) -> Message {
IngestMessage::Ingest { IngestMessage::Ingest {
@@ -743,6 +748,7 @@ macro_rules! editor_proxy_types {
} }
editor_proxy_types! { editor_proxy_types! {
FileType = editor::messages::frontend::utility_types::FileType;
IngestAction = editor::messages::portfolio::ingest::utility_types::IngestAction; IngestAction = editor::messages::portfolio::ingest::utility_types::IngestAction;
LayoutTarget = editor::messages::layout::utility_types::layout_widget::LayoutTarget; LayoutTarget = editor::messages::layout::utility_types::layout_widget::LayoutTarget;
DockingSplitDirection = editor::messages::portfolio::utility_types::DockingSplitDirection; DockingSplitDirection = editor::messages::portfolio::utility_types::DockingSplitDirection;
@@ -153,6 +153,44 @@ impl Image<Color> {
} }
} }
/// Decodes an image file of any supported format.
pub fn from_encoded(data: &[u8]) -> Option<Self> {
let image = decode(data)?;
// Float samples hold linear light, as in HDR and EXR files, while integer samples are gamma encoded
let linear = matches!(image.color(), ::image::ColorType::Rgb32F | ::image::ColorType::Rgba32F);
// An EXR file stores its color multiplied by its alpha, unlike the other formats and unlike `Color`
let premultiplied = ::image::guess_format(data).is_ok_and(|format| format == ::image::ImageFormat::OpenExr);
// Light brighter than white is clipped, since adjustments, the GPU upload, and export all work within this range
let in_range = |value: f32| if value.is_nan() { 0. } else { value.clamp(0., 1.) };
let image = image.to_rgba32f();
let data = image
.chunks_exact(4)
.map(|pixel| {
if !linear {
return Color::from_gamma_srgb_channels(pixel[0], pixel[1], pixel[2], pixel[3]);
}
let alpha = in_range(pixel[3]);
let divisor = if premultiplied && alpha > 0. { alpha } else { 1. };
Color::from_rgbaf32_unchecked(in_range(pixel[0] / divisor), in_range(pixel[1] / divisor), in_range(pixel[2] / divisor), alpha)
})
.collect();
Some(Image {
width: image.width(),
height: image.height(),
data,
base64_string: None,
})
}
/// The pixel size of an image file, if it decodes in full as [`Image::from_encoded`] needs it to.
pub fn encoded_size(data: &[u8]) -> Option<(u32, u32)> {
decode(data).map(|image| (image.width(), image.height()))
}
pub fn to_png(&self) -> Vec<u8> { pub fn to_png(&self) -> Vec<u8> {
use ::image::ImageEncoder; use ::image::ImageEncoder;
let (data, width, height) = self.to_flat_u8(); let (data, width, height) = self.to_flat_u8();
@@ -163,6 +201,12 @@ impl Image<Color> {
} }
} }
/// A TGA file has no signature to recognize it by, so a file of no recognized format is tried as one.
fn decode(data: &[u8]) -> Option<::image::DynamicImage> {
let format = ::image::guess_format(data).unwrap_or(::image::ImageFormat::Tga);
::image::load_from_memory_with_format(data, format).ok()
}
use super::*; use super::*;
impl<P: Alpha + RGB> Image<P> impl<P: Alpha + RGB> Image<P>
where where
@@ -289,4 +333,65 @@ mod test {
assert_eq!(image.to_flat_u8().0, bytes); assert_eq!(image.to_flat_u8().0, bytes);
} }
#[test]
fn decodes_each_format_including_one_with_no_signature() {
use super::*;
use ::image::ImageFormat::{Bmp, Ico, Png, Tga, Tiff, WebP};
// Opaque red, then half transparent blue
let pixels = ::image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 0, 255, 128]).unwrap();
for format in [Png, WebP, Tiff, Bmp, Tga, Ico] {
let mut encoded = Vec::new();
pixels.write_to(&mut std::io::Cursor::new(&mut encoded), format).unwrap();
let image = Image::from_encoded(&encoded).unwrap_or_else(|| panic!("{format:?} should decode"));
assert_eq!(Image::encoded_size(&encoded), Some((2, 1)), "{format:?}");
assert_eq!(image.to_flat_u8().0, pixels.as_raw().as_slice(), "{format:?}");
}
assert!(Image::from_encoded(b"not an image").is_none());
}
#[test]
fn recognized_format_that_cannot_be_read_is_not_tried_as_tga() {
use super::*;
// The signature of a PNM file, a format that is recognized but not read, begins what is also a well-formed TGA header
let mut file = vec![0; 18];
file[..3].copy_from_slice(b"P6\n");
// One pixel wide and tall, at 24 bits per pixel and per color map entry, since the `6` reads as having a color map
file[7] = 24;
file[12] = 1;
file[14] = 1;
file[16] = 24;
// The ID field whose length the `P` gives, then a run of one blue pixel
file.extend([0; b'P' as usize]);
file.extend([0, 255, 0, 0]);
assert!(::image::load_from_memory_with_format(&file, ::image::ImageFormat::Tga).is_ok());
assert!(Image::from_encoded(&file).is_none());
}
#[test]
fn float_samples_are_read_as_linear_light_within_range() {
use super::*;
let exr = |image: ::image::DynamicImage| {
let mut encoded = Vec::new();
image.write_to(&mut std::io::Cursor::new(&mut encoded), ::image::ImageFormat::OpenExr).unwrap();
Image::from_encoded(&encoded).unwrap().data[0]
};
// Not decoded as gamma, light brighter than white clipped, and a sample that is not a number zeroed
let color = exr(::image::DynamicImage::ImageRgb32F(::image::Rgb32FImage::from_raw(1, 1, vec![0.5, 2., f32::NAN]).unwrap()));
assert_eq!((color.r(), color.g(), color.b(), color.a()), (0.5, 1., 0., 1.));
// Color stored multiplied by its alpha comes out straight, and stays as stored where the alpha is zero
let color = exr(::image::DynamicImage::ImageRgba32F(::image::Rgba32FImage::from_raw(1, 1, vec![0.25, 0.125, 0., 0.5]).unwrap()));
assert_eq!((color.r(), color.g(), color.b(), color.a()), (0.5, 0.25, 0., 0.5));
let color = exr(::image::DynamicImage::ImageRgba32F(::image::Rgba32FImage::from_raw(1, 1, vec![0.25, 0., 0., 0.]).unwrap()));
assert_eq!((color.r(), color.a()), (0.25, 0.));
}
} }
@@ -2,6 +2,7 @@
use base64::Engine; use base64::Engine;
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
use canvas_utils::{Canvas, CanvasHandle}; use canvas_utils::{Canvas, CanvasHandle};
use core_types::Ctx;
use core_types::color::SRGBA8; use core_types::color::SRGBA8;
use core_types::list::Item; use core_types::list::Item;
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
@@ -12,7 +13,6 @@ use core_types::ops::Convert;
use core_types::transform::Footprint; use core_types::transform::Footprint;
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
use core_types::{ATTR_EDITOR_MERGED_LAYERS, ATTR_TRANSFORM}; use core_types::{ATTR_EDITOR_MERGED_LAYERS, ATTR_TRANSFORM};
use core_types::{Color, Ctx};
pub use graph_craft::application_io::resource::{Resource, ResourceHash}; pub use graph_craft::application_io::resource::{Resource, ResourceHash};
pub use graph_craft::application_io::*; pub use graph_craft::application_io::*;
pub use graph_craft::document::value::RenderOutputType; pub use graph_craft::document::value::RenderOutputType;
@@ -160,22 +160,13 @@ async fn load_resource<'a: 'n>(_: impl Ctx, _primary: (), #[name("URL")] url: It
} }
} }
/// Converts raw binary data to a raster image. /// Converts the raw byte data of an image file to a raster image.
/// ///
/// Works with standard image format (PNG, JPEG, WebP, etc.). Automatically converts the color space to linear sRGB for accurate compositing. /// Supports the formats: PNG, JPG, GIF, WEBP, TIFF, BMP, TGA, ICO, HDR, EXR.
#[node_macro::node(category("Web Request"))] #[node_macro::node(category("Web Request"))]
fn decode_image(_: impl Ctx, data: Item<Resource>) -> Item<Raster<CPU>> { fn decode_image(_: impl Ctx, data: Item<Resource>) -> Item<Raster<CPU>> {
let data = data.into_element(); let data = data.into_element();
let Some(image) = image::load_from_memory(data.as_ref()).ok() else { let Some(image) = Image::from_encoded(data.as_ref()) else { return Item::default() };
return Item::default();
};
let image = image.to_rgba32f();
let image = Image {
data: image.chunks(4).map(|pixel| Color::from_gamma_srgb_channels(pixel[0], pixel[1], pixel[2], pixel[3])).collect(),
width: image.width(),
height: image.height(),
..Default::default()
};
Item::new_from_element(Raster::new_cpu(image)) Item::new_from_element(Raster::new_cpu(image))
} }
+5 -10
View File
@@ -250,7 +250,10 @@ pub fn empty_image(_: impl Ctx, transform: Item<DAffine2>, color: Item<Color>) -
Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform) Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform)
} }
#[node_macro::node(category(""))] /// Displays an image from a file.
///
/// Reads PNG, JPG, GIF, WEBP, TIFF, BMP, TGA, ICO, HDR, and EXR files. Light brighter than white, as HDR and EXR files can hold, is clipped to white.
#[node_macro::node(category("Raster"))]
pub fn image<'a: 'n>( pub fn image<'a: 'n>(
_: impl Ctx, _: impl Ctx,
_primary: (), _primary: (),
@@ -259,16 +262,8 @@ pub fn image<'a: 'n>(
resource: Item<Resource>, resource: Item<Resource>,
) -> Item<Raster<CPU>> { ) -> Item<Raster<CPU>> {
let resource = resource.into_element(); let resource = resource.into_element();
let image_data = resource.as_ref(); let Some(image) = Image::from_encoded(resource.as_ref()) else { return Item::default() };
let Some(image) = ::image::load_from_memory(image_data).ok() else { return Item::default() };
let image = image.to_rgba32f();
let image = Image {
data: image.chunks(4).map(|pixel| Color::from_gamma_srgb_channels(pixel[0], pixel[1], pixel[2], pixel[3])).collect(),
width: image.width(),
height: image.height(),
..Default::default()
};
Item::new_from_element(Raster::new_cpu(image)) Item::new_from_element(Raster::new_cpu(image))
} }