From b459b46802a760a4a81ce91fdf24390e07375fb0 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 26 Aug 2025 15:04:25 +0200 Subject: refactor: type-safety & separate concerns --- apps/web/src/lib/media-processing.ts | 5 +- apps/web/src/lib/mediabunny-utils.ts | 8 +- apps/web/src/lib/storage/storage-service.ts | 18 +- apps/web/src/lib/timeline-renderer.ts | 338 +++++++++++++--------------- 4 files changed, 174 insertions(+), 195 deletions(-) (limited to 'apps/web/src/lib') diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts index 15c71626..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 { MediaFile } from "@/types/media"; import { generateThumbnail, getVideoInfo } from "./mediabunny-utils"; -export interface ProcessedMediaItem extends Omit {} +export interface ProcessedMediaItem extends Omit {} export async function processMediaFiles( files: FileList | File[], diff --git a/apps/web/src/lib/mediabunny-utils.ts b/apps/web/src/lib/mediabunny-utils.ts index 28e6b97f..fcb3da0f 100644 --- a/apps/web/src/lib/mediabunny-utils.ts +++ b/apps/web/src/lib/mediabunny-utils.ts @@ -151,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 { + // Media operations + async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise { 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 { + ): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = this.getProjectMediaAdapters(projectId); @@ -192,14 +192,14 @@ class StorageService { }; } - async loadAllMediaItems(projectId: string): Promise { + async loadAllMediaFiles(projectId: string): Promise { 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 { + async deleteMediaFile(projectId: string, id: string): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = this.getProjectMediaAdapters(projectId); diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 1c1eafcd..06825896 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -1,179 +1,159 @@ -import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; -import type { TimelineTrack } from "@/types/timeline"; -import type { MediaItem } from "@/stores/media-store"; - -export interface RenderContext { - ctx: CanvasRenderingContext2D; - time: number; - canvasWidth: number; - canvasHeight: number; - tracks: TimelineTrack[]; - mediaItems: MediaItem[]; - backgroundColor?: string; - useCache?: boolean; - cache?: Map< - string, - Map< - number, - { - draw: ( - c: CanvasRenderingContext2D, - x: number, - y: number, - w: number, - h: number - ) => void; - } - > - >; - fps: number; -} - -export async function renderTimelineFrame({ - ctx, - time, - canvasWidth, - canvasHeight, - tracks, - mediaItems, - backgroundColor, - useCache = true, - cache, - fps, -}: RenderContext): Promise { - // Background - ctx.clearRect(0, 0, canvasWidth, canvasHeight); - if (backgroundColor && backgroundColor !== "transparent") { - ctx.fillStyle = backgroundColor; - ctx.fillRect(0, 0, canvasWidth, canvasHeight); - } - - const scaleX = 1; - const scaleY = 1; - const idToMedia = new Map(mediaItems.map((m) => [m.id, m] as const)); - const active: Array<{ - track: TimelineTrack; - element: TimelineTrack["elements"][number]; - mediaItem: MediaItem | 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: MediaItem | 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((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(); - } - } -} +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; +} + +export async function renderTimelineFrame({ + ctx, + time, + canvasWidth, + canvasHeight, + tracks, + mediaFiles, + backgroundColor, +}: RenderContext): Promise { + // Background + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + if (backgroundColor && backgroundColor !== "transparent") { + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, canvasWidth, canvasHeight); + } + + const scaleX = 1; + const scaleY = 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((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(); + } + } +} -- cgit v1.3.1