Skip to content

Commit 29efb71

Browse files
committed
Implement animation export
1 parent d5f0140 commit 29efb71

18 files changed

Lines changed: 532 additions & 56 deletions

File tree

Cargo.lock

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ lzma-rust2 = { version = "0.16", default-features = false, features = ["std", "e
219219
scraper = "0.25"
220220
linesweeper = "0.3"
221221
smallvec = "1.13.2"
222+
zip = { version = "8", default-features = false }
222223

223224
[workspace.lints.rust]
224225
unexpected_cfgs = { level = "allow", check-cfg = ['cfg(target_arch, values("spirv"))'] }

desktop/src/app.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,12 @@ impl App {
249249
});
250250
}
251251
DesktopFrontendMessage::WriteFile { path, content } => {
252+
if let Some(parent) = path.parent()
253+
&& !parent.as_os_str().is_empty()
254+
&& let Err(e) = fs::create_dir_all(parent)
255+
{
256+
tracing::error!("Failed to create parent directory {}: {}", parent.display(), e);
257+
}
252258
if let Err(e) = fs::write(&path, content) {
253259
tracing::error!("Failed to write file {}: {}", path.display(), e);
254260
}

desktop/wrapper/src/handle_desktop_wrapper_message.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess
3131
SaveFileDialogContext::File { content } => {
3232
dispatcher.respond(DesktopFrontendMessage::WriteFile { path, content });
3333
}
34+
SaveFileDialogContext::MultipleFiles { files } => {
35+
// Treat the chosen path as the folder name; strip any extension the user typed (e.g. "MyAnim.png" → "MyAnim").
36+
// The `WriteFile` handler creates parent directories if they don't exist, so the folder is materialized on first write.
37+
let folder = path.with_extension("");
38+
for (filename, content) in files {
39+
let file_path = folder.join(&filename);
40+
dispatcher.respond(DesktopFrontendMessage::WriteFile { path: file_path, content });
41+
}
42+
}
3443
},
3544
DesktopWrapperMessage::OpenFile { path, content } => {
3645
let message = PortfolioMessage::OpenFile { path, content };

desktop/wrapper/src/intercept_frontend_message.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#[cfg(target_os = "macos")]
22
use graphite_editor::messages::layout::utility_types::layout_widget::LayoutTarget;
3+
use graphite_editor::messages::frontend::utility_types::ExportAnimationFrame;
34
use graphite_editor::messages::prelude::FrontendMessage;
45

56
use super::DesktopWrapperMessageDispatcher;
@@ -59,6 +60,52 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
5960
context: SaveFileDialogContext::File { content },
6061
});
6162
}
63+
FrontendMessage::TriggerExportAnimation {
64+
name,
65+
extension,
66+
mime,
67+
size,
68+
folder,
69+
frames,
70+
} => {
71+
// Materialize each frame to bytes; SVG strings are encoded as UTF-8.
72+
// Raster-needs-canvas-rasterize frames can't be encoded here without a Rust SVG rasterizer,
73+
// so fall through to the frontend zip path in that case.
74+
let mut needs_frontend_rasterization = false;
75+
let mut materialized = Vec::with_capacity(frames.len());
76+
for (index, frame) in frames.iter().enumerate() {
77+
let filename = format!("{name}_{:04}.{extension}", index + 1);
78+
let bytes = match frame {
79+
ExportAnimationFrame::Svg(svg) if extension == "svg" => svg.as_bytes().to_vec(),
80+
ExportAnimationFrame::Bytes(bytes) => bytes.to_vec(),
81+
ExportAnimationFrame::Svg(_) => {
82+
needs_frontend_rasterization = true;
83+
break;
84+
}
85+
};
86+
materialized.push((filename, bytes));
87+
}
88+
89+
if needs_frontend_rasterization {
90+
return Some(FrontendMessage::TriggerExportAnimation {
91+
name,
92+
extension,
93+
mime,
94+
size,
95+
folder,
96+
frames,
97+
});
98+
}
99+
100+
// The dialog name is the folder the frames go into (analogous to the .zip on web).
101+
dispatcher.respond(DesktopFrontendMessage::SaveFileDialog {
102+
title: "Save Animation Frames Folder As".to_string(),
103+
default_filename: name.clone(),
104+
default_folder: folder,
105+
filters: Vec::new(),
106+
context: SaveFileDialogContext::MultipleFiles { files: materialized },
107+
});
108+
}
62109
FrontendMessage::TriggerVisitLink { url } => {
63110
dispatcher.respond(DesktopFrontendMessage::OpenUrl(url));
64111
}

desktop/wrapper/src/messages.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ pub enum OpenFileDialogContext {
113113
pub enum SaveFileDialogContext {
114114
Document { document_id: DocumentId, content: Vec<u8> },
115115
File { content: Vec<u8> },
116+
/// Multiple files written into a folder whose path is the user-chosen path with any extension stripped
117+
/// (e.g. picking `MyAnim.png` yields a `MyAnim/` folder). Each `(filename, content)` entry is written
118+
/// inside that folder, which is created if it doesn't exist.
119+
MultipleFiles { files: Vec<(String, Vec<u8>)> },
116120
}
117121

118122
pub enum MenuItem {

editor/src/messages/dialog/export_dialog/export_dialog_message.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ pub enum ExportDialogMessage {
77
FileType { file_type: FileType },
88
ScaleFactor { factor: f64 },
99
ExportBounds { bounds: ExportBounds },
10+
Animated { animated: bool },
11+
Fps { fps: f64 },
12+
StartSeconds { start: f64 },
13+
EndSeconds { end: f64 },
1014

1115
Submit,
1216
}

editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::messages::frontend::utility_types::{ExportBounds, FileType};
1+
use crate::messages::frontend::utility_types::{AnimationExport, ExportBounds, FileType};
22
use crate::messages::layout::utility_types::widget_prelude::*;
33
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
44
use crate::messages::prelude::*;
@@ -16,6 +16,10 @@ pub struct ExportDialogMessageHandler {
1616
pub bounds: ExportBounds,
1717
pub artboards: HashMap<LayerNodeIdentifier, String>,
1818
pub has_selection: bool,
19+
pub animated: bool,
20+
pub fps: f64,
21+
pub start_seconds: f64,
22+
pub end_seconds: f64,
1923
}
2024

2125
impl Default for ExportDialogMessageHandler {
@@ -26,10 +30,21 @@ impl Default for ExportDialogMessageHandler {
2630
bounds: Default::default(),
2731
artboards: Default::default(),
2832
has_selection: false,
33+
animated: false,
34+
fps: 30.,
35+
start_seconds: 0.,
36+
end_seconds: 1.,
2937
}
3038
}
3139
}
3240

41+
impl ExportDialogMessageHandler {
42+
fn total_frames(&self) -> u32 {
43+
let duration = (self.end_seconds - self.start_seconds).max(0.);
44+
((duration * self.fps).round() as i64).max(1) as u32
45+
}
46+
}
47+
3348
#[message_handler_data]
3449
impl MessageHandler<ExportDialogMessage, ExportDialogMessageContext<'_>> for ExportDialogMessageHandler {
3550
fn process_message(&mut self, message: ExportDialogMessage, responses: &mut VecDeque<Message>, context: ExportDialogMessageContext) {
@@ -39,6 +54,15 @@ impl MessageHandler<ExportDialogMessage, ExportDialogMessageContext<'_>> for Exp
3954
ExportDialogMessage::FileType { file_type } => self.file_type = file_type,
4055
ExportDialogMessage::ScaleFactor { factor } => self.scale_factor = factor,
4156
ExportDialogMessage::ExportBounds { bounds } => self.bounds = bounds,
57+
ExportDialogMessage::Animated { animated } => self.animated = animated,
58+
ExportDialogMessage::Fps { fps } => self.fps = fps.max(0.001),
59+
ExportDialogMessage::StartSeconds { start } => {
60+
self.start_seconds = start.max(0.);
61+
if self.end_seconds < self.start_seconds {
62+
self.end_seconds = self.start_seconds;
63+
}
64+
}
65+
ExportDialogMessage::EndSeconds { end } => self.end_seconds = end.max(self.start_seconds),
4266

4367
ExportDialogMessage::Submit => {
4468
// Fall back to "All Artwork" if "Selection" was chosen but nothing is currently selected
@@ -52,13 +76,21 @@ impl MessageHandler<ExportDialogMessage, ExportDialogMessageContext<'_>> for Exp
5276
ExportBounds::Artboard(layer) => self.artboards.get(&layer).cloned(),
5377
_ => None,
5478
};
79+
80+
let animation = self.animated.then(|| AnimationExport {
81+
fps: self.fps,
82+
start_seconds: self.start_seconds,
83+
total_frames: self.total_frames(),
84+
});
85+
5586
responses.add_front(PortfolioMessage::SubmitDocumentExport {
5687
name: portfolio.active_document().map(|document| document.name.clone()).unwrap_or_default(),
5788
file_type: self.file_type,
5889
scale_factor: self.scale_factor,
5990
bounds,
6091
artboard_name,
6192
artboard_count: self.artboards.len(),
93+
animation,
6294
})
6395
}
6496
}
@@ -163,6 +195,74 @@ impl LayoutHolder for ExportDialogMessageHandler {
163195
DropdownInput::new(entries).selected_index(Some(index as u32)).widget_instance(),
164196
];
165197

166-
Layout(vec![LayoutGroup::row(export_type), LayoutGroup::row(resolution), LayoutGroup::row(export_area)])
198+
let animation_checkbox_id = CheckboxId::new();
199+
let animation_toggle = vec![
200+
TextLabel::new("Animation").table_align(true).min_width(100).for_checkbox(animation_checkbox_id).widget_instance(),
201+
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
202+
CheckboxInput::new(self.animated)
203+
.on_update(|checkbox_input: &CheckboxInput| ExportDialogMessage::Animated { animated: checkbox_input.checked }.into())
204+
.for_label(animation_checkbox_id)
205+
.widget_instance(),
206+
];
207+
208+
let mut layout_groups = vec![
209+
LayoutGroup::row(export_type),
210+
LayoutGroup::row(resolution),
211+
LayoutGroup::row(export_area),
212+
LayoutGroup::row(animation_toggle),
213+
];
214+
215+
if self.animated {
216+
let fps_row = vec![
217+
TextLabel::new("FPS").table_align(true).min_width(100).widget_instance(),
218+
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
219+
NumberInput::new(Some(self.fps))
220+
.unit(" fps")
221+
.min(0.001)
222+
.max(1000.)
223+
.increment_step(1.)
224+
.on_update(|number_input: &NumberInput| ExportDialogMessage::Fps { fps: number_input.value.unwrap() }.into())
225+
.min_width(200)
226+
.widget_instance(),
227+
];
228+
229+
let start_row = vec![
230+
TextLabel::new("Start").table_align(true).min_width(100).widget_instance(),
231+
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
232+
NumberInput::new(Some(self.start_seconds))
233+
.unit(" sec")
234+
.min(0.)
235+
.increment_step(0.1)
236+
.on_update(|number_input: &NumberInput| ExportDialogMessage::StartSeconds { start: number_input.value.unwrap() }.into())
237+
.min_width(200)
238+
.widget_instance(),
239+
];
240+
241+
let end_row = vec![
242+
TextLabel::new("End").table_align(true).min_width(100).widget_instance(),
243+
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
244+
NumberInput::new(Some(self.end_seconds))
245+
.unit(" sec")
246+
.min(self.start_seconds)
247+
.increment_step(0.1)
248+
.on_update(|number_input: &NumberInput| ExportDialogMessage::EndSeconds { end: number_input.value.unwrap() }.into())
249+
.min_width(200)
250+
.widget_instance(),
251+
];
252+
253+
let frame_count = self.total_frames();
254+
let frames_row = vec![
255+
TextLabel::new("Frames").table_align(true).min_width(100).widget_instance(),
256+
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
257+
TextLabel::new(format!("{frame_count} frame{}", if frame_count == 1 { "" } else { "s" })).widget_instance(),
258+
];
259+
260+
layout_groups.push(LayoutGroup::row(fps_row));
261+
layout_groups.push(LayoutGroup::row(start_row));
262+
layout_groups.push(LayoutGroup::row(end_row));
263+
layout_groups.push(LayoutGroup::row(frames_row));
264+
}
265+
266+
Layout(layout_groups)
167267
}
168268
}

editor/src/messages/frontend/frontend_message.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use super::IconName;
22
use super::utility_types::{MouseCursorIcon, PersistedState};
33
use crate::messages::app_window::app_window_message_handler::AppWindowPlatform;
4-
use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage};
4+
use crate::messages::frontend::utility_types::{DocumentInfo, ExportAnimationFrame, EyedropperPreviewImage};
55
use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
66
use crate::messages::layout::utility_types::widget_prelude::*;
77
use crate::messages::portfolio::document::node_graph::utility_types::{
@@ -107,6 +107,23 @@ pub enum FrontendMessage {
107107
mime: String,
108108
size: (f64, f64),
109109
},
110+
/// Export one or more animation frames as a bundle.
111+
/// On web, the frontend zips the frames into an uncompressed `.zip` and triggers a single download.
112+
/// On desktop, the wrapper intercepts this and writes each frame as a separate file to a user-chosen folder.
113+
TriggerExportAnimation {
114+
/// Base name without the index suffix or extension (e.g. "MyDocument" or "MyDocument - Artboard 1").
115+
name: String,
116+
/// File extension without leading dot ("png", "jpg", "svg").
117+
extension: String,
118+
/// MIME type matching the extension (e.g. "image/png").
119+
mime: String,
120+
/// Pixel size of each rasterized frame, used when the frontend needs to canvas-rasterize from SVG.
121+
size: (f64, f64),
122+
/// Document folder, used as a default location for the desktop save dialog.
123+
folder: Option<PathBuf>,
124+
/// One entry per frame, in playback order. Each frame is either an SVG string or pre-encoded bytes.
125+
frames: Vec<ExportAnimationFrame>,
126+
},
110127
TriggerFetchAndOpenDocument {
111128
name: String,
112129
filename: String,

editor/src/messages/frontend/utility_types.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ impl FileType {
5959
FileType::Svg => "image/svg+xml",
6060
}
6161
}
62+
63+
pub fn to_extension(self) -> &'static str {
64+
match self {
65+
FileType::Png => "png",
66+
FileType::Jpg => "jpg",
67+
FileType::Svg => "svg",
68+
}
69+
}
6270
}
6371

6472
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -70,6 +78,39 @@ pub enum ExportBounds {
7078
Artboard(LayerNodeIdentifier),
7179
}
7280

81+
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
82+
pub struct AnimationExport {
83+
pub fps: f64,
84+
pub start_seconds: f64,
85+
pub total_frames: u32,
86+
}
87+
88+
impl Default for AnimationExport {
89+
fn default() -> Self {
90+
Self {
91+
fps: 30.,
92+
start_seconds: 0.,
93+
total_frames: 30,
94+
}
95+
}
96+
}
97+
98+
impl AnimationExport {
99+
pub fn frame_time_seconds(&self, frame_index: u32) -> f64 {
100+
self.start_seconds + frame_index as f64 / self.fps
101+
}
102+
}
103+
104+
/// One frame of a multi-frame animation export, in playback order.
105+
/// `Svg` is text that the frontend can save directly (for SVG export) or rasterize via canvas (for PNG/JPG export).
106+
/// `Bytes` is already-encoded image bytes from the GPU export path, ready to write directly.
107+
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
108+
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
109+
pub enum ExportAnimationFrame {
110+
Svg(String),
111+
Bytes(serde_bytes::ByteBuf),
112+
}
113+
73114
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
74115
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
75116
pub struct EyedropperPreviewImage {

0 commit comments

Comments
 (0)