summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline-renderer.ts
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/lib/timeline-renderer.ts
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/lib/timeline-renderer.ts')
-rw-r--r--apps/web/src/lib/timeline-renderer.ts279
1 files changed, 0 insertions, 279 deletions
diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts
deleted file mode 100644
index b2650b7c..00000000
--- a/apps/web/src/lib/timeline-renderer.ts
+++ /dev/null
@@ -1,279 +0,0 @@
-import type { TimelineTrack } from "@/types/timeline";
-import type { MediaFile } from "@/types/media";
-import type { BlurIntensity } from "@/types/project";
-import { videoCache } from "./video-cache";
-import { drawCssBackground } from "./canvas-gradients";
-
-export interface RenderContext {
- ctx: CanvasRenderingContext2D;
- time: number;
- canvasWidth: number;
- canvasHeight: number;
- tracks: TimelineTrack[];
- mediaFiles: MediaFile[];
- backgroundColor?: string;
- backgroundType?: "color" | "blur";
- blurIntensity?: BlurIntensity;
- projectCanvasSize?: { width: number; height: number };
-}
-
-const imageElementCache = new Map<string, HTMLImageElement>();
-
-async function getImageElement(
- mediaItem: MediaFile
-): Promise<HTMLImageElement> {
- const cacheKey = mediaItem.id;
- const cached = imageElementCache.get(cacheKey);
- if (cached) return cached;
- 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);
- });
- imageElementCache.set(cacheKey, img);
- return img;
-}
-
-export async function renderTimelineFrame({
- ctx,
- time,
- canvasWidth,
- canvasHeight,
- tracks,
- mediaFiles,
- backgroundColor,
- backgroundType,
- blurIntensity,
- projectCanvasSize,
-}: RenderContext): Promise<void> {
- // Background
- ctx.clearRect(0, 0, canvasWidth, canvasHeight);
- if (
- backgroundColor &&
- backgroundColor !== "transparent" &&
- !backgroundColor.includes("gradient")
- ) {
- ctx.fillStyle = backgroundColor;
- ctx.fillRect(0, 0, canvasWidth, canvasHeight);
- }
-
- // If backgroundColor is a CSS gradient string, draw it
- if (backgroundColor && backgroundColor.includes("gradient")) {
- drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor);
- }
-
- const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1;
- const scaleY = projectCanvasSize
- ? canvasHeight / projectCanvasSize.height
- : 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.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 });
- }
- }
- }
-
- // If background is set to blur, draw the active media as a blurred cover layer first
- if (backgroundType === "blur") {
- const blurPx = Math.max(0, blurIntensity ?? 8);
- // Find a suitable media element (video/image) among active elements
- const bgCandidate = active.find(({ element, mediaItem }) => {
- return (
- element.type === "media" &&
- mediaItem !== null &&
- (mediaItem.type === "video" || mediaItem.type === "image")
- );
- });
- if (bgCandidate && bgCandidate.mediaItem) {
- const { element, mediaItem } = bgCandidate;
- try {
- if (mediaItem.type === "video") {
- const localTime = time - element.startTime + element.trimStart;
- const frame = await videoCache.getFrameAt(
- mediaItem.id,
- mediaItem.file,
- Math.max(0, localTime)
- );
- if (frame) {
- const mediaW = Math.max(1, mediaItem.width || canvasWidth);
- const mediaH = Math.max(1, mediaItem.height || canvasHeight);
- const coverScale = Math.max(
- canvasWidth / mediaW,
- canvasHeight / mediaH
- );
- const drawW = mediaW * coverScale;
- const drawH = mediaH * coverScale;
- const drawX = (canvasWidth - drawW) / 2;
- const drawY = (canvasHeight - drawH) / 2;
- ctx.save();
- ctx.filter = `blur(${blurPx}px)`;
- ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH);
- ctx.restore();
- }
- } else if (mediaItem.type === "image") {
- const img = await getImageElement(mediaItem);
- const mediaW = Math.max(
- 1,
- mediaItem.width || img.naturalWidth || canvasWidth
- );
- const mediaH = Math.max(
- 1,
- mediaItem.height || img.naturalHeight || canvasHeight
- );
- const coverScale = Math.max(
- canvasWidth / mediaW,
- canvasHeight / mediaH
- );
- const drawW = mediaW * coverScale;
- const drawH = mediaH * coverScale;
- const drawX = (canvasWidth - drawW) / 2;
- const drawY = (canvasHeight - drawH) / 2;
- ctx.save();
- ctx.filter = `blur(${blurPx}px)`;
- ctx.drawImage(img, drawX, drawY, drawW, drawH);
- ctx.restore();
- }
- } catch {
- // Ignore background blur failures; foreground will still render
- }
- }
- }
-
- for (const { element, mediaItem } of active) {
- if (element.type === "media" && mediaItem) {
- if (mediaItem.type === "video") {
- try {
- const localTime = time - element.startTime + element.trimStart;
-
- const frame = await videoCache.getFrameAt(
- mediaItem.id,
- mediaItem.file,
- localTime
- );
- if (!frame) 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;
-
- ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH);
- } catch (error) {
- console.warn(
- `Failed to render video frame for ${mediaItem.name}:`,
- error
- );
- }
- }
- 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 text = element;
- const posX = canvasWidth / 2 + text.x * scaleX;
- const posY = canvasHeight / 2 + text.y * scaleY;
- ctx.save();
- ctx.translate(posX, posY);
- ctx.rotate((text.rotation * Math.PI) / 180);
- ctx.globalAlpha = Math.max(0, Math.min(1, text.opacity));
- const px = text.fontSize * scaleX;
- const weight = text.fontWeight === "bold" ? "bold " : "";
- const style = text.fontStyle === "italic" ? "italic " : "";
- ctx.font = `${style}${weight}${px}px ${text.fontFamily}`;
- ctx.fillStyle = text.color;
- ctx.textAlign = text.textAlign as CanvasTextAlign;
- ctx.textBaseline = "middle";
- const metrics = ctx.measureText(text.content);
- const hasBoxMetrics =
- "actualBoundingBoxAscent" in metrics &&
- "actualBoundingBoxDescent" in metrics;
- const ascent = hasBoxMetrics
- ? (
- metrics as TextMetrics & {
- actualBoundingBoxAscent: number;
- actualBoundingBoxDescent: number;
- }
- ).actualBoundingBoxAscent
- : px * 0.8;
- const descent = hasBoxMetrics
- ? (
- metrics as TextMetrics & {
- actualBoundingBoxAscent: number;
- actualBoundingBoxDescent: number;
- }
- ).actualBoundingBoxDescent
- : px * 0.2;
- const textW = metrics.width;
- const textH = ascent + descent;
- const padX = 8 * scaleX;
- const padY = 4 * scaleX;
- if (text.backgroundColor) {
- ctx.save();
- ctx.fillStyle = text.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(text.content, 0, 0);
- ctx.restore();
- }
- }
-}