- 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>
451 lines
14 KiB
TypeScript
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()
|
|
}
|