mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
Implement animation export
This commit is contained in:
19
Cargo.lock
generated
19
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"))'] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user