Spaces:
Running
Running
Download taurus/js/encoder.js from Ethscriptions/POLS: direct link, hf CLI and curl.
- Browser
- Download file 6.15 kB
-
https://huggingface.co/spaces/Ethscriptions/POLS/resolve/main/taurus/js/encoder.js
- Command line
-
hf download hf://spaces/Ethscriptions/POLS/taurus/js/encoder.js
-
curl -L -o encoder.js https://huggingface.co/spaces/Ethscriptions/POLS/resolve/main/taurus/js/encoder.js
6.15 kB
| // ♉ 金牛座 —— 本地导出 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' }; | |
| } | |