mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: type-safety & separate concerns
This commit is contained in:
@@ -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[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user