import { Output, Mp4OutputFormat, BufferTarget, CanvasSource, AudioBufferSource, getFirstEncodableVideoCodec, getFirstEncodableAudioCodec, } from 'mediabunny' import { Engine, LW, LH } from './engine' import { frameAt, DURATION } from './scenes' import { getSoundtrack } from './audio' export type Fps = 24 | 30 | 60 export interface ExportProgress { phase: string; p: number; frame?: number; total?: number; eta?: number } export async function exportMp4(fps: Fps, onProgress: (e: ExportProgress) => void, signal: AbortSignal, limit = DURATION): Promise { if (typeof VideoEncoder === 'undefined') throw new Error('当前浏览器不支持本地视频编码,请使用最新版 Chrome / Edge / Safari 17+') const W = LW, H = LH const bitrate = fps === 60 ? 16e6 : fps === 30 ? 12e6 : 10e6 const vcodec = await getFirstEncodableVideoCodec(['avc', 'hevc', 'vp9', 'av1'], { width: W, height: H, bitrate }) if (!vcodec) throw new Error('当前设备无法编码 1080×1920 视频') onProgress({ phase: '准备音轨', p: 0 }) const sound = await getSoundtrack((p, l) => onProgress({ phase: l, p: p * 0.03 })) const acodec = await getFirstEncodableAudioCodec(['aac', 'opus'], { numberOfChannels: 2, sampleRate: sound.sampleRate, bitrate: 192e3 }) const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H const ctx = canvas.getContext('2d', { alpha: false })! const eng = new Engine(); eng.setSize(W, H) const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() }) const video = new CanvasSource(canvas, { codec: vcodec, bitrate, keyFrameInterval: 2 }) output.addVideoTrack(video, { frameRate: fps }) let audio: AudioBufferSource | null = null if (acodec) { audio = new AudioBufferSource({ codec: acodec, bitrate: 192e3 }); output.addAudioTrack(audio) } await output.start() if (audio) { // trim/pad to exact film length const n = Math.round(limit * sound.sampleRate) const ab = new AudioBuffer({ length: n, numberOfChannels: 2, sampleRate: sound.sampleRate }) for (let c = 0; c < 2; c++) ab.copyToChannel(sound.getChannelData(Math.min(c, sound.numberOfChannels - 1)).subarray(0, n), c) await audio.add(ab); audio.close() } const total = Math.floor(limit * fps) const t0 = performance.now() try { for (let i = 0; i < total; i++) { if (signal.aborted) throw new DOMException('cancelled', 'AbortError') const t = i / fps eng.render(frameAt(t), ctx, t) await video.add(t, 1 / fps) if (i % 6 === 0 || i === total - 1) { const el = (performance.now() - t0) / 1000 const p = (i + 1) / total onProgress({ phase: '逐帧渲染编码', p: 0.03 + p * 0.95, frame: i + 1, total, eta: el / p * (1 - p) }) await new Promise(r => setTimeout(r, 0)) } } video.close() onProgress({ phase: '封装 MP4', p: 0.99 }) await output.finalize() } catch (e) { await output.cancel().catch(() => {}) eng.renderer.dispose() throw e } eng.renderer.dispose() const buf = (output.target as BufferTarget).buffer! onProgress({ phase: '完成', p: 1 }) return new Blob([buf], { type: 'video/mp4' }) }