mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-19 19:08:05 +08:00
Implement animation export
This commit is contained in:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user