2025-08-26 15:04:25 +02:00
|
|
|
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;
|
2025-08-27 00:52:39 +02:00
|
|
|
projectCanvasSize?: { width: number; height: number };
|
2025-08-26 15:04:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function renderTimelineFrame({
|
|
|
|
|
ctx,
|
|
|
|
|
time,
|
|
|
|
|
canvasWidth,
|
|
|
|
|
canvasHeight,
|
|
|
|
|
tracks,
|
|
|
|
|
mediaFiles,
|
|
|
|
|
backgroundColor,
|
2025-08-27 00:52:39 +02:00
|
|
|
projectCanvasSize,
|
2025-08-26 15:04:25 +02:00
|
|
|
}: RenderContext): Promise<void> {
|
|
|
|
|
// Background
|
|
|
|
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
|
|
|
if (backgroundColor && backgroundColor !== "transparent") {
|
|
|
|
|
ctx.fillStyle = backgroundColor;
|
|
|
|
|
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-27 00:52:39 +02:00
|
|
|
const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1;
|
|
|
|
|
const scaleY = projectCanvasSize
|
|
|
|
|
? canvasHeight / projectCanvasSize.height
|
|
|
|
|
: 1;
|
2025-08-26 15:04:25 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|