mx / src /video /exporter.ts
Ethscriptions's picture
Step 6-7: 播放器 + MP4 导出 + 文案 + 封面
d61897b verified
Raw History Blame Contribute Delete
3.24 kB
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<Blob> {
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' })
}