OpenCut/apps/web/src/core/managers/renderer-manager.ts

253 lines
6.4 KiB
TypeScript

import type { EditorCore } from "@/core";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import type { ExportOptions, ExportResult } from "@/lib/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 } from "opencut-wasm";
import { frameRateToFloat } from "@/lib/fps/utils";
import { downloadBlob } from "@/utils/browser";
type SnapshotResult =
| { success: true; blob: Blob; filename: string }
| { success: false; error: string };
export class RendererManager {
private renderTree: RootNode | null = null;
private _isDegraded = false;
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
get isDegraded(): boolean {
return this._isDegraded;
}
setDegraded(degraded: boolean): void {
if (this._isDegraded === degraded) return;
this._isDegraded = degraded;
this.notify();
}
setRenderTree({ renderTree }: { renderTree: RootNode | null }): void {
this.renderTree = renderTree;
this.notify();
}
getRenderTree(): RootNode | null {
return this.renderTree;
}
async saveSnapshot(): Promise<{ success: boolean; error?: string }> {
const snapshot = await this.createSnapshot();
if (!snapshot.success) {
return snapshot;
}
downloadBlob({ blob: snapshot.blob, filename: snapshot.filename });
return { success: true };
}
async copySnapshot(): Promise<{ success: boolean; error?: string }> {
if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) {
return {
success: false,
error: "Clipboard image copy is not supported in this browser",
};
}
const snapshot = await this.createSnapshot();
if (!snapshot.success) {
return snapshot;
}
try {
await navigator.clipboard.write([
new ClipboardItem({
[snapshot.blob.type || "image/png"]: snapshot.blob,
}),
]);
return { success: true };
} catch (error) {
console.error("Copy snapshot failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
private async createSnapshot(): Promise<SnapshotResult> {
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 renderTime = Math.min(
this.editor.playback.getCurrentTime(),
this.editor.timeline.getLastFrameTime(),
);
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({ time: renderTime, rate: fps })!.replace(/:/g, "-");
const safeName =
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
"snapshot";
const filename = `${safeName}-${timecode}.png`;
return { success: true, blob, filename };
} catch (error) {
console.error("Snapshot capture failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
async exportProject({
options,
onProgress,
onCancel,
}: {
options: ExportOptions;
onProgress?: ({ progress }: { progress: number }) => void;
onCancel?: () => boolean;
}): Promise<ExportResult> {
const { format, quality, fps, includeAudio } = options;
try {
const tracks = this.editor.scenes.getActiveScene().tracks;
const mediaAssets = this.editor.media.getAssets();
const activeProject = this.editor.project.getActive();
if (!activeProject) {
return { success: false, error: "No active project" };
}
const duration = this.editor.timeline.getTotalDuration();
if (duration === 0) {
return { success: false, error: "Project is empty" };
}
const exportFps = fps ?? activeProject.settings.fps;
const canvasSize = activeProject.settings.canvasSize;
let audioBuffer: AudioBuffer | null = null;
if (includeAudio) {
onProgress?.({ progress: 0.05 });
audioBuffer = await createTimelineAudioBuffer({
tracks,
mediaAssets,
duration,
});
}
const scene = buildScene({
tracks,
mediaAssets,
duration,
canvasSize,
background: activeProject.settings.background,
});
const exporter = new SceneExporter({
width: canvasSize.width,
height: canvasSize.height,
fps: exportFps,
format,
quality,
shouldIncludeAudio: !!includeAudio,
audioBuffer: audioBuffer || undefined,
});
exporter.on("progress", (progress) => {
const adjustedProgress = includeAudio
? 0.05 + progress * 0.95
: progress;
onProgress?.({ progress: adjustedProgress });
});
let cancelled = false;
const checkCancel = () => {
if (onCancel?.()) {
cancelled = true;
exporter.cancel();
}
};
const cancelInterval = setInterval(checkCancel, 100);
try {
const buffer = await exporter.export({ rootNode: scene });
clearInterval(cancelInterval);
if (cancelled) {
return { success: false, cancelled: true };
}
if (!buffer) {
return { success: false, error: "Export failed to produce buffer" };
}
return {
success: true,
buffer,
};
} finally {
clearInterval(cancelInterval);
}
} catch (error) {
console.error("Export failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown export error",
};
}
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => {
fn();
});
}
}