Spaces:
Running
Running
File size: 6,153 Bytes
9b7ae8a | 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 80 | // ♉ 金牛座 —— 本地导出 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' };
}
|