Implement animation export

This commit is contained in:
Keavon Chambers
2026-05-17 11:44:57 -04:00
parent 21d2994059
commit 1d3d32c169
18 changed files with 541 additions and 58 deletions

19
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"))'] }

View File

@@ -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);
}

View File

@@ -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 };

View File

@@ -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));
}

View File

@@ -111,8 +111,19 @@ pub enum OpenFileDialogContext {
}
pub enum SaveFileDialogContext {
Document { document_id: DocumentId, content: Vec<u8> },
File { content: Vec<u8> },
Document {
document_id: DocumentId,
content: Vec<u8>,
},
File {
content: Vec<u8>,
},
/// 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<u8>)>,
},
}
pub enum MenuItem {

View File

@@ -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,
}

View File

@@ -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<LayerNodeIdentifier, String>,
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<ExportDialogMessage, ExportDialogMessageContext<'_>> for ExportDialogMessageHandler {
fn process_message(&mut self, message: ExportDialogMessage, responses: &mut VecDeque<Message>, context: ExportDialogMessageContext) {
@@ -39,6 +54,15 @@ impl MessageHandler<ExportDialogMessage, ExportDialogMessageContext<'_>> 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<ExportDialogMessage, ExportDialogMessageContext<'_>> 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<ExportDialogMessage, ExportDialogMessageContext<'_>> 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)
}
}

View File

@@ -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<PathBuf>,
/// One entry per frame, in playback order. Each frame is either an SVG string or pre-encoded bytes.
frames: Vec<ExportAnimationFrame>,
},
TriggerFetchAndOpenDocument {
name: String,
filename: String,

View File

@@ -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 {

View File

@@ -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<String>,
artboard_count: usize,
animation: Option<AnimationExport>,
},
SubmitActiveGraphRender,
SubmitGraphRender {

View File

@@ -1441,6 +1441,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
bounds,
artboard_name,
artboard_count,
animation,
..Default::default()
};
let result = self.executor.submit_document_export(document, document_id, export_config);

View File

@@ -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<NodeId>,
/// 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<u64, AnimationExportAccumulator>,
next_animation_export_id: u64,
}
#[derive(Debug)]
struct AnimationExportAccumulator {
name: String,
file_type: FileType,
size: UVec2,
folder: Option<PathBuf>,
artboard_name: Option<String>,
artboard_count: usize,
frames: Vec<Option<ExportAnimationFrame>>,
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<Message>) -> Result<(), String> {
fn process_export(&mut self, node_graph_output: TaggedValue, export_config: ExportConfig, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) -> 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<Message>,
) -> 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::<Result<Vec<_>, _>>()?;
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<u8>, width: u32, height: u32) -> Result<Vec<u8>, 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

View File

@@ -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<String>,
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<AnimationExport>,
/// Set on each per-frame `ExportConfig` to identify which animation export it belongs to and which frame index it represents.
pub animation_frame: Option<AnimationExportFrame>,
}
#[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)]

View File

@@ -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);
}

View File

@@ -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<typeof createPortfolioStore>;
@@ -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");

View File

@@ -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

View File

@@ -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<Vec<u8>, JsValue> {
use std::io::{Cursor, Write};
use zip::write::{SimpleFileOptions, ZipWriter};
let mut buffer = Cursor::new(Vec::<u8>::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::<js_sys::Array>()
.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<f64> {
let value = math_parser::evaluate(expression)