summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-02-23 06:44:27 +0100
committerMaze Winther <mazewinther@gmail.com>2026-02-23 06:44:27 +0100
commit5616d4e9f35e20775791ada8d0a271620fc74925 (patch)
tree95f4f393ba5c694d361f998a01af9ffd745eb592 /apps/web/src
parent71970d5f5f5a2d439581785b1e3644ac2e55c1f5 (diff)
downloadopencut-5616d4e9f35e20775791ada8d0a271620fc74925.tar.gz
feat: save frame as image
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/components/editor/panels/preview/context-menu.tsx5
-rw-r--r--apps/web/src/core/managers/renderer-manager.ts66
-rw-r--r--apps/web/src/utils/browser.ts17
3 files changed, 88 insertions, 0 deletions
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<HTMLElement | null>;
}) {
+ const editor = useEditor();
const { overlays, setOverlayVisibility } = usePreviewStore();
return (
@@ -21,6 +23,9 @@ export function PreviewContextMenu({
<ContextMenuItem onClick={onToggleFullscreen} inset>
Full screen
</ContextMenuItem>
+ <ContextMenuItem onClick={() => editor.renderer.saveSnapshot()} inset>
+ Save snapshot
+ </ContextMenuItem>
<ContextMenuCheckboxItem
checked={overlays.bookmarks}
onCheckedChange={(checked) =>
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<Blob | null>((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,
}: {