From 7e8c6cc432c0991557e6421f30d8c3c4ee14cac1 Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 1 Oct 2025 21:13:40 +0200 Subject: [PATCH] Desktop: Native export (#3188) * Testing native export with raster convert implementation * Jpg export * Fix transparent export * move texture conversion to runtime * fixup * move image encoding into editor export function * remove unused frontend message * remove unused type --- Cargo.lock | 1 + editor/Cargo.toml | 1 + editor/src/node_graph_executor.rs | 97 ++++++++++++++++---- editor/src/node_graph_executor/runtime.rs | 60 ++++++++---- node-graph/gapplication-io/src/lib.rs | 4 - node-graph/graph-craft/src/document/value.rs | 7 +- node-graph/gstd/src/render_node.rs | 25 +++-- node-graph/wgpu-executor/src/lib.rs | 2 +- 8 files changed, 148 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 15d14669..ae28a7bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2310,6 +2310,7 @@ dependencies = [ "graph-craft", "graphene-std", "graphite-proc-macros", + "image", "interpreted-executor", "js-sys", "kurbo", diff --git a/editor/Cargo.toml b/editor/Cargo.toml index ab215597..0571a565 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -45,6 +45,7 @@ web-sys = { workspace = true } vello = { workspace = true } base64 = { workspace = true } spin = { workspace = true } +image = { workspace = true } # Optional local dependencies wgpu-executor = { workspace = true, optional = true } diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 8e5624f7..1622e31c 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -190,6 +190,12 @@ impl NodeGraphExecutor { let size = bounds[1] - bounds[0]; let transform = DAffine2::from_translation(bounds[0]).inverse(); + let export_format = if export_config.file_type == FileType::Svg || cfg!(not(feature = "gpu")) { + graphene_std::application_io::ExportFormat::Svg + } else { + graphene_std::application_io::ExportFormat::Texture + }; + let render_config = RenderConfig { viewport: Footprint { transform: DAffine2::from_scale(DVec2::splat(export_config.scale_factor)) * transform, @@ -197,7 +203,7 @@ impl NodeGraphExecutor { ..Default::default() }, time: Default::default(), - export_format: graphene_std::application_io::ExportFormat::Svg, + export_format, render_mode: document.render_mode, hide_artboards: export_config.transparent_background, for_export: true, @@ -219,28 +225,81 @@ impl NodeGraphExecutor { } fn export(&self, node_graph_output: TaggedValue, export_config: ExportConfig, responses: &mut VecDeque) -> Result<(), String> { - let TaggedValue::RenderOutput(RenderOutput { - data: RenderOutputType::Svg { svg, .. }, - .. - }) = node_graph_output - else { - return Err("Incorrect render type for exporting (expected RenderOutput::Svg)".to_string()); - }; - let ExportConfig { - file_type, name, size, scale_factor, .. + file_type, + name, + size, + scale_factor, + #[cfg(feature = "gpu")] + transparent_background, + .. } = export_config; - let file_suffix = &format!(".{file_type:?}").to_lowercase(); - let name = name + file_suffix; + let file_extension = match file_type { + FileType::Svg => "svg", + FileType::Png => "png", + FileType::Jpg => "jpg", + }; + let name = format!("{name}.{file_extension}"); - if file_type == FileType::Svg { - responses.add(FrontendMessage::TriggerSaveFile { name, content: svg.into_bytes() }); - } else { - let mime = file_type.to_mime().to_string(); - let size = (size * scale_factor).into(); - responses.add(FrontendMessage::TriggerExportImage { svg, name, mime, size }); - } + match node_graph_output { + TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Svg { svg, .. }, + .. + }) => { + if file_type == FileType::Svg { + responses.add(FrontendMessage::TriggerSaveFile { name, content: svg.into_bytes() }); + } else { + let mime = file_type.to_mime().to_string(); + let size = (size * scale_factor).into(); + responses.add(FrontendMessage::TriggerExportImage { svg, name, mime, size }); + } + } + #[cfg(feature = "gpu")] + TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Buffer { data, width, height }, + .. + }) if file_type != FileType::Svg => { + use image::buffer::ConvertBuffer; + use image::{ImageFormat, RgbImage, RgbaImage}; + + let Some(image) = RgbaImage::from_raw(width, height, data) else { + return Err(format!("Failed to create image buffer for export")); + }; + + let mut encoded = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut encoded); + + match file_type { + FileType::Png => { + let result = if transparent_background { + image.write_to(&mut cursor, ImageFormat::Png) + } else { + let image: RgbImage = image.convert(); + image.write_to(&mut cursor, ImageFormat::Png) + }; + if let Err(err) = result { + return Err(format!("Failed to encode PNG: {err}")); + } + } + FileType::Jpg => { + 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(format!("SVG cannot be exported from an image buffer")); + } + } + + responses.add(FrontendMessage::TriggerSaveFile { name, content: encoded }); + } + _ => { + return Err(format!("Incorrect render type for exporting to an SVG ({file_type:?}, {node_graph_output})")); + } + }; Ok(()) } diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index b89c5b2f..e7a1e0bc 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -7,9 +7,11 @@ use graph_craft::graphene_compiler::Compiler; use graph_craft::proto::GraphErrors; use graph_craft::wasm_application_io::EditorPreferences; use graph_craft::{ProtoNodeIdentifier, concrete}; -use graphene_std::application_io::{ImageTexture, NodeGraphUpdateMessage, NodeGraphUpdateSender, RenderConfig}; +use graphene_std::application_io::{ApplicationIo, ImageTexture, NodeGraphUpdateMessage, NodeGraphUpdateSender, RenderConfig}; use graphene_std::bounds::RenderBoundingBox; use graphene_std::memo::IORecord; +use graphene_std::ops::Convert; +use graphene_std::raster_types::Raster; use graphene_std::renderer::{Render, RenderParams, SvgRender}; use graphene_std::renderer::{RenderSvgSegmentList, SvgSegment}; use graphene_std::table::{Table, TableRow}; @@ -220,16 +222,44 @@ impl NodeRuntime { // Resolve the result from the inspection by accessing the monitor node let inspect_result = self.inspect_state.and_then(|state| state.access(&self.executor)); - let texture = if let Ok(TaggedValue::RenderOutput(RenderOutput { - data: RenderOutputType::Texture(texture), - .. - })) = &result - { - // We can early return becaus we know that there is at most one execution request and it will always be handled last - Some(texture.clone()) - } else { - None + let (result, texture) = match result { + Ok(TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Texture(image_texture), + metadata, + })) if render_config.for_export => { + let executor = self + .editor_api + .application_io + .as_ref() + .unwrap() + .gpu_executor() + .expect("GPU executor should be available when we receive a texture"); + + let raster_cpu = Raster::new_gpu(image_texture.texture).convert(Footprint::BOUNDLESS, executor).await; + + let (data, width, height) = raster_cpu.to_flat_u8(); + + ( + Ok(TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Buffer { data, width, height }, + metadata, + })), + None, + ) + } + Ok(TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Texture(texture), + metadata, + })) => ( + Ok(TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Texture(texture.clone()), + metadata, + })), + Some(texture), + ), + r => (r, None), }; + self.sender.send_execution_response(ExecutionResponse { execution_id, result, @@ -274,18 +304,12 @@ impl NodeRuntime { async fn execute_network(&mut self, render_config: RenderConfig) -> Result { use graph_craft::graphene_compiler::Executor; - let result = match self.executor.input_type() { + match self.executor.input_type() { Some(t) if t == concrete!(RenderConfig) => (&self.executor).execute(render_config).await.map_err(|e| e.to_string()), Some(t) if t == concrete!(()) => (&self.executor).execute(()).await.map_err(|e| e.to_string()), Some(t) => Err(format!("Invalid input type {t:?}")), _ => Err(format!("No input type:\n{:?}", self.node_graph_errors)), - }; - let result = match result { - Ok(value) => value, - Err(e) => return Err(e), - }; - - Ok(result) + } } /// Updates state data diff --git a/node-graph/gapplication-io/src/lib.rs b/node-graph/gapplication-io/src/lib.rs index 5f0b8ee5..0edaf96d 100644 --- a/node-graph/gapplication-io/src/lib.rs +++ b/node-graph/gapplication-io/src/lib.rs @@ -222,10 +222,6 @@ pub trait GetEditorPreferences { pub enum ExportFormat { #[default] Svg, - Png { - transparent: bool, - }, - Jpeg, Canvas, Texture, } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 0e0534d9..18e9533f 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -441,11 +441,16 @@ pub enum RenderOutputType { CanvasFrame(SurfaceFrame), #[serde(skip)] Texture(ImageTexture), + #[serde(skip)] + Buffer { + data: Vec, + width: u32, + height: u32, + }, Svg { svg: String, image_data: Vec<(u64, Image)>, }, - Image(Vec), } impl Hash for RenderOutput { diff --git a/node-graph/gstd/src/render_node.rs b/node-graph/gstd/src/render_node.rs index 5966d801..ad40d91c 100644 --- a/node-graph/gstd/src/render_node.rs +++ b/node-graph/gstd/src/render_node.rs @@ -44,7 +44,6 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + data: impl Node, Output = T>, editor_api: impl Node, Output = &'a WasmEditorApi>, ) -> RenderIntermediate { - let mut render = SvgRender::new(); let render_params = ctx .vararg(0) .expect("Did not find var args") @@ -59,9 +58,20 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + data.collect_metadata(&mut metadata, footprint, None); let contains_artboard = data.contains_artboard(); - let editor_api = editor_api.eval(None).await; + let use_vello = { + #[cfg(target_family = "wasm")] + { + let editor_api = editor_api.eval(None).await; + !render_params.for_export && editor_api.editor_preferences.use_vello() && matches!(render_params.render_output_type, graphene_svg_renderer::RenderOutputType::Vello) + } + #[cfg(not(target_family = "wasm"))] + { + let _ = editor_api; + matches!(render_params.render_output_type, graphene_svg_renderer::RenderOutputType::Vello) + } + }; - if !render_params.for_export && editor_api.editor_preferences.use_vello() && matches!(render_params.render_output_type, graphene_svg_renderer::RenderOutputType::Vello) { + if use_vello { let mut scene = vello::Scene::new(); let mut context = wgpu_executor::RenderContext::default(); @@ -73,6 +83,8 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + contains_artboard, } } else { + let mut render = SvgRender::new(); + data.render_svg(&mut render, render_params); RenderIntermediate { @@ -93,11 +105,10 @@ async fn create_context<'a: 'n>( let render_output_type = match render_config.export_format { ExportFormat::Svg => RenderOutputTypeRequest::Svg, - ExportFormat::Png { .. } => todo!(), - ExportFormat::Jpeg => todo!(), - ExportFormat::Canvas => RenderOutputTypeRequest::Vello, ExportFormat::Texture => RenderOutputTypeRequest::Vello, + ExportFormat::Canvas => RenderOutputTypeRequest::Vello, }; + let render_params = RenderParams { render_mode: render_config.render_mode, hide_artboards: render_config.hide_artboards, @@ -106,6 +117,7 @@ async fn create_context<'a: 'n>( footprint: Footprint::default(), ..Default::default() }; + let ctx = OwnedContextImpl::default() .with_footprint(footprint) .with_real_time(render_config.time.time) @@ -198,6 +210,7 @@ async fn render<'a: 'n>( if !contains_artboard && !render_params.hide_artboards { background = Color::WHITE; } + if let Some(surface_handle) = surface_handle { exec.render_vello_scene(&scene, &surface_handle, footprint.resolution, context, background) .await diff --git a/node-graph/wgpu-executor/src/lib.rs b/node-graph/wgpu-executor/src/lib.rs index 42fdd9e1..9fd7e431 100644 --- a/node-graph/wgpu-executor/src/lib.rs +++ b/node-graph/wgpu-executor/src/lib.rs @@ -124,7 +124,7 @@ impl WgpuExecutor { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC, format: VELLO_SURFACE_FORMAT, view_formats: &[], });