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 "@/lib/time"; 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 listeners = new Set<() => void>(); constructor(private editor: EditorCore) {} 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 { 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 = this.editor.playback.getCurrentTime(); 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`; 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 { const { format, quality, fps, includeAudio } = options; try { const tracks = this.editor.timeline.getTracks(); 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(); }); } }