Skip to content

Display images in the SVG viewport renderer via canvases instead of base64 PNGs #2903

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Jul 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions editor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ num_enum = { workspace = true }
usvg = { workspace = true }
once_cell = { workspace = true }
web-sys = { workspace = true }
bytemuck = { workspace = true }

# Required dependencies
spin = "0.9.8"
Expand Down
4 changes: 4 additions & 0 deletions editor/src/messages/frontend/frontend_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::messages::portfolio::document::utility_types::wires::{WirePath, WireP
use crate::messages::prelude::*;
use crate::messages::tool::utility_types::HintData;
use graph_craft::document::NodeId;
use graphene_std::raster::Image;
use graphene_std::raster::color::Color;
use graphene_std::text::Font;

Expand Down Expand Up @@ -179,6 +180,9 @@ pub enum FrontendMessage {
UpdateDocumentArtwork {
svg: String,
},
UpdateImageData {
image_data: Vec<(u64, Image<Color>)>,
},
UpdateDocumentBarLayout {
#[serde(rename = "layoutTarget")]
layout_target: LayoutTarget,
Expand Down
9 changes: 5 additions & 4 deletions editor/src/node_graph_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ impl NodeGraphExecutor {

fn export(&self, node_graph_output: TaggedValue, export_config: ExportConfig, responses: &mut VecDeque<Message>) -> Result<(), String> {
let TaggedValue::RenderOutput(RenderOutput {
data: graphene_std::wasm_application_io::RenderOutputType::Svg(svg),
data: graphene_std::wasm_application_io::RenderOutputType::Svg { svg, .. },
..
}) = node_graph_output
else {
Expand Down Expand Up @@ -350,15 +350,16 @@ impl NodeGraphExecutor {
match node_graph_output {
TaggedValue::RenderOutput(render_output) => {
match render_output.data {
graphene_std::wasm_application_io::RenderOutputType::Svg(svg) => {
graphene_std::wasm_application_io::RenderOutputType::Svg { svg, image_data } => {
// Send to frontend
responses.add(FrontendMessage::UpdateImageData { image_data });
responses.add(FrontendMessage::UpdateDocumentArtwork { svg });
}
graphene_std::wasm_application_io::RenderOutputType::CanvasFrame(frame) => {
let matrix = format_transform_matrix(frame.transform);
let transform = if matrix.is_empty() { String::new() } else { format!(" transform=\"{}\"", matrix) };
let transform = if matrix.is_empty() { String::new() } else { format!(" transform=\"{matrix}\"") };
let svg = format!(
r#"<svg><foreignObject width="{}" height="{}"{transform}><div data-canvas-placeholder="canvas{}"></div></foreignObject></svg>"#,
r#"<svg><foreignObject width="{}" height="{}"{transform}><div data-canvas-placeholder="{}"></div></foreignObject></svg>"#,
frame.resolution.x, frame.resolution.y, frame.surface_id.0
);
responses.add(FrontendMessage::UpdateDocumentArtwork { svg });
Expand Down
17 changes: 15 additions & 2 deletions frontend/src/components/panels/Document.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,25 @@

const placeholders = window.document.querySelectorAll("[data-viewport] [data-canvas-placeholder]");
// Replace the placeholders with the actual canvas elements
placeholders.forEach((placeholder) => {
Array.from(placeholders).forEach((placeholder) => {
const canvasName = placeholder.getAttribute("data-canvas-placeholder");
if (!canvasName) return;
// Get the canvas element from the global storage
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const canvas = (window as any).imageCanvases[canvasName];
let canvas = (window as any).imageCanvases[canvasName];

if (canvasName !== "0" && canvas.parentElement) {
var newCanvas = window.document.createElement("canvas");
var context = newCanvas.getContext("2d");

newCanvas.width = canvas.width;
newCanvas.height = canvas.height;

context?.drawImage(canvas, 0, 0);

canvas = newCanvas;
}

placeholder.replaceWith(canvas);
});
}
Expand Down
96 changes: 95 additions & 1 deletion frontend/wasm/src/editor_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,27 @@ use editor::messages::portfolio::utility_types::Platform;
use editor::messages::prelude::*;
use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
use graph_craft::document::NodeId;
use graphene_std::raster::Image;
use graphene_std::raster::color::Color;
use js_sys::{Object, Reflect};
use serde::Serialize;
use serde_wasm_bindgen::{self, from_value};
use std::cell::RefCell;
use std::sync::atomic::Ordering;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, window};

static IMAGE_DATA_HASH: AtomicU64 = AtomicU64::new(0);

fn calculate_hash<T: std::hash::Hash>(t: &T) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
let mut hasher = DefaultHasher::new();
t.hash(&mut hasher);
hasher.finish()
}

/// Set the random seed used by the editor by calling this from JS upon initialization.
/// This is necessary because WASM doesn't have a random number generator.
Expand All @@ -37,6 +51,75 @@ pub fn wasm_memory() -> JsValue {
wasm_bindgen::memory()
}

fn render_image_data_to_canvases(image_data: &[(u64, Image<Color>)]) {
let window = match window() {
Some(window) => window,
None => {
error!("Cannot render canvas: window object not found");
return;
}
};
let document = window.document().expect("window should have a document");
let window_obj = Object::from(window);
let image_canvases_key = JsValue::from_str("imageCanvases");

let canvases_obj = match Reflect::get(&window_obj, &image_canvases_key) {
Ok(obj) if !obj.is_undefined() && !obj.is_null() => obj,
_ => {
let new_obj = Object::new();
if Reflect::set(&window_obj, &image_canvases_key, &new_obj).is_err() {
error!("Failed to create and set imageCanvases object on window");
return;
}
new_obj.into()
}
};
let canvases_obj = Object::from(canvases_obj);

for (placeholder_id, image) in image_data.iter() {
let canvas_name = placeholder_id.to_string();
let js_key = JsValue::from_str(&canvas_name);

if Reflect::has(&canvases_obj, &js_key).unwrap_or(false) || image.width == 0 || image.height == 0 {
continue;
}

let canvas: HtmlCanvasElement = document
.create_element("canvas")
.expect("Failed to create canvas element")
.dyn_into::<HtmlCanvasElement>()
.expect("Failed to cast element to HtmlCanvasElement");

canvas.set_width(image.width);
canvas.set_height(image.height);

let context: CanvasRenderingContext2d = canvas
.get_context("2d")
.expect("Failed to get 2d context")
.expect("2d context was not found")
.dyn_into::<CanvasRenderingContext2d>()
.expect("Failed to cast context to CanvasRenderingContext2d");
let u8_data: Vec<u8> = image.data.iter().flat_map(|color| color.to_rgba8_srgb()).collect();
let clamped_u8_data = wasm_bindgen::Clamped(&u8_data[..]);
match ImageData::new_with_u8_clamped_array_and_sh(clamped_u8_data, image.width, image.height) {
Ok(image_data_obj) => {
if context.put_image_data(&image_data_obj, 0., 0.).is_err() {
error!("Failed to put image data on canvas for id: {placeholder_id}");
}
}
Err(e) => {
error!("Failed to create ImageData for id: {placeholder_id}: {e:?}");
}
}

let js_value = JsValue::from(canvas);

if Reflect::set(&canvases_obj, &js_key, &js_value).is_err() {
error!("Failed to set canvas '{canvas_name}' on imageCanvases object");
}
}
}

// ============================================================================

/// This struct is, via wasm-bindgen, used by JS to interact with the editor backend. It does this by calling functions, which are `impl`ed
Expand Down Expand Up @@ -88,6 +171,17 @@ impl EditorHandle {

// Sends a FrontendMessage to JavaScript
fn send_frontend_message_to_js(&self, mut message: FrontendMessage) {
if let FrontendMessage::UpdateImageData { ref image_data } = message {
let new_hash = calculate_hash(image_data);
let prev_hash = IMAGE_DATA_HASH.load(Ordering::Relaxed);

if new_hash != prev_hash {
render_image_data_to_canvases(image_data.as_slice());
IMAGE_DATA_HASH.store(new_hash, Ordering::Relaxed);
}
return;
}

if let FrontendMessage::UpdateDocumentLayerStructure { data_buffer } = message {
message = FrontendMessage::UpdateDocumentLayerStructureJs { data_buffer: data_buffer.into() };
}
Expand Down
4 changes: 4 additions & 0 deletions node-graph/gcore/src/blending.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ impl AlphaBlending {
clip: if t < 0.5 { self.clip } else { other.clip },
}
}

pub fn opacity(&self, mask: bool) -> f32 {
self.opacity * if mask { 1. } else { self.fill }
}
}

#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
Expand Down
18 changes: 8 additions & 10 deletions node-graph/gcore/src/raster.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
use crate::GraphicGroupTable;
pub use crate::color::*;
use crate::raster_types::{CPU, RasterDataTable};
use crate::vector::VectorDataTable;
use std::fmt::Debug;

#[cfg(target_arch = "spirv")]
use spirv_std::num_traits::float::Float;

/// as to not yet rename all references
pub mod color {
pub use super::*;
}

pub mod image;

pub use self::image::Image;
pub use self::image::{Image, TransformImage};
use crate::GraphicGroupTable;
pub use crate::color::*;
use crate::raster_types::{CPU, RasterDataTable};
use crate::vector::VectorDataTable;
#[cfg(target_arch = "spirv")]
use spirv_std::num_traits::float::Float;
use std::fmt::Debug;

pub trait Bitmap {
type Pixel: Pixel;
Expand Down
7 changes: 7 additions & 0 deletions node-graph/gcore/src/raster/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ pub struct Image<P: Pixel> {
// TODO: Currently it is always anchored at the top left corner at (0, 0). The bottom right corner of the new origin field would correspond to (1, 1).
}

#[derive(Debug, Clone, dyn_any::DynAny, Default, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
pub struct TransformImage(pub DAffine2);

impl Hash for TransformImage {
fn hash<H: std::hash::Hasher>(&self, _: &mut H) {}
}

impl<P: Pixel + Debug> Debug for Image<P> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let length = self.data.len();
Expand Down
8 changes: 2 additions & 6 deletions node-graph/gcore/src/vector/vector_nodes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,6 @@ where
// Create and add mirrored instance
for mut instance in instance.instance_iter() {
instance.transform = reflected_transform * instance.transform;
instance.source_node_id = None;
result_table.push(instance);
}

Expand Down Expand Up @@ -448,6 +447,7 @@ async fn round_corners(
.map(|source| {
let source_transform = *source.transform;
let source_transform_inverse = source_transform.inverse();
let source_node_id = source.source_node_id;
let source = source.instance;

let upstream_graphic_group = source.upstream_graphic_group.clone();
Expand Down Expand Up @@ -542,7 +542,7 @@ async fn round_corners(
instance: result,
transform: source_transform,
alpha_blending: Default::default(),
source_node_id: None,
source_node_id: *source_node_id,
}
})
.collect()
Expand Down Expand Up @@ -645,7 +645,6 @@ async fn box_warp(_: impl Ctx, vector_data: VectorDataTable, #[expose] rectangle
// Add this to the table and reset the transform since we've applied it directly to the points
vector_data_instance.instance = result;
vector_data_instance.transform = DAffine2::IDENTITY;
vector_data_instance.source_node_id = None;
vector_data_instance
})
.collect()
Expand Down Expand Up @@ -917,7 +916,6 @@ async fn bounding_box(_: impl Ctx, vector_data: VectorDataTable) -> VectorDataTa
result.style.set_stroke_transform(DAffine2::IDENTITY);

vector_data_instance.instance = result;
vector_data_instance.source_node_id = None;
vector_data_instance
})
.collect()
Expand Down Expand Up @@ -1013,7 +1011,6 @@ async fn offset_path(_: impl Ctx, vector_data: VectorDataTable, distance: f64, j
}

vector_data_instance.instance = result;
vector_data_instance.source_node_id = None;
vector_data_instance
})
.collect()
Expand Down Expand Up @@ -1068,7 +1065,6 @@ async fn solidify_stroke(_: impl Ctx, vector_data: VectorDataTable) -> VectorDat
}

vector_data_instance.instance = result;
vector_data_instance.source_node_id = None;
vector_data_instance
})
.collect()
Expand Down
5 changes: 3 additions & 2 deletions node-graph/graph-craft/src/document/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub use glam::{DAffine2, DVec2, IVec2, UVec2};
use graphene_application_io::SurfaceFrame;
use graphene_brush::brush_cache::BrushCache;
use graphene_brush::brush_stroke::BrushStroke;
use graphene_core::raster::Image;
use graphene_core::raster_types::CPU;
use graphene_core::transform::ReferencePoint;
use graphene_core::uuid::NodeId;
Expand Down Expand Up @@ -424,10 +425,10 @@ pub struct RenderOutput {
pub metadata: RenderMetadata,
}

#[derive(Debug, Clone, PartialEq, dyn_any::DynAny, Hash, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Hash, PartialEq, dyn_any::DynAny, serde::Serialize, serde::Deserialize)]
pub enum RenderOutputType {
CanvasFrame(SurfaceFrame),
Svg(String),
Svg { svg: String, image_data: Vec<(u64, Image<Color>)> },
Image(Vec<u8>),
}

Expand Down
7 changes: 4 additions & 3 deletions node-graph/graph-craft/src/wasm_application_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use graphene_application_io::{ApplicationError, ApplicationIo, ResourceFuture, S
#[cfg(target_arch = "wasm32")]
use js_sys::{Object, Reflect};
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Arc;
#[cfg(target_arch = "wasm32")]
use std::sync::atomic::AtomicU64;
Expand Down Expand Up @@ -39,7 +40,7 @@ impl Drop for WindowWrapper {
let wrapper = || {
if let Ok(canvases) = Reflect::get(&window, &image_canvases_key) {
// Convert key and value to JsValue
let js_key = JsValue::from_str(format!("canvas{}", self.window.window_id).as_str());
let js_key = JsValue::from_str(self.window.window_id.to_string().as_str());

// Use Reflect API to set property
Reflect::delete_property(&canvases.into(), &js_key)?;
Expand Down Expand Up @@ -200,7 +201,7 @@ impl ApplicationIo for WasmApplicationIo {
}

// Convert key and value to JsValue
let js_key = JsValue::from_str(format!("canvas{}", id).as_str());
let js_key = JsValue::from_str(id.to_string().as_str());
let js_value = JsValue::from(canvas.clone());

let canvases = Object::from(canvases.unwrap());
Expand Down Expand Up @@ -253,7 +254,7 @@ impl ApplicationIo for WasmApplicationIo {
let wrapper = || {
if let Ok(canvases) = Reflect::get(&window, &image_canvases_key) {
// Convert key and value to JsValue
let js_key = JsValue::from_str(format!("canvas{}", surface_id.0).as_str());
let js_key = JsValue::from_str(surface_id.0.to_string().as_str());

// Use Reflect API to set property
Reflect::delete_property(&canvases.into(), &js_key)?;
Expand Down
1 change: 0 additions & 1 deletion node-graph/graster-nodes/src/dehaze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ async fn dehaze(_: impl Ctx, image_frame: RasterDataTable<CPU>, strength: Percen
};

image_frame_instance.instance = Raster::new_cpu(dehazed_image);
image_frame_instance.source_node_id = None;
image_frame_instance
})
.collect()
Expand Down
1 change: 0 additions & 1 deletion node-graph/graster-nodes/src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ async fn blur(
};

image_instance.instance = blurred_image;
image_instance.source_node_id = None;
image_instance
})
.collect()
Expand Down
Loading
Loading