Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"))'] }
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
14 changes: 14 additions & 0 deletions desktop/wrapper/src/handle_desktop_wrapper_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess
SaveFileDialogContext::File { content } => {
dispatcher.respond(DesktopFrontendMessage::WriteFile { path, content });
}
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 = 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Sanitize generated frame names before joining them to the export folder.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At desktop/wrapper/src/handle_desktop_wrapper_message.rs, line 39:

<comment>Sanitize generated frame names before joining them to the export folder.</comment>

<file context>
@@ -31,6 +31,15 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess
+				// 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 });
+				}
</file context>
Suggested change
let file_path = folder.join(&filename);
let safe_filename = filename.replace('/', "_").replace('\\', "_");
let file_path = folder.join(&safe_filename);

dispatcher.respond(DesktopFrontendMessage::WriteFile { path: file_path, content });
}
}
},
DesktopWrapperMessage::OpenFile { path, content } => {
let message = PortfolioMessage::OpenFile { path, content };
Expand Down
56 changes: 56 additions & 0 deletions desktop/wrapper/src/intercept_frontend_message.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -59,6 +60,61 @@ 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.
// 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!("{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(),
ExportAnimationFrame::Svg(_) => {
needs_frontend_rasterization = true;
break;
}
};
materialized.push((filename, bytes));
}

if needs_frontend_rasterization {
return Some(FrontendMessage::TriggerExportAnimation {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Forwarding TriggerExportAnimation here sends SVG-backed raster exports back to the web ZIP downloader instead of the desktop folder-save flow, so desktop export behavior becomes inconsistent for that case.

(Based on your team's feedback about aligning behavior with comments and TODO expectations.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At desktop/wrapper/src/intercept_frontend_message.rs, line 90:

<comment>Forwarding `TriggerExportAnimation` here sends SVG-backed raster exports back to the web ZIP downloader instead of the desktop folder-save flow, so desktop export behavior becomes inconsistent for that case.

(Based on your team's feedback about aligning behavior with comments and TODO expectations.) </comment>

<file context>
@@ -59,6 +60,52 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
+			}
+
+			if needs_frontend_rasterization {
+				return Some(FrontendMessage::TriggerExportAnimation {
+					name,
+					extension,
</file context>

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: safe_base,
default_folder: folder,
filters: Vec::new(),
context: SaveFileDialogContext::MultipleFiles {
files: materialized,
expected_extension: extension,
},
});
}
FrontendMessage::TriggerVisitLink { url } => {
dispatcher.respond(DesktopFrontendMessage::OpenUrl(url));
}
Expand Down
17 changes: 15 additions & 2 deletions desktop/wrapper/src/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,21 @@ 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 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<u8>)>,
expected_extension: String,
},
}

pub enum MenuItem {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Original file line number Diff line number Diff line change
@@ -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::*;
Expand All @@ -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 {
Expand All @@ -26,10 +30,32 @@ impl Default for ExportDialogMessageHandler {
bounds: Default::default(),
artboards: Default::default(),
has_selection: false,
animated: false,
fps: 30.,
start_seconds: 0.,
end_seconds: 1.,
}
}
}

/// 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.);
// `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
}
}

#[message_handler_data]
impl MessageHandler<ExportDialogMessage, ExportDialogMessageContext<'_>> for ExportDialogMessageHandler {
fn process_message(&mut self, message: ExportDialogMessage, responses: &mut VecDeque<Message>, context: ExportDialogMessageContext) {
Expand All @@ -39,6 +65,20 @@ 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 } => {
// 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 = 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 = 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
Expand All @@ -52,13 +92,21 @@ 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(),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
});

responses.add_front(PortfolioMessage::SubmitDocumentExport {
name: portfolio.active_document().map(|document| document.name.clone()).unwrap_or_default(),
file_type: self.file_type,
scale_factor: self.scale_factor,
bounds,
artboard_name,
artboard_count: self.artboards.len(),
animation,
})
}
}
Expand Down Expand Up @@ -163,6 +211,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)
}
}
19 changes: 18 additions & 1 deletion editor/src/messages/frontend/frontend_message.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading