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
This commit is contained in:
Timon 2025-10-01 21:13:40 +02:00 committed by GitHub
parent c697b61a7e
commit 7e8c6cc432
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 148 additions and 49 deletions

1
Cargo.lock generated
View File

@ -2310,6 +2310,7 @@ dependencies = [
"graph-craft", "graph-craft",
"graphene-std", "graphene-std",
"graphite-proc-macros", "graphite-proc-macros",
"image",
"interpreted-executor", "interpreted-executor",
"js-sys", "js-sys",
"kurbo", "kurbo",

View File

@ -45,6 +45,7 @@ web-sys = { workspace = true }
vello = { workspace = true } vello = { workspace = true }
base64 = { workspace = true } base64 = { workspace = true }
spin = { workspace = true } spin = { workspace = true }
image = { workspace = true }
# Optional local dependencies # Optional local dependencies
wgpu-executor = { workspace = true, optional = true } wgpu-executor = { workspace = true, optional = true }

View File

@ -190,6 +190,12 @@ impl NodeGraphExecutor {
let size = bounds[1] - bounds[0]; let size = bounds[1] - bounds[0];
let transform = DAffine2::from_translation(bounds[0]).inverse(); 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 { let render_config = RenderConfig {
viewport: Footprint { viewport: Footprint {
transform: DAffine2::from_scale(DVec2::splat(export_config.scale_factor)) * transform, transform: DAffine2::from_scale(DVec2::splat(export_config.scale_factor)) * transform,
@ -197,7 +203,7 @@ impl NodeGraphExecutor {
..Default::default() ..Default::default()
}, },
time: Default::default(), time: Default::default(),
export_format: graphene_std::application_io::ExportFormat::Svg, export_format,
render_mode: document.render_mode, render_mode: document.render_mode,
hide_artboards: export_config.transparent_background, hide_artboards: export_config.transparent_background,
for_export: true, for_export: true,
@ -219,28 +225,81 @@ impl NodeGraphExecutor {
} }
fn export(&self, node_graph_output: TaggedValue, export_config: ExportConfig, responses: &mut VecDeque<Message>) -> Result<(), String> { fn export(&self, node_graph_output: TaggedValue, export_config: ExportConfig, responses: &mut VecDeque<Message>) -> 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 { let ExportConfig {
file_type, name, size, scale_factor, .. file_type,
name,
size,
scale_factor,
#[cfg(feature = "gpu")]
transparent_background,
..
} = export_config; } = export_config;
let file_suffix = &format!(".{file_type:?}").to_lowercase(); let file_extension = match file_type {
let name = name + file_suffix; FileType::Svg => "svg",
FileType::Png => "png",
FileType::Jpg => "jpg",
};
let name = format!("{name}.{file_extension}");
if file_type == FileType::Svg { match node_graph_output {
responses.add(FrontendMessage::TriggerSaveFile { name, content: svg.into_bytes() }); TaggedValue::RenderOutput(RenderOutput {
} else { data: RenderOutputType::Svg { svg, .. },
let mime = file_type.to_mime().to_string(); ..
let size = (size * scale_factor).into(); }) => {
responses.add(FrontendMessage::TriggerExportImage { svg, name, mime, size }); 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(()) Ok(())
} }

View File

@ -7,9 +7,11 @@ use graph_craft::graphene_compiler::Compiler;
use graph_craft::proto::GraphErrors; use graph_craft::proto::GraphErrors;
use graph_craft::wasm_application_io::EditorPreferences; use graph_craft::wasm_application_io::EditorPreferences;
use graph_craft::{ProtoNodeIdentifier, concrete}; 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::bounds::RenderBoundingBox;
use graphene_std::memo::IORecord; 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::{Render, RenderParams, SvgRender};
use graphene_std::renderer::{RenderSvgSegmentList, SvgSegment}; use graphene_std::renderer::{RenderSvgSegmentList, SvgSegment};
use graphene_std::table::{Table, TableRow}; use graphene_std::table::{Table, TableRow};
@ -220,16 +222,44 @@ impl NodeRuntime {
// Resolve the result from the inspection by accessing the monitor node // 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 inspect_result = self.inspect_state.and_then(|state| state.access(&self.executor));
let texture = if let Ok(TaggedValue::RenderOutput(RenderOutput { let (result, texture) = match result {
data: RenderOutputType::Texture(texture), Ok(TaggedValue::RenderOutput(RenderOutput {
.. data: RenderOutputType::Texture(image_texture),
})) = &result metadata,
{ })) if render_config.for_export => {
// We can early return becaus we know that there is at most one execution request and it will always be handled last let executor = self
Some(texture.clone()) .editor_api
} else { .application_io
None .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 { self.sender.send_execution_response(ExecutionResponse {
execution_id, execution_id,
result, result,
@ -274,18 +304,12 @@ impl NodeRuntime {
async fn execute_network(&mut self, render_config: RenderConfig) -> Result<TaggedValue, String> { async fn execute_network(&mut self, render_config: RenderConfig) -> Result<TaggedValue, String> {
use graph_craft::graphene_compiler::Executor; 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!(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) if t == concrete!(()) => (&self.executor).execute(()).await.map_err(|e| e.to_string()),
Some(t) => Err(format!("Invalid input type {t:?}")), Some(t) => Err(format!("Invalid input type {t:?}")),
_ => Err(format!("No input type:\n{:?}", self.node_graph_errors)), _ => 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 /// Updates state data

View File

@ -222,10 +222,6 @@ pub trait GetEditorPreferences {
pub enum ExportFormat { pub enum ExportFormat {
#[default] #[default]
Svg, Svg,
Png {
transparent: bool,
},
Jpeg,
Canvas, Canvas,
Texture, Texture,
} }

View File

@ -441,11 +441,16 @@ pub enum RenderOutputType {
CanvasFrame(SurfaceFrame), CanvasFrame(SurfaceFrame),
#[serde(skip)] #[serde(skip)]
Texture(ImageTexture), Texture(ImageTexture),
#[serde(skip)]
Buffer {
data: Vec<u8>,
width: u32,
height: u32,
},
Svg { Svg {
svg: String, svg: String,
image_data: Vec<(u64, Image<Color>)>, image_data: Vec<(u64, Image<Color>)>,
}, },
Image(Vec<u8>),
} }
impl Hash for RenderOutput { impl Hash for RenderOutput {

View File

@ -44,7 +44,6 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send +
data: impl Node<Context<'static>, Output = T>, data: impl Node<Context<'static>, Output = T>,
editor_api: impl Node<Context<'static>, Output = &'a WasmEditorApi>, editor_api: impl Node<Context<'static>, Output = &'a WasmEditorApi>,
) -> RenderIntermediate { ) -> RenderIntermediate {
let mut render = SvgRender::new();
let render_params = ctx let render_params = ctx
.vararg(0) .vararg(0)
.expect("Did not find var args") .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); data.collect_metadata(&mut metadata, footprint, None);
let contains_artboard = data.contains_artboard(); 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 scene = vello::Scene::new();
let mut context = wgpu_executor::RenderContext::default(); let mut context = wgpu_executor::RenderContext::default();
@ -73,6 +83,8 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send +
contains_artboard, contains_artboard,
} }
} else { } else {
let mut render = SvgRender::new();
data.render_svg(&mut render, render_params); data.render_svg(&mut render, render_params);
RenderIntermediate { RenderIntermediate {
@ -93,11 +105,10 @@ async fn create_context<'a: 'n>(
let render_output_type = match render_config.export_format { let render_output_type = match render_config.export_format {
ExportFormat::Svg => RenderOutputTypeRequest::Svg, ExportFormat::Svg => RenderOutputTypeRequest::Svg,
ExportFormat::Png { .. } => todo!(),
ExportFormat::Jpeg => todo!(),
ExportFormat::Canvas => RenderOutputTypeRequest::Vello,
ExportFormat::Texture => RenderOutputTypeRequest::Vello, ExportFormat::Texture => RenderOutputTypeRequest::Vello,
ExportFormat::Canvas => RenderOutputTypeRequest::Vello,
}; };
let render_params = RenderParams { let render_params = RenderParams {
render_mode: render_config.render_mode, render_mode: render_config.render_mode,
hide_artboards: render_config.hide_artboards, hide_artboards: render_config.hide_artboards,
@ -106,6 +117,7 @@ async fn create_context<'a: 'n>(
footprint: Footprint::default(), footprint: Footprint::default(),
..Default::default() ..Default::default()
}; };
let ctx = OwnedContextImpl::default() let ctx = OwnedContextImpl::default()
.with_footprint(footprint) .with_footprint(footprint)
.with_real_time(render_config.time.time) .with_real_time(render_config.time.time)
@ -198,6 +210,7 @@ async fn render<'a: 'n>(
if !contains_artboard && !render_params.hide_artboards { if !contains_artboard && !render_params.hide_artboards {
background = Color::WHITE; background = Color::WHITE;
} }
if let Some(surface_handle) = surface_handle { if let Some(surface_handle) = surface_handle {
exec.render_vello_scene(&scene, &surface_handle, footprint.resolution, context, background) exec.render_vello_scene(&scene, &surface_handle, footprint.resolution, context, background)
.await .await

View File

@ -124,7 +124,7 @@ impl WgpuExecutor {
mip_level_count: 1, mip_level_count: 1,
sample_count: 1, sample_count: 1,
dimension: wgpu::TextureDimension::D2, 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, format: VELLO_SURFACE_FORMAT,
view_formats: &[], view_formats: &[],
}); });