diff options
Diffstat (limited to 'apps/web/src/lib/timeline-renderer.ts')
| -rw-r--r-- | apps/web/src/lib/timeline-renderer.ts | 208 |
1 files changed, 162 insertions, 46 deletions
diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 6241a7d9..b2650b7c 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -1,6 +1,8 @@ -import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; 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; @@ -10,9 +12,29 @@ export interface RenderContext { 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, @@ -21,15 +43,26 @@ export async function renderTimelineFrame({ tracks, mediaFiles, backgroundColor, + backgroundType, + blurIntensity, projectCanvasSize, }: RenderContext): Promise<void> { // Background ctx.clearRect(0, 0, canvasWidth, canvasHeight); - if (backgroundColor && backgroundColor !== "transparent") { + 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 @@ -44,7 +77,7 @@ export async function renderTimelineFrame({ 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; + if (element.hidden) continue; const elementStart = element.startTime; const elementEnd = element.startTime + @@ -62,34 +95,103 @@ export async function renderTimelineFrame({ } } + // 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") { - 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); + try { + const localTime = time - element.startTime + element.trimStart; - const localTime = time - element.startTime + element.trimStart; - const sample = await sink.getSample(localTime); - if (!sample) continue; + 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; - sample.draw(ctx, drawX, drawY, drawW, drawH); + 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(); @@ -118,33 +220,47 @@ export async function renderTimelineFrame({ } } if (element.type === "text") { - const posX = canvasWidth / 2 + (element as any).x * scaleX; - const posY = canvasHeight / 2 + (element as any).y * scaleY; + 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(((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.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((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 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 ((element as any).backgroundColor) { + if (text.backgroundColor) { ctx.save(); - ctx.fillStyle = (element as any).backgroundColor; + ctx.fillStyle = text.backgroundColor; let bgLeft = -textW / 2; if (ctx.textAlign === "left") bgLeft = 0; if (ctx.textAlign === "right") bgLeft = -textW; @@ -156,7 +272,7 @@ export async function renderTimelineFrame({ ); ctx.restore(); } - ctx.fillText((element as any).content, 0, 0); + ctx.fillText(text.content, 0, 0); ctx.restore(); } } |
