- 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>
145 lines
3.9 KiB
TypeScript
145 lines
3.9 KiB
TypeScript
import type { Scene } from '@/types/scene'
|
|
import { drawSceneFrame, loadSceneImages } from './render'
|
|
|
|
function effectiveEnd(scene: Scene): number {
|
|
let max = 0
|
|
for (const layer of scene.layers) {
|
|
for (const kf of layer.keyframes) {
|
|
if (kf.time > max) max = kf.time
|
|
}
|
|
}
|
|
if (max <= 0) return scene.duration
|
|
return Math.min(max, scene.duration)
|
|
}
|
|
|
|
function playbackRange(scene: Scene): { start: number; end: number } {
|
|
const autoEnd = effectiveEnd(scene)
|
|
const start = Math.max(0, Math.min(scene.duration, scene.inTime ?? 0))
|
|
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
|
|
const end = Math.max(start, Math.min(scene.duration, rawEnd))
|
|
return { start, end }
|
|
}
|
|
|
|
export interface VideoExportResult {
|
|
blob: Blob
|
|
mimeType: string
|
|
extension: string
|
|
}
|
|
|
|
export interface VideoExportOptions {
|
|
onProgress?: (progress: number) => void
|
|
signal?: AbortSignal
|
|
bitsPerSecond?: number
|
|
}
|
|
|
|
const MIME_CANDIDATES = [
|
|
'video/webm;codecs=vp9',
|
|
'video/webm;codecs=vp8',
|
|
'video/webm',
|
|
'video/mp4;codecs=h264',
|
|
'video/mp4',
|
|
]
|
|
|
|
export function pickMimeType(): string | null {
|
|
if (typeof MediaRecorder === 'undefined') return null
|
|
for (const mime of MIME_CANDIDATES) {
|
|
if (MediaRecorder.isTypeSupported?.(mime)) return mime
|
|
}
|
|
return null
|
|
}
|
|
|
|
export async function renderSceneToVideo(
|
|
scene: Scene,
|
|
opts: VideoExportOptions = {},
|
|
): Promise<VideoExportResult> {
|
|
const mimeType = pickMimeType()
|
|
if (!mimeType) {
|
|
throw new Error('MediaRecorder is not supported by this browser.')
|
|
}
|
|
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = scene.canvas.width
|
|
canvas.height = scene.canvas.height
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) throw new Error('2D context unavailable')
|
|
|
|
const images = await loadSceneImages(scene)
|
|
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
|
|
const rangeLen = Math.max(0, rangeOut - rangeIn)
|
|
|
|
drawSceneFrame(ctx, scene, rangeIn, images)
|
|
|
|
const stream = canvas.captureStream(scene.fps)
|
|
const recorder = new MediaRecorder(stream, {
|
|
mimeType,
|
|
videoBitsPerSecond: opts.bitsPerSecond ?? 8_000_000,
|
|
})
|
|
|
|
const chunks: Blob[] = []
|
|
recorder.ondataavailable = (e) => {
|
|
if (e.data && e.data.size > 0) chunks.push(e.data)
|
|
}
|
|
|
|
const stopped = new Promise<void>((resolve) => {
|
|
recorder.onstop = () => resolve()
|
|
})
|
|
|
|
recorder.start()
|
|
const startedAt = performance.now()
|
|
|
|
const finished = new Promise<void>((resolve, reject) => {
|
|
let raf = 0
|
|
const step = () => {
|
|
if (opts.signal?.aborted) {
|
|
cancelAnimationFrame(raf)
|
|
try {
|
|
recorder.stop()
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
stream.getTracks().forEach((t) => t.stop())
|
|
reject(new DOMException('aborted', 'AbortError'))
|
|
return
|
|
}
|
|
const elapsed = (performance.now() - startedAt) / 1000
|
|
const t = rangeIn + Math.min(elapsed, rangeLen)
|
|
drawSceneFrame(ctx, scene, t, images)
|
|
opts.onProgress?.(rangeLen > 0 ? Math.min(1, elapsed / rangeLen) : 1)
|
|
if (elapsed >= rangeLen) {
|
|
try {
|
|
recorder.stop()
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
stream.getTracks().forEach((t) => t.stop())
|
|
resolve()
|
|
return
|
|
}
|
|
raf = requestAnimationFrame(step)
|
|
}
|
|
raf = requestAnimationFrame(step)
|
|
})
|
|
|
|
await finished
|
|
await stopped
|
|
|
|
const blob = new Blob(chunks, { type: mimeType })
|
|
const extension = mimeType.includes('mp4') ? 'mp4' : 'webm'
|
|
return { blob, mimeType, extension }
|
|
}
|
|
|
|
export async function downloadSceneAsVideo(
|
|
scene: Scene,
|
|
opts: VideoExportOptions = {},
|
|
): Promise<void> {
|
|
const { blob, extension } = await renderSceneToVideo(scene, opts)
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `animation.${extension}`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
}
|