import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene' import { sampleLayer } from './interpolate' export async function loadSceneImages(scene: Scene): Promise> { const result: Record = {} const images = scene.assets.filter( (a): a is Extract => a.kind === 'image', ) await Promise.all( images.map( (a) => new Promise((resolve) => { const img = new Image() img.crossOrigin = 'anonymous' img.onload = () => { result[a.id] = img resolve() } img.onerror = () => resolve() img.src = a.src }), ), ) return result } interface ShadowState { sx: number sy: number blur: number color: string } function hexToRgba(hex: string, alpha: number): string { const h = hex.trim().replace('#', '') const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h if (full.length < 6) return `rgba(0, 0, 0, ${alpha})` const n = parseInt(full.slice(0, 6), 16) if (Number.isNaN(n)) return `rgba(0, 0, 0, ${alpha})` const r = (n >> 16) & 255 const g = (n >> 8) & 255 const b = n & 255 return `rgba(${r}, ${g}, ${b}, ${alpha})` } function computeShadow(p: AnimatableProps): ShadowState | null { const opacity = p.shadowOpacity ?? 0 if (opacity <= 0) return null const blur = p.shadowBlur ?? 0 const distance = p.shadowDistance ?? 0 if (blur <= 0 && distance <= 0) return null const angle = p.shadowAngle ?? 90 const rad = (angle * Math.PI) / 180 return { sx: Math.cos(rad) * distance, sy: Math.sin(rad) * distance, blur, color: hexToRgba(p.shadowColor ?? '#000000', opacity), } } function applyShadow(ctx: CanvasRenderingContext2D, s: ShadowState | null) { if (!s) { ctx.shadowColor = 'transparent' ctx.shadowBlur = 0 ctx.shadowOffsetX = 0 ctx.shadowOffsetY = 0 return } ctx.shadowColor = s.color ctx.shadowBlur = s.blur ctx.shadowOffsetX = s.sx ctx.shadowOffsetY = s.sy } function clearShadow(ctx: CanvasRenderingContext2D) { ctx.shadowColor = 'transparent' ctx.shadowBlur = 0 ctx.shadowOffsetX = 0 ctx.shadowOffsetY = 0 } export function tracePolygonPath( ctx: CanvasRenderingContext2D, samplePoints: { x: number; y: number }[], assetPoints: import('@/types/scene').Point[], offX: number, offY: number, closed: boolean, ): void { const n = samplePoints.length if (n < 2) return ctx.beginPath() ctx.moveTo(samplePoints[0].x - offX, samplePoints[0].y - offY) const endI = closed ? n : n - 1 for (let i = 0; i < endI; i++) { const aPos = samplePoints[i] const bIdx = (i + 1) % n const bPos = samplePoints[bIdx] const aAsset = assetPoints[i] const bAsset = assetPoints[bIdx] const aHas = aAsset && aAsset.type && aAsset.type !== 'corner' const bHas = bAsset && bAsset.type && bAsset.type !== 'corner' if (aHas || bHas) { const aSp = samplePoints[i] as import('@/types/scene').Point const bSp = samplePoints[bIdx] as import('@/types/scene').Point const aoutX = aHas ? (aSp.outX ?? aAsset.outX ?? 0) : 0 const aoutY = aHas ? (aSp.outY ?? aAsset.outY ?? 0) : 0 const binX = bHas ? (bSp.inX ?? bAsset.inX ?? 0) : 0 const binY = bHas ? (bSp.inY ?? bAsset.inY ?? 0) : 0 ctx.bezierCurveTo( aPos.x + aoutX - offX, aPos.y + aoutY - offY, bPos.x + binX - offX, bPos.y + binY - offY, bPos.x - offX, bPos.y - offY, ) } else { ctx.lineTo(bPos.x - offX, bPos.y - offY) } } if (closed) ctx.closePath() } function drawLayers( ctx: CanvasRenderingContext2D, layers: Layer[], assetMap: Map, images: Record, time: number, ) { for (let i = layers.length - 1; i >= 0; i--) { const layer = layers[i] if (!layer.visible) continue const asset = assetMap.get(layer.assetId) if (!asset) continue const p = sampleLayer(layer, time) ctx.save() ctx.globalAlpha *= p.opacity ctx.translate(p.x, p.y) ctx.rotate((p.rotation * Math.PI) / 180) ctx.scale(p.scaleX, p.scaleY) const shadow = computeShadow(p) applyShadow(ctx, shadow) const rawStroke = p.stroke ?? ('stroke' in asset ? asset.stroke : undefined) const strokeWidth = p.strokeWidth ?? ('strokeWidth' in asset ? asset.strokeWidth : undefined) ?? 0 const stroke = strokeWidth > 0 ? rawStroke || '#000000' : undefined const hasStroke = !!stroke && stroke !== 'transparent' && strokeWidth > 0 if (asset.kind === 'rect') { const offX = layer.offsetX ?? asset.width / 2 const offY = layer.offsetY ?? asset.height / 2 const fill = p.fill ?? asset.fill const blur = p.blur ?? asset.blur ?? 0 if (blur > 0) ctx.filter = `blur(${blur}px)` if (fill && fill !== 'transparent') { ctx.fillStyle = fill ctx.fillRect(-offX, -offY, asset.width, asset.height) } if (hasStroke) { ctx.strokeStyle = stroke! ctx.lineWidth = strokeWidth ctx.strokeRect(-offX, -offY, asset.width, asset.height) } ctx.filter = 'none' } else if (asset.kind === 'circle') { const offX = layer.offsetX ?? 0 const offY = layer.offsetY ?? 0 const fill = p.fill ?? asset.fill const blur = p.blur ?? asset.blur ?? 0 if (blur > 0) ctx.filter = `blur(${blur}px)` ctx.beginPath() ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2) if (fill && fill !== 'transparent') { ctx.fillStyle = fill ctx.fill() } if (hasStroke) { ctx.strokeStyle = stroke! ctx.lineWidth = strokeWidth ctx.stroke() } ctx.filter = 'none' } else if (asset.kind === 'text') { const offX = layer.offsetX ?? 0 const offY = layer.offsetY ?? 0 const fill = p.fill ?? asset.fill ctx.font = `${asset.fontSize}px sans-serif` ctx.textBaseline = 'top' if (fill && fill !== 'transparent') { ctx.fillStyle = fill ctx.fillText(asset.text, -offX, -offY) } if (hasStroke) { ctx.strokeStyle = stroke! ctx.lineWidth = strokeWidth ctx.strokeText(asset.text, -offX, -offY) } } else if (asset.kind === 'polygon') { const offX = layer.offsetX ?? 0 const offY = layer.offsetY ?? 0 const fill = p.fill ?? asset.fill const blur = p.blur ?? asset.blur ?? 0 const pts = p.points ?? asset.points const closed = asset.closed !== false const needed = closed ? 3 : 2 if (pts.length >= needed) { if (blur > 0) ctx.filter = `blur(${blur}px)` tracePolygonPath(ctx, pts, asset.points, offX, offY, closed) if (closed && fill && fill !== 'transparent') { ctx.fillStyle = fill ctx.fill() } if (hasStroke) { ctx.strokeStyle = stroke! ctx.lineWidth = strokeWidth ctx.lineJoin = 'round' ctx.lineCap = 'round' ctx.stroke() } ctx.filter = 'none' } } else if (asset.kind === 'image') { const img = images[asset.id] if (img) { const offX = layer.offsetX ?? asset.width / 2 const offY = layer.offsetY ?? asset.height / 2 ctx.drawImage(img, -offX, -offY, asset.width, asset.height) } } clearShadow(ctx) ctx.restore() } } function drawMaskShape( ctx: CanvasRenderingContext2D, layer: Layer, assetMap: Map, time: number, ) { const asset = assetMap.get(layer.assetId) if (!asset) return if ( asset.kind !== 'rect' && asset.kind !== 'circle' && asset.kind !== 'text' && asset.kind !== 'polygon' ) return const p = sampleLayer(layer, time) ctx.save() ctx.translate(p.x, p.y) ctx.rotate((p.rotation * Math.PI) / 180) ctx.scale(p.scaleX, p.scaleY) ctx.fillStyle = '#000000' if (asset.kind === 'rect') { const offX = layer.offsetX ?? asset.width / 2 const offY = layer.offsetY ?? asset.height / 2 ctx.fillRect(-offX, -offY, asset.width, asset.height) } else if (asset.kind === 'circle') { const offX = layer.offsetX ?? 0 const offY = layer.offsetY ?? 0 ctx.beginPath() ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2) ctx.fill() } else if (asset.kind === 'text') { const offX = layer.offsetX ?? 0 const offY = layer.offsetY ?? 0 ctx.font = `${asset.fontSize}px sans-serif` ctx.textBaseline = 'top' ctx.fillText(asset.text, -offX, -offY) } else { const offX = layer.offsetX ?? 0 const offY = layer.offsetY ?? 0 const pts = p.points ?? asset.points const closed = asset.closed !== false if (closed && pts.length >= 3) { tracePolygonPath(ctx, pts, asset.points, offX, offY, true) ctx.fill() } } ctx.restore() } function drawMaskedGroup( ctx: CanvasRenderingContext2D, children: Layer[], assetMap: Map, images: Record, time: number, ) { const masks = children.filter((l) => l.isMask) const content = children.filter((l) => !l.isMask) if (masks.length === 0) { drawLayers(ctx, children, assetMap, images, time) return } const w = ctx.canvas.width const h = ctx.canvas.height const off = document.createElement('canvas') off.width = w off.height = h const offCtx = off.getContext('2d') if (!offCtx) { drawLayers(ctx, content, assetMap, images, time) return } const m = ctx.getTransform() offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f) drawLayers(offCtx, content, assetMap, images, time) for (const mask of masks) { offCtx.save() offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in' drawMaskShape(offCtx, mask, assetMap, time) offCtx.restore() } ctx.save() ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.drawImage(off, 0, 0) ctx.restore() } export function drawSceneFrame( ctx: CanvasRenderingContext2D, scene: Scene, time: number, images: Record, options?: { crop?: boolean }, ) { const lw = scene.canvas.width const lh = scene.canvas.height const crop = options?.crop !== false ctx.save() if (crop) { ctx.beginPath() ctx.rect(0, 0, lw, lh) ctx.clip() } // A background video takes over the backdrop — keep the stage area // transparent so the DOM video underneath is visible. if (!scene.backgroundVideo) { ctx.fillStyle = scene.canvas.background ctx.fillRect(0, 0, lw, lh) } const assetMap = new Map() for (const a of scene.assets) assetMap.set(a.id, a) const layerById = new Map() for (const l of scene.layers) layerById.set(l.id, l) const instancesByTrack = new Map() for (const ci of scene.clipInstances ?? []) { const arr = instancesByTrack.get(ci.clipTrackId) ?? [] arr.push(ci) instancesByTrack.set(ci.clipTrackId, arr) } const clipById = new Map[number]>() for (const c of scene.clips ?? []) clipById.set(c.id, c) const groupById = new Map[number]>() for (const g of scene.groups ?? []) groupById.set(g.id, g) const rawOrder = scene.trackOrder?.length ? scene.trackOrder : scene.layers.map((l) => l.id) // Top-level mask layers (i.e. not inside a group) mask everything below // them in the stack until the very bottom. Render everything into an // offscreen buffer so we can apply each mask to whatever has been drawn // so far; we only pay for this when at least one top-level mask exists. const hasTopLevelMask = rawOrder.some((id) => { const l = layerById.get(id) return !!l && !!l.isMask }) let drawCtx: CanvasRenderingContext2D = ctx let off: HTMLCanvasElement | null = null if (hasTopLevelMask) { off = document.createElement('canvas') off.width = ctx.canvas.width off.height = ctx.canvas.height const oc = off.getContext('2d') if (oc) { const m = ctx.getTransform() oc.setTransform(m.a, m.b, m.c, m.d, m.e, m.f) drawCtx = oc } else { off = null } } for (let i = rawOrder.length - 1; i >= 0; i--) { const id = rawOrder[i] const layer = layerById.get(id) if (layer) { if (layer.isMask) { // Apply this mask to everything already drawn into the offscreen // buffer (the entire stack below it). drawCtx.save() drawCtx.globalCompositeOperation = layer.maskMode === 'negative' ? 'destination-out' : 'destination-in' drawMaskShape(drawCtx, layer, assetMap, time) drawCtx.restore() } else { drawLayers(drawCtx, [layer], assetMap, images, time) } continue } const instances = instancesByTrack.get(id) ?? [] if (instances.length) { for (const inst of instances) { const clip = clipById.get(inst.clipId) if (!clip) continue const trimStart = inst.trimStart ?? 0 const trimEnd = inst.trimEnd ?? 0 const visible = Math.max(0, clip.duration - trimStart - trimEnd) const offsetIntoBlock = time - inst.startTime if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue drawLayers(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock) } continue } const group = groupById.get(id) if (group) { const childLayers: Layer[] = [] for (let k = group.childIds.length - 1; k >= 0; k--) { const cl = layerById.get(group.childIds[k]) if (cl) childLayers.push(cl) } drawMaskedGroup(drawCtx, childLayers, assetMap, images, time) } } if (off) { ctx.save() ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.drawImage(off, 0, 0) ctx.restore() } ctx.restore() }