mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-23 18:08:12 +08:00
Implement animation export
This commit is contained in:
@@ -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