// ♉ 金牛座 —— 本地导出 MP4(WebCodecs + mp4-muxer,逐帧离线渲染;不支持时回退 MediaRecorder 实时录制) import { Muxer, ArrayBufferTarget } from '../vendor/mp4-muxer.mjs'; export const SIZES = { 1080: [1920, 1080], 720: [1280, 720] }; export const hasWebCodecs = () => typeof VideoEncoder === 'function' && typeof AudioEncoder === 'function' && typeof VideoFrame === 'function'; async function pickVideo(W, H, fps) { const px = W * H, bitrate = Math.round((px >= 1920 * 1080 ? 10e6 : 5e6) * (fps > 30 ? 1.5 : fps < 25 ? .85 : 1)); for (const codec of ['avc1.640033', 'avc1.64002A', 'avc1.640028', 'avc1.4D0033', 'avc1.42E033', 'avc1.42001F']) for (const hw of ['prefer-hardware', 'no-preference']) { const c = { codec, width: W, height: H, bitrate, framerate: fps, hardwareAcceleration: hw, avc: { format: 'avc' } }; try { if ((await VideoEncoder.isConfigSupported(c)).supported) return c; } catch (e) { /* 尝试下一种 */ } } throw new Error('当前浏览器无法编码 H.264 视频'); } async function pickAudio() { for (const [codec, mux] of [['mp4a.40.2', 'aac'], ['opus', 'opus']]) { const c = { codec, sampleRate: 48000, numberOfChannels: 2, bitrate: 192000 }; try { if ((await AudioEncoder.isConfigSupported(c)).supported) return [c, mux]; } catch (e) { /* 下一种 */ } } return [null, null]; } // 把音轨切片并重采样到 48k 立体声 async function sliceAudio(buf, from, to) { const len = Math.ceil((to - from) * 48000), ctx = new OfflineAudioContext(2, len, 48000), src = ctx.createBufferSource(); src.buffer = buf; src.connect(ctx.destination); src.start(0, from, to - from); return ctx.startRendering(); } /** * @param o { fps, orient:'landscape'|'portrait', res:1080|720, from, to, audio:AudioBuffer, draw(g,t,W,H), onProgress(k,msg), signal } */ export async function exportMP4(o) { let [W, H] = SIZES[o.res] || SIZES[1080]; if (o.orient === 'portrait') [W, H] = [H, W]; const fps = o.fps, from = o.from ?? 0, to = o.to, N = Math.round((to - from) * fps); const vc = await pickVideo(W, H, fps), [ac, amux] = o.audio ? await pickAudio() : [null, null]; const muxer = new Muxer({ target: new ArrayBufferTarget(), fastStart: 'in-memory', firstTimestampBehavior: 'offset', video: { codec: 'avc', width: W, height: H, frameRate: fps }, ...(ac ? { audio: { codec: amux, sampleRate: 48000, numberOfChannels: 2 } } : {}) }); let err = null; const venc = new VideoEncoder({ output: (c, m) => muxer.addVideoChunk(c, m), error: e => err = e }); venc.configure(vc); // 音频 if (ac) { o.onProgress?.(0, '准备音轨…'); const ab = await sliceAudio(o.audio, from, to), aenc = new AudioEncoder({ output: (c, m) => muxer.addAudioChunk(c, m), error: e => err = e }); aenc.configure(ac); const L = ab.getChannelData(0), R = ab.getChannelData(1), step = 4800; for (let i = 0; i < ab.length; i += step) { const n = Math.min(step, ab.length - i), d = new Float32Array(n * 2); d.set(L.subarray(i, i + n), 0); d.set(R.subarray(i, i + n), n); const a = new AudioData({ format: 'f32-planar', sampleRate: 48000, numberOfFrames: n, numberOfChannels: 2, timestamp: Math.round(i / 48000 * 1e6), data: d }); aenc.encode(a); a.close(); } await aenc.flush(); aenc.close(); } // 视频:逐帧离线渲染 const cv = typeof OffscreenCanvas === 'function' ? new OffscreenCanvas(W, H) : Object.assign(document.createElement('canvas'), { width: W, height: H }); const g = cv.getContext('2d', { alpha: false }); const t0 = performance.now(); for (let i = 0; i < N; i++) { if (o.signal?.aborted) { venc.close(); throw new DOMException('已取消', 'AbortError'); } if (err) throw err; g.setTransform(1, 0, 0, 1, 0, 0); g.fillStyle = '#000'; g.fillRect(0, 0, W, H); o.draw(g, from + i / fps, W, H); const f = new VideoFrame(cv, { timestamp: Math.round(i * 1e6 / fps), duration: Math.round(1e6 / fps) }); venc.encode(f, { keyFrame: i % (fps * 2) === 0 }); f.close(); while (venc.encodeQueueSize > 6) await new Promise(r => setTimeout(r, 2)); if (i % 6 === 0) { const k = (i + 1) / N, el = (performance.now() - t0) / 1000; o.onProgress?.(k, `渲染第 ${i + 1}/${N} 帧 · 剩余约 ${Math.max(0, Math.round(el / k - el))} 秒`); await new Promise(r => setTimeout(r, 0)); } } await venc.flush(); venc.close(); if (err) throw err; muxer.finalize(); o.onProgress?.(1, '完成'); return { blob: new Blob([muxer.target.buffer], { type: 'video/mp4' }), W, H, fps, audio: amux || 'none', codec: vc.codec }; } /** 回退:实时录屏(时长 = 片长),格式取浏览器支持的 mp4 / webm */ export async function recordRealtime(o) { let [W, H] = SIZES[o.res] || SIZES[1080]; if (o.orient === 'portrait') [W, H] = [H, W]; const cv = Object.assign(document.createElement('canvas'), { width: W, height: H }), g = cv.getContext('2d'); const stream = cv.captureStream(o.fps), ctx = new AudioContext(), dst = ctx.createMediaStreamDestination(), src = ctx.createBufferSource(); src.buffer = o.audio; src.connect(dst); dst.stream.getAudioTracks().forEach(t => stream.addTrack(t)); const type = ['video/mp4;codecs=avc1,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm'].find(t => MediaRecorder.isTypeSupported(t)); const rec = new MediaRecorder(stream, { mimeType: type, videoBitsPerSecond: 10e6 }), parts = []; rec.ondataavailable = e => parts.push(e.data); const from = o.from ?? 0, dur = o.to - from; await ctx.resume(); const start = ctx.currentTime + .1; src.start(start, from); rec.start(500); await new Promise(res => { const tick = () => { const t = ctx.currentTime - start; if (t >= dur || o.signal?.aborted) return res(); g.fillStyle = '#000'; g.fillRect(0, 0, W, H); o.draw(g, from + Math.max(0, t), W, H); o.onProgress?.(t / dur, `实时录制中 ${Math.round(t)}/${Math.round(dur)} 秒`); requestAnimationFrame(tick); }; tick(); }); rec.stop(); src.stop(); await new Promise(r => rec.onstop = r); ctx.close(); return { blob: new Blob(parts, { type }), W, H, fps: o.fps, audio: 'realtime', ext: type.includes('mp4') ? 'mp4' : 'webm' }; }