From 1d3d32c16913fb79d4d0da40269c93bf7d9acb13 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 17 May 2026 11:44:57 -0400 Subject: [PATCH 1/2] Implement animation export --- Cargo.lock | 19 ++ Cargo.toml | 1 + desktop/src/app.rs | 6 + .../src/handle_desktop_wrapper_message.rs | 9 + .../wrapper/src/intercept_frontend_message.rs | 47 ++++ desktop/wrapper/src/messages.rs | 15 +- .../export_dialog/export_dialog_message.rs | 4 + .../export_dialog_message_handler.rs | 104 +++++++- .../src/messages/frontend/frontend_message.rs | 19 +- editor/src/messages/frontend/utility_types.rs | 41 +++ .../messages/portfolio/portfolio_message.rs | 3 +- .../portfolio/portfolio_message_handler.rs | 2 + editor/src/node_graph_executor.rs | 234 ++++++++++++++---- editor/src/node_graph_executor/runtime.rs | 13 +- .../widgets/inputs/NumberInput.svelte | 4 +- frontend/src/stores/portfolio.ts | 43 ++++ frontend/wrapper/Cargo.toml | 1 + frontend/wrapper/src/editor_wrapper.rs | 34 +++ 18 files changed, 541 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 531cc58d3d..f70d298da6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2193,6 +2193,7 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "wgpu", + "zip", ] [[package]] @@ -5901,6 +5902,12 @@ dependencies = [ "core_maths", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -7599,6 +7606,18 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "typed-path", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 3e3fdddb43..ec98ff71a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -219,6 +219,7 @@ lzma-rust2 = { version = "0.16", default-features = false, features = ["std", "e scraper = "0.25" linesweeper = "0.3" smallvec = "1.13.2" +zip = { version = "8", default-features = false } [workspace.lints.rust] unexpected_cfgs = { level = "allow", check-cfg = ['cfg(target_arch, values("spirv"))'] } diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 63d7d13495..3694f7af48 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -249,6 +249,12 @@ impl App { }); } DesktopFrontendMessage::WriteFile { path, content } => { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + && let Err(e) = fs::create_dir_all(parent) + { + tracing::error!("Failed to create parent directory {}: {}", parent.display(), e); + } if let Err(e) = fs::write(&path, content) { tracing::error!("Failed to write file {}: {}", path.display(), e); } diff --git a/desktop/wrapper/src/handle_desktop_wrapper_message.rs b/desktop/wrapper/src/handle_desktop_wrapper_message.rs index 1062d77ad9..d17fca6038 100644 --- a/desktop/wrapper/src/handle_desktop_wrapper_message.rs +++ b/desktop/wrapper/src/handle_desktop_wrapper_message.rs @@ -31,6 +31,15 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess SaveFileDialogContext::File { content } => { dispatcher.respond(DesktopFrontendMessage::WriteFile { path, content }); } + SaveFileDialogContext::MultipleFiles { files } => { + // Treat the chosen path as the folder name; strip any extension the user typed (e.g. "MyAnim.png" → "MyAnim"). + // The `WriteFile` handler creates parent directories if they don't exist, so the folder is materialized on first write. + let folder = path.with_extension(""); + for (filename, content) in files { + let file_path = folder.join(&filename); + dispatcher.respond(DesktopFrontendMessage::WriteFile { path: file_path, content }); + } + } }, DesktopWrapperMessage::OpenFile { path, content } => { let message = PortfolioMessage::OpenFile { path, content }; diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 2504746f8a..d17e0bdc59 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -1,3 +1,4 @@ +use graphite_editor::messages::frontend::utility_types::ExportAnimationFrame; #[cfg(target_os = "macos")] use graphite_editor::messages::layout::utility_types::layout_widget::LayoutTarget; use graphite_editor::messages::prelude::FrontendMessage; @@ -59,6 +60,52 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD context: SaveFileDialogContext::File { content }, }); } + FrontendMessage::TriggerExportAnimation { + name, + extension, + mime, + size, + folder, + frames, + } => { + // Materialize each frame to bytes; SVG strings are encoded as UTF-8. + // Raster-needs-canvas-rasterize frames can't be encoded here without a Rust SVG rasterizer, + // so fall through to the frontend zip path in that case. + let mut needs_frontend_rasterization = false; + let mut materialized = Vec::with_capacity(frames.len()); + for (index, frame) in frames.iter().enumerate() { + let filename = format!("{name}_{:04}.{extension}", index + 1); + let bytes = match frame { + ExportAnimationFrame::Svg(svg) if extension == "svg" => svg.as_bytes().to_vec(), + ExportAnimationFrame::Bytes(bytes) => bytes.to_vec(), + ExportAnimationFrame::Svg(_) => { + needs_frontend_rasterization = true; + break; + } + }; + materialized.push((filename, bytes)); + } + + if needs_frontend_rasterization { + return Some(FrontendMessage::TriggerExportAnimation { + name, + extension, + mime, + size, + folder, + frames, + }); + } + + // The dialog name is the folder the frames go into (analogous to the .zip on web). + dispatcher.respond(DesktopFrontendMessage::SaveFileDialog { + title: "Save Animation Frames Folder As".to_string(), + default_filename: name.clone(), + default_folder: folder, + filters: Vec::new(), + context: SaveFileDialogContext::MultipleFiles { files: materialized }, + }); + } FrontendMessage::TriggerVisitLink { url } => { dispatcher.respond(DesktopFrontendMessage::OpenUrl(url)); } diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index a571eb49ac..2e3cb82a11 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -111,8 +111,19 @@ pub enum OpenFileDialogContext { } pub enum SaveFileDialogContext { - Document { document_id: DocumentId, content: Vec }, - File { content: Vec }, + Document { + document_id: DocumentId, + content: Vec, + }, + File { + content: Vec, + }, + /// Multiple files written into a folder whose path is the user-chosen path with any extension stripped + /// (e.g. picking `MyAnim.png` yields a `MyAnim/` folder). Each `(filename, content)` entry is written + /// inside that folder, which is created if it doesn't exist. + MultipleFiles { + files: Vec<(String, Vec)>, + }, } pub enum MenuItem { diff --git a/editor/src/messages/dialog/export_dialog/export_dialog_message.rs b/editor/src/messages/dialog/export_dialog/export_dialog_message.rs index 6c6a9364f5..99ee4f0e75 100644 --- a/editor/src/messages/dialog/export_dialog/export_dialog_message.rs +++ b/editor/src/messages/dialog/export_dialog/export_dialog_message.rs @@ -7,6 +7,10 @@ pub enum ExportDialogMessage { FileType { file_type: FileType }, ScaleFactor { factor: f64 }, ExportBounds { bounds: ExportBounds }, + Animated { animated: bool }, + Fps { fps: f64 }, + StartSeconds { start: f64 }, + EndSeconds { end: f64 }, Submit, } 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..bbe451b503 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 @@ -1,4 +1,4 @@ -use crate::messages::frontend::utility_types::{ExportBounds, FileType}; +use crate::messages::frontend::utility_types::{AnimationExport, ExportBounds, FileType}; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::prelude::*; @@ -16,6 +16,10 @@ pub struct ExportDialogMessageHandler { pub bounds: ExportBounds, pub artboards: HashMap, pub has_selection: bool, + pub animated: bool, + pub fps: f64, + pub start_seconds: f64, + pub end_seconds: f64, } impl Default for ExportDialogMessageHandler { @@ -26,10 +30,21 @@ impl Default for ExportDialogMessageHandler { bounds: Default::default(), artboards: Default::default(), has_selection: false, + animated: false, + fps: 30., + start_seconds: 0., + end_seconds: 1., } } } +impl ExportDialogMessageHandler { + fn total_frames(&self) -> u32 { + let duration = (self.end_seconds - self.start_seconds).max(0.); + ((duration * self.fps).round() as i64).max(1) as u32 + } +} + #[message_handler_data] impl MessageHandler> for ExportDialogMessageHandler { fn process_message(&mut self, message: ExportDialogMessage, responses: &mut VecDeque, context: ExportDialogMessageContext) { @@ -39,6 +54,15 @@ impl MessageHandler> for Exp ExportDialogMessage::FileType { file_type } => self.file_type = file_type, ExportDialogMessage::ScaleFactor { factor } => self.scale_factor = factor, ExportDialogMessage::ExportBounds { bounds } => self.bounds = bounds, + ExportDialogMessage::Animated { animated } => self.animated = animated, + ExportDialogMessage::Fps { fps } => self.fps = fps.max(0.001), + ExportDialogMessage::StartSeconds { start } => { + self.start_seconds = start.max(0.); + if self.end_seconds < self.start_seconds { + self.end_seconds = self.start_seconds; + } + } + ExportDialogMessage::EndSeconds { end } => self.end_seconds = end.max(self.start_seconds), ExportDialogMessage::Submit => { // Fall back to "All Artwork" if "Selection" was chosen but nothing is currently selected @@ -52,6 +76,13 @@ impl MessageHandler> for Exp ExportBounds::Artboard(layer) => self.artboards.get(&layer).cloned(), _ => None, }; + + let animation = self.animated.then(|| AnimationExport { + fps: self.fps, + start_seconds: self.start_seconds, + total_frames: self.total_frames(), + }); + responses.add_front(PortfolioMessage::SubmitDocumentExport { name: portfolio.active_document().map(|document| document.name.clone()).unwrap_or_default(), file_type: self.file_type, @@ -59,6 +90,7 @@ impl MessageHandler> for Exp bounds, artboard_name, artboard_count: self.artboards.len(), + animation, }) } } @@ -163,6 +195,74 @@ impl LayoutHolder for ExportDialogMessageHandler { DropdownInput::new(entries).selected_index(Some(index as u32)).widget_instance(), ]; - Layout(vec![LayoutGroup::row(export_type), LayoutGroup::row(resolution), LayoutGroup::row(export_area)]) + let animation_checkbox_id = CheckboxId::new(); + let animation_toggle = vec![ + TextLabel::new("Animation").table_align(true).min_width(100).for_checkbox(animation_checkbox_id).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + CheckboxInput::new(self.animated) + .on_update(|checkbox_input: &CheckboxInput| ExportDialogMessage::Animated { animated: checkbox_input.checked }.into()) + .for_label(animation_checkbox_id) + .widget_instance(), + ]; + + let mut layout_groups = vec![ + LayoutGroup::row(export_type), + LayoutGroup::row(resolution), + LayoutGroup::row(export_area), + LayoutGroup::row(animation_toggle), + ]; + + if self.animated { + let fps_row = vec![ + TextLabel::new("FPS").table_align(true).min_width(100).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + NumberInput::new(Some(self.fps)) + .unit(" fps") + .min(0.001) + .max(1000.) + .increment_step(1.) + .on_update(|number_input: &NumberInput| ExportDialogMessage::Fps { fps: number_input.value.unwrap() }.into()) + .min_width(200) + .widget_instance(), + ]; + + let start_row = vec![ + TextLabel::new("Start").table_align(true).min_width(100).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + NumberInput::new(Some(self.start_seconds)) + .unit(" sec") + .min(0.) + .increment_step(0.1) + .on_update(|number_input: &NumberInput| ExportDialogMessage::StartSeconds { start: number_input.value.unwrap() }.into()) + .min_width(200) + .widget_instance(), + ]; + + let end_row = vec![ + TextLabel::new("End").table_align(true).min_width(100).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + NumberInput::new(Some(self.end_seconds)) + .unit(" sec") + .min(self.start_seconds) + .increment_step(0.1) + .on_update(|number_input: &NumberInput| ExportDialogMessage::EndSeconds { end: number_input.value.unwrap() }.into()) + .min_width(200) + .widget_instance(), + ]; + + let frame_count = self.total_frames(); + let frames_row = vec![ + TextLabel::new("Frames").table_align(true).min_width(100).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextLabel::new(format!("{frame_count} frame{}", if frame_count == 1 { "" } else { "s" })).widget_instance(), + ]; + + layout_groups.push(LayoutGroup::row(fps_row)); + layout_groups.push(LayoutGroup::row(start_row)); + layout_groups.push(LayoutGroup::row(end_row)); + layout_groups.push(LayoutGroup::row(frames_row)); + } + + Layout(layout_groups) } } diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index c7b4489fbb..dbd099cedc 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}; +use crate::messages::frontend::utility_types::{DocumentInfo, ExportAnimationFrame, EyedropperPreviewImage}; 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::{ @@ -107,6 +107,23 @@ pub enum FrontendMessage { mime: String, size: (f64, f64), }, + /// Export one or more animation frames as a bundle. + /// On web, the frontend zips the frames into an uncompressed `.zip` and triggers a single download. + /// On desktop, the wrapper intercepts this and writes each frame as a separate file to a user-chosen folder. + TriggerExportAnimation { + /// Base name without the index suffix or extension (e.g. "MyDocument" or "MyDocument - Artboard 1"). + name: String, + /// File extension without leading dot ("png", "jpg", "svg"). + extension: String, + /// MIME type matching the extension (e.g. "image/png"). + mime: String, + /// Pixel size of each rasterized frame, used when the frontend needs to canvas-rasterize from SVG. + size: (f64, f64), + /// Document folder, used as a default location for the desktop save dialog. + folder: Option, + /// One entry per frame, in playback order. Each frame is either an SVG string or pre-encoded bytes. + frames: Vec, + }, TriggerFetchAndOpenDocument { name: String, filename: String, diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index 7d9e619fbb..cbb28641b5 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -59,6 +59,14 @@ impl FileType { FileType::Svg => "image/svg+xml", } } + + pub fn to_extension(self) -> &'static str { + match self { + FileType::Png => "png", + FileType::Jpg => "jpg", + FileType::Svg => "svg", + } + } } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -70,6 +78,39 @@ pub enum ExportBounds { Artboard(LayerNodeIdentifier), } +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct AnimationExport { + pub fps: f64, + pub start_seconds: f64, + pub total_frames: u32, +} + +impl Default for AnimationExport { + fn default() -> Self { + Self { + fps: 30., + start_seconds: 0., + total_frames: 30, + } + } +} + +impl AnimationExport { + pub fn frame_time_seconds(&self, frame_index: u32) -> f64 { + self.start_seconds + frame_index as f64 / self.fps + } +} + +/// One frame of a multi-frame animation export, in playback order. +/// `Svg` is text that the frontend can save directly (for SVG export) or rasterize via canvas (for PNG/JPG export). +/// `Bytes` is already-encoded image bytes from the GPU export path, ready to write directly. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum ExportAnimationFrame { + Svg(String), + Bytes(serde_bytes::ByteBuf), +} + #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))] #[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct EyedropperPreviewImage { diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index e188278989..ebc17067a1 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -1,7 +1,7 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier; use super::persistent_state::PersistentStateMessage; use super::utility_types::{DockingSplitDirection, PanelGroupId, PanelType}; -use crate::messages::frontend::utility_types::{ExportBounds, FileType, PersistedState}; +use crate::messages::frontend::utility_types::{AnimationExport, ExportBounds, FileType, PersistedState}; use crate::messages::portfolio::document::utility_types::clipboards::Clipboard; use crate::messages::portfolio::utility_types::FontCatalog; use crate::messages::prelude::*; @@ -178,6 +178,7 @@ pub enum PortfolioMessage { bounds: ExportBounds, artboard_name: Option, artboard_count: usize, + animation: Option, }, SubmitActiveGraphRender, SubmitGraphRender { diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 8dd03294ec..4bcc9b1f7d 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -1441,6 +1441,7 @@ impl MessageHandler> for Portfolio bounds, artboard_name, artboard_count, + animation, } => { let document_id = self.active_document_id.expect("Tried to render non-existent document"); let document = self.documents.get_mut(&document_id).expect("Tried to render non-existent document"); @@ -1451,6 +1452,7 @@ impl MessageHandler> for Portfolio bounds, artboard_name, artboard_count, + animation, ..Default::default() }; let result = self.executor.submit_document_export(document, document_id, export_config); diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 30bcf64f1f..5b59425655 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -1,4 +1,4 @@ -use crate::messages::frontend::utility_types::{ExportBounds, FileType}; +use crate::messages::frontend::utility_types::{ExportAnimationFrame, ExportBounds, FileType}; use crate::messages::prelude::*; use glam::{DAffine2, DVec2, UVec2}; use graph_craft::application_io::EditorPreferences; @@ -13,6 +13,8 @@ use graphene_std::text::FontCache; use graphene_std::transform::Footprint; use graphene_std::vector::Vector; use interpreted_executor::dynamic_executor::ResolvedDocumentNodeTypesDelta; +use std::path::PathBuf; +use std::time::Duration; mod runtime_io; pub use runtime_io::NodeRuntimeIO; @@ -59,6 +61,23 @@ pub struct NodeGraphExecutor { /// so the runtime can splice its monitor node alongside the target rather than only at the top level. /// Tracking the previously-sent value lets `update_node_graph` re-send the network when the inspection target changes. previous_node_to_inspect: Vec, + /// Per-export accumulator for in-progress animation exports. Each frame execution pushes its rendered output here into the matching u64 export ID slot. + /// Once `frames_received == total_frames`, the accumulator is drained into a single `TriggerExportAnimation` message. + pending_animation_exports: HashMap, + next_animation_export_id: u64, +} + +#[derive(Debug)] +struct AnimationExportAccumulator { + name: String, + file_type: FileType, + size: UVec2, + folder: Option, + artboard_name: Option, + artboard_count: usize, + frames: Vec>, + frames_received: u32, + total_frames: u32, } #[derive(Debug, Clone)] @@ -81,6 +100,8 @@ impl NodeGraphExecutor { node_graph_hash: 0, current_execution_id: 0, previous_node_to_inspect: Vec::new(), + pending_animation_exports: HashMap::new(), + next_animation_export_id: 0, }; (node_runtime, node_executor) } @@ -264,7 +285,7 @@ impl NodeGraphExecutor { ..Default::default() }; - let render_config = RenderConfig { + let base_render_config = RenderConfig { viewport, scale: export_config.scale_factor, time: Default::default(), @@ -276,18 +297,65 @@ impl NodeGraphExecutor { }; export_config.size = resolution; - // Execute the node graph + // Send the network update once; the runtime keeps it for all subsequent executions. self.runtime_io .send(GraphRuntimeRequest::GraphUpdate(GraphUpdate { network, node_to_inspect: Vec::new() })) .map_err(|e| e.to_string())?; - let execution_id = self.queue_execution(render_config); - self.futures.push_back(( - execution_id, - ExecutionContext { - export_config: Some(export_config), - document_id, - }, - )); + + if let Some(animation) = export_config.animation { + // Allocate an export ID and accumulator, then queue one execution per frame + let export_id = self.next_animation_export_id; + self.next_animation_export_id = self.next_animation_export_id.wrapping_add(1); + + let folder = document.path.as_ref().and_then(|path| path.parent()).map(|parent| parent.to_path_buf()); + self.pending_animation_exports.insert( + export_id, + AnimationExportAccumulator { + name: export_config.name.clone(), + file_type: export_config.file_type, + size: resolution, + folder, + artboard_name: export_config.artboard_name.clone(), + artboard_count: export_config.artboard_count, + frames: (0..animation.total_frames).map(|_| None).collect(), + frames_received: 0, + total_frames: animation.total_frames, + }, + ); + + for frame_index in 0..animation.total_frames { + let frame_seconds = animation.frame_time_seconds(frame_index); + let animation_time = Duration::from_secs_f64(frame_seconds.max(0.)); + let timing = TimingInformation { time: frame_seconds, animation_time }; + + let frame_render_config = RenderConfig { time: timing, ..base_render_config }; + + let mut frame_export_config = export_config.clone(); + frame_export_config.animation_frame = Some(AnimationExportFrame { + export_id, + frame_index, + total_frames: animation.total_frames, + }); + + let execution_id = self.queue_execution(frame_render_config); + self.futures.push_back(( + execution_id, + ExecutionContext { + export_config: Some(frame_export_config), + document_id, + }, + )); + } + } else { + let execution_id = self.queue_execution(base_render_config); + self.futures.push_back(( + execution_id, + ExecutionContext { + export_config: Some(export_config), + document_id, + }, + )); + } Ok(()) } @@ -459,7 +527,12 @@ impl NodeGraphExecutor { Ok(()) } - fn process_export(&self, node_graph_output: TaggedValue, export_config: ExportConfig, document: &DocumentMessageHandler, responses: &mut VecDeque) -> Result<(), String> { + fn process_export(&mut self, node_graph_output: TaggedValue, export_config: ExportConfig, document: &DocumentMessageHandler, responses: &mut VecDeque) -> Result<(), String> { + // Route animation frames into the per-export accumulator. The final message is emitted when all frames arrive. + if let Some(animation_frame) = export_config.animation_frame { + return self.process_animation_frame(node_graph_output, &export_config, animation_frame, responses); + } + let ExportConfig { file_type, name, @@ -503,43 +576,7 @@ 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()); - } - } - + let encoded = encode_raster_buffer(file_type, data, width, height)?; responses.add(FrontendMessage::TriggerSaveFile { name, folder, @@ -553,6 +590,105 @@ impl NodeGraphExecutor { Ok(()) } + + fn process_animation_frame( + &mut self, + node_graph_output: TaggedValue, + export_config: &ExportConfig, + animation_frame: AnimationExportFrame, + responses: &mut VecDeque, + ) -> Result<(), String> { + let file_type = export_config.file_type; + + let frame_data = match node_graph_output { + TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Svg { svg, .. }, + .. + }) => ExportAnimationFrame::Svg(svg), + #[cfg(feature = "gpu")] + TaggedValue::RenderOutput(RenderOutput { + data: RenderOutputType::Buffer { data, width, height }, + .. + }) if file_type != FileType::Svg => { + let encoded = encode_raster_buffer(file_type, data, width, height)?; + ExportAnimationFrame::Bytes(serde_bytes::ByteBuf::from(encoded)) + } + other => return Err(format!("Incorrect render type for animation frame ({file_type:?}, {other})")), + }; + + let Some(accumulator) = self.pending_animation_exports.get_mut(&animation_frame.export_id) else { + // Export was cancelled or already finalized, drop it quietly + return Ok(()); + }; + + let index = animation_frame.frame_index as usize; + if let Some(slot) = accumulator.frames.get_mut(index) + && slot.is_none() + { + *slot = Some(frame_data); + accumulator.frames_received += 1; + } + + if accumulator.frames_received < accumulator.total_frames { + return Ok(()); + } + + // All frames received: drain the accumulator and emit a single message + let accumulator = self.pending_animation_exports.remove(&animation_frame.export_id).expect("Accumulator was present"); + let base_name = match (accumulator.artboard_name, accumulator.artboard_count) { + (Some(artboard_name), count) if count > 1 => format!("{} - {}", accumulator.name, artboard_name), + _ => accumulator.name, + }; + let frames: Vec<_> = accumulator + .frames + .into_iter() + .enumerate() + .map(|(i, f)| f.ok_or_else(|| format!("Missing animation frame {i}"))) + .collect::, _>>()?; + + responses.add(FrontendMessage::TriggerExportAnimation { + name: base_name, + extension: accumulator.file_type.to_extension().to_string(), + mime: accumulator.file_type.to_mime().to_string(), + size: accumulator.size.as_dvec2().into(), + folder: accumulator.folder, + frames, + }); + + Ok(()) + } +} + +#[cfg(feature = "gpu")] +fn encode_raster_buffer(file_type: FileType, data: Vec, width: u32, height: u32) -> Result, String> { + 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 => image.write_to(&mut cursor, ImageFormat::Png).map_err(|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(); + image.write_to(&mut cursor, ImageFormat::Jpeg).map_err(|err| format!("Failed to encode JPG: {err}"))?; + } + FileType::Svg => return Err("SVG cannot be exported from an image buffer".to_string()), + } + + Ok(encoded) } // Re-export for usage by tests in other modules diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index abe6dca28d..0a91c350ee 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -1,5 +1,5 @@ use super::*; -use crate::messages::frontend::utility_types::{ExportBounds, FileType}; +use crate::messages::frontend::utility_types::{AnimationExport, ExportBounds, FileType}; use glam::{DAffine2, DVec2, UVec2}; use graph_craft::application_io::{PlatformApplicationIo, PlatformEditorApi}; use graph_craft::document::value::{RenderOutput, RenderOutputType, TaggedValue}; @@ -91,6 +91,17 @@ pub struct ExportConfig { pub size: UVec2, pub artboard_name: Option, pub artboard_count: usize, + /// Set when this export is part of a multi-frame animation export. `None` means a normal single-frame export. + pub animation: Option, + /// Set on each per-frame `ExportConfig` to identify which animation export it belongs to and which frame index it represents. + pub animation_frame: Option, +} + +#[derive(Default, Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] +pub struct AnimationExportFrame { + pub export_id: u64, + pub frame_index: u32, + pub total_frames: u32, } #[derive(Clone)] diff --git a/frontend/src/components/widgets/inputs/NumberInput.svelte b/frontend/src/components/widgets/inputs/NumberInput.svelte index 4c88e28d2d..75d7c3db1a 100644 --- a/frontend/src/components/widgets/inputs/NumberInput.svelte +++ b/frontend/src/components/widgets/inputs/NumberInput.svelte @@ -215,9 +215,9 @@ return `${unitlessDisplayValue}${unPluralize(unit, displayValue)}`; } - // Removes the trailing "s" from a unit if the quantity is 1. + // Removes the "s" suffix from a unit if the quantity is 1. function unPluralize(unit: string, quantity: number): string { - if (quantity !== 1 || !unit.endsWith("s")) return unit; + if (quantity !== 1 || !unit.endsWith("s") || unit.trim().length < 2) return unit; return unit.slice(0, -1); } diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 8665ccda7e..2d38a01a1c 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -6,6 +6,7 @@ import type { SubscriptionsRouter } from "/src/subscriptions-router"; import { downloadFile, downloadFileBlob, upload } from "/src/utility-functions/files"; import { rasterizeSVG } from "/src/utility-functions/rasterization"; import { patchLayout } from "/src/utility-functions/widgets"; +import { createZipFromFiles } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { EditorWrapper, DocumentInfo, LayerPanelEntry, LayerStructureEntry, Layout, WorkspacePanelLayout } from "/wrapper/pkg/graphite_wasm_wrapper"; export type PortfolioStore = ReturnType; @@ -129,6 +130,47 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: } }); + // TODO: This handler orchestrates rasterization + zipping in JS because PNG/JPG frames arrive as SVG strings + // TODO: that need the frontend's canvas-based `rasterizeSVG()` to encode. Once SVG rasterization moves to + // TODO: always occur in Rust, the executor can build the .zip itself and emit a single `TriggerSaveFile`, + // TODO: matching how PNG/JPG/SVG/.graphite single-file exports work today. + subscriptions.subscribeFrontendMessage("TriggerExportAnimation", async (data) => { + const { name, extension, mime, size, frames } = data; + const isRaster = extension === "png" || extension === "jpg"; + const backgroundColor = mime.endsWith("jpeg") ? "white" : undefined; + const padWidth = Math.max(4, String(frames.length).length); + + // Materialize each frame to bytes, rasterizing SVG via canvas when the destination format is raster + const entries: [string, Uint8Array][] = []; + for (let i = 0; i < frames.length; i++) { + const frame = frames[i]; + const filename = `${name}_${String(i + 1).padStart(padWidth, "0")}.${extension}`; + + let bytes: Uint8Array; + if ("Bytes" in frame) { + bytes = frame.Bytes; + } else if (isRaster) { + let blob: Blob; + try { + blob = await rasterizeSVG(frame.Svg, size[0], size[1], mime, backgroundColor); + } catch { + // Skip frames that fail to rasterize (e.g. zero-sized) rather than aborting the whole export + continue; + } + bytes = new Uint8Array(await blob.arrayBuffer()); + } else { + bytes = new TextEncoder().encode(frame.Svg); + } + entries.push([filename, bytes]); + } + + if (entries.length === 0) return; + + // Build the .zip in Rust (uncompressed store mode); web APIs can only deliver a single download, so the user gets one .zip + const zipBytes = createZipFromFiles(entries); + downloadFileBlob(`${name}.zip`, new Blob([new Uint8Array(zipBytes)], { type: "application/zip" })); + }); + subscriptions.subscribeFrontendMessage("UpdateWorkspacePanelLayout", (data) => { update((state) => { state.panelLayout = data.panelLayout; @@ -196,6 +238,7 @@ export function destroyPortfolioStore() { subscriptions.unsubscribeFrontendMessage("TriggerSaveDocument"); subscriptions.unsubscribeFrontendMessage("TriggerSaveFile"); subscriptions.unsubscribeFrontendMessage("TriggerExportImage"); + subscriptions.unsubscribeFrontendMessage("TriggerExportAnimation"); subscriptions.unsubscribeFrontendMessage("UpdateWorkspacePanelLayout"); subscriptions.unsubscribeLayoutUpdate("WelcomeScreenButtons"); subscriptions.unsubscribeLayoutUpdate("PropertiesPanel"); diff --git a/frontend/wrapper/Cargo.toml b/frontend/wrapper/Cargo.toml index f69599f664..3a5b148b4f 100644 --- a/frontend/wrapper/Cargo.toml +++ b/frontend/wrapper/Cargo.toml @@ -39,6 +39,7 @@ web-sys = { workspace = true } ron = { workspace = true } serde_json = { workspace = true } node-macro = { workspace = true } +zip = { workspace = true } [package.metadata.wasm-pack.profile.dev] wasm-opt = false diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 306f338af7..9099d0869c 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -974,6 +974,40 @@ impl EditorWrapper { // Static functions callable from JavaScript without an Editor instance // ==================================================================== +/// Build an uncompressed (store-only) ZIP archive from a list of `[filename, bytes]` entries. +/// +/// Used by the animation export flow on the web build: web APIs cannot offer a multi-file save, +/// so the frontend packs all frames into a single `.zip` to download. On desktop, individual files +/// are written to a user-chosen folder instead and this function is unused. +#[wasm_bindgen(js_name = createZipFromFiles)] +pub fn create_zip_from_files(entries: js_sys::Array) -> Result, JsValue> { + use std::io::{Cursor, Write}; + use zip::write::{SimpleFileOptions, ZipWriter}; + + let mut buffer = Cursor::new(Vec::::new()); + let mut writer = ZipWriter::new(&mut buffer); + + // Skip compression since raster/SVG payloads are already small or already compressed + let options: SimpleFileOptions = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).unix_permissions(0o644); + + for entry in entries.iter() { + let pair = entry + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("createZipFromFiles: each entry must be a [filename, bytes] array"))?; + + let filename = pair.get(0).as_string().ok_or_else(|| JsValue::from_str("createZipFromFiles: filename must be a string"))?; + let bytes = js_sys::Uint8Array::new(&pair.get(1)).to_vec(); + + writer + .start_file(filename, options) + .map_err(|e| JsValue::from_str(&format!("createZipFromFiles: start_file failed: {e}")))?; + writer.write_all(&bytes).map_err(|e| JsValue::from_str(&format!("createZipFromFiles: write_all failed: {e}")))?; + } + + writer.finish().map_err(|e| JsValue::from_str(&format!("createZipFromFiles: finish failed: {e}")))?; + Ok(buffer.into_inner()) +} + #[wasm_bindgen(js_name = evaluateMathExpression)] pub fn evaluate_math_expression(expression: &str) -> Option { let value = math_parser::evaluate(expression) From 5faf5e67f2c00d94a9372cdff6d14d32984e4782 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 17 May 2026 15:44:02 -0400 Subject: [PATCH 2/2] Code review fixes --- .../src/handle_desktop_wrapper_message.rs | 11 +++- .../wrapper/src/intercept_frontend_message.rs | 15 ++++- desktop/wrapper/src/messages.rs | 6 +- .../export_dialog_message_handler.rs | 24 ++++++-- editor/src/messages/frontend/utility_types.rs | 17 ++++++ editor/src/node_graph_executor.rs | 59 ++++++++++++++++--- frontend/src/stores/portfolio.ts | 39 ++++++------ .../src/utility-functions/rasterization.ts | 25 ++++---- frontend/wrapper/src/editor_wrapper.rs | 5 +- 9 files changed, 150 insertions(+), 51 deletions(-) diff --git a/desktop/wrapper/src/handle_desktop_wrapper_message.rs b/desktop/wrapper/src/handle_desktop_wrapper_message.rs index d17fca6038..2edbc015a9 100644 --- a/desktop/wrapper/src/handle_desktop_wrapper_message.rs +++ b/desktop/wrapper/src/handle_desktop_wrapper_message.rs @@ -31,10 +31,15 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess SaveFileDialogContext::File { content } => { dispatcher.respond(DesktopFrontendMessage::WriteFile { path, content }); } - SaveFileDialogContext::MultipleFiles { files } => { - // Treat the chosen path as the folder name; strip any extension the user typed (e.g. "MyAnim.png" → "MyAnim"). + SaveFileDialogContext::MultipleFiles { files, expected_extension } => { + // Treat the chosen path as the folder name. Strip only the export's expected extension (e.g. ".png" for a + // PNG animation export) so that arbitrary dotted folder names like `v1.0` are preserved as-is, while a user + // who typed `MyAnim.png` still gets a `MyAnim/` folder rather than a `MyAnim.png/` folder. // The `WriteFile` handler creates parent directories if they don't exist, so the folder is materialized on first write. - let folder = path.with_extension(""); + let folder = match path.extension().and_then(|e| e.to_str()) { + Some(ext) if ext.eq_ignore_ascii_case(&expected_extension) => path.with_extension(""), + _ => path, + }; for (filename, content) in files { let file_path = folder.join(&filename); dispatcher.respond(DesktopFrontendMessage::WriteFile { path: file_path, content }); diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index d17e0bdc59..7ffaa8900c 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -71,10 +71,16 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD // Materialize each frame to bytes; SVG strings are encoded as UTF-8. // Raster-needs-canvas-rasterize frames can't be encoded here without a Rust SVG rasterizer, // so fall through to the frontend zip path in that case. + // TODO: This fallback is inconsistent with the desktop folder-save flow for the rest of the animation + // export — desktop users will see a .zip download instead of a folder. Once SVG rasterization moves to + // Rust (resvg), this fallback can be removed and all frame paths can save into the chosen folder. let mut needs_frontend_rasterization = false; let mut materialized = Vec::with_capacity(frames.len()); + // Dynamic zero-pad width so files keep sorting in playback order beyond 9,999 frames. + let pad_width = frames.len().to_string().len().max(4); + let safe_base = graphite_editor::messages::frontend::utility_types::sanitize_filename_component(&name); for (index, frame) in frames.iter().enumerate() { - let filename = format!("{name}_{:04}.{extension}", index + 1); + let filename = format!("{safe_base}_{:0pad$}.{extension}", index + 1, pad = pad_width); let bytes = match frame { ExportAnimationFrame::Svg(svg) if extension == "svg" => svg.as_bytes().to_vec(), ExportAnimationFrame::Bytes(bytes) => bytes.to_vec(), @@ -100,10 +106,13 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD // The dialog name is the folder the frames go into (analogous to the .zip on web). dispatcher.respond(DesktopFrontendMessage::SaveFileDialog { title: "Save Animation Frames Folder As".to_string(), - default_filename: name.clone(), + default_filename: safe_base, default_folder: folder, filters: Vec::new(), - context: SaveFileDialogContext::MultipleFiles { files: materialized }, + context: SaveFileDialogContext::MultipleFiles { + files: materialized, + expected_extension: extension, + }, }); } FrontendMessage::TriggerVisitLink { url } => { diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 2e3cb82a11..26da75d832 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -118,11 +118,13 @@ pub enum SaveFileDialogContext { File { content: Vec, }, - /// Multiple files written into a folder whose path is the user-chosen path with any extension stripped - /// (e.g. picking `MyAnim.png` yields a `MyAnim/` folder). Each `(filename, content)` entry is written + /// Multiple files written into a folder whose path is the user-chosen path with the matching `expected_extension` + /// stripped (e.g. for a PNG animation export, picking `MyAnim.png` yields a `MyAnim/` folder, while picking `v1.0` + /// is preserved as `v1.0/` because `.0` isn't the expected extension). Each `(filename, content)` entry is written /// inside that folder, which is created if it doesn't exist. MultipleFiles { files: Vec<(String, Vec)>, + expected_extension: String, }, } 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 bbe451b503..12f464a037 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 @@ -38,10 +38,21 @@ impl Default for ExportDialogMessageHandler { } } +/// Upper bound on how many frames a single animation export will queue. Each frame is rendered then held +/// in memory until the whole batch ships to the frontend, so we cap to avoid pathological allocations from +/// large time ranges. Tuned for typical animation lengths (10k frames is ~5.5 min at 30 fps, ~2.8 min at 60 fps). +pub const ANIMATION_EXPORT_MAX_FRAMES: u32 = 10_000; + impl ExportDialogMessageHandler { fn total_frames(&self) -> u32 { + // Sanitize against non-finite values that could otherwise propagate into `Duration::from_secs_f64` and panic. + if !self.fps.is_finite() || self.fps <= 0. || !self.start_seconds.is_finite() || !self.end_seconds.is_finite() { + return 1; + } let duration = (self.end_seconds - self.start_seconds).max(0.); - ((duration * self.fps).round() as i64).max(1) as u32 + // `ceil` so a duration slightly longer than a frame multiple still gets the trailing frame. + let raw = (duration * self.fps).ceil() as i64; + raw.clamp(1, ANIMATION_EXPORT_MAX_FRAMES as i64) as u32 } } @@ -55,14 +66,19 @@ impl MessageHandler> for Exp ExportDialogMessage::ScaleFactor { factor } => self.scale_factor = factor, ExportDialogMessage::ExportBounds { bounds } => self.bounds = bounds, ExportDialogMessage::Animated { animated } => self.animated = animated, - ExportDialogMessage::Fps { fps } => self.fps = fps.max(0.001), + ExportDialogMessage::Fps { fps } => { + // Reject non-finite/non-positive values so we never feed NaN or infinity into duration math downstream. + self.fps = if fps.is_finite() && fps > 0. { fps } else { 0.001 }; + } ExportDialogMessage::StartSeconds { start } => { - self.start_seconds = start.max(0.); + self.start_seconds = if start.is_finite() { start.max(0.) } else { 0. }; if self.end_seconds < self.start_seconds { self.end_seconds = self.start_seconds; } } - ExportDialogMessage::EndSeconds { end } => self.end_seconds = end.max(self.start_seconds), + ExportDialogMessage::EndSeconds { end } => { + self.end_seconds = if end.is_finite() { end.max(self.start_seconds) } else { self.start_seconds }; + } ExportDialogMessage::Submit => { // Fall back to "All Artwork" if "Selection" was chosen but nothing is currently selected diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index cbb28641b5..0bfb1ac391 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -111,6 +111,23 @@ pub enum ExportAnimationFrame { Bytes(serde_bytes::ByteBuf), } +/// Replace characters that have special meaning in filesystem paths (or are otherwise unsafe in filenames) with `_`. +/// Used to neutralize user-controlled strings — document names, artboard names — before they become export filenames, +/// so a name like `..\evil` or `foo/bar` can't escape the export folder or create nested zip entries. +pub fn sanitize_filename_component(name: &str) -> String { + let cleaned: String = name + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '<' | '>' | '|' | '"' | '\0' => '_', + c if c.is_control() => '_', + c => c, + }) + .collect(); + // Strip leading/trailing dots and whitespace; Windows treats trailing `.` / ` ` as hidden and `..` is the parent dir. + let trimmed = cleaned.trim().trim_matches('.').trim(); + if trimmed.is_empty() { "Untitled".to_string() } else { trimmed.to_string() } +} + #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))] #[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct EyedropperPreviewImage { diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 5b59425655..042f49b02b 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -303,7 +303,17 @@ impl NodeGraphExecutor { .map_err(|e| e.to_string())?; if let Some(animation) = export_config.animation { - // Allocate an export ID and accumulator, then queue one execution per frame + // Defense-in-depth: the dialog already validates these, but reject non-finite/non-positive values here + // too so a corrupt message can't reach `Duration::from_secs_f64` (which panics on NaN/negative/huge). + if !animation.fps.is_finite() || animation.fps <= 0. || !animation.start_seconds.is_finite() { + return Err("Animation export rejected: fps and start time must be finite, with fps > 0".to_string()); + } + + // Allocate an export ID and accumulator, then queue one execution per frame. + // TODO: All encoded frames are held in `pending_animation_exports` until the last frame arrives, so peak + // memory grows with `total_frames * encoded_frame_size`. For long/high-resolution animations, this could + // exhaust memory. A bounded cap is enforced upstream in the export dialog (ANIMATION_EXPORT_MAX_FRAMES), + // but a true fix would stream frames out incrementally instead of accumulating. let export_id = self.next_animation_export_id; self.next_animation_export_id = self.next_animation_export_id.wrapping_add(1); @@ -325,7 +335,10 @@ impl NodeGraphExecutor { for frame_index in 0..animation.total_frames { let frame_seconds = animation.frame_time_seconds(frame_index); - let animation_time = Duration::from_secs_f64(frame_seconds.max(0.)); + // `Duration::from_secs_f64` panics on negative/NaN/huge values; clamp defensively (we've already + // validated `fps`/`start_seconds` above, but a far-future `start_seconds` could still overflow). + let safe_seconds = if frame_seconds.is_finite() { frame_seconds.clamp(0., 1e9) } else { 0. }; + let animation_time = Duration::from_secs_f64(safe_seconds); let timing = TimingInformation { time: frame_seconds, animation_time }; let frame_render_config = RenderConfig { time: timing, ..base_render_config }; @@ -382,6 +395,24 @@ impl NodeGraphExecutor { document.network_interface.update_click_targets(HashMap::new()); document.network_interface.update_outlines(HashMap::new()); document.network_interface.update_vector_modify(HashMap::new()); + + // If this failure belongs to an animation export, drop its accumulator so the partially + // rendered frames don't stay pinned in memory. Subsequent failed frames for the same + // export are then silently ignored by `process_animation_frame`. + // TODO: An export can also leak if it's interrupted by something *outside* this error + // path — e.g. the document is closed mid-export. A proper fix would route a + // cancellation through `pending_animation_exports`. Tracked separately. + let leaked_export_id = self + .futures + .iter() + .find(|(fid, _)| *fid == execution_id) + .and_then(|(_, ctx)| ctx.export_config.as_ref()) + .and_then(|cfg| cfg.animation_frame) + .map(|af| af.export_id); + if let Some(export_id) = leaked_export_id { + self.pending_animation_exports.remove(&export_id); + } + return Err(format!("Node graph evaluation failed:\n{e}")); } }; @@ -609,11 +640,19 @@ impl NodeGraphExecutor { TaggedValue::RenderOutput(RenderOutput { data: RenderOutputType::Buffer { data, width, height }, .. - }) if file_type != FileType::Svg => { - let encoded = encode_raster_buffer(file_type, data, width, height)?; - ExportAnimationFrame::Bytes(serde_bytes::ByteBuf::from(encoded)) + }) if file_type != FileType::Svg => match encode_raster_buffer(file_type, data, width, height) { + Ok(encoded) => ExportAnimationFrame::Bytes(serde_bytes::ByteBuf::from(encoded)), + Err(err) => { + // Drop the partial accumulator so its already-received frames don't leak. + self.pending_animation_exports.remove(&animation_frame.export_id); + return Err(err); + } + }, + other => { + // Drop the partial accumulator so its already-received frames don't leak. + self.pending_animation_exports.remove(&animation_frame.export_id); + return Err(format!("Incorrect render type for animation frame ({file_type:?}, {other})")); } - other => return Err(format!("Incorrect render type for animation frame ({file_type:?}, {other})")), }; let Some(accumulator) = self.pending_animation_exports.get_mut(&animation_frame.export_id) else { @@ -635,9 +674,11 @@ impl NodeGraphExecutor { // All frames received: drain the accumulator and emit a single message let accumulator = self.pending_animation_exports.remove(&animation_frame.export_id).expect("Accumulator was present"); - let base_name = match (accumulator.artboard_name, accumulator.artboard_count) { - (Some(artboard_name), count) if count > 1 => format!("{} - {}", accumulator.name, artboard_name), - _ => accumulator.name, + // Sanitize before the name reaches filesystem joins or zip entry names downstream. + let safe_doc_name = crate::messages::frontend::utility_types::sanitize_filename_component(&accumulator.name); + let base_name = match (accumulator.artboard_name.as_deref(), accumulator.artboard_count) { + (Some(artboard_name), count) if count > 1 => format!("{safe_doc_name} - {}", crate::messages::frontend::utility_types::sanitize_filename_component(artboard_name)), + _ => safe_doc_name, }; let frames: Vec<_> = accumulator .frames diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 2d38a01a1c..afc96e8dce 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -140,28 +140,29 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: const backgroundColor = mime.endsWith("jpeg") ? "white" : undefined; const padWidth = Math.max(4, String(frames.length).length); - // Materialize each frame to bytes, rasterizing SVG via canvas when the destination format is raster + // Materialize each frame to bytes, rasterizing SVG via canvas when the destination format is raster. + // Any per-frame failure aborts the export rather than silently dropping frames, so the user never gets + // a zip with mismatched indices vs. the requested playback range. const entries: [string, Uint8Array][] = []; - for (let i = 0; i < frames.length; i++) { - const frame = frames[i]; - const filename = `${name}_${String(i + 1).padStart(padWidth, "0")}.${extension}`; - - let bytes: Uint8Array; - if ("Bytes" in frame) { - bytes = frame.Bytes; - } else if (isRaster) { - let blob: Blob; - try { - blob = await rasterizeSVG(frame.Svg, size[0], size[1], mime, backgroundColor); - } catch { - // Skip frames that fail to rasterize (e.g. zero-sized) rather than aborting the whole export - continue; + try { + for (let i = 0; i < frames.length; i++) { + const frame = frames[i]; + const filename = `${name}_${String(i + 1).padStart(padWidth, "0")}.${extension}`; + + let bytes: Uint8Array; + if ("Bytes" in frame) { + bytes = frame.Bytes; + } else if (isRaster) { + const blob = await rasterizeSVG(frame.Svg, size[0], size[1], mime, backgroundColor); + bytes = new Uint8Array(await blob.arrayBuffer()); + } else { + bytes = new TextEncoder().encode(frame.Svg); } - bytes = new Uint8Array(await blob.arrayBuffer()); - } else { - bytes = new TextEncoder().encode(frame.Svg); + entries.push([filename, bytes]); } - entries.push([filename, bytes]); + } catch (error) { + editor.errorDialog("Animation export failed", error instanceof Error ? error.message : String(error)); + return; } if (entries.length === 0) return; diff --git a/frontend/src/utility-functions/rasterization.ts b/frontend/src/utility-functions/rasterization.ts index 8a0807ac2d..4c96bd0f33 100644 --- a/frontend/src/utility-functions/rasterization.ts +++ b/frontend/src/utility-functions/rasterization.ts @@ -17,18 +17,23 @@ export async function rasterizeSVGCanvas(svg: string, width: number, height: num const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(svgBlob); - // Load the Image from the URL and wait until it's done + // Load the Image from the URL and wait until it's done. Reject on error so callers don't hang forever + // if the SVG fails to decode (malformed markup, zero-sized viewport with no width/height attrs, etc.). const image = new Image(); image.src = url; - await new Promise((resolve) => { - image.onload = () => resolve(); - }); - - // Draw our SVG to the canvas - context?.drawImage(image, 0, 0, width, height); - - // Clean up the SVG blob URL (once the URL is revoked, the SVG blob data itself is garbage collected after `svgBlob` goes out of scope) - URL.revokeObjectURL(url); + try { + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("Failed to decode SVG for rasterization")); + }); + + // Draw our SVG to the canvas + context?.drawImage(image, 0, 0, width, height); + } finally { + // Always clean up the SVG blob URL (once the URL is revoked, the SVG blob data itself is garbage + // collected after `svgBlob` goes out of scope), even if loading or drawing threw. + URL.revokeObjectURL(url); + } return canvas; } diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 9099d0869c..1904e1e38d 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -996,10 +996,13 @@ pub fn create_zip_from_files(entries: js_sys::Array) -> Result, JsValue> .ok_or_else(|| JsValue::from_str("createZipFromFiles: each entry must be a [filename, bytes] array"))?; let filename = pair.get(0).as_string().ok_or_else(|| JsValue::from_str("createZipFromFiles: filename must be a string"))?; + // Defense in depth: callers should already sanitize, but if a stray path separator leaks through, + // replace it here so the entry can't become a nested path inside the archive. + let safe_filename: String = filename.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect(); let bytes = js_sys::Uint8Array::new(&pair.get(1)).to_vec(); writer - .start_file(filename, options) + .start_file(safe_filename, options) .map_err(|e| JsValue::from_str(&format!("createZipFromFiles: start_file failed: {e}")))?; writer.write_all(&bytes).map_err(|e| JsValue::from_str(&format!("createZipFromFiles: write_all failed: {e}")))?; }