mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: save frame as image
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { usePreviewStore } from "@/stores/preview-store";
|
import { usePreviewStore } from "@/stores/preview-store";
|
||||||
|
|
||||||
export function PreviewContextMenu({
|
export function PreviewContextMenu({
|
||||||
@@ -14,6 +15,7 @@ export function PreviewContextMenu({
|
|||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
containerRef: React.RefObject<HTMLElement | null>;
|
containerRef: React.RefObject<HTMLElement | null>;
|
||||||
}) {
|
}) {
|
||||||
|
const editor = useEditor();
|
||||||
const { overlays, setOverlayVisibility } = usePreviewStore();
|
const { overlays, setOverlayVisibility } = usePreviewStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,6 +23,9 @@ export function PreviewContextMenu({
|
|||||||
<ContextMenuItem onClick={onToggleFullscreen} inset>
|
<ContextMenuItem onClick={onToggleFullscreen} inset>
|
||||||
Full screen
|
Full screen
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => editor.renderer.saveSnapshot()} inset>
|
||||||
|
Save snapshot
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuCheckboxItem
|
<ContextMenuCheckboxItem
|
||||||
checked={overlays.bookmarks}
|
checked={overlays.bookmarks}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||||
import type { ExportOptions, ExportResult } from "@/types/export";
|
import type { ExportOptions, ExportResult } from "@/types/export";
|
||||||
|
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||||
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||||
import { buildScene } from "@/services/renderer/scene-builder";
|
import { buildScene } from "@/services/renderer/scene-builder";
|
||||||
import { createTimelineAudioBuffer } from "@/lib/media/audio";
|
import { createTimelineAudioBuffer } from "@/lib/media/audio";
|
||||||
|
import { formatTimeCode, getLastFrameTime } from "@/lib/time";
|
||||||
|
import { downloadBlob } from "@/utils/browser";
|
||||||
|
|
||||||
export class RendererManager {
|
export class RendererManager {
|
||||||
private renderTree: RootNode | null = null;
|
private renderTree: RootNode | null = null;
|
||||||
@@ -20,6 +23,69 @@ export class RendererManager {
|
|||||||
return this.renderTree;
|
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({
|
async exportProject({
|
||||||
options,
|
options,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -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({
|
export function isTypableDOMElement({
|
||||||
element,
|
element,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
Reference in New Issue
Block a user