From 2ed5e775e583cda1c314b93ec9e4d4429438e75d Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 19 Sep 2026 17:34:05 -0700 Subject: [PATCH] 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 --- Cargo.lock | 72 ++++++++++++ Cargo.toml | 6 + .../export_dialog_message_handler.rs | 31 ++++- .../src/messages/frontend/frontend_message.rs | 5 +- editor/src/messages/frontend/utility_types.rs | 106 ++++++++++++++++-- .../ingest/ingest_message_handler.rs | 21 +++- .../portfolio/ingest/utility_types.rs | 8 +- .../messages/portfolio/portfolio_message.rs | 7 ++ .../portfolio/portfolio_message_handler.rs | 12 ++ editor/src/node_graph_executor.rs | 48 +------- frontend/src/stores/portfolio.ts | 18 ++- .../src/utility-functions/rasterization.ts | 18 --- frontend/wrapper/src/editor_commands.rs | 6 + .../libraries/raster-types/src/image.rs | 105 +++++++++++++++++ .../nodes/gstd/src/platform_application_io.rs | 17 +-- node-graph/nodes/raster/src/std_nodes.rs | 15 +-- 16 files changed, 373 insertions(+), 122 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b630b41aa..46fe4ec6c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,6 +276,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -1497,6 +1503,21 @@ dependencies = [ "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]] name = "fancy-regex" version = "0.18.0" @@ -2831,14 +2852,27 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", + "exr", "gif", + "image-webp", "num-traits", "png 0.17.16", "serde", + "tiff", "zune-core", "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]] name = "imagesize" version = "0.14.0" @@ -3091,6 +3125,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + [[package]] name = "js-sys" version = "0.3.98" @@ -3169,6 +3209,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libbz2-rs-sys" version = "0.2.2" @@ -4390,6 +4436,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.3" @@ -5904,6 +5956,17 @@ dependencies = [ "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]] name = "time" version = "0.3.41" @@ -7993,6 +8056,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "zune-jpeg" version = "0.4.20" diff --git a/Cargo.toml b/Cargo.toml index 0acbca2967..433c33e069 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -192,6 +192,12 @@ image = { version = "0.25", default-features = false, features = [ "jpeg", "bmp", "gif", + "webp", + "tiff", + "ico", + "tga", + "hdr", + "exr", "serde", ] } pretty_assertions = "1.4" diff --git a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs index 73b80e23d1..849524484d 100644 --- a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs +++ b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs @@ -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![ diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 7db225d633..f309f51d61 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -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 { diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index 4288edda2f..b0a8a7655f 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -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) -> Result, 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, 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()); + } +} diff --git a/editor/src/messages/portfolio/ingest/ingest_message_handler.rs b/editor/src/messages/portfolio/ingest/ingest_message_handler.rs index e7ca8c9679..3fa5ce8178 100644 --- a/editor/src/messages/portfolio/ingest/ingest_message_handler.rs +++ b/editor/src/messages/portfolio/ingest/ingest_message_handler.rs @@ -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 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(); diff --git a/editor/src/messages/portfolio/ingest/utility_types.rs b/editor/src/messages/portfolio/ingest/utility_types.rs index d96b55af9b..897755741e 100644 --- a/editor/src/messages/portfolio/ingest/utility_types.rs +++ b/editor/src/messages/portfolio/ingest/utility_types.rs @@ -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())); diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 4ad7cfcdfa..52636ff959 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -167,6 +167,13 @@ pub enum PortfolioMessage { artboard_name: Option, artboard_count: usize, }, + SaveRasterizedExport { + name: String, + file_type: FileType, + width: u32, + height: u32, + data: Vec, + }, SubmitActiveGraphRender, SubmitGraphRender { document_id: DocumentId, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 7ffff7c34c..4674ccad38 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -1266,6 +1266,18 @@ impl MessageHandler> 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 }); diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index a7a9abf6eb..4211a9b9c3 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -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})")); diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 4dbee58f2b..caca362fd7 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -3,8 +3,8 @@ import { SvelteMap } from "svelte/reactivity"; import { writable } from "svelte/store"; import type { Writable } from "svelte/store"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; -import { acceptStringFromFilters, downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files"; -import { rasterizeSVG } from "/src/utility-functions/rasterization"; +import { acceptStringFromFilters, downloadFile, upload } from "/src/utility-functions/files"; +import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization"; import { patchLayout } from "/src/utility-functions/widgets"; 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) => { - 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) - const backgroundColor = mime.endsWith("jpeg") ? "white" : undefined; - - // Rasterize the SVG to an image file + // Rasterize the SVG and hand its pixels back to the editor for encoding 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 - downloadFileBlob(name, blob); + editor.saveRasterizedExport(name, fileType, pixels.width, pixels.height, new Uint8Array(pixels.data.buffer)); } catch { // Fail silently if there's an error rasterizing the SVG, such as a zero-sized image } diff --git a/frontend/src/utility-functions/rasterization.ts b/frontend/src/utility-functions/rasterization.ts index 5eebbeddab..5b67c9ddd4 100644 --- a/frontend/src/utility-functions/rasterization.ts +++ b/frontend/src/utility-functions/rasterization.ts @@ -33,24 +33,6 @@ export async function rasterizeSVGCanvas(svg: string, width: number, height: num 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 { - 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((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 { // Special handling to rasterize an SVG file let svgImageData; diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index 863d4f9264..a422215ffe 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -604,6 +604,11 @@ mod editor_commands { 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) -> Message { + PortfolioMessage::SaveRasterizedExport { name, file_type, width, height, data }.into() + } + /// A file headed for a known action, picked in the dialog that `TriggerBrowse` opened fn ingest_picked(name: String, mime_type: String, data: Vec, action: IngestAction) -> Message { IngestMessage::Ingest { @@ -743,6 +748,7 @@ macro_rules! editor_proxy_types { } editor_proxy_types! { + FileType = editor::messages::frontend::utility_types::FileType; IngestAction = editor::messages::portfolio::ingest::utility_types::IngestAction; LayoutTarget = editor::messages::layout::utility_types::layout_widget::LayoutTarget; DockingSplitDirection = editor::messages::portfolio::utility_types::DockingSplitDirection; diff --git a/node-graph/libraries/raster-types/src/image.rs b/node-graph/libraries/raster-types/src/image.rs index e87117a7b3..2533d117da 100644 --- a/node-graph/libraries/raster-types/src/image.rs +++ b/node-graph/libraries/raster-types/src/image.rs @@ -153,6 +153,44 @@ impl Image { } } + /// Decodes an image file of any supported format. + pub fn from_encoded(data: &[u8]) -> Option { + 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 { use ::image::ImageEncoder; let (data, width, height) = self.to_flat_u8(); @@ -163,6 +201,12 @@ impl Image { } } +/// 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::*; impl Image

where @@ -289,4 +333,65 @@ mod test { 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.)); + } } diff --git a/node-graph/nodes/gstd/src/platform_application_io.rs b/node-graph/nodes/gstd/src/platform_application_io.rs index 269c1dc380..2441e793ea 100644 --- a/node-graph/nodes/gstd/src/platform_application_io.rs +++ b/node-graph/nodes/gstd/src/platform_application_io.rs @@ -2,6 +2,7 @@ use base64::Engine; #[cfg(target_family = "wasm")] use canvas_utils::{Canvas, CanvasHandle}; +use core_types::Ctx; use core_types::color::SRGBA8; use core_types::list::Item; #[cfg(target_family = "wasm")] @@ -12,7 +13,6 @@ use core_types::ops::Convert; use core_types::transform::Footprint; #[cfg(target_family = "wasm")] 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::*; 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"))] fn decode_image(_: impl Ctx, data: Item) -> Item> { let data = data.into_element(); - let Some(image) = image::load_from_memory(data.as_ref()).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() - }; + let Some(image) = Image::from_encoded(data.as_ref()) else { return Item::default() }; Item::new_from_element(Raster::new_cpu(image)) } diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 23b248ab2e..0aab02039f 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -250,7 +250,10 @@ pub fn empty_image(_: impl Ctx, transform: Item, color: Item) - 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>( _: impl Ctx, _primary: (), @@ -259,16 +262,8 @@ pub fn image<'a: 'n>( resource: Item, ) -> Item> { 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)) }