File size: 3,237 Bytes
68fdb58
 
 
 
 
 
 
 
 
 
 
 
d61897b
68fdb58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d61897b
68fdb58
 
 
 
 
d61897b
68fdb58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
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' })
}