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.ts296
-rw-r--r--apps/web/src/lib/media-processing.ts30
-rw-r--r--apps/web/src/lib/mediabunny-utils.ts (renamed from apps/web/src/lib/ffmpeg-utils.ts)303
-rw-r--r--apps/web/src/lib/storage/storage-service.ts18
-rw-r--r--apps/web/src/lib/timeline-renderer.ts163
-rw-r--r--apps/web/src/lib/utils.ts4
6 files changed, 554 insertions, 260 deletions
diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts
new file mode 100644
index 00000000..f25f5f6b
--- /dev/null
+++ b/apps/web/src/lib/export.ts
@@ -0,0 +1,296 @@
+import {
+ Output,
+ Mp4OutputFormat,
+ WebMOutputFormat,
+ BufferTarget,
+ CanvasSource,
+ AudioBufferSource,
+ 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",
+ includeAudio: true,
+};
+
+const qualityMap = {
+ low: QUALITY_LOW,
+ medium: QUALITY_MEDIUM,
+ high: QUALITY_HIGH,
+ very_high: QUALITY_VERY_HIGH,
+};
+
+interface AudioElement {
+ buffer: AudioBuffer;
+ startTime: number;
+ duration: number;
+ trimStart: number;
+ trimEnd: number;
+ muted: boolean;
+}
+
+async function createTimelineAudioBuffer(
+ tracks: any[],
+ mediaFiles: any[],
+ duration: number,
+ sampleRate: number = 44100
+): Promise<AudioBuffer | null> {
+ // Get Web Audio context
+ const audioContext = new (window.AudioContext ||
+ (window as any).webkitAudioContext)();
+
+ // Collect all audio elements from timeline
+ const audioElements: AudioElement[] = [];
+ const mediaMap = new Map(mediaFiles.map((m) => [m.id, m]));
+
+ for (const track of tracks) {
+ if (track.muted) continue;
+
+ for (const element of track.elements) {
+ if (element.type !== "media") continue;
+
+ const mediaItem = mediaMap.get(element.mediaId);
+ if (!mediaItem || mediaItem.type !== "audio") continue;
+
+ const visibleDuration =
+ element.duration - element.trimStart - element.trimEnd;
+ if (visibleDuration <= 0) continue;
+
+ try {
+ // Decode audio file
+ const arrayBuffer = await mediaItem.file.arrayBuffer();
+ const audioBuffer = await audioContext.decodeAudioData(
+ arrayBuffer.slice(0)
+ );
+
+ audioElements.push({
+ buffer: audioBuffer,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ muted: element.muted || track.muted || false,
+ });
+ } catch (error) {
+ console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
+ }
+ }
+ }
+
+ if (audioElements.length === 0) {
+ return null; // No audio to mix
+ }
+
+ // Create output buffer
+ const outputChannels = 2; // Stereo
+ const outputLength = Math.ceil(duration * sampleRate);
+ const outputBuffer = audioContext.createBuffer(
+ outputChannels,
+ outputLength,
+ sampleRate
+ );
+
+ // Mix all audio elements
+ for (const element of audioElements) {
+ if (element.muted) continue;
+
+ const {
+ buffer,
+ startTime,
+ trimStart,
+ trimEnd,
+ duration: elementDuration,
+ } = element;
+
+ // Calculate timing
+ const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
+ const sourceDuration = elementDuration - trimStart - trimEnd;
+ const sourceLengthSamples = Math.floor(sourceDuration * buffer.sampleRate);
+ const outputStartSample = Math.floor(startTime * sampleRate);
+
+ // Resample if needed (simple approach)
+ const resampleRatio = sampleRate / buffer.sampleRate;
+ const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
+
+ // Mix each channel
+ for (let channel = 0; channel < outputChannels; channel++) {
+ const outputData = outputBuffer.getChannelData(channel);
+ const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
+ const sourceData = buffer.getChannelData(sourceChannel);
+
+ for (let i = 0; i < resampledLength; i++) {
+ const outputIndex = outputStartSample + i;
+ if (outputIndex >= outputLength) break;
+
+ // Simple resampling (could be improved with proper interpolation)
+ const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
+ if (sourceIndex >= sourceData.length) break;
+
+ outputData[outputIndex] += sourceData[sourceIndex];
+ }
+ }
+ }
+
+ return outputBuffer;
+}
+
+export async function exportProject(
+ options: ExportOptions
+): Promise<ExportResult> {
+ const { format, quality, fps, includeAudio, 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 });
+
+ // Add audio track if requested (but don't add data yet)
+ let audioSource: AudioBufferSource | null = null;
+ let audioBuffer: AudioBuffer | null = null;
+
+ if (includeAudio) {
+ onProgress?.(0.05); // 5% for audio processing
+
+ audioBuffer = await createTimelineAudioBuffer(
+ tracks,
+ mediaFiles,
+ duration
+ );
+
+ if (audioBuffer) {
+ audioSource = new AudioBufferSource({
+ codec: format === "webm" ? "opus" : "aac", // Opus for WebM, AAC for MP4
+ bitrate: qualityMap[quality], // Use same quality for audio
+ });
+
+ output.addAudioTrack(audioSource);
+ }
+ }
+
+ // Start the output (after all tracks are added)
+ await output.start();
+
+ // Now add audio data after starting
+ if (audioSource && audioBuffer) {
+ await audioSource.add(audioBuffer);
+ audioSource.close();
+ }
+
+ 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);
+
+ // Adjust progress to account for audio processing (5% at start)
+ const videoProgress = includeAudio
+ ? 0.05 + (frameIndex / totalFrames) * 0.95
+ : frameIndex / totalFrames;
+ onProgress?.(videoProgress);
+ }
+
+ 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/media-processing.ts b/apps/web/src/lib/media-processing.ts
index 353c3113..7a31466f 100644
--- a/apps/web/src/lib/media-processing.ts
+++ b/apps/web/src/lib/media-processing.ts
@@ -1,14 +1,13 @@
import { toast } from "sonner";
import {
getFileType,
- generateVideoThumbnail,
getMediaDuration,
getImageDimensions,
- type MediaItem,
} from "@/stores/media-store";
-import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils";
+import { MediaFile } from "@/types/media";
+import { generateThumbnail, getVideoInfo } from "./mediabunny-utils";
-export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
+export interface ProcessedMediaItem extends Omit<MediaFile, "id"> {}
export async function processMediaFiles(
files: FileList | File[],
@@ -37,33 +36,23 @@ export async function processMediaFiles(
try {
if (fileType === "image") {
- // Get image dimensions
const dimensions = await getImageDimensions(file);
width = dimensions.width;
height = dimensions.height;
} else if (fileType === "video") {
try {
- // Use FFmpeg for comprehensive video info extraction
- const videoInfo = await getVideoInfo(file);
+ const videoInfo = await getVideoInfo({ videoFile: file });
duration = videoInfo.duration;
width = videoInfo.width;
height = videoInfo.height;
fps = videoInfo.fps;
- // Generate thumbnail using FFmpeg
- thumbnailUrl = await generateThumbnail(file, 1);
+ thumbnailUrl = await generateThumbnail({
+ videoFile: file,
+ timeInSeconds: 1,
+ });
} catch (error) {
- console.warn(
- "FFmpeg processing failed, falling back to basic processing:",
- error
- );
- // Fallback to basic processing
- const videoResult = await generateVideoThumbnail(file);
- thumbnailUrl = videoResult.thumbnailUrl;
- width = videoResult.width;
- height = videoResult.height;
- duration = await getMediaDuration(file);
- // FPS will remain undefined for fallback
+ console.warn("Video processing failed", error);
}
} else if (fileType === "audio") {
// For audio, we don't set width/height/fps (they'll be undefined)
@@ -82,7 +71,6 @@ export async function processMediaFiles(
fps,
});
- // Yield back to the event loop to keep the UI responsive
await new Promise((resolve) => setTimeout(resolve, 0));
completed += 1;
diff --git a/apps/web/src/lib/ffmpeg-utils.ts b/apps/web/src/lib/mediabunny-utils.ts
index 0643ecb9..fcb3da0f 100644
--- a/apps/web/src/lib/ffmpeg-utils.ts
+++ b/apps/web/src/lib/mediabunny-utils.ts
@@ -1,7 +1,7 @@
import { FFmpeg } from "@ffmpeg/ffmpeg";
-import { toBlobURL } from "@ffmpeg/util";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
+import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
let ffmpeg: FFmpeg | null = null;
@@ -14,256 +14,99 @@ export const initFFmpeg = async (): Promise<FFmpeg> => {
return ffmpeg;
};
-export const generateThumbnail = async (
- videoFile: File,
- timeInSeconds = 1
-): Promise<string> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
- const outputName = "thumbnail.jpg";
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- // Generate thumbnail at specific time
- await ffmpeg.exec([
- "-i",
- inputName,
- "-ss",
- timeInSeconds.toString(),
- "-vframes",
- "1",
- "-vf",
- "scale=320:240",
- "-q:v",
- "2",
- outputName,
- ]);
+export async function generateThumbnail({
+ videoFile,
+ timeInSeconds,
+}: {
+ videoFile: File;
+ timeInSeconds: number;
+}): Promise<string> {
+ const input = new Input({
+ source: new BlobSource(videoFile),
+ formats: ALL_FORMATS,
+ });
- // Read output file
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "image/jpeg" });
-
- // Cleanup
- await ffmpeg.deleteFile(inputName);
- await ffmpeg.deleteFile(outputName);
+ const videoTrack = await input.getPrimaryVideoTrack();
+ if (!videoTrack) {
+ throw new Error("No video track found in the file");
+ }
- return URL.createObjectURL(blob);
-};
+ // Check if we can decode this video
+ const canDecode = await videoTrack.canDecode();
+ if (!canDecode) {
+ throw new Error("Video codec not supported for decoding");
+ }
-export const trimVideo = async (
- videoFile: File,
- startTime: number,
- endTime: number,
- onProgress?: (progress: number) => void
-): Promise<Blob> => {
- const ffmpeg = await initFFmpeg();
+ const sink = new VideoSampleSink(videoTrack);
- const inputName = "input.mp4";
- const outputName = "output.mp4";
+ const frame = await sink.getSample(timeInSeconds);
- // Set up progress callback
- if (onProgress) {
- ffmpeg.on("progress", ({ progress }) => {
- onProgress(progress * 100);
- });
+ if (!frame) {
+ throw new Error("Could not get frame at specified time");
}
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- const duration = endTime - startTime;
+ const canvas = document.createElement("canvas");
+ canvas.width = 320;
+ canvas.height = 240;
+ const ctx = canvas.getContext("2d");
- // Trim video
- await ffmpeg.exec([
- "-i",
- inputName,
- "-ss",
- startTime.toString(),
- "-t",
- duration.toString(),
- "-c",
- "copy", // Use stream copy for faster processing
- outputName,
- ]);
-
- // Read output file
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "video/mp4" });
+ if (!ctx) {
+ throw new Error("Could not get canvas context");
+ }
- // Cleanup
- await ffmpeg.deleteFile(inputName);
- await ffmpeg.deleteFile(outputName);
+ frame.draw(ctx, 0, 0, 320, 240);
- return blob;
-};
+ return new Promise((resolve, reject) => {
+ canvas.toBlob(
+ (blob) => {
+ if (blob) {
+ resolve(URL.createObjectURL(blob));
+ } else {
+ reject(new Error("Failed to create thumbnail blob"));
+ }
+ },
+ "image/jpeg",
+ 0.8
+ );
+ });
+}
-export const getVideoInfo = async (
- videoFile: File
-): Promise<{
+export async function getVideoInfo({
+ videoFile,
+}: {
+ videoFile: File;
+}): Promise<{
duration: number;
width: number;
height: number;
fps: number;
-}> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- // Capture FFmpeg stderr output with a one-time listener pattern
- let ffmpegOutput = "";
- let listening = true;
- const listener = (data: string) => {
- if (listening) ffmpegOutput += data;
- };
- ffmpeg.on("log", ({ message }) => listener(message));
-
- // Run ffmpeg to get info (stderr will contain the info)
- try {
- await ffmpeg.exec(["-i", inputName, "-f", "null", "-"]);
- } catch (error) {
- listening = false;
- await ffmpeg.deleteFile(inputName);
- console.error("FFmpeg execution failed:", error);
- throw new Error(
- "Failed to extract video info. The file may be corrupted or in an unsupported format."
- );
- }
+}> {
+ const input = new Input({
+ source: new BlobSource(videoFile),
+ formats: ALL_FORMATS,
+ });
- // Disable listener after exec completes
- listening = false;
+ const duration = await input.computeDuration();
+ const videoTrack = await input.getPrimaryVideoTrack();
- // Cleanup
- await ffmpeg.deleteFile(inputName);
-
- // Parse output for duration, resolution, and fps
- // Example: Duration: 00:00:10.00, start: 0.000000, bitrate: 1234 kb/s
- // Example: Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 30 fps, 30 tbr, 90k tbn, 60 tbc
-
- const durationMatch = ffmpegOutput.match(/Duration: (\d+):(\d+):([\d.]+)/);
- let duration = 0;
- if (durationMatch) {
- const [, h, m, s] = durationMatch;
- duration = parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
+ if (!videoTrack) {
+ throw new Error("No video track found in the file");
}
- const videoStreamMatch = ffmpegOutput.match(
- /Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/
- );
- let width = 0,
- height = 0,
- fps = 0;
- if (videoStreamMatch) {
- width = parseInt(videoStreamMatch[1]);
- height = parseInt(videoStreamMatch[2]);
- fps = parseFloat(videoStreamMatch[3]);
- }
+ // Get frame rate from packet statistics
+ const packetStats = await videoTrack.computePacketStats(100);
+ const fps = packetStats.averagePacketRate;
return {
duration,
- width,
- height,
+ width: videoTrack.displayWidth,
+ height: videoTrack.displayHeight,
fps,
};
-};
-
-export const convertToWebM = async (
- videoFile: File,
- onProgress?: (progress: number) => void
-): Promise<Blob> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
- const outputName = "output.webm";
-
- // Set up progress callback
- if (onProgress) {
- ffmpeg.on("progress", ({ progress }) => {
- onProgress(progress * 100);
- });
- }
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- // Convert to WebM
- await ffmpeg.exec([
- "-i",
- inputName,
- "-c:v",
- "libvpx-vp9",
- "-crf",
- "30",
- "-b:v",
- "0",
- "-c:a",
- "libopus",
- outputName,
- ]);
-
- // Read output file
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "video/webm" });
-
- // Cleanup
- await ffmpeg.deleteFile(inputName);
- await ffmpeg.deleteFile(outputName);
-
- return blob;
-};
-
-export const extractAudio = async (
- videoFile: File,
- format: "mp3" | "wav" = "mp3"
-): Promise<Blob> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
- const outputName = `output.${format}`;
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- // Extract audio
- await ffmpeg.exec([
- "-i",
- inputName,
- "-vn", // Disable video
- "-acodec",
- format === "mp3" ? "libmp3lame" : "pcm_s16le",
- outputName,
- ]);
-
- // Read output file
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: `audio/${format}` });
-
- // Cleanup
- await ffmpeg.deleteFile(inputName);
- await ffmpeg.deleteFile(outputName);
-
- return blob;
-};
+}
+// Audio mixing for timeline - keeping FFmpeg for now due to complexity
+// TODO: Replace with Mediabunny audio processing when implementing canvas preview
export const extractTimelineAudio = async (
onProgress?: (progress: number) => void
): Promise<Blob> => {
@@ -308,14 +151,14 @@ export const extractTimelineAudio = async (
for (const element of track.elements) {
if (element.type === "media") {
- const mediaItem = mediaStore.mediaItems.find(
+ const mediaFile = mediaStore.mediaFiles.find(
(m) => m.id === element.mediaId
);
- if (!mediaItem) continue;
+ if (!mediaFile) continue;
- if (mediaItem.type === "video" || mediaItem.type === "audio") {
+ if (mediaFile.type === "video" || mediaFile.type === "audio") {
audioElements.push({
- file: mediaItem.file,
+ file: mediaFile.file,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
index b3376a5f..3aadf883 100644
--- a/apps/web/src/lib/storage/storage-service.ts
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -1,5 +1,5 @@
import { TProject } from "@/types/project";
-import { MediaItem } from "@/stores/media-store";
+import { MediaFile } from "@/types/media";
import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter";
import {
@@ -124,8 +124,8 @@ class StorageService {
await this.projectsAdapter.remove(id);
}
- // Media operations - now project-specific
- async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> {
+ // Media operations
+ async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
@@ -148,10 +148,10 @@ class StorageService {
await mediaMetadataAdapter.set(mediaItem.id, metadata);
}
- async loadMediaItem(
+ async loadMediaFile(
projectId: string,
id: string
- ): Promise<MediaItem | null> {
+ ): Promise<MediaFile | null> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
@@ -192,14 +192,14 @@ class StorageService {
};
}
- async loadAllMediaItems(projectId: string): Promise<MediaItem[]> {
+ async loadAllMediaFiles(projectId: string): Promise<MediaFile[]> {
const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
const mediaIds = await mediaMetadataAdapter.list();
- const mediaItems: MediaItem[] = [];
+ const mediaItems: MediaFile[] = [];
for (const id of mediaIds) {
- const item = await this.loadMediaItem(projectId, id);
+ const item = await this.loadMediaFile(projectId, id);
if (item) {
mediaItems.push(item);
}
@@ -208,7 +208,7 @@ class StorageService {
return mediaItems;
}
- async deleteMediaItem(projectId: string, id: string): Promise<void> {
+ async deleteMediaFile(projectId: string, id: string): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts
new file mode 100644
index 00000000..6241a7d9
--- /dev/null
+++ b/apps/web/src/lib/timeline-renderer.ts
@@ -0,0 +1,163 @@
+import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
+import type { TimelineTrack } from "@/types/timeline";
+import type { MediaFile } from "@/types/media";
+
+export interface RenderContext {
+ ctx: CanvasRenderingContext2D;
+ time: number;
+ canvasWidth: number;
+ canvasHeight: number;
+ tracks: TimelineTrack[];
+ mediaFiles: MediaFile[];
+ backgroundColor?: string;
+ projectCanvasSize?: { width: number; height: number };
+}
+
+export async function renderTimelineFrame({
+ ctx,
+ time,
+ canvasWidth,
+ canvasHeight,
+ tracks,
+ mediaFiles,
+ backgroundColor,
+ projectCanvasSize,
+}: RenderContext): Promise<void> {
+ // Background
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight);
+ if (backgroundColor && backgroundColor !== "transparent") {
+ ctx.fillStyle = backgroundColor;
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight);
+ }
+
+ 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;
+ element: TimelineTrack["elements"][number];
+ mediaItem: MediaFile | null;
+ }> = [];
+
+ for (let t = tracks.length - 1; t >= 0; t -= 1) {
+ const track = tracks[t];
+ for (const element of track.elements) {
+ if ((element as any).hidden) continue;
+ const elementStart = element.startTime;
+ const elementEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ if (time >= elementStart && time < elementEnd) {
+ let mediaItem: MediaFile | null = null;
+ if (element.type === "media") {
+ mediaItem =
+ element.mediaId === "test"
+ ? null
+ : idToMedia.get(element.mediaId) || null;
+ }
+ active.push({ track, element, mediaItem });
+ }
+ }
+ }
+
+ for (const { element, mediaItem } of active) {
+ if (element.type === "media" && mediaItem) {
+ if (mediaItem.type === "video") {
+ const input = new Input({
+ source: new BlobSource(mediaItem.file),
+ formats: ALL_FORMATS,
+ });
+ const track = await input.getPrimaryVideoTrack();
+ if (!track) continue;
+ const decodable = await track.canDecode();
+ if (!decodable) continue;
+ const sink = new VideoSampleSink(track);
+
+ const localTime = time - element.startTime + element.trimStart;
+ const sample = await sink.getSample(localTime);
+ if (!sample) continue;
+
+ const mediaW = Math.max(1, mediaItem.width || canvasWidth);
+ const mediaH = Math.max(1, mediaItem.height || canvasHeight);
+ const containScale = Math.min(
+ canvasWidth / mediaW,
+ canvasHeight / mediaH
+ );
+ const drawW = mediaW * containScale;
+ const drawH = mediaH * containScale;
+ const drawX = (canvasWidth - drawW) / 2;
+ const drawY = (canvasHeight - drawH) / 2;
+ sample.draw(ctx, drawX, drawY, drawW, drawH);
+ }
+ if (mediaItem.type === "image") {
+ const img = new Image();
+ await new Promise<void>((resolve, reject) => {
+ img.onload = () => resolve();
+ img.onerror = () => reject(new Error("Image load failed"));
+ img.src = mediaItem.url || URL.createObjectURL(mediaItem.file);
+ });
+ const mediaW = Math.max(
+ 1,
+ mediaItem.width || img.naturalWidth || canvasWidth
+ );
+ const mediaH = Math.max(
+ 1,
+ mediaItem.height || img.naturalHeight || canvasHeight
+ );
+ const containScale = Math.min(
+ canvasWidth / mediaW,
+ canvasHeight / mediaH
+ );
+ const drawW = mediaW * containScale;
+ const drawH = mediaH * containScale;
+ const drawX = (canvasWidth - drawW) / 2;
+ const drawY = (canvasHeight - drawH) / 2;
+ ctx.drawImage(img, drawX, drawY, drawW, drawH);
+ }
+ }
+ if (element.type === "text") {
+ const posX = canvasWidth / 2 + (element as any).x * scaleX;
+ const posY = canvasHeight / 2 + (element as any).y * scaleY;
+ ctx.save();
+ ctx.translate(posX, posY);
+ ctx.rotate(((element as any).rotation * Math.PI) / 180);
+ ctx.globalAlpha = Math.max(0, Math.min(1, (element as any).opacity));
+ const px = (element as any).fontSize * scaleX;
+ const weight = (element as any).fontWeight === "bold" ? "bold " : "";
+ const style = (element as any).fontStyle === "italic" ? "italic " : "";
+ ctx.font = `${style}${weight}${px}px ${(element as any).fontFamily}`;
+ ctx.fillStyle = (element as any).color;
+ ctx.textAlign = (element as any).textAlign;
+ ctx.textBaseline = "middle";
+ const metrics = ctx.measureText((element as any).content);
+ const ascent =
+ (metrics as unknown as { actualBoundingBoxAscent?: number })
+ .actualBoundingBoxAscent ?? px * 0.8;
+ const descent =
+ (metrics as unknown as { actualBoundingBoxDescent?: number })
+ .actualBoundingBoxDescent ?? px * 0.2;
+ const textW = metrics.width;
+ const textH = ascent + descent;
+ const padX = 8 * scaleX;
+ const padY = 4 * scaleX;
+ if ((element as any).backgroundColor) {
+ ctx.save();
+ ctx.fillStyle = (element as any).backgroundColor;
+ let bgLeft = -textW / 2;
+ if (ctx.textAlign === "left") bgLeft = 0;
+ if (ctx.textAlign === "right") bgLeft = -textW;
+ ctx.fillRect(
+ bgLeft - padX,
+ -textH / 2 - padY,
+ textW + padX * 2,
+ textH + padY * 2
+ );
+ ctx.restore();
+ }
+ ctx.fillText((element as any).content, 0, 0);
+ ctx.restore();
+ }
+ }
+}
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
index 8d2ee03a..42159b2e 100644
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -7,6 +7,10 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+export function uppercase(str: string) {
+ return str.toUpperCase();
+}
+
/**
* Generates a UUID v4 string
* Uses crypto.randomUUID() if available, otherwise falls back to a custom implementation