From adc8cb37fcec21afac848b1e5dd724394c344050 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 27 Aug 2025 00:52:39 +0200 Subject: another huge refactor --- apps/web/src/lib/export.ts | 147 ++++++++++++++++++++++++++++++++++ apps/web/src/lib/timeline-renderer.ts | 8 +- 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/export.ts (limited to 'apps/web/src/lib') diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts new file mode 100644 index 00000000..69bc8d1d --- /dev/null +++ b/apps/web/src/lib/export.ts @@ -0,0 +1,147 @@ +import { + Output, + Mp4OutputFormat, + WebMOutputFormat, + BufferTarget, + CanvasSource, + QUALITY_LOW, + QUALITY_MEDIUM, + QUALITY_HIGH, + QUALITY_VERY_HIGH, +} from "mediabunny"; +import { renderTimelineFrame } from "./timeline-renderer"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { useProjectStore } from "@/stores/project-store"; +import { DEFAULT_FPS } from "@/stores/project-store"; +import { ExportOptions, ExportResult } from "@/types/export"; + +export const DEFAULT_EXPORT_OPTIONS: ExportOptions = { + format: "mp4", + quality: "high", +}; + +const qualityMap = { + low: QUALITY_LOW, + medium: QUALITY_MEDIUM, + high: QUALITY_HIGH, + very_high: QUALITY_VERY_HIGH, +}; + +export async function exportProject( + options: ExportOptions +): Promise { + const { format, quality, fps, onProgress, onCancel } = options; + + try { + const timelineStore = useTimelineStore.getState(); + const mediaStore = useMediaStore.getState(); + const projectStore = useProjectStore.getState(); + + const { tracks, getTotalDuration } = timelineStore; + const { mediaFiles } = mediaStore; + const { activeProject } = projectStore; + + if (!activeProject) { + return { success: false, error: "No active project" }; + } + + const duration = getTotalDuration(); + if (duration === 0) { + return { success: false, error: "Project is empty" }; + } + + const exportFps = fps || activeProject.fps || DEFAULT_FPS; + const canvasSize = activeProject.canvasSize; + + const outputFormat = + format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); + + // BufferTarget for smaller files, StreamTarget for larger ones + // TODO: Implement StreamTarget + const output = new Output({ + format: outputFormat, + target: new BufferTarget(), + }); + + // Canvas for rendering + const canvas = document.createElement("canvas"); + canvas.width = canvasSize.width; + canvas.height = canvasSize.height; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + return { success: false, error: "Failed to create canvas context" }; + } + + const videoSource = new CanvasSource(canvas, { + codec: format === "webm" ? "vp9" : "avc", // VP9 for WebM, H.264 for MP4 + bitrate: qualityMap[quality], + }); + + output.addVideoTrack(videoSource, { frameRate: exportFps }); + + // Start the output + await output.start(); + + const totalFrames = Math.ceil(duration * exportFps); + let cancelled = false; + + // Render each frame + for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) { + // Check for cancellation + if (onCancel?.()) { + cancelled = true; + break; + } + + const time = frameIndex / exportFps; + + await renderTimelineFrame({ + ctx, + time, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + tracks, + mediaFiles, + backgroundColor: + activeProject.backgroundType === "blur" + ? "transparent" + : activeProject.backgroundColor || "#000000", + projectCanvasSize: canvasSize, + }); + + const frameDuration = 1 / exportFps; + await videoSource.add(time, frameDuration); + + onProgress?.(frameIndex / totalFrames); + } + + if (cancelled) { + await output.cancel(); + return { success: false, cancelled: true }; + } + videoSource.close(); + await output.finalize(); + onProgress?.(1); + + return { + success: true, + buffer: output.target.buffer || undefined, + }; + } catch (error) { + console.error("Export failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown export error", + }; + } +} + +export function getExportMimeType(format: "mp4" | "webm"): string { + return format === "webm" ? "video/webm" : "video/mp4"; +} + +export function getExportFileExtension(format: "mp4" | "webm"): string { + return `.${format}`; +} diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 06825896..6241a7d9 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -10,6 +10,7 @@ export interface RenderContext { tracks: TimelineTrack[]; mediaFiles: MediaFile[]; backgroundColor?: string; + projectCanvasSize?: { width: number; height: number }; } export async function renderTimelineFrame({ @@ -20,6 +21,7 @@ export async function renderTimelineFrame({ tracks, mediaFiles, backgroundColor, + projectCanvasSize, }: RenderContext): Promise { // Background ctx.clearRect(0, 0, canvasWidth, canvasHeight); @@ -28,8 +30,10 @@ export async function renderTimelineFrame({ ctx.fillRect(0, 0, canvasWidth, canvasHeight); } - const scaleX = 1; - const scaleY = 1; + const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1; + const scaleY = projectCanvasSize + ? canvasHeight / projectCanvasSize.height + : 1; const idToMedia = new Map(mediaFiles.map((m) => [m.id, m] as const)); const active: Array<{ track: TimelineTrack; -- cgit v1.3.1