refactor: type-safety & separate concerns

This commit is contained in:
Maze Winther
2025-08-26 15:04:25 +02:00
parent 2f7cd11ca1
commit b459b46802
22 changed files with 430 additions and 542 deletions
+2 -3
View File
@@ -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[],
+4 -4
View File
@@ -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,
+9 -9
View File
@@ -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);
+159 -179
View File
@@ -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();
}
}
}