// ==UserScript== // @name B站 m4s 合并 / 在线解析下载 // @namespace https://github.com/bili-m4s-merge // @version 2.0.0 // @description 纯浏览器方案(mp4box.js,无需 ffmpeg、无需 SharedArrayBuffer、无需任何本地软件)。① 在线解析当前 B 站视频并下载音视频流合并为 MP4;② 选择本地 video.m4s + audio.m4s 合并为 MP4。无损封装,速度快。 // @author WorkBuddy // @match https://www.bilibili.com/* // @match https://www.bilibili.tv/* // @match https://m.bilibili.com/* // @grant none // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // mp4box.js 单文件 UMD 构建(纯 JS,不需要 SharedArrayBuffer,普通页面即可运行) const MP4BOX_CDN = 'https://cdn.jsdelivr.net/npm/mp4box@0.5.2/dist/mp4box.all.min.js'; let MP4Box = null; let loading = false; // ---------- 动态加载 mp4box.js ---------- function loadScript(url) { return new Promise((resolve, reject) => { if (document.querySelector('script[data-mp4box]')) return resolve(); const s = document.createElement('script'); s.src = url; s.dataset.mp4box = '1'; s.onload = () => resolve(); s.onerror = () => reject(new Error('加载 mp4box.js 失败(可能是网络问题)')); document.head.appendChild(s); }); } async function ensureMP4Box(log) { if (MP4Box && MP4Box.createFile && typeof DataStream !== 'undefined') return MP4Box; if (loading) throw new Error('mp4box.js 正在加载中…'); loading = true; log('加载 mp4box.js …'); await loadScript(MP4BOX_CDN); MP4Box = window.MP4Box || window.mp4box || MP4Box; if (!MP4Box || !MP4Box.createFile) throw new Error('mp4box.js 加载后不可用'); loading = false; log('mp4box.js 就绪 ✓'); return MP4Box; } // ---------- 工具 ---------- function boxToBytes(box) { const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN); box.write(stream); return new Uint8Array(stream.buffer, 0, stream.position); } function stripBoxHeader(bytes) { // 去掉 8 字节 box 头(size + type),返回独立的 ArrayBuffer // 写入端 avcDecoderConfigRecord / hevcDecoderConfigRecord 必须是真正的 ArrayBuffer,不能传 Uint8Array 视图 return bytes.buffer.slice(bytes.byteOffset + 8, bytes.byteOffset + bytes.length); } function downloadBlob(blob, name) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 8000); } function sanitize(name) { return (name || 'bilibili_merged').replace(/[\\/:*?"<>|\n\r\t]/g, '_').slice(0, 120); } // ---------- MD5(WBI 签名用,浏览器无原生 MD5;采用经 Node crypto 交叉校验的 blueimp-md5 实现) ---------- function safeAdd(x, y) { const lsw = (x & 0xffff) + (y & 0xffff); const msw = (x >> 16) + (y >> 16) + (lsw >> 16); return (msw << 16) | (lsw & 0xffff); } function bitRotateLeft(num, cnt) { return (num << cnt) | (num >>> (32 - cnt)); } function md5cmn(q, a, b, x, s, t) { return safeAdd(bitRotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b); } function md5ff(a, b, c, d, x, s, t) { return md5cmn((b & c) | (~b & d), a, b, x, s, t); } function md5gg(a, b, c, d, x, s, t) { return md5cmn((b & d) | (c & ~d), a, b, x, s, t); } function md5hh(a, b, c, d, x, s, t) { return md5cmn(b ^ c ^ d, a, b, x, s, t); } function md5ii(a, b, c, d, x, s, t) { return md5cmn(c ^ (b | ~d), a, b, x, s, t); } function binlMD5(x, len) { x[len >> 5] |= 0x80 << len % 32; x[(((len + 64) >>> 9) << 4) + 14] = len; let a = 1732584193, b = -271733879, c = -1732584194, d = 271733878; for (let i = 0; i < x.length; i += 16) { const olda = a, oldb = b, oldc = c, oldd = d; a = md5ff(a,b,c,d,x[i],7,-680876936); d = md5ff(d,a,b,c,x[i+1],12,-389564586); c = md5ff(c,d,a,b,x[i+2],17,606105819); b = md5ff(b,c,d,a,x[i+3],22,-1044525330); a = md5ff(a,b,c,d,x[i+4],7,-176418897); d = md5ff(d,a,b,c,x[i+5],12,1200080426); c = md5ff(c,d,a,b,x[i+6],17,-1473231341); b = md5ff(b,c,d,a,x[i+7],22,-45705983); a = md5ff(a,b,c,d,x[i+8],7,1770035416); d = md5ff(d,a,b,c,x[i+9],12,-1958414417); c = md5ff(c,d,a,b,x[i+10],17,-42063); b = md5ff(b,c,d,a,x[i+11],22,-1990404162); a = md5ff(a,b,c,d,x[i+12],7,1804603682); d = md5ff(d,a,b,c,x[i+13],12,-40341101); c = md5ff(c,d,a,b,x[i+14],17,-1502002290); b = md5ff(b,c,d,a,x[i+15],22,1236535329); a = md5gg(a,b,c,d,x[i+1],5,-165796510); d = md5gg(d,a,b,c,x[i+6],9,-1069501632); c = md5gg(c,d,a,b,x[i+11],14,643717713); b = md5gg(b,c,d,a,x[i],20,-373897302); a = md5gg(a,b,c,d,x[i+5],5,-701558691); d = md5gg(d,a,b,c,x[i+10],9,38016083); c = md5gg(c,d,a,b,x[i+15],14,-660478335); b = md5gg(b,c,d,a,x[i+4],20,-405537848); a = md5gg(a,b,c,d,x[i+9],5,568446438); d = md5gg(d,a,b,c,x[i+14],9,-1019803690); c = md5gg(c,d,a,b,x[i+3],14,-187363961); b = md5gg(b,c,d,a,x[i+8],20,1163531501); a = md5gg(a,b,c,d,x[i+13],5,-1444681467); d = md5gg(d,a,b,c,x[i+2],9,-51403784); c = md5gg(c,d,a,b,x[i+7],14,1735328473); b = md5gg(b,c,d,a,x[i+12],20,-1926607734); a = md5hh(a,b,c,d,x[i+5],4,-378558); d = md5hh(d,a,b,c,x[i+8],11,-2022574463); c = md5hh(c,d,a,b,x[i+11],16,1839030562); b = md5hh(b,c,d,a,x[i+14],23,-35309556); a = md5hh(a,b,c,d,x[i+1],4,-1530992060); d = md5hh(d,a,b,c,x[i+4],11,1272893353); c = md5hh(c,d,a,b,x[i+7],16,-155497632); b = md5hh(b,c,d,a,x[i+10],23,-1094730640); a = md5hh(a,b,c,d,x[i+13],4,681279174); d = md5hh(d,a,b,c,x[i],11,-358537222); c = md5hh(c,d,a,b,x[i+3],16,-722521979); b = md5hh(b,c,d,a,x[i+6],23,76029189); a = md5hh(a,b,c,d,x[i+9],4,-640364487); d = md5hh(d,a,b,c,x[i+12],11,-421815835); c = md5hh(c,d,a,b,x[i+15],16,530742520); b = md5hh(b,c,d,a,x[i+2],23,-995338651); a = md5ii(a,b,c,d,x[i],6,-198630844); d = md5ii(d,a,b,c,x[i+7],10,1126891415); c = md5ii(c,d,a,b,x[i+14],15,-1416354905); b = md5ii(b,c,d,a,x[i+5],21,-57434055); a = md5ii(a,b,c,d,x[i+12],6,1700485571); d = md5ii(d,a,b,c,x[i+3],10,-1894986606); c = md5ii(c,d,a,b,x[i+10],15,-1051523); b = md5ii(b,c,d,a,x[i+1],21,-2054922799); a = md5ii(a,b,c,d,x[i+8],6,1873313359); d = md5ii(d,a,b,c,x[i+15],10,-30611744); c = md5ii(c,d,a,b,x[i+6],15,-1560198380); b = md5ii(b,c,d,a,x[i+13],21,1309151649); a = md5ii(a,b,c,d,x[i+4],6,-145523070); d = md5ii(d,a,b,c,x[i+11],10,-1120210379); c = md5ii(c,d,a,b,x[i+2],15,718787259); b = md5ii(b,c,d,a,x[i+9],21,-343485551); a = safeAdd(a, olda); b = safeAdd(b, oldb); c = safeAdd(c, oldc); d = safeAdd(d, oldd); } return [a, b, c, d]; } function binl2rstr(input) { let output = ''; const length32 = input.length * 32; for (let i = 0; i < length32; i += 8) output += String.fromCharCode((input[i >> 5] >>> i % 32) & 0xff); return output; } function rstr2binl(input) { const output = []; output[(input.length >> 2) - 1] = undefined; for (let i = 0; i < output.length; i += 1) output[i] = 0; const length8 = input.length * 8; for (let i = 0; i < length8; i += 8) output[i >> 5] |= (input.charCodeAt(i / 8) & 0xff) << i % 32; return output; } function rstrMD5(s) { return binl2rstr(binlMD5(rstr2binl(s), s.length * 8)); } function rstr2hex(input) { const hexTab = '0123456789abcdef'; let output = ''; for (let i = 0; i < input.length; i += 1) { const x = input.charCodeAt(i); output += hexTab.charAt((x >>> 4) & 0x0f) + hexTab.charAt(x & 0x0f); } return output; } function str2rstrUTF8(input) { return unescape(encodeURIComponent(input)); } function rawMD5(s) { return rstrMD5(str2rstrUTF8(s)); } function hexMD5(s) { return rstr2hex(rawMD5(s)); } function md5(s) { return hexMD5(s); } // ---------- 解析单个 m4s/mp4 片段,提取样本 + 轨道配置 ---------- function extractSamples(arrayBuffer) { return new Promise((resolve, reject) => { const file = MP4Box.createFile(); const collected = []; let total = 0; let trackInfo = null; let finished = false; const finish = () => { if (finished) return; finished = true; const trak = file.getTrackById(trackInfo.id); resolve({ samples: collected, track: trackInfo, trak }); }; file.onError = (e) => reject(new Error('解析失败: ' + e)); file.onReady = (info) => { if (!info.tracks || !info.tracks.length) { reject(new Error('文件中未找到音视频轨道')); return; } trackInfo = info.tracks[0]; total = trackInfo.nb_samples || 0; file.setExtractionOptions(trackInfo.id, null, { nbSamples: Infinity }); file.start(); }; file.onSamples = (id, user, samples) => { for (let i = 0; i < samples.length; i++) collected.push(samples[i]); if (total > 0 ? collected.length >= total : collected.length > 0) finish(); }; arrayBuffer.fileStart = 0; file.appendBuffer(arrayBuffer); file.flush(); // 兜底:若解析异常未触发回调 setTimeout(() => { if (!finished && collected.length) finish(); }, 15000); }); } // ---------- 核心:合并 视频流 + 音频流 -> MP4 ---------- async function mergeToMP4(videoBuf, audioBuf, outputName, log) { const MB = await ensureMP4Box(log); log('解析视频流…'); const v = await extractSamples(videoBuf); log('解析音频流…'); const a = await extractSamples(audioBuf); // ---- 视频轨配置 ---- const vEntry = v.trak.mdia.minf.stbl.stsd.entries[0]; let vType, vConfigBox = null; if (vEntry.avcC) { vType = 'avc1'; } else if (vEntry.hvcC) { vType = 'hvc1'; vConfigBox = vEntry.hvcC; // mp4box 0.5.x 写入端不识别 hevcDecoderConfigRecord,需手动 addBox } else if (vEntry.av1C) { vType = 'av01'; vConfigBox = vEntry.av1C; } else { throw new Error('不支持的视频编码(仅支持 H.264/HEVC/AV1,当前: ' + (v.track.codec || '未知') + ')。'); } // ---- 音频轨配置(esds)---- const aEntry = a.trak.mdia.minf.stbl.stsd.entries[0]; const aEsds = aEntry.esds || (aEntry.wave && aEntry.wave.esds); if (!aEsds) throw new Error('未找到音频 esds 配置,无法封装。'); log('封装为 MP4(无损)…'); const out = MB.createFile(); const vId = out.addTrack({ type: vType, width: v.track.video.width, height: v.track.video.height, timescale: v.track.timescale, duration: v.track.duration, ...(vType === 'avc1' ? { avcDecoderConfigRecord: stripBoxHeader(boxToBytes(vEntry.avcC)) } : {}), }); // HEVC/AV1:把原样解析到的配置盒子挂到输出 stsd entry if (vConfigBox) { out.getTrackById(vId).mdia.minf.stbl.stsd.entries[0].addBox(vConfigBox); } const aId = out.addTrack({ type: 'mp4a', hdlr: 'soun', timescale: a.track.timescale, samplerate: (a.track.audio.sample_rate || 48000) << 16, channel_count: a.track.audio.channel_count || 2, samplesize: 16, description: aEsds, }); for (const s of v.samples) { out.addSample(vId, new Uint8Array(s.data), { duration: s.duration, cts: s.cts, dts: s.dts, is_sync: s.is_sync, }); } for (const s of a.samples) { out.addSample(aId, new Uint8Array(s.data), { duration: s.duration, cts: s.cts, dts: s.dts, is_sync: s.is_sync, }); } const stream = out.getBuffer(); // mp4box.js 0.5.x 的 getBuffer() 直接返回 ArrayBuffer;旧版可能返回带 .buffer/.position 的 DataStream,做兼容 const outBytes = stream instanceof ArrayBuffer ? stream : (stream.buffer && typeof stream.position === 'number' ? stream.buffer.slice(0, stream.position) : stream); const blob = new Blob([outBytes], { type: 'video/mp4' }); downloadBlob(blob, sanitize(outputName) + '.mp4'); log('✅ 合并完成并触发下载: ' + sanitize(outputName) + '.mp4'); } // ---------- B 站 WBI 签名(playurl 等接口强制校验) ---------- const WBI_TABLE = [46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52]; let _wbiMixinKey = null; async function getWbiMixinKey() { if (_wbiMixinKey) return _wbiMixinKey; const nav = await fetch('https://api.bilibili.com/x/web-interface/nav', { credentials: 'include', headers: { Referer: location.origin + '/' }, }).then((r) => r.json()); if (!nav.data || !nav.data.wbi_img) throw new Error('获取 WBI 密钥失败(请确认已在 B 站登录)。'); const img = nav.data.wbi_img.img_url.split('/').pop().split('.')[0]; const sub = nav.data.wbi_img.sub_url.split('/').pop().split('.')[0]; const combined = img + sub; _wbiMixinKey = WBI_TABLE.map((i) => combined[i]).join('').slice(0, 32); return _wbiMixinKey; } // 带 WBI 签名的 GET 请求,返回 JSON async function wbiGet(urlBase, params) { const mixin = await getWbiMixinKey(); const p = Object.assign({ wts: Math.floor(Date.now() / 1000) }, params); const query = Object.keys(p).sort() .map((k) => encodeURIComponent(k) + '=' + encodeURIComponent(p[k])).join('&'); const w_rid = md5(query + mixin); const url = urlBase + '?' + query + '&w_rid=' + w_rid; const resp = await fetch(url, { credentials: 'include', headers: { Referer: location.origin + '/' } }); return resp.json(); } // ---------- 在线解析当前 B 站视频 ---------- function codecName(v) { const c = (v.codecs || '').toLowerCase(); if (c.startsWith('hvc1') || c.startsWith('hev1') || v.codecid === 12) return 'HEVC'; if (c.startsWith('avc1') || c.startsWith('avc3') || v.codecid === 7) return 'AVC'; if (c.startsWith('av01') || v.codecid === 13) return 'AV1'; return v.codecs || ('codecid:' + (v.codecid || '?')); } function isHevc(v) { const c = (v.codecs || '').toLowerCase(); return c.startsWith('hvc1') || c.startsWith('hev1') || v.codecid === 12; } function isAvc(v) { const c = (v.codecs || '').toLowerCase(); return c.startsWith('avc1') || c.startsWith('avc3') || v.codecid === 7; } function pickVideoStream(videoList) { if (!videoList || !videoList.length) return null; // 按清晰度 id 分组,优先最高清晰度;同清晰度优先 HEVC(hvc1/hev1),其次 AVC const byId = {}; for (const v of videoList) { const id = v.id || 0; if (!byId[id]) byId[id] = []; byId[id].push(v); } const ids = Object.keys(byId).map(Number).sort((a, b) => b - a); for (const id of ids) { const list = byId[id]; const hevc = list.find(isHevc); if (hevc) return hevc; } // 没有 HEVC 时,返回最高清晰度的 AVC;如果连 AVC 都没有则返回最高带宽 for (const id of ids) { const list = byId[id]; const avc = list.find(isAvc); if (avc) return avc; } return videoList.reduce((x, y) => ((y.bandwidth || 0) > (x.bandwidth || 0) ? y : x)); } function pickAudioStream(dash) { const sources = []; if (dash.audio && dash.audio.length) sources.push(...dash.audio); if (dash.dolby && dash.dolby.audio && dash.dolby.audio.length) sources.push(...dash.dolby.audio); if (dash.flac && dash.flac.audio && dash.flac.audio.length) sources.push(...dash.flac.audio); if (!sources.length) return null; // 优先无损/高码率:flac > dolby > 普通高码率 return sources.reduce((x, y) => ((y.bandwidth || 0) > (x.bandwidth || 0) ? y : x)); } async function parseCurrentVideo(log) { const m = location.href.match(/BV[0-9A-Za-z]+/); if (!m) throw new Error('当前页面未识别到 BV 号,请在本站视频播放页使用。'); const bvid = m[0]; log('读取视频信息: ' + bvid); const view = await wbiGet('https://api.bilibili.com/x/web-interface/view', { bvid }); if (view.code !== 0) throw new Error('获取视频信息失败: ' + (view.message || view.code)); const cid = view.data.pages[0].cid; const title = view.data.title || bvid; log('读取播放地址(WBI 签名)…'); // fnval=4048: 请求 DASH + HEVC/AV1/4K/8K 等全部可用流 const play = await wbiGet('https://api.bilibili.com/x/player/playurl', { bvid, cid, qn: 127, fnval: 4048, fourk: 1, fnver: 0, }); if (play.code !== 0 || !play.data || !play.data.dash) { throw new Error('获取播放地址失败: ' + (play.message || play.code) + '(可能需要登录/大会员,或所选清晰度受限)'); } const dash = play.data.dash; const v = pickVideoStream(dash.video); const a = pickAudioStream(dash); if (!v || !a) throw new Error('未获取到音视频流(可能需要登录或该清晰度受限)。'); return { title, videoUrl: v.baseUrl || v.base_url, audioUrl: a.baseUrl || a.base_url, vCodec: codecName(v), vId: v.id, }; } async function downloadArrayBuffer(url, log, label) { log('下载 ' + label + ' 数据流…'); const domain = url.replace(/^https?:\/\/([^/]+).*/, '$1'); try { const resp = await fetch(url, { // CDN 链接已带签名,不需要携带 B 站主站 cookie;带 credentials 在部分浏览器/扩展下会触发额外限制 credentials: 'omit', headers: { Referer: 'https://www.bilibili.com/' }, }); if (!resp.ok) throw new Error(label + ' 下载失败 (HTTP ' + resp.status + ' @ ' + domain + ')'); const buf = await resp.arrayBuffer(); log(label + ' 下载完成 (' + (buf.byteLength / 1048576).toFixed(1) + ' MB)'); return buf; } catch (e) { if (e.name === 'TypeError' && /fetch|network|cors/i.test(e.message)) { throw new Error(label + ' 下载被浏览器/扩展拦截 (CORS/混合内容/域名: ' + domain + ')。建议改用下方“本地合并”。'); } throw new Error(label + ' 下载失败: ' + e.message + ' (域名: ' + domain + ')'); } } // ---------- UI ---------- function buildUI() { if (document.getElementById('bili-m4s-panel')) return; const style = document.createElement('style'); style.textContent = `#bili-m4s-panel{position:fixed;right:16px;bottom:16px;width:340px;background:#fff;border:1px solid #fb7299;border-radius:12px;box-shadow:0 6px 24px rgba(0,0,0,.18);z-index:2147483647;font-family:-apple-system,"PingFang SC",Microsoft YaHei,sans-serif;font-size:13px;color:#222;overflow:hidden} #bili-m4s-panel .hd{background:#fb7299;color:#fff;padding:8px 12px;cursor:move;display:flex;justify-content:space-between;align-items:center;font-weight:600} #bili-m4s-panel .bd{padding:10px 12px;display:flex;flex-direction:column;gap:8px} #bili-m4s-panel input[type=file]{font-size:12px;width:100%} #bili-m4s-panel button{background:#fb7299;color:#fff;border:none;border-radius:8px;padding:8px;cursor:pointer;font-size:13px} #bili-m4s-panel button:disabled{opacity:.5;cursor:not-allowed} #bili-m4s-panel button.ghost{background:#fff;color:#fb7299;border:1px solid #fb7299} #bili-m4s-panel .log{background:#f6f7f9;border-radius:8px;padding:6px;height:110px;overflow:auto;white-space:pre-wrap;font-family:Consolas,monospace;font-size:11px;color:#333} #bili-m4s-panel .sec{border:1px solid #f0d6e0;border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:6px} #bili-m4s-panel .sec h4{margin:0;font-size:12px;color:#fb7299} #bili-m4s-panel .x{cursor:pointer;font-weight:400} #bili-m4s-panel .note{font-size:11px;color:#888;line-height:1.4} #bili-m4s-panel input.txt{width:100%;box-sizing:border-box;padding:4px;border:1px solid #ddd;border-radius:6px}`; document.head.appendChild(style); const panel = document.createElement('div'); panel.id = 'bili-m4s-panel'; panel.innerHTML = `
📦 B站 m4s 合并工具 v2

① 在线解析并下载合并(当前视频页)

自动读取当前播放页的 BV 号,下载音视频流并封装为 MP4。若提示跨域/登录限制,请改用下方本地合并。

② 本地文件合并(video.m4s + audio.m4s)

视频流
音频流
输出文件名
就绪。选择一种方式开始。
`; document.body.appendChild(panel); const log = (msg) => { const el = panel.querySelector('#m4s-log'); el.textContent += '\n' + msg; el.scrollTop = el.scrollHeight; }; panel.querySelector('.x').onclick = () => panel.remove(); // 拖拽 const hd = panel.querySelector('.hd'); let sx, sy, ox, oy, drag = false; hd.onmousedown = (e) => { drag = true; sx = e.clientX; sy = e.clientY; const r = panel.getBoundingClientRect(); ox = r.left; oy = r.top; }; document.onmousemove = (e) => { if (drag) { panel.style.left = (ox + e.clientX - sx) + 'px'; panel.style.top = (oy + e.clientY - sy) + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; } }; document.onmouseup = () => drag = false; const lock = (el) => (el.disabled = true); const unlock = (el) => (el.disabled = false); // 在线解析 panel.querySelector('#m4s-online').onclick = async () => { const btn = panel.querySelector('#m4s-online'); lock(btn); try { await ensureMP4Box(log); const info = await parseCurrentVideo(log); log('编码: ' + info.vCodec + ',清晰度ID: ' + info.vId + ',标题: ' + info.title); const vBuf = await downloadArrayBuffer(info.videoUrl, log, '视频'); const aBuf = await downloadArrayBuffer(info.audioUrl, log, '音频'); await mergeToMP4(vBuf, aBuf, info.title, log); } catch (e) { log('❌ ' + e.message); } finally { unlock(btn); } }; // 本地合并 panel.querySelector('#m4s-merge').onclick = async () => { const btn = panel.querySelector('#m4s-merge'); const v = panel.querySelector('#m4s-v').files[0]; const a = panel.querySelector('#m4s-a').files[0]; if (!v || !a) { log('请先选择视频流和音频流两个文件。'); return; } lock(btn); try { const vBuf = await v.arrayBuffer(); vBuf.fileStart = 0; const aBuf = await a.arrayBuffer(); aBuf.fileStart = 0; const name = panel.querySelector('#m4s-name').value || 'bilibili_merged'; await mergeToMP4(vBuf, aBuf, name, log); } catch (e) { log('❌ ' + e.message); } finally { unlock(btn); } }; } // 延迟确保页面稳定 setTimeout(buildUI, 1500); // 暴露给控制台手动调用 window.__biliM4sMerge = { mergeToMP4, parseCurrentVideo }; })();