mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-27 08:28:11 +08:00
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:
Generated
+72
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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})"));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user