Spaces:
Running
Running
File size: 5,398 Bytes
e67dc56 | 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 71 72 73 74 75 76 77 78 79 | // ♈ 浏览器本地导出 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' };
}
|