Files
RUWIO/src/lib/render.ts
T
thandClaude Opus 4.7 d4c4830fe2 0.6.0: background video, in/out range, top-level masks, track icons
- background video layer (document-level), syncs to playhead in editor and fullscreen
- playback in/out brackets on the scrub ruler; i/o set them, r rewinds to in-point
- togglePlay, tick, fullscreen loop, WebM + server FFmpeg export all honor [in, out]
- top-level mask layers cascade over everything below until a group boundary
- fullscreen preview slaves localTime to the bg video so it stops stuttering
- welcome "new project" now actually loads a blank scene (was a no-op)
- default canvas background hardened to white via normalizeScene
- new keyframe button (+) in the name-spacer, backspace/delete removes selected kfs
- drag-end auto-selects the created/shifted keyframe so the path is visible
- animation path updates live during drag and spans every selected keyframe
- track rows show an icon per asset kind (rect/circle/image/text/polygon/freeform)
- default mask mode is negative, buttons read − then +
- sticky track-name columns raised above playhead/brackets; orange bracket styling
- tests for playback range, blank scene, mask defaults, cascading top-level masks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:19:34 +02:00

451 lines
14 KiB
TypeScript

import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene'
import { sampleLayer } from './interpolate'
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
const result: Record<string, HTMLImageElement> = {}
const images = scene.assets.filter(
(a): a is Extract<Asset, { kind: 'image' }> => a.kind === 'image',
)
await Promise.all(
images.map(
(a) =>
new Promise<void>((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<string, Asset>,
images: Record<string, HTMLImageElement>,
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<string, Asset>,
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<string, Asset>,
images: Record<string, HTMLImageElement>,
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<string, HTMLImageElement>,
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<string, Asset>()
for (const a of scene.assets) assetMap.set(a.id, a)
const layerById = new Map<string, Layer>()
for (const l of scene.layers) layerById.set(l.id, l)
const instancesByTrack = new Map<string, typeof scene.clipInstances>()
for (const ci of scene.clipInstances ?? []) {
const arr = instancesByTrack.get(ci.clipTrackId) ?? []
arr.push(ci)
instancesByTrack.set(ci.clipTrackId, arr)
}
const clipById = new Map<string, NonNullable<typeof scene.clips>[number]>()
for (const c of scene.clips ?? []) clipById.set(c.id, c)
const groupById = new Map<string, NonNullable<typeof scene.groups>[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()
}