summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/export-utils.ts294
1 files changed, 0 insertions, 294 deletions
diff --git a/apps/web/src/lib/export-utils.ts b/apps/web/src/lib/export-utils.ts
deleted file mode 100644
index 8fae2bfb..00000000
--- a/apps/web/src/lib/export-utils.ts
+++ /dev/null
@@ -1,294 +0,0 @@
-import { FFmpeg } from "@ffmpeg/ffmpeg";
-import { initFFmpeg } from "./ffmpeg-utils";
-import { TimelineTrack, TimelineElement } from "@/types/timeline";
-import { MediaItem } from "@/stores/media-store";
-import { TProject } from "@/types/project";
-
-export type ExportOptions = {
- format: "mp4" | "webm" | "mov";
- quality: "low" | "medium" | "high";
- resolution: "720p" | "1080p" | "4k";
- fps: number;
-};
-
-export type ExportProgress = {
- phase: "preparing" | "processing" | "finalizing" | "complete";
- progress: number; // 0-100
- message: string;
-};
-
-export class VideoExporter {
- private ffmpeg: FFmpeg | null = null;
- private onProgress?: (progress: ExportProgress) => void;
- private progressCallback?: (data: { progress: number }) => void;
-
- constructor(onProgress?: (progress: ExportProgress) => void) {
- this.onProgress = onProgress;
- }
-
- private updateProgress(
- phase: ExportProgress["phase"],
- progress: number,
- message: string
- ) {
- if (this.onProgress) {
- this.onProgress({ phase, progress, message });
- }
- }
-
- async exportProject(
- project: TProject,
- tracks: TimelineTrack[],
- mediaItems: MediaItem[],
- options: ExportOptions
- ): Promise<Blob> {
- this.updateProgress("preparing", 0, "Initializing FFmpeg...");
-
- this.ffmpeg = await initFFmpeg();
-
- // Set up progress callback for FFmpeg
- this.progressCallback = ({ progress }) => {
- this.updateProgress("processing", progress * 100, "Rendering video...");
- };
- this.ffmpeg.on("progress", this.progressCallback);
-
- try {
- this.updateProgress("preparing", 10, "Analyzing timeline...");
-
- // Get timeline duration
- const duration = this.calculateTimelineDuration(tracks);
- if (duration === 0) {
- throw new Error("Timeline is empty - nothing to export");
- }
-
- this.updateProgress("preparing", 20, "Preparing media files...");
-
- // Process media tracks (video/image/audio)
- const mediaTracks = tracks.filter(
- (track) => track.type === "media" || track.type === "audio"
- );
-
- if (mediaTracks.length === 0) {
- throw new Error("No media tracks found to export");
- }
-
- this.updateProgress("preparing", 40, "Setting up export parameters...");
-
- // Get resolution settings
- const { width, height } = this.getResolutionDimensions(
- options.resolution
- );
-
- // Create a simple concatenation for now
- // In a full implementation, this would handle overlays, transitions, etc.
- const outputBlob = await this.renderSimpleTimeline(
- mediaTracks,
- mediaItems,
- duration,
- options,
- width,
- height
- );
-
- this.updateProgress("complete", 100, "Export complete!");
- return outputBlob;
- } catch (error) {
- console.error("Export failed:", error);
- throw error;
- }
- }
-
- private calculateTimelineDuration(tracks: TimelineTrack[]): number {
- let maxDuration = 0;
-
- for (const track of tracks) {
- for (const element of track.elements) {
- const elementEnd =
- element.startTime +
- element.duration -
- element.trimStart -
- element.trimEnd;
- maxDuration = Math.max(maxDuration, elementEnd);
- }
- }
-
- return maxDuration;
- }
-
- private getResolutionDimensions(resolution: string): {
- width: number;
- height: number;
- } {
- switch (resolution) {
- case "720p":
- return { width: 1280, height: 720 };
- case "1080p":
- return { width: 1920, height: 1080 };
- case "4k":
- return { width: 3840, height: 2160 };
- default:
- return { width: 1920, height: 1080 };
- }
- }
-
- private async renderSimpleTimeline(
- tracks: TimelineTrack[],
- mediaItems: MediaItem[],
- duration: number,
- options: ExportOptions,
- width: number,
- height: number
- ): Promise<Blob> {
- if (!this.ffmpeg) throw new Error("FFmpeg not initialized");
-
- // For now, let's implement a simple case: export the first video/image element
- // A full implementation would handle complex timeline rendering
-
- const firstMediaTrack = tracks.find((track) => track.type === "media");
- if (!firstMediaTrack || firstMediaTrack.elements.length === 0) {
- throw new Error("No media elements found to export");
- }
-
- const firstElement = firstMediaTrack.elements[0];
-
- // Type guard to ensure we have a media element
- if (firstElement.type !== "media") {
- throw new Error("First element is not a media element");
- }
-
- const mediaItem = mediaItems.find(
- (item) => item.id === firstElement.mediaId
- );
-
- if (!mediaItem || !mediaItem.file) {
- throw new Error("Media file not found for export");
- }
-
- this.updateProgress("processing", 10, "Processing media file...");
-
- const inputName = `input.${this.getFileExtension(mediaItem.file.name)}`;
- const outputName = `output.${options.format}`;
-
- // Write input file
- await this.ffmpeg.writeFile(
- inputName,
- new Uint8Array(await mediaItem.file.arrayBuffer())
- );
-
- // Build FFmpeg command based on media type and options
- const ffmpegArgs = this.buildFFmpegCommand(
- inputName,
- outputName,
- firstElement,
- options,
- width,
- height,
- duration
- );
-
- this.updateProgress("processing", 30, "Rendering video...");
-
- // Execute FFmpeg command
- await this.ffmpeg.exec(ffmpegArgs);
-
- this.updateProgress("finalizing", 90, "Finalizing export...");
-
- // Read output file
- const data = await this.ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: `video/${options.format}` });
-
- // Cleanup
- await this.ffmpeg.deleteFile(inputName);
- await this.ffmpeg.deleteFile(outputName);
-
- return blob;
- }
-
- private buildFFmpegCommand(
- inputName: string,
- outputName: string,
- element: TimelineElement,
- options: ExportOptions,
- width: number,
- height: number,
- totalDuration: number
- ): string[] {
- const args = ["-i", inputName];
-
- // Handle trimming
- if (element.trimStart > 0) {
- args.push("-ss", element.trimStart.toString());
- }
-
- // Set duration
- const elementDuration =
- element.duration - element.trimStart - element.trimEnd;
- args.push("-t", Math.min(elementDuration, totalDuration).toString());
-
- // Set resolution and scaling
- args.push(
- "-vf",
- `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`
- );
-
- // Set frame rate
- args.push("-r", options.fps.toString());
-
- // Set quality/bitrate based on options
- const { videoBitrate, audioBitrate } = this.getQualitySettings(
- options.quality
- );
- args.push("-b:v", videoBitrate, "-b:a", audioBitrate);
-
- // Set codec based on format
- if (options.format === "mp4") {
- args.push("-c:v", "libx264", "-c:a", "aac");
- } else if (options.format === "webm") {
- args.push("-c:v", "libvpx-vp9", "-c:a", "libopus");
- } else if (options.format === "mov") {
- args.push("-c:v", "libx264", "-c:a", "aac");
- }
-
- args.push(outputName);
- return args;
- }
-
- private getQualitySettings(quality: string): {
- videoBitrate: string;
- audioBitrate: string;
- } {
- switch (quality) {
- case "low":
- return { videoBitrate: "1M", audioBitrate: "128k" };
- case "medium":
- return { videoBitrate: "3M", audioBitrate: "192k" };
- case "high":
- return { videoBitrate: "8M", audioBitrate: "320k" };
- default:
- return { videoBitrate: "3M", audioBitrate: "192k" };
- }
- }
-
- private getFileExtension(filename: string): string {
- return filename.split(".").pop() || "mp4";
- }
-
- cleanup() {
- if (this.ffmpeg && this.progressCallback) {
- // Remove all progress listeners
- this.ffmpeg.off("progress", this.progressCallback);
- }
- }
-}
-
-// Utility function to download blob as file
-export function downloadBlob(blob: Blob, filename: string) {
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-}