Spaces:
Running
Running
Download aries/src/export.js from Ethscriptions/POLS: direct link, hf CLI and curl.
- Browser
- Download file 5.4 kB
-
https://huggingface.co/spaces/Ethscriptions/POLS/resolve/main/aries/src/export.js
- Command line
-
hf download hf://spaces/Ethscriptions/POLS/aries/src/export.js
-
curl -L -o export.js https://huggingface.co/spaces/Ethscriptions/POLS/resolve/main/aries/src/export.js
5.4 kB
| // ♈ 浏览器本地导出 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' }; | |
| } | |