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 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 apps/web/src/lib/export.ts (limited to 'apps/web/src/lib/export.ts') 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}`; +} -- cgit v1.3.1 From 70a9bfebb196d5c0bde35ce0a165b696bb9ae1d0 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Thu, 28 Aug 2025 21:52:03 +0200 Subject: feat: add audio support for export --- apps/web/src/components/editor/export-button.tsx | 22 ++ apps/web/src/lib/export.ts | 443 +++++++++++++++-------- apps/web/src/types/export.ts | 35 +- 3 files changed, 336 insertions(+), 164 deletions(-) (limited to 'apps/web/src/lib/export.ts') diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index e24d20df..c9738e81 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -7,6 +7,7 @@ import { Button } from "../ui/button"; import { Label } from "../ui/label"; import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; import { Progress } from "../ui/progress"; +import { Checkbox } from "../ui/checkbox"; import { cn } from "@/lib/utils"; import { exportProject, @@ -75,6 +76,9 @@ function ExportPopover({ const [quality, setQuality] = useState( DEFAULT_EXPORT_OPTIONS.quality ); + const [includeAudio, setIncludeAudio] = useState( + DEFAULT_EXPORT_OPTIONS.includeAudio || true + ); const [isExporting, setIsExporting] = useState(false); const [progress, setProgress] = useState(0); const [exportResult, setExportResult] = useState(null); @@ -90,6 +94,7 @@ function ExportPopover({ format, quality, fps: activeProject.fps, + includeAudio, onProgress: setProgress, onCancel: () => false, // TODO: Add cancel functionality }); @@ -197,6 +202,23 @@ function ExportPopover({ + + +
+ setIncludeAudio(!!checked)} + /> + +
+