Spaces:
Running
Running
Download src/video/exporter.ts from Ethscriptions/mx: direct link, hf CLI and curl.
- Browser
- Download file 3.24 kB
-
https://huggingface.co/spaces/Ethscriptions/mx/resolve/main/src/video/exporter.ts
- Command line
-
hf download hf://spaces/Ethscriptions/mx/src/video/exporter.ts
-
curl -L -o exporter.ts https://huggingface.co/spaces/Ethscriptions/mx/resolve/main/src/video/exporter.ts
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' }) | |
| } | |