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

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)]