From 5616d4e9f35e20775791ada8d0a271620fc74925 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 23 Feb 2026 06:44:27 +0100 Subject: [PATCH] feat: save frame as image --- .../editor/panels/preview/context-menu.tsx | 5 ++ .../web/src/core/managers/renderer-manager.ts | 66 +++++++++++++++++++ apps/web/src/utils/browser.ts | 17 +++++ 3 files changed, 88 insertions(+) diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx index d5cf535a..d4bcdcae 100644 --- a/apps/web/src/components/editor/panels/preview/context-menu.tsx +++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx @@ -5,6 +5,7 @@ import { ContextMenuContent, ContextMenuItem, } from "@/components/ui/context-menu"; +import { useEditor } from "@/hooks/use-editor"; import { usePreviewStore } from "@/stores/preview-store"; export function PreviewContextMenu({ @@ -14,6 +15,7 @@ export function PreviewContextMenu({ onToggleFullscreen: () => void; containerRef: React.RefObject; }) { + const editor = useEditor(); const { overlays, setOverlayVisibility } = usePreviewStore(); return ( @@ -21,6 +23,9 @@ export function PreviewContextMenu({ Full screen + editor.renderer.saveSnapshot()} inset> + Save snapshot + diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts index 22f1ebb8..03463b2c 100644 --- a/apps/web/src/core/managers/renderer-manager.ts +++ b/apps/web/src/core/managers/renderer-manager.ts @@ -1,9 +1,12 @@ import type { EditorCore } from "@/core"; import type { RootNode } from "@/services/renderer/nodes/root-node"; import type { ExportOptions, ExportResult } from "@/types/export"; +import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import { SceneExporter } from "@/services/renderer/scene-exporter"; import { buildScene } from "@/services/renderer/scene-builder"; import { createTimelineAudioBuffer } from "@/lib/media/audio"; +import { formatTimeCode, getLastFrameTime } from "@/lib/time"; +import { downloadBlob } from "@/utils/browser"; export class RendererManager { private renderTree: RootNode | null = null; @@ -20,6 +23,69 @@ export class RendererManager { return this.renderTree; } + async saveSnapshot(): Promise<{ success: boolean; error?: string }> { + try { + const renderTree = this.getRenderTree(); + const activeProject = this.editor.project.getActive(); + + if (!renderTree || !activeProject) { + return { success: false, error: "No project or scene to capture" }; + } + + const duration = this.editor.timeline.getTotalDuration(); + if (duration === 0) { + return { success: false, error: "Project is empty" }; + } + + const { canvasSize, fps } = activeProject.settings; + const currentTime = this.editor.playback.getCurrentTime(); + const lastFrameTime = getLastFrameTime({ duration, fps }); + const renderTime = Math.min(currentTime, lastFrameTime); + + const renderer = new CanvasRenderer({ + width: canvasSize.width, + height: canvasSize.height, + fps, + }); + + const tempCanvas = document.createElement("canvas"); + tempCanvas.width = canvasSize.width; + tempCanvas.height = canvasSize.height; + + await renderer.renderToCanvas({ + node: renderTree, + time: renderTime, + targetCanvas: tempCanvas, + }); + + const blob = await new Promise((resolve) => { + tempCanvas.toBlob((result) => resolve(result), "image/png"); + }); + + if (!blob) { + return { success: false, error: "Failed to create image" }; + } + + const timecode = formatTimeCode({ + timeInSeconds: renderTime, + fps, + }).replace(/:/g, "-"); + const safeName = activeProject.metadata.name + .replace(/[<>:"/\\|?*]/g, "-") + .trim() || "snapshot"; + const filename = `${safeName}-${timecode}.png`; + + downloadBlob({ blob, filename }); + return { success: true }; + } catch (error) { + console.error("Save snapshot failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + async exportProject({ options, }: { diff --git a/apps/web/src/utils/browser.ts b/apps/web/src/utils/browser.ts index 2b9aa297..cfd3b31b 100644 --- a/apps/web/src/utils/browser.ts +++ b/apps/web/src/utils/browser.ts @@ -1,3 +1,20 @@ +export function downloadBlob({ + blob, + filename, +}: { + blob: Blob; + filename: string; +}): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} + export function isTypableDOMElement({ element, }: {