Implement animation export

This commit is contained in:
Keavon Chambers
2026-05-17 15:29:09 -04:00
parent 21d2994059
commit 1d3d32c169
18 changed files with 541 additions and 58 deletions
@@ -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);
}
+43
View File
@@ -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");
+1
View File
@@ -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
+34
View File
@@ -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)