summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-08-26 15:04:25 +0200
committerMaze Winther <mazewinther@gmail.com>2025-08-26 15:04:25 +0200
commitb459b46802a760a4a81ce91fdf24390e07375fb0 (patch)
treee0070578d2b57acfba5f119cbf1abc6c9db5e741 /apps/web/src/lib
parent2f7cd11ca123884969f90a94e59593d355518839 (diff)
downloadopencut-b459b46802a760a4a81ce91fdf24390e07375fb0.tar.gz
refactor: type-safety & separate concerns
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/media-processing.ts5
-rw-r--r--apps/web/src/lib/mediabunny-utils.ts8
-rw-r--r--apps/web/src/lib/storage/storage-service.ts18
-rw-r--r--apps/web/src/lib/timeline-renderer.ts338
4 files changed, 174 insertions, 195 deletions
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<MediaItem, "id"> {}
+export interface ProcessedMediaItem extends Omit<MediaFile, "id"> {}
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<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
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<void> {
- // 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<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();
- }
- }
-}
+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<void> {
+ // 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<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();
+ }
+ }
+}