// ♈ 浏览器本地导出 MP4:WebCodecs + mp4-muxer(无 WebCodecs 时降级 MediaRecorder 实时录制) import { Muxer, ArrayBufferTarget } from '../vendor/mp4-muxer.mjs'; async function pickVideo(W, H, fps) { const px = W * H, br = Math.round((px >= 2e6 ? 12e6 : 6e6) * (fps >= 50 ? 1.45 : fps <= 24 ? .85 : 1)); for (const codec of ['avc1.640034', 'avc1.640033', 'avc1.640028', 'avc1.4d0034', 'avc1.42003e', 'avc1.42002a']) { const cfg = { codec, width: W, height: H, bitrate: br, framerate: fps, avc: { format: 'avc' }, latencyMode: 'quality' }; try { const r = await VideoEncoder.isConfigSupported(cfg); if (r.supported) return cfg; } catch (e) { } } return null; } async function pickAudio(sr) { for (const [codec, mux] of [['mp4a.40.2', 'aac'], ['opus', 'opus']]) { const cfg = { codec, sampleRate: sr, numberOfChannels: 2, bitrate: 160000 }; try { const r = await AudioEncoder.isConfigSupported(cfg); if (r.supported) return { cfg, mux }; } catch (e) { } } return null; } export const canWebCodecs = () => 'VideoEncoder' in window && 'AudioEncoder' in window && 'VideoFrame' in window; /** * draw(ctx, t, W, H) 纯函数;audio: AudioBuffer(完整音轨) */ export async function exportMP4({ W, H, fps, length, draw, audio, onProgress = () => { }, isCancelled = () => false }) { if (!canWebCodecs()) return exportRecorder({ W, H, fps, length, draw, audio, onProgress, isCancelled }); const vcfg = await pickVideo(W, H, fps); if (!vcfg) throw new Error('当前浏览器不支持 H.264 编码'); const acfg = audio ? await pickAudio(audio.sampleRate) : null; const muxer = new Muxer({ target: new ArrayBufferTarget(), fastStart: 'in-memory', firstTimestampBehavior: 'offset', video: { codec: 'avc', width: W, height: H, frameRate: fps }, ...(acfg ? { audio: { codec: acfg.mux, numberOfChannels: 2, sampleRate: audio.sampleRate } } : {}), }); let err = null; const venc = new VideoEncoder({ output: (c, m) => muxer.addVideoChunk(c, m), error: e => err = e }); venc.configure(vcfg); let aenc = null; if (acfg) { aenc = new AudioEncoder({ output: (c, m) => muxer.addAudioChunk(c, m), error: e => err = e }); aenc.configure(acfg.cfg); } // 音频:分块送入 if (aenc) { const sr = audio.sampleRate, N = audio.length, L = audio.getChannelData(0), R = audio.numberOfChannels > 1 ? audio.getChannelData(1) : L; const total = Math.min(N, Math.ceil(length * sr)), CH = sr; // 1 秒一块 for (let o = 0; o < total; o += CH) { const n = Math.min(CH, total - o), data = new Float32Array(n * 2); data.set(L.subarray(o, o + n), 0); data.set(R.subarray(o, o + n), n); const ad = new AudioData({ format: 'f32-planar', sampleRate: sr, numberOfFrames: n, numberOfChannels: 2, timestamp: Math.round(o / sr * 1e6), data }); aenc.encode(ad); ad.close(); } } const cv = document.createElement('canvas'); cv.width = W; cv.height = H; const ctx = cv.getContext('2d', { alpha: false }); const frames = Math.round(length * fps), gop = fps * 2; const t0 = performance.now(); for (let i = 0; i < frames; i++) { if (err) throw err; if (isCancelled()) { try { venc.close(); aenc && aenc.close(); } catch (e) { } return null; } draw(ctx, i / fps, W, H); const vf = new VideoFrame(cv, { timestamp: Math.round(i * 1e6 / fps), duration: Math.round(1e6 / fps) }); venc.encode(vf, { keyFrame: i % gop === 0 }); vf.close(); while (venc.encodeQueueSize > 6) await new Promise(r => setTimeout(r, 4)); if (i % 6 === 0) { const el = (performance.now() - t0) / 1000; onProgress((i + 1) / frames, el / (i + 1) * (frames - i - 1)); await new Promise(r => setTimeout(r, 0)); } } await venc.flush(); if (aenc) await aenc.flush(); if (err) throw err; muxer.finalize(); onProgress(1, 0); return { blob: new Blob([muxer.target.buffer], { type: 'video/mp4' }), ext: 'mp4', audioCodec: acfg ? acfg.mux : 'none', videoCodec: vcfg.codec }; } // 降级方案:实时录制(时长 = 视频时长) async function exportRecorder({ W, H, fps, length, draw, audio, onProgress, isCancelled }) { const cv = document.createElement('canvas'); cv.width = W; cv.height = H; const ctx = cv.getContext('2d'); const stream = cv.captureStream(fps); const ac = new AudioContext({ sampleRate: audio ? audio.sampleRate : 48000 }); const dest = ac.createMediaStreamDestination(); if (audio) { const s = ac.createBufferSource(); s.buffer = audio; s.connect(dest); s.start(ac.currentTime + .1); } dest.stream.getAudioTracks().forEach(t => stream.addTrack(t)); const mime = ['video/mp4;codecs=avc1,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm'].find(m => MediaRecorder.isTypeSupported(m)); const rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 10e6 }); const parts = []; rec.ondataavailable = e => parts.push(e.data); const done = new Promise(r => rec.onstop = r); rec.start(1000); const start = ac.currentTime + .1; await new Promise(res => { const tick = () => { const t = ac.currentTime - start; if (isCancelled() || t >= length) return res(); draw(ctx, Math.max(0, t), W, H); onProgress(t / length, length - t); requestAnimationFrame(tick); }; tick(); }); rec.stop(); await done; ac.close(); return { blob: new Blob(parts, { type: mime }), ext: mime.includes('mp4') ? 'mp4' : 'webm', audioCodec: 'rec', videoCodec: 'rec' }; }