diff options
Diffstat (limited to 'apps/web/src/lib')
| -rw-r--r-- | apps/web/src/lib/export.ts | 296 | ||||
| -rw-r--r-- | apps/web/src/lib/media-processing.ts | 30 | ||||
| -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.ts | 18 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline-renderer.ts | 163 | ||||
| -rw-r--r-- | apps/web/src/lib/utils.ts | 4 |
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 |
