From f37074727cd9a651ad96f2d15c9f7001f88ab170 Mon Sep 17 00:00:00 2001 From: Ziyuan Guo Date: Thu, 23 Sep 2021 17:13:47 +0900 Subject: [PATCH] add basic support for hdr10 video --- biliTwin.user.js | 15761 ++++++++++++++++---------------- biliTwinBabelCompiled.user.js | 931 +- src/biliuserjs/bilimonkey.js | 21 + 3 files changed, 8396 insertions(+), 8317 deletions(-) diff --git a/biliTwin.user.js b/biliTwin.user.js index c55bb63..20d5694 100644 --- a/biliTwin.user.js +++ b/biliTwin.user.js @@ -82,223 +82,223 @@ var window = typeof unsafeWindow !== "undefined" && unsafeWindow || self var top = window.top // workaround -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. -*/ - -/** - * Basically a Promise that exposes its resolve and reject callbacks - */ -class AsyncContainer { - /*** - * The thing is, if we cannot cancel a promise, we should at least be able to - * explicitly mark a promise as garbage collectible. - * - * Yes, this is something like cancelable Promise. But I insist they are different. - */ - constructor(callback) { - // 1. primary promise - this.primaryPromise = new Promise((s, j) => { - this.resolve = arg => { s(arg); return arg; }; - this.reject = arg => { j(arg); return arg; }; - }); - - // 2. hang promise - this.hangReturn = Symbol(); - this.hangPromise = new Promise(s => this.hang = () => s(this.hangReturn)); - this.destroiedThen = this.hangPromise.then.bind(this.hangPromise); - this.primaryPromise.then(() => this.state = 'fulfilled'); - this.primaryPromise.catch(() => this.state = 'rejected'); - this.hangPromise.then(() => this.state = 'hanged'); - - // 4. race - this.promise = Promise - .race([this.primaryPromise, this.hangPromise]) - .then(s => s == this.hangReturn ? new Promise(() => { }) : s); - - // 5. inherit - this.then = this.promise.then.bind(this.promise); - this.catch = this.promise.catch.bind(this.promise); - this.finally = this.promise.finally.bind(this.promise); - - // 6. optional callback - if (typeof callback == 'function') callback(this.resolve, this.reject); - } - - /*** - * Memory leak notice: - * - * The V8 implementation of Promise requires - * 1. the resolve handler of a Promise - * 2. the reject handler of a Promise - * 3. !! the Promise object itself !! - * to be garbage collectible to correctly free Promise runtime contextes - * - * This piece of code will work - * void (async () => { - * const buf = new Uint8Array(1024 * 1024 * 1024); - * for (let i = 0; i < buf.length; i++) buf[i] = i; - * await new Promise(() => { }); - * return buf; - * })(); - * if (typeof gc == 'function') gc(); - * - * This piece of code will cause a Promise context mem leak - * const deadPromise = new Promise(() => { }); - * void (async () => { - * const buf = new Uint8Array(1024 * 1024 * 1024); - * for (let i = 0; i < buf.length; i++) buf[i] = i; - * await deadPromise; - * return buf; - * })(); - * if (typeof gc == 'function') gc(); - * - * In other words, do NOT directly inherit from promise. You will need to - * dereference it on destroying. - */ - destroy() { - this.hang(); - this.resolve = () => { }; - this.reject = this.resolve; - this.hang = this.resolve; - this.primaryPromise = null; - this.hangPromise = null; - this.promise = null; - this.then = this.resolve; - this.catch = this.resolve; - this.finally = this.resolve; - this.destroiedThen = f => f(); - /*** - * For ease of debug, do not dereference hangReturn - * - * If run from console, mysteriously this tiny symbol will help correct gc - * before a console.clear - */ - //this.hangReturn = null; - } - - static _UNIT_TEST() { - const containers = []; - async function foo() { - const buf = new Uint8Array(600 * 1024 * 1024); - for (let i = 0; i < buf.length; i++) buf[i] = i; - const ac = new AsyncContainer(); - ac.destroiedThen(() => console.log('asyncContainer destroied')); - containers.push(ac); - await ac; - return buf; - } - const foos = [foo(), foo(), foo()]; - containers.forEach(e => e.destroy()); - console.warn('Check your RAM usage. I allocated 1.8GB in three dead-end promises.'); - return [foos, containers]; - } +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +/** + * Basically a Promise that exposes its resolve and reject callbacks + */ +class AsyncContainer { + /*** + * The thing is, if we cannot cancel a promise, we should at least be able to + * explicitly mark a promise as garbage collectible. + * + * Yes, this is something like cancelable Promise. But I insist they are different. + */ + constructor(callback) { + // 1. primary promise + this.primaryPromise = new Promise((s, j) => { + this.resolve = arg => { s(arg); return arg; }; + this.reject = arg => { j(arg); return arg; }; + }); + + // 2. hang promise + this.hangReturn = Symbol(); + this.hangPromise = new Promise(s => this.hang = () => s(this.hangReturn)); + this.destroiedThen = this.hangPromise.then.bind(this.hangPromise); + this.primaryPromise.then(() => this.state = 'fulfilled'); + this.primaryPromise.catch(() => this.state = 'rejected'); + this.hangPromise.then(() => this.state = 'hanged'); + + // 4. race + this.promise = Promise + .race([this.primaryPromise, this.hangPromise]) + .then(s => s == this.hangReturn ? new Promise(() => { }) : s); + + // 5. inherit + this.then = this.promise.then.bind(this.promise); + this.catch = this.promise.catch.bind(this.promise); + this.finally = this.promise.finally.bind(this.promise); + + // 6. optional callback + if (typeof callback == 'function') callback(this.resolve, this.reject); + } + + /*** + * Memory leak notice: + * + * The V8 implementation of Promise requires + * 1. the resolve handler of a Promise + * 2. the reject handler of a Promise + * 3. !! the Promise object itself !! + * to be garbage collectible to correctly free Promise runtime contextes + * + * This piece of code will work + * void (async () => { + * const buf = new Uint8Array(1024 * 1024 * 1024); + * for (let i = 0; i < buf.length; i++) buf[i] = i; + * await new Promise(() => { }); + * return buf; + * })(); + * if (typeof gc == 'function') gc(); + * + * This piece of code will cause a Promise context mem leak + * const deadPromise = new Promise(() => { }); + * void (async () => { + * const buf = new Uint8Array(1024 * 1024 * 1024); + * for (let i = 0; i < buf.length; i++) buf[i] = i; + * await deadPromise; + * return buf; + * })(); + * if (typeof gc == 'function') gc(); + * + * In other words, do NOT directly inherit from promise. You will need to + * dereference it on destroying. + */ + destroy() { + this.hang(); + this.resolve = () => { }; + this.reject = this.resolve; + this.hang = this.resolve; + this.primaryPromise = null; + this.hangPromise = null; + this.promise = null; + this.then = this.resolve; + this.catch = this.resolve; + this.finally = this.resolve; + this.destroiedThen = f => f(); + /*** + * For ease of debug, do not dereference hangReturn + * + * If run from console, mysteriously this tiny symbol will help correct gc + * before a console.clear + */ + //this.hangReturn = null; + } + + static _UNIT_TEST() { + const containers = []; + async function foo() { + const buf = new Uint8Array(600 * 1024 * 1024); + for (let i = 0; i < buf.length; i++) buf[i] = i; + const ac = new AsyncContainer(); + ac.destroiedThen(() => console.log('asyncContainer destroied')); + containers.push(ac); + await ac; + return buf; + } + const foos = [foo(), foo(), foo()]; + containers.forEach(e => e.destroy()); + console.warn('Check your RAM usage. I allocated 1.8GB in three dead-end promises.'); + return [foos, containers]; + } } -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. -*/ - -/** - * Provides common util for all bilibili user scripts - */ -class BiliUserJS { - static async getPlayerWin() { - if (location.href.includes('/watchlater/#/list')) { - await new Promise(resolve => { - window.addEventListener('hashchange', () => resolve(location.href), { once: true }); - }); - } - if (!document.getElementById('bilibili-player')) { - if (document.querySelector("video")) { - top.location.reload(); // 刷新 - } else { - await new Promise(resolve => { - const observer = new MutationObserver(() => { - if (document.getElementById('bilibili-player')) { - resolve(document.getElementById('bilibili-player')); - observer.disconnect(); - } - }); - observer.observe(document, { childList: true, subtree: true }); - }); - } - } - if (document.getElementById('bilibiliPlayer')) { - return window; - } - else { - return new Promise(resolve => { - const observer = new MutationObserver(() => { - if (document.getElementById('bilibiliPlayer')) { - observer.disconnect(); - resolve(window); - } - }); - observer.observe(document.getElementById('bilibili-player'), { childList: true }); - }); - } - } - - static tryGetPlayerWinSync() { - if (document.getElementById('bilibiliPlayer')) { - return window; - } - else if (document.querySelector('#bofqi > object')) { - throw 'Need H5 Player'; - } - } - - static getCidRefreshPromise(playerWin) { - /*********** - * !!!Race condition!!! - * We must finish everything within one microtask queue! - * - * bilibili script: - * videoElement.remove() -> setTimeout(0) -> [[microtask]] -> load playurl - * \- synchronous macrotask -/ || \- synchronous - * || - * the only position to inject monkey.sniffDefaultFormat - */ - const cidRefresh = new AsyncContainer(); - - // 1. no active video element in document => cid refresh - const observer = new MutationObserver(() => { - if (!playerWin.document.getElementsByTagName('video')[0]) { - observer.disconnect(); - cidRefresh.resolve(); - } - }); - observer.observe(playerWin.document.getElementById('bilibiliPlayer'), { childList: true }); - - // 2. playerWin unload => cid refresh - playerWin.addEventListener('unload', () => Promise.resolve().then(() => cidRefresh.resolve())); - - return cidRefresh; - } - - static async domContentLoadedThen(func) { - if (document.readyState == 'loading') { - return new Promise(resolve => { - document.addEventListener('DOMContentLoaded', () => resolve(func()), { once: true }); - }) - } - else { - return func(); - } - } +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +/** + * Provides common util for all bilibili user scripts + */ +class BiliUserJS { + static async getPlayerWin() { + if (location.href.includes('/watchlater/#/list')) { + await new Promise(resolve => { + window.addEventListener('hashchange', () => resolve(location.href), { once: true }); + }); + } + if (!document.getElementById('bilibili-player')) { + if (document.querySelector("video")) { + top.location.reload(); // 刷新 + } else { + await new Promise(resolve => { + const observer = new MutationObserver(() => { + if (document.getElementById('bilibili-player')) { + resolve(document.getElementById('bilibili-player')); + observer.disconnect(); + } + }); + observer.observe(document, { childList: true, subtree: true }); + }); + } + } + if (document.getElementById('bilibiliPlayer')) { + return window; + } + else { + return new Promise(resolve => { + const observer = new MutationObserver(() => { + if (document.getElementById('bilibiliPlayer')) { + observer.disconnect(); + resolve(window); + } + }); + observer.observe(document.getElementById('bilibili-player'), { childList: true }); + }); + } + } + + static tryGetPlayerWinSync() { + if (document.getElementById('bilibiliPlayer')) { + return window; + } + else if (document.querySelector('#bofqi > object')) { + throw 'Need H5 Player'; + } + } + + static getCidRefreshPromise(playerWin) { + /*********** + * !!!Race condition!!! + * We must finish everything within one microtask queue! + * + * bilibili script: + * videoElement.remove() -> setTimeout(0) -> [[microtask]] -> load playurl + * \- synchronous macrotask -/ || \- synchronous + * || + * the only position to inject monkey.sniffDefaultFormat + */ + const cidRefresh = new AsyncContainer(); + + // 1. no active video element in document => cid refresh + const observer = new MutationObserver(() => { + if (!playerWin.document.getElementsByTagName('video')[0]) { + observer.disconnect(); + cidRefresh.resolve(); + } + }); + observer.observe(playerWin.document.getElementById('bilibiliPlayer'), { childList: true }); + + // 2. playerWin unload => cid refresh + playerWin.addEventListener('unload', () => Promise.resolve().then(() => cidRefresh.resolve())); + + return cidRefresh; + } + + static async domContentLoadedThen(func) { + if (document.readyState == 'loading') { + return new Promise(resolve => { + document.addEventListener('DOMContentLoaded', () => resolve(func()), { once: true }); + }) + } + else { + return func(); + } + } } /** @@ -320,179 +320,179 @@ const setTimeoutDo = (promise, ms) => { return Promise.race([promise, t]) }; -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. -*/ - -/** - * A promisified indexedDB with large file(>100MB) support - */ -class CacheDB { - constructor(dbName = 'biliMonkey', osName = 'flv', keyPath = 'name', maxItemSize = 100 * 1024 * 1024) { - // Neither Chrome or Firefox can handle item size > 100M - this.dbName = dbName; - this.osName = osName; - this.keyPath = keyPath; - this.maxItemSize = maxItemSize; - this.db = null; - } - - async getDB() { - if (this.db) return this.db; - this.db = await new Promise((resolve, reject) => { - const openRequest = indexedDB.open(this.dbName); - openRequest.onupgradeneeded = e => { - const db = e.target.result; - if (!db.objectStoreNames.contains(this.osName)) { - db.createObjectStore(this.osName, { keyPath: this.keyPath }); - } - }; - openRequest.onsuccess = e => { - return resolve(e.target.result); - }; - openRequest.onerror = reject; - }); - return this.db; - } - - async addData(item, name = item.name, data = item.data || item) { - if (!data instanceof Blob) throw 'CacheDB: data must be a Blob'; - const itemChunks = []; - const numChunks = Math.ceil(data.size / this.maxItemSize); - for (let i = 0; i < numChunks; i++) { - itemChunks.push({ - name: `${name}/part_${i}`, - numChunks, - data: data.slice(i * this.maxItemSize, (i + 1) * this.maxItemSize) - }); - } - - const reqCascade = new Promise(async (resolve, reject) => { - const db = await this.getDB(); - const objectStore = db.transaction([this.osName], 'readwrite').objectStore(this.osName); - const onsuccess = e => { - const chunk = itemChunks.pop(); - if (!chunk) return resolve(e); - const req = objectStore.add(chunk); - req.onerror = reject; - req.onsuccess = onsuccess; - }; - onsuccess(); - }); - - return reqCascade; - } - - async putData(item, name = item.name, data = item.data || item) { - if (!data instanceof Blob) throw 'CacheDB: data must be a Blob'; - const itemChunks = []; - const numChunks = Math.ceil(data.size / this.maxItemSize); - for (let i = 0; i < numChunks; i++) { - itemChunks.push({ - name: `${name}/part_${i}`, - numChunks, - data: data.slice(i * this.maxItemSize, (i + 1) * this.maxItemSize) - }); - } - - const reqCascade = new Promise(async (resolve, reject) => { - const db = await this.getDB(); - const objectStore = db.transaction([this.osName], 'readwrite').objectStore(this.osName); - const onsuccess = e => { - const chunk = itemChunks.pop(); - if (!chunk) return resolve(e); - const req = objectStore.put(chunk); - req.onerror = reject; - req.onsuccess = onsuccess; - }; - onsuccess(); - }); - - return reqCascade; - } - - async getData(name) { - const reqCascade = new Promise(async (resolve, reject) => { - const dataChunks = []; - const db = await this.getDB(); // 浏览器默认在隐私浏览模式中禁用 IndexedDB ,这一步会超时 - const objectStore = db.transaction([this.osName], 'readwrite').objectStore(this.osName); - const probe = objectStore.get(`${name}/part_0`); - probe.onerror = reject; - probe.onsuccess = e => { - // 1. Probe fails => key does not exist - if (!probe.result) return resolve(null); - - // 2. How many chunks to retrieve? - const { numChunks } = probe.result; - - // 3. Cascade on the remaining chunks - const onsuccess = e => { - dataChunks.push(e.target.result.data); - if (dataChunks.length == numChunks) return resolve(dataChunks); - const req = objectStore.get(`${name}/part_${dataChunks.length}`); - req.onerror = reject; - req.onsuccess = onsuccess; - }; - onsuccess(e); - }; - }); - - // 浏览器默认在隐私浏览模式中禁用 IndexedDB ,添加超时 - const dataChunks = await setTimeoutDo(reqCascade, 5 * 1000); - - return dataChunks ? { name, data: new Blob(dataChunks) } : null; - } - - async deleteData(name) { - const reqCascade = new Promise(async (resolve, reject) => { - let currentChunkNum = 0; - const db = await this.getDB(); - const objectStore = db.transaction([this.osName], 'readwrite').objectStore(this.osName); - const probe = objectStore.get(`${name}/part_0`); - probe.onerror = reject; - probe.onsuccess = e => { - // 1. Probe fails => key does not exist - if (!probe.result) return resolve(null); - - // 2. How many chunks to delete? - const { numChunks } = probe.result; - - // 3. Cascade on the remaining chunks - const onsuccess = e => { - const req = objectStore.delete(`${name}/part_${currentChunkNum}`); - req.onerror = reject; - req.onsuccess = onsuccess; - currentChunkNum++; - if (currentChunkNum == numChunks) return resolve(e); - }; - onsuccess(); - }; - }); - - return reqCascade; - } - - async deleteEntireDB() { - const req = indexedDB.deleteDatabase(this.dbName); - return new Promise((resolve, reject) => { - req.onsuccess = () => resolve(this.db = null); - req.onerror = reject; - }); - } - - static async _UNIT_TEST() { - let db = new CacheDB(); - console.warn('Storing 201MB...'); - console.log(await db.putData(new Blob([new ArrayBuffer(201 * 1024 * 1024)]), 'test')); - console.warn('Deleting 201MB...'); - console.log(await db.deleteData('test')); - } +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +/** + * A promisified indexedDB with large file(>100MB) support + */ +class CacheDB { + constructor(dbName = 'biliMonkey', osName = 'flv', keyPath = 'name', maxItemSize = 100 * 1024 * 1024) { + // Neither Chrome or Firefox can handle item size > 100M + this.dbName = dbName; + this.osName = osName; + this.keyPath = keyPath; + this.maxItemSize = maxItemSize; + this.db = null; + } + + async getDB() { + if (this.db) return this.db; + this.db = await new Promise((resolve, reject) => { + const openRequest = indexedDB.open(this.dbName); + openRequest.onupgradeneeded = e => { + const db = e.target.result; + if (!db.objectStoreNames.contains(this.osName)) { + db.createObjectStore(this.osName, { keyPath: this.keyPath }); + } + }; + openRequest.onsuccess = e => { + return resolve(e.target.result); + }; + openRequest.onerror = reject; + }); + return this.db; + } + + async addData(item, name = item.name, data = item.data || item) { + if (!data instanceof Blob) throw 'CacheDB: data must be a Blob'; + const itemChunks = []; + const numChunks = Math.ceil(data.size / this.maxItemSize); + for (let i = 0; i < numChunks; i++) { + itemChunks.push({ + name: `${name}/part_${i}`, + numChunks, + data: data.slice(i * this.maxItemSize, (i + 1) * this.maxItemSize) + }); + } + + const reqCascade = new Promise(async (resolve, reject) => { + const db = await this.getDB(); + const objectStore = db.transaction([this.osName], 'readwrite').objectStore(this.osName); + const onsuccess = e => { + const chunk = itemChunks.pop(); + if (!chunk) return resolve(e); + const req = objectStore.add(chunk); + req.onerror = reject; + req.onsuccess = onsuccess; + }; + onsuccess(); + }); + + return reqCascade; + } + + async putData(item, name = item.name, data = item.data || item) { + if (!data instanceof Blob) throw 'CacheDB: data must be a Blob'; + const itemChunks = []; + const numChunks = Math.ceil(data.size / this.maxItemSize); + for (let i = 0; i < numChunks; i++) { + itemChunks.push({ + name: `${name}/part_${i}`, + numChunks, + data: data.slice(i * this.maxItemSize, (i + 1) * this.maxItemSize) + }); + } + + const reqCascade = new Promise(async (resolve, reject) => { + const db = await this.getDB(); + const objectStore = db.transaction([this.osName], 'readwrite').objectStore(this.osName); + const onsuccess = e => { + const chunk = itemChunks.pop(); + if (!chunk) return resolve(e); + const req = objectStore.put(chunk); + req.onerror = reject; + req.onsuccess = onsuccess; + }; + onsuccess(); + }); + + return reqCascade; + } + + async getData(name) { + const reqCascade = new Promise(async (resolve, reject) => { + const dataChunks = []; + const db = await this.getDB(); // 浏览器默认在隐私浏览模式中禁用 IndexedDB ,这一步会超时 + const objectStore = db.transaction([this.osName], 'readwrite').objectStore(this.osName); + const probe = objectStore.get(`${name}/part_0`); + probe.onerror = reject; + probe.onsuccess = e => { + // 1. Probe fails => key does not exist + if (!probe.result) return resolve(null); + + // 2. How many chunks to retrieve? + const { numChunks } = probe.result; + + // 3. Cascade on the remaining chunks + const onsuccess = e => { + dataChunks.push(e.target.result.data); + if (dataChunks.length == numChunks) return resolve(dataChunks); + const req = objectStore.get(`${name}/part_${dataChunks.length}`); + req.onerror = reject; + req.onsuccess = onsuccess; + }; + onsuccess(e); + }; + }); + + // 浏览器默认在隐私浏览模式中禁用 IndexedDB ,添加超时 + const dataChunks = await setTimeoutDo(reqCascade, 5 * 1000); + + return dataChunks ? { name, data: new Blob(dataChunks) } : null; + } + + async deleteData(name) { + const reqCascade = new Promise(async (resolve, reject) => { + let currentChunkNum = 0; + const db = await this.getDB(); + const objectStore = db.transaction([this.osName], 'readwrite').objectStore(this.osName); + const probe = objectStore.get(`${name}/part_0`); + probe.onerror = reject; + probe.onsuccess = e => { + // 1. Probe fails => key does not exist + if (!probe.result) return resolve(null); + + // 2. How many chunks to delete? + const { numChunks } = probe.result; + + // 3. Cascade on the remaining chunks + const onsuccess = e => { + const req = objectStore.delete(`${name}/part_${currentChunkNum}`); + req.onerror = reject; + req.onsuccess = onsuccess; + currentChunkNum++; + if (currentChunkNum == numChunks) return resolve(e); + }; + onsuccess(); + }; + }); + + return reqCascade; + } + + async deleteEntireDB() { + const req = indexedDB.deleteDatabase(this.dbName); + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(this.db = null); + req.onerror = reject; + }); + } + + static async _UNIT_TEST() { + let db = new CacheDB(); + console.warn('Storing 201MB...'); + console.log(await db.putData(new Blob([new ArrayBuffer(201 * 1024 * 1024)]), 'test')); + console.warn('Deleting 201MB...'); + console.log(await db.deleteData('test')); + } } /*** @@ -723,151 +723,151 @@ class Mutex { } } -/** - * @typedef DanmakuColor - * @property {number} r - * @property {number} g - * @property {number} b - */ - /** - * @typedef Danmaku - * @property {string} text - * @property {number} time - * @property {string} mode - * @property {number} size - * @property {DanmakuColor} color - * @property {boolean} bottom - * @property {string=} sender - */ - - const parser = {}; - - /** - * @param {Danmaku} danmaku - * @returns {boolean} - */ - const danmakuFilter = danmaku => { - if (!danmaku) return false; - if (!danmaku.text) return false; - if (!danmaku.mode) return false; - if (!danmaku.size) return false; - if (danmaku.time < 0 || danmaku.time >= 360000) return false; - return true; - }; - - const parseRgb256IntegerColor = color => { - const rgb = parseInt(color, 10); - const r = (rgb >>> 4) & 0xff; - const g = (rgb >>> 2) & 0xff; - const b = (rgb >>> 0) & 0xff; - return { r, g, b }; - }; - - const parseNiconicoColor = mail => { - const colorTable = { - red: { r: 255, g: 0, b: 0 }, - pink: { r: 255, g: 128, b: 128 }, - orange: { r: 255, g: 184, b: 0 }, - yellow: { r: 255, g: 255, b: 0 }, - green: { r: 0, g: 255, b: 0 }, - cyan: { r: 0, g: 255, b: 255 }, - blue: { r: 0, g: 0, b: 255 }, - purple: { r: 184, g: 0, b: 255 }, - black: { r: 0, g: 0, b: 0 }, - }; - const defaultColor = { r: 255, g: 255, b: 255 }; - const line = mail.toLowerCase().split(/\s+/); - const color = Object.keys(colorTable).find(color => line.includes(color)); - return color ? colorTable[color] : defaultColor; - }; - - const parseNiconicoMode = mail => { - const line = mail.toLowerCase().split(/\s+/); - if (line.includes('ue')) return 'TOP'; - if (line.includes('shita')) return 'BOTTOM'; - return 'RTL'; - }; - - const parseNiconicoSize = mail => { - const line = mail.toLowerCase().split(/\s+/); - if (line.includes('big')) return 36; - if (line.includes('small')) return 16; - return 25; - }; - - /** - * @param {string|ArrayBuffer} content - * @return {{ cid: number, danmaku: Array }} - */ - parser.bilibili = function (content) { - const text = typeof content === 'string' ? content : new TextDecoder('utf-8').decode(content); - const clean = text.replace(/(?:[\0-\x08\x0B\f\x0E-\x1F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF])/g, '').replace(/.*?\?>/,""); - const data = (new DOMParser()).parseFromString(clean, 'text/xml'); - const cid = +data.querySelector('chatid,oid').textContent; - /** @type {Array} */ - const danmaku = Array.from(data.querySelectorAll('d')).map(d => { - const p = d.getAttribute('p'); - const [time, mode, size, color, create, bottom, sender, id] = p.split(','); - return { - text: d.textContent, - time: +time, - // We do not support ltr mode - mode: [null, 'RTL', 'RTL', 'RTL', 'BOTTOM', 'TOP'][+mode], - size: +size, - color: parseRgb256IntegerColor(color), - bottom: bottom > 0, - sender, - }; - }).filter(danmakuFilter); - return { cid, danmaku }; - }; - - /** - * @param {string|ArrayBuffer} content - * @return {{ cid: number, danmaku: Array }} - */ - parser.acfun = function (content) { - const text = typeof content === 'string' ? content : new TextDecoder('utf-8').decode(content); - const data = JSON.parse(text); - const list = data.reduce((x, y) => x.concat(y), []); - const danmaku = list.map(line => { - const [time, color, mode, size, sender, create, uuid] = line.c.split(','), text = line.m; - return { - text, - time: +time, - color: parseRgb256IntegerColor(+color), - mode: [null, 'RTL', null, null, 'BOTTOM', 'TOP'][mode], - size: +size, - bottom: false, - uuid, - }; - }).filter(danmakuFilter); - return { danmaku }; - }; - - /** - * @param {string|ArrayBuffer} content - * @return {{ cid: number, danmaku: Array }} - */ - parser.niconico = function (content) { - const text = typeof content === 'string' ? content : new TextDecoder('utf-8').decode(content); - const data = JSON.parse(text); - const list = data.map(item => item.chat).filter(x => x); - const { thread } = list.find(comment => comment.thread); - const danmaku = list.map(comment => { - if (!comment.content || !(comment.vpos >= 0) || !comment.no) return null; - const { vpos, mail = '', content, no } = comment; - return { - text: content, - time: vpos / 100, - color: parseNiconicoColor(mail), - mode: parseNiconicoMode(mail), - size: parseNiconicoSize(mail), - bottom: false, - id: no, - }; - }).filter(danmakuFilter); - return { thread, danmaku }; +/** + * @typedef DanmakuColor + * @property {number} r + * @property {number} g + * @property {number} b + */ + /** + * @typedef Danmaku + * @property {string} text + * @property {number} time + * @property {string} mode + * @property {number} size + * @property {DanmakuColor} color + * @property {boolean} bottom + * @property {string=} sender + */ + + const parser = {}; + + /** + * @param {Danmaku} danmaku + * @returns {boolean} + */ + const danmakuFilter = danmaku => { + if (!danmaku) return false; + if (!danmaku.text) return false; + if (!danmaku.mode) return false; + if (!danmaku.size) return false; + if (danmaku.time < 0 || danmaku.time >= 360000) return false; + return true; + }; + + const parseRgb256IntegerColor = color => { + const rgb = parseInt(color, 10); + const r = (rgb >>> 4) & 0xff; + const g = (rgb >>> 2) & 0xff; + const b = (rgb >>> 0) & 0xff; + return { r, g, b }; + }; + + const parseNiconicoColor = mail => { + const colorTable = { + red: { r: 255, g: 0, b: 0 }, + pink: { r: 255, g: 128, b: 128 }, + orange: { r: 255, g: 184, b: 0 }, + yellow: { r: 255, g: 255, b: 0 }, + green: { r: 0, g: 255, b: 0 }, + cyan: { r: 0, g: 255, b: 255 }, + blue: { r: 0, g: 0, b: 255 }, + purple: { r: 184, g: 0, b: 255 }, + black: { r: 0, g: 0, b: 0 }, + }; + const defaultColor = { r: 255, g: 255, b: 255 }; + const line = mail.toLowerCase().split(/\s+/); + const color = Object.keys(colorTable).find(color => line.includes(color)); + return color ? colorTable[color] : defaultColor; + }; + + const parseNiconicoMode = mail => { + const line = mail.toLowerCase().split(/\s+/); + if (line.includes('ue')) return 'TOP'; + if (line.includes('shita')) return 'BOTTOM'; + return 'RTL'; + }; + + const parseNiconicoSize = mail => { + const line = mail.toLowerCase().split(/\s+/); + if (line.includes('big')) return 36; + if (line.includes('small')) return 16; + return 25; + }; + + /** + * @param {string|ArrayBuffer} content + * @return {{ cid: number, danmaku: Array }} + */ + parser.bilibili = function (content) { + const text = typeof content === 'string' ? content : new TextDecoder('utf-8').decode(content); + const clean = text.replace(/(?:[\0-\x08\x0B\f\x0E-\x1F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF])/g, '').replace(/.*?\?>/,""); + const data = (new DOMParser()).parseFromString(clean, 'text/xml'); + const cid = +data.querySelector('chatid,oid').textContent; + /** @type {Array} */ + const danmaku = Array.from(data.querySelectorAll('d')).map(d => { + const p = d.getAttribute('p'); + const [time, mode, size, color, create, bottom, sender, id] = p.split(','); + return { + text: d.textContent, + time: +time, + // We do not support ltr mode + mode: [null, 'RTL', 'RTL', 'RTL', 'BOTTOM', 'TOP'][+mode], + size: +size, + color: parseRgb256IntegerColor(color), + bottom: bottom > 0, + sender, + }; + }).filter(danmakuFilter); + return { cid, danmaku }; + }; + + /** + * @param {string|ArrayBuffer} content + * @return {{ cid: number, danmaku: Array }} + */ + parser.acfun = function (content) { + const text = typeof content === 'string' ? content : new TextDecoder('utf-8').decode(content); + const data = JSON.parse(text); + const list = data.reduce((x, y) => x.concat(y), []); + const danmaku = list.map(line => { + const [time, color, mode, size, sender, create, uuid] = line.c.split(','), text = line.m; + return { + text, + time: +time, + color: parseRgb256IntegerColor(+color), + mode: [null, 'RTL', null, null, 'BOTTOM', 'TOP'][mode], + size: +size, + bottom: false, + uuid, + }; + }).filter(danmakuFilter); + return { danmaku }; + }; + + /** + * @param {string|ArrayBuffer} content + * @return {{ cid: number, danmaku: Array }} + */ + parser.niconico = function (content) { + const text = typeof content === 'string' ? content : new TextDecoder('utf-8').decode(content); + const data = JSON.parse(text); + const list = data.map(item => item.chat).filter(x => x); + const { thread } = list.find(comment => comment.thread); + const danmaku = list.map(comment => { + if (!comment.content || !(comment.vpos >= 0) || !comment.no) return null; + const { vpos, mail = '', content, no } = comment; + return { + text: content, + time: vpos / 100, + color: parseNiconicoColor(mail), + mode: parseNiconicoMode(mail), + size: parseNiconicoSize(mail), + bottom: false, + id: no, + }; + }).filter(danmakuFilter); + return { thread, danmaku }; }; const font = {}; @@ -943,237 +943,237 @@ font.valid = (function () { return validFont; }()); -const rtlCanvas = function (options) { - const { - resolutionX: wc, // width of canvas - resolutionY: hc, // height of canvas - bottomReserved: b, // reserved bottom height for subtitle - rtlDuration: u, // duration appeared on screen - maxDelay: maxr, // max allowed delay - } = options; - - // Initial canvas border - let used = [ - // p: top - // m: bottom - // tf: time completely enter screen - // td: time completely leave screen - // b: allow conflict with subtitle - // add a fake danmaku for describe top of screen - { p: -Infinity, m: 0, tf: Infinity, td: Infinity, b: false }, - // add a fake danmaku for describe bottom of screen - { p: hc, m: Infinity, tf: Infinity, td: Infinity, b: false }, - // add a fake danmaku for placeholder of subtitle - { p: hc - b, m: hc, tf: Infinity, td: Infinity, b: true }, - ]; - // Find out some position is available - const available = (hv, t0s, t0l, b) => { - const suggestion = []; - // Upper edge of candidate position should always be bottom of other danmaku (or top of screen) - used.forEach(i => { - if (i.m + hv >= hc) return; - const p = i.m; - const m = p + hv; - let tas = t0s; - let tal = t0l; - // and left border should be right edge of others - used.forEach(j => { - if (j.p >= m) return; - if (j.m <= p) return; - if (j.b && b) return; - tas = Math.max(tas, j.tf); - tal = Math.max(tal, j.td); - }); - const r = Math.max(tas - t0s, tal - t0l); - if (r > maxr) return; - // save a candidate position - suggestion.push({ p, r }); - }); - // sorted by its vertical position - suggestion.sort((x, y) => x.p - y.p); - let mr = maxr; - // the bottom and later choice should be ignored - const filtered = suggestion.filter(i => { - if (i.r >= mr) return false; - mr = i.r; - return true; - }); - return filtered; - }; - // mark some area as used - let use = (p, m, tf, td) => { - used.push({ p, m, tf, td, b: false }); - }; - // remove danmaku not needed anymore by its time - const syn = (t0s, t0l) => { - used = used.filter(i => i.tf > t0s || i.td > t0l); - }; - // give a score in range [0, 1) for some position - const score = i => { - if (i.r > maxr) return -Infinity; - return 1 - Math.hypot(i.r / maxr, i.p / hc) * Math.SQRT1_2; - }; - // add some danmaku - return line => { - const { - time: t0s, // time sent (start to appear if no delay) - width: wv, // width of danmaku - height: hv, // height of danmaku - bottom: b, // is subtitle - } = line; - const t0l = wc / (wv + wc) * u + t0s; // time start to leave - syn(t0s, t0l); - const al = available(hv, t0s, t0l, b); - if (!al.length) return null; - const scored = al.map(i => [score(i), i]); - const best = scored.reduce((x, y) => { - return x[0] > y[0] ? x : y; - })[1]; - const ts = t0s + best.r; // time start to enter - const tf = wv / (wv + wc) * u + ts; // time complete enter - const td = u + ts; // time complete leave - use(best.p, best.p + hv, tf, td); - return { - top: best.p, - time: ts, - }; - }; - }; - - const fixedCanvas = function (options) { - const { - resolutionY: hc, - bottomReserved: b, - fixDuration: u, - maxDelay: maxr, - } = options; - let used = [ - { p: -Infinity, m: 0, td: Infinity, b: false }, - { p: hc, m: Infinity, td: Infinity, b: false }, - { p: hc - b, m: hc, td: Infinity, b: true }, - ]; - // Find out some available position - const fr = (p, m, t0s, b) => { - let tas = t0s; - used.forEach(j => { - if (j.p >= m) return; - if (j.m <= p) return; - if (j.b && b) return; - tas = Math.max(tas, j.td); - }); - const r = tas - t0s; - if (r > maxr) return null; - return { r, p, m }; - }; - // layout for danmaku at top - const top = (hv, t0s, b) => { - const suggestion = []; - used.forEach(i => { - if (i.m + hv >= hc) return; - suggestion.push(fr(i.m, i.m + hv, t0s, b)); - }); - return suggestion.filter(x => x); - }; - // layout for danmaku at bottom - const bottom = (hv, t0s, b) => { - const suggestion = []; - used.forEach(i => { - if (i.p - hv <= 0) return; - suggestion.push(fr(i.p - hv, i.p, t0s, b)); - }); - return suggestion.filter(x => x); - }; - const use = (p, m, td) => { - used.push({ p, m, td, b: false }); - }; - const syn = t0s => { - used = used.filter(i => i.td > t0s); - }; - // Score every position - const score = (i, is_top) => { - if (i.r > maxr) return -Infinity; - const f = p => is_top ? p : (hc - p); - return 1 - (i.r / maxr * (31 / 32) + f(i.p) / hc * (1 / 32)); - }; - return function (line) { - const { time: t0s, height: hv, bottom: b } = line; - const is_top = line.mode === 'TOP'; - syn(t0s); - const al = (is_top ? top : bottom)(hv, t0s, b); - if (!al.length) return null; - const scored = al.map(function (i) { return [score(i, is_top), i]; }); - const best = scored.reduce(function (x, y) { - return x[0] > y[0] ? x : y; - }, [-Infinity, null])[1]; - if (!best) return null; - use(best.p, best.m, best.r + t0s + u); - return { top: best.p, time: best.r + t0s }; - }; - }; - - const placeDanmaku = function (options) { - const layers = options.maxOverlap; - const normal = Array(layers).fill(null).map(x => rtlCanvas(options)); - const fixed = Array(layers).fill(null).map(x => fixedCanvas(options)); - return function (line) { - line.fontSize = Math.round(line.size * options.fontSize); - line.height = line.fontSize; - line.width = line.width || font.text(options.fontFamily, line.text, line.fontSize) || 1; - - if (line.mode === 'RTL') { - const pos = normal.reduce((pos, layer) => pos || layer(line), null); - if (!pos) return null; - const { top, time } = pos; - line.layout = { - type: 'Rtl', - start: { - x: options.resolutionX + line.width / 2, - y: top + line.height, - time, - }, - end: { - x: -line.width / 2, - y: top + line.height, - time: options.rtlDuration + time, - }, - }; - } else if (['TOP', 'BOTTOM'].includes(line.mode)) { - const pos = fixed.reduce((pos, layer) => pos || layer(line), null); - if (!pos) return null; - const { top, time } = pos; - line.layout = { - type: 'Fix', - start: { - x: Math.round(options.resolutionX / 2), - y: top + line.height, - time, - }, - end: { - time: options.fixDuration + time, - }, - }; - } - return line; - }; - }; - - // main layout algorithm - const layout = async function (danmaku, optionGetter) { - const options = JSON.parse(JSON.stringify(optionGetter)); - const sorted = danmaku.slice(0).sort(({ time: x }, { time: y }) => x - y); - const place = placeDanmaku(options); - const result = Array(sorted.length); - let length = 0; - for (let i = 0, l = sorted.length; i < l; i++) { - let placed = place(sorted[i]); - if (placed) result[length++] = placed; - if ((i + 1) % 1000 === 0) { - await new Promise(resolve => setTimeout(resolve, 0)); - } - } - result.length = length; - result.sort((x, y) => x.layout.start.time - y.layout.start.time); - return result; +const rtlCanvas = function (options) { + const { + resolutionX: wc, // width of canvas + resolutionY: hc, // height of canvas + bottomReserved: b, // reserved bottom height for subtitle + rtlDuration: u, // duration appeared on screen + maxDelay: maxr, // max allowed delay + } = options; + + // Initial canvas border + let used = [ + // p: top + // m: bottom + // tf: time completely enter screen + // td: time completely leave screen + // b: allow conflict with subtitle + // add a fake danmaku for describe top of screen + { p: -Infinity, m: 0, tf: Infinity, td: Infinity, b: false }, + // add a fake danmaku for describe bottom of screen + { p: hc, m: Infinity, tf: Infinity, td: Infinity, b: false }, + // add a fake danmaku for placeholder of subtitle + { p: hc - b, m: hc, tf: Infinity, td: Infinity, b: true }, + ]; + // Find out some position is available + const available = (hv, t0s, t0l, b) => { + const suggestion = []; + // Upper edge of candidate position should always be bottom of other danmaku (or top of screen) + used.forEach(i => { + if (i.m + hv >= hc) return; + const p = i.m; + const m = p + hv; + let tas = t0s; + let tal = t0l; + // and left border should be right edge of others + used.forEach(j => { + if (j.p >= m) return; + if (j.m <= p) return; + if (j.b && b) return; + tas = Math.max(tas, j.tf); + tal = Math.max(tal, j.td); + }); + const r = Math.max(tas - t0s, tal - t0l); + if (r > maxr) return; + // save a candidate position + suggestion.push({ p, r }); + }); + // sorted by its vertical position + suggestion.sort((x, y) => x.p - y.p); + let mr = maxr; + // the bottom and later choice should be ignored + const filtered = suggestion.filter(i => { + if (i.r >= mr) return false; + mr = i.r; + return true; + }); + return filtered; + }; + // mark some area as used + let use = (p, m, tf, td) => { + used.push({ p, m, tf, td, b: false }); + }; + // remove danmaku not needed anymore by its time + const syn = (t0s, t0l) => { + used = used.filter(i => i.tf > t0s || i.td > t0l); + }; + // give a score in range [0, 1) for some position + const score = i => { + if (i.r > maxr) return -Infinity; + return 1 - Math.hypot(i.r / maxr, i.p / hc) * Math.SQRT1_2; + }; + // add some danmaku + return line => { + const { + time: t0s, // time sent (start to appear if no delay) + width: wv, // width of danmaku + height: hv, // height of danmaku + bottom: b, // is subtitle + } = line; + const t0l = wc / (wv + wc) * u + t0s; // time start to leave + syn(t0s, t0l); + const al = available(hv, t0s, t0l, b); + if (!al.length) return null; + const scored = al.map(i => [score(i), i]); + const best = scored.reduce((x, y) => { + return x[0] > y[0] ? x : y; + })[1]; + const ts = t0s + best.r; // time start to enter + const tf = wv / (wv + wc) * u + ts; // time complete enter + const td = u + ts; // time complete leave + use(best.p, best.p + hv, tf, td); + return { + top: best.p, + time: ts, + }; + }; + }; + + const fixedCanvas = function (options) { + const { + resolutionY: hc, + bottomReserved: b, + fixDuration: u, + maxDelay: maxr, + } = options; + let used = [ + { p: -Infinity, m: 0, td: Infinity, b: false }, + { p: hc, m: Infinity, td: Infinity, b: false }, + { p: hc - b, m: hc, td: Infinity, b: true }, + ]; + // Find out some available position + const fr = (p, m, t0s, b) => { + let tas = t0s; + used.forEach(j => { + if (j.p >= m) return; + if (j.m <= p) return; + if (j.b && b) return; + tas = Math.max(tas, j.td); + }); + const r = tas - t0s; + if (r > maxr) return null; + return { r, p, m }; + }; + // layout for danmaku at top + const top = (hv, t0s, b) => { + const suggestion = []; + used.forEach(i => { + if (i.m + hv >= hc) return; + suggestion.push(fr(i.m, i.m + hv, t0s, b)); + }); + return suggestion.filter(x => x); + }; + // layout for danmaku at bottom + const bottom = (hv, t0s, b) => { + const suggestion = []; + used.forEach(i => { + if (i.p - hv <= 0) return; + suggestion.push(fr(i.p - hv, i.p, t0s, b)); + }); + return suggestion.filter(x => x); + }; + const use = (p, m, td) => { + used.push({ p, m, td, b: false }); + }; + const syn = t0s => { + used = used.filter(i => i.td > t0s); + }; + // Score every position + const score = (i, is_top) => { + if (i.r > maxr) return -Infinity; + const f = p => is_top ? p : (hc - p); + return 1 - (i.r / maxr * (31 / 32) + f(i.p) / hc * (1 / 32)); + }; + return function (line) { + const { time: t0s, height: hv, bottom: b } = line; + const is_top = line.mode === 'TOP'; + syn(t0s); + const al = (is_top ? top : bottom)(hv, t0s, b); + if (!al.length) return null; + const scored = al.map(function (i) { return [score(i, is_top), i]; }); + const best = scored.reduce(function (x, y) { + return x[0] > y[0] ? x : y; + }, [-Infinity, null])[1]; + if (!best) return null; + use(best.p, best.m, best.r + t0s + u); + return { top: best.p, time: best.r + t0s }; + }; + }; + + const placeDanmaku = function (options) { + const layers = options.maxOverlap; + const normal = Array(layers).fill(null).map(x => rtlCanvas(options)); + const fixed = Array(layers).fill(null).map(x => fixedCanvas(options)); + return function (line) { + line.fontSize = Math.round(line.size * options.fontSize); + line.height = line.fontSize; + line.width = line.width || font.text(options.fontFamily, line.text, line.fontSize) || 1; + + if (line.mode === 'RTL') { + const pos = normal.reduce((pos, layer) => pos || layer(line), null); + if (!pos) return null; + const { top, time } = pos; + line.layout = { + type: 'Rtl', + start: { + x: options.resolutionX + line.width / 2, + y: top + line.height, + time, + }, + end: { + x: -line.width / 2, + y: top + line.height, + time: options.rtlDuration + time, + }, + }; + } else if (['TOP', 'BOTTOM'].includes(line.mode)) { + const pos = fixed.reduce((pos, layer) => pos || layer(line), null); + if (!pos) return null; + const { top, time } = pos; + line.layout = { + type: 'Fix', + start: { + x: Math.round(options.resolutionX / 2), + y: top + line.height, + time, + }, + end: { + time: options.fixDuration + time, + }, + }; + } + return line; + }; + }; + + // main layout algorithm + const layout = async function (danmaku, optionGetter) { + const options = JSON.parse(JSON.stringify(optionGetter)); + const sorted = danmaku.slice(0).sort(({ time: x }, { time: y }) => x - y); + const place = placeDanmaku(options); + const result = Array(sorted.length); + let length = 0; + for (let i = 0, l = sorted.length; i < l; i++) { + let placed = place(sorted[i]); + if (placed) result[length++] = placed; + if ((i + 1) % 1000 === 0) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + result.length = length; + result.sort((x, y) => x.layout.start.time - y.layout.start.time); + return result; }; // escape string for ass @@ -1364,104 +1364,104 @@ const rtlCanvas = function (options) { return blob; }; -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. -*/ - -/** - * An API wrapper of tiansh/ass-danmaku for liqi0816/bilitwin - */ -class ASSConverter { - /** - * @typedef {ExtOption} - * @property {number} resolutionX canvas width for drawing danmaku (px) - * @property {number} resolutionY canvas height for drawing danmaku (px) - * @property {number} bottomReserved reserved height at bottom for drawing danmaku (px) - * @property {string} fontFamily danmaku font family - * @property {number} fontSize danmaku font size (ratio) - * @property {number} textSpace space between danmaku (px) - * @property {number} rtlDuration duration of right to left moving danmaku appeared on screen (s) - * @property {number} fixDuration duration of keep bottom / top danmaku appeared on screen (s) - * @property {number} maxDelay // maxinum amount of allowed delay (s) - * @property {number} textOpacity // opacity of text, in range of [0, 1] - * @property {number} maxOverlap // maxinum layers of danmaku - */ - - /** - * @param {ExtOption} option tiansh/ass-danmaku compatible option - */ - constructor(option = {}) { - this.option = option; - } - - get option() { - return this.normalizedOption; - } - - set option(e) { - return this.normalizedOption = normalize(e); - } - - /** - * @param {Danmaku[]} danmaku use ASSConverter.parseXML - * @param {string} title - * @param {string} originalURL - */ - async genASS(danmaku, title = 'danmaku', originalURL = 'anonymous xml') { - const layout$$1 = await layout(danmaku, this.option); - const ass$$1 = ass({ - content: danmaku, - layout: layout$$1, - meta: { - name: title, - url: originalURL - } - }, this.option); - return ass$$1; - } - - async genASSBlob(danmaku, title = 'danmaku', originalURL = 'anonymous xml') { - return convertToBlob(await this.genASS(danmaku, title, originalURL)); - } - - /** - * @typedef DanmakuColor - * @property {number} r - * @property {number} g - * @property {number} b - */ - - /** - * @typedef Danmaku - * @property {string} text - * @property {number} time - * @property {string} mode - * @property {number} size - * @property {DanmakuColor} color - * @property {boolean} bottom - * @property {string=} sender - */ - - /** - * @param {string} xml bilibili danmaku xml - * @returns {Danmaku[]} - */ - static parseXML(xml) { - return parser.bilibili(xml).danmaku; - } - - - static _UNIT_TEST() { - const e = new ASSConverter(); - const xml = `chat.bilibili.com328737580600000k-v真第一五分钟前惊呆了!神王代表虚空66666666666666666这要吹多长时间反而不是,疾病是个恶魔,别人说她伪装成了精灵精灵都会吃就不能大部分都是铜币么?吓死我了。。。???儿砸怕是要吹到缺氧哦233333333333333菜鸡的借口竟然吹蜡烛打医生这暴击率太高了医生好想进10万,血,上万甲前一个命都没了23333333333333儿砸~应该姆西自己控制洛斯 七杀点太快了差评现在前一个连命都没了啊喂不如走到面前用扫射 基本全中 伤害爆表这是这个游戏最震撼的几幕之一`; - console.log(window.ass = e.genASSBlob(ASSConverter.parseXML(xml))); - } +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +/** + * An API wrapper of tiansh/ass-danmaku for liqi0816/bilitwin + */ +class ASSConverter { + /** + * @typedef {ExtOption} + * @property {number} resolutionX canvas width for drawing danmaku (px) + * @property {number} resolutionY canvas height for drawing danmaku (px) + * @property {number} bottomReserved reserved height at bottom for drawing danmaku (px) + * @property {string} fontFamily danmaku font family + * @property {number} fontSize danmaku font size (ratio) + * @property {number} textSpace space between danmaku (px) + * @property {number} rtlDuration duration of right to left moving danmaku appeared on screen (s) + * @property {number} fixDuration duration of keep bottom / top danmaku appeared on screen (s) + * @property {number} maxDelay // maxinum amount of allowed delay (s) + * @property {number} textOpacity // opacity of text, in range of [0, 1] + * @property {number} maxOverlap // maxinum layers of danmaku + */ + + /** + * @param {ExtOption} option tiansh/ass-danmaku compatible option + */ + constructor(option = {}) { + this.option = option; + } + + get option() { + return this.normalizedOption; + } + + set option(e) { + return this.normalizedOption = normalize(e); + } + + /** + * @param {Danmaku[]} danmaku use ASSConverter.parseXML + * @param {string} title + * @param {string} originalURL + */ + async genASS(danmaku, title = 'danmaku', originalURL = 'anonymous xml') { + const layout$$1 = await layout(danmaku, this.option); + const ass$$1 = ass({ + content: danmaku, + layout: layout$$1, + meta: { + name: title, + url: originalURL + } + }, this.option); + return ass$$1; + } + + async genASSBlob(danmaku, title = 'danmaku', originalURL = 'anonymous xml') { + return convertToBlob(await this.genASS(danmaku, title, originalURL)); + } + + /** + * @typedef DanmakuColor + * @property {number} r + * @property {number} g + * @property {number} b + */ + + /** + * @typedef Danmaku + * @property {string} text + * @property {number} time + * @property {string} mode + * @property {number} size + * @property {DanmakuColor} color + * @property {boolean} bottom + * @property {string=} sender + */ + + /** + * @param {string} xml bilibili danmaku xml + * @returns {Danmaku[]} + */ + static parseXML(xml) { + return parser.bilibili(xml).danmaku; + } + + + static _UNIT_TEST() { + const e = new ASSConverter(); + const xml = `chat.bilibili.com328737580600000k-v真第一五分钟前惊呆了!神王代表虚空66666666666666666这要吹多长时间反而不是,疾病是个恶魔,别人说她伪装成了精灵精灵都会吃就不能大部分都是铜币么?吓死我了。。。???儿砸怕是要吹到缺氧哦233333333333333菜鸡的借口竟然吹蜡烛打医生这暴击率太高了医生好想进10万,血,上万甲前一个命都没了23333333333333儿砸~应该姆西自己控制洛斯 七杀点太快了差评现在前一个连命都没了啊喂不如走到面前用扫射 基本全中 伤害爆表这是这个游戏最震撼的几幕之一`; + console.log(window.ass = e.genASSBlob(ASSConverter.parseXML(xml))); + } } /*** @@ -1612,640 +1612,8 @@ class HookedFunction extends Function { } } -/*** - * BiliMonkey - * A bilibili user script - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * The FLV merge utility is a Javascript translation of - * https://github.com/grepmusic/flvmerge - * by grepmusic - * - * The ASS convert utility is a fork of - * https://github.com/tiansh/ass-danmaku - * by tiansh - * - * The FLV demuxer is from - * https://github.com/Bilibili/flv.js/ - * by zheng qian - * - * The EMBL builder is from - * - * by ryiwamoto -*/ - -class BiliMonkey { - constructor(playerWin, option = BiliMonkey.optionDefaults) { - this.playerWin = playerWin; - this.protocol = playerWin.location.protocol; - this.cid = null; - this.flvs = null; - this.mp4 = null; - this.ass = null; - this.flvFormatName = null; - this.mp4FormatName = null; - this.fallbackFormatName = null; - this.cidAsyncContainer = new AsyncContainer(); - this.cidAsyncContainer.then(cid => { this.cid = cid; /** this.ass = this.getASS(); */ }); - if (typeof top.cid === 'string') this.cidAsyncContainer.resolve(top.cid); - - /*** - * cache + proxy = Service Worker - * Hope bilibili will have a SW as soon as possible. - * partial = Stream - * Hope the fetch API will be stabilized as soon as possible. - * If you are using your grandpa's browser, do not enable these functions. - */ - this.cache = option.cache; - this.partial = option.partial; - this.proxy = option.proxy; - this.blocker = option.blocker; - this.font = option.font; - this.option = option; - if (this.cache && (!(this.cache instanceof CacheDB))) this.cache = new CacheDB('biliMonkey', 'flv', 'name'); - - this.flvsDetailedFetch = []; - this.flvsBlob = []; - - this.defaultFormatPromise = null; - this.queryInfoMutex = new Mutex(); - - this.destroy = new HookedFunction(); - } - - /*** - * Guide: for ease of debug, please use format name(flv720) instead of format value(64) unless necessary - * Guide: for ease of html concat, please use string format value('64') instead of number(parseInt('64')) - */ - lockFormat(format) { - // null => uninitialized - // async pending => another one is working on it - // async resolve => that guy just finished work - // sync value => someone already finished work - const toast = this.playerWin.document.getElementsByClassName('bilibili-player-video-toast-top')[0]; - if (toast) toast.style.visibility = 'hidden'; - if (format == this.fallbackFormatName) return null; - switch (format) { - // Single writer is not a must. - // Plus, if one writer fail, others should be able to overwrite its garbage. - case 'flv_p60': - case 'flv720_p60': - case 'hdflv2': - case 'flv': - case 'flv720': - case 'flv480': - case 'flv360': - //if (this.flvs) return this.flvs; - return this.flvs = new AsyncContainer(); - case 'hdmp4': - case 'mp4': - //if (this.mp4) return this.mp4; - return this.mp4 = new AsyncContainer(); - default: - throw `lockFormat error: ${format} is a unrecognizable format`; - } - } - - resolveFormat(res, shouldBe) { - const toast = this.playerWin.document.getElementsByClassName('bilibili-player-video-toast-top')[0]; - if (toast) { - toast.style.visibility = ''; - if (toast.children.length) toast.children[0].style.visibility = 'hidden'; - const video = this.playerWin.document.getElementsByTagName('video')[0]; - if (video) { - const h = () => { - if (toast.children.length) toast.children[0].style.visibility = 'hidden'; - }; - video.addEventListener('emptied', h, { once: true }); - setTimeout(() => video.removeEventListener('emptied', h), 500); - } - - } - if (res.format == this.fallbackFormatName) return null; - switch (res.format) { - case 'flv_p60': - case 'flv720_p60': - case 'hdflv2': - case 'flv': - case 'flv720': - case 'flv480': - case 'flv360': - if (shouldBe && shouldBe != res.format) { - this.flvs = null; - throw `URL interface error: response is not ${shouldBe}`; - } - return this.flvs = this.flvs.resolve(res.durl.map(e => e.url.replace('http:', this.protocol))); - case 'hdmp4': - case 'mp4': - if (shouldBe && shouldBe != res.format) { - this.mp4 = null; - throw `URL interface error: response is not ${shouldBe}`; - } - return this.mp4 = this.mp4.resolve(res.durl[0].url.replace('http:', this.protocol)); - default: - throw `resolveFormat error: ${res.format} is a unrecognizable format`; - } - } - - getVIPStatus() { - const data = this.playerWin.sessionStorage.getItem('bili_login_status'); - try { - return JSON.parse(data).some(e => e instanceof Object && e.vipStatus); - } - catch (e) { - return false; - } - } - - async getASS() { - if (this.ass) return this.ass; - this.ass = await new Promise(async resolve => { - // 1. cid - if (!this.cid) this.cid = this.playerWin.cid; - - // 2. options - const bilibili_player_settings = this.playerWin.localStorage.bilibili_player_settings && JSON.parse(this.playerWin.localStorage.bilibili_player_settings); - - // 2.1 blocker - let danmaku = await BiliMonkey.fetchDanmaku(this.cid); - if (bilibili_player_settings && this.blocker) { - const i = bilibili_player_settings.block.list.map(e => e.v).join('|'); - if (i) { - const regexp = new RegExp(i); - danmaku = danmaku.filter(e => !regexp.test(e.text) && !regexp.test(e.sender)); - } - } - - // 2.2 font - const option = bilibili_player_settings && this.font && { - 'fontFamily': bilibili_player_settings.setting_config['fontfamily'] != 'custom' ? bilibili_player_settings.setting_config['fontfamily'].split(/, ?/) : bilibili_player_settings.setting_config['fontfamilycustom'].split(/, ?/), - 'fontSize': parseFloat(bilibili_player_settings.setting_config['fontsize']), - 'textOpacity': parseFloat(bilibili_player_settings.setting_config['opacity']), - 'bold': bilibili_player_settings.setting_config['bold'] ? 1 : 0, - } || undefined; - - // 2.3 resolution - if (this.option.resolution) { - Object.assign(option, { - 'resolutionX': +this.option.resolutionX || 560, - 'resolutionY': +this.option.resolutionY || 420 - }); - } - - // 3. generate - const data = await new ASSConverter(option).genASSBlob( - danmaku, top.document.title, top.location.href - ); - resolve(top.URL.createObjectURL(data)); - }); - return this.ass; - } - - async queryInfo(format) { - return this.queryInfoMutex.lockAndAwait(async () => { - switch (format) { - case 'video': - if (this.flvs) - return this.video_format; - - const isBangumi = location.pathname.includes("bangumi") || location.hostname.includes("bangumi"); - const apiPath = isBangumi ? "/pgc/player/web/playurl" : "/x/player/playurl"; - - const qn = (this.option.enableVideoMaxResolution && this.option.videoMaxResolution) || "120"; - const api_url = `https://api.bilibili.com${apiPath}?avid=${aid}&cid=${cid}&otype=json&fourk=1&qn=${qn}`; - - const re = await fetch(api_url, { credentials: 'include' }); - const apiJson = await re.json(); - - let data = apiJson.data || apiJson.result; - // console.log(data) - let durls = data && data.durl; - - if (!durls) { - const _zc = window.Gc || window.zc || - Object.values(window).filter( - x => typeof x == "string" && x.includes("[Info]") - )[0]; - - data = JSON.parse( - _zc.split("\n").filter( - x => x.startsWith("{") - ).pop() - ); - - const _data_X = data.Y || data.X || - Object.values(data).filter( - x => typeof x == "object" && Object.prototype.toString.call(x) == "[object Object]" - )[0]; - - durls = _data_X.segments || [_data_X]; - } - - // console.log(data) - - let flvs = durls.map(url_obj => url_obj.url.replace("http://", "https://")); - - this.flvs = flvs; - - let video_format = data.format && (data.format.match(/mp4|flv/) || [])[0]; - - this.video_format = video_format; - - return video_format - case 'ass': - if (this.ass) - return this.ass; - else - return this.getASS(this.flvFormatName); - default: - throw `Bilimonkey: What is format ${format}?`; - } - }); - } - - hangPlayer() { - this.playerWin.document.getElementsByTagName('video')[0].src = "data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAsxtZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE0OCByMjY0MyA1YzY1NzA0IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNSAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTEgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTI1IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAADmWIhABf/qcv4FM6/0nHAAAC7G1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAoAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAIWdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAoAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAQAAAAEAAAAAAAJGVkdHMAAAAcZWxzdAAAAAAAAAABAAAAKAAAAAAAAQAAAAABjm1kaWEAAAAgbWRoZAAAAAAAAAAAAAAAAAAAMgAAAAIAFccAAAAAAC1oZGxyAAAAAAAAAAB2aWRlAAAAAAAAAAAAAAAAVmlkZW9IYW5kbGVyAAAAATltaW5mAAAAFHZtaGQAAAABAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAAD5c3RibAAAAJVzdHNkAAAAAAAAAAEAAACFYXZjMQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAQABAASAAAAEgAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABj//wAAAC9hdmNDAWQACv/hABZnZAAKrNlehAAAAwAEAAADAMg8SJZYAQAGaOvjyyLAAAAAGHN0dHMAAAAAAAAAAQAAAAEAAAIAAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAABAAAAAQAAABRzdHN6AAAAAAAAAsQAAAABAAAAFHN0Y28AAAAAAAAAAQAAADAAAABidWR0YQAAAFptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAAC1pbHN0AAAAJal0b28AAAAdZGF0YQAAAAEAAAAATGF2ZjU2LjQwLjEwMQ=="; - } - - async loadFLVFromCache(index) { - if (!this.cache) return; - if (!this.flvs) throw 'BiliMonkey: info uninitialized'; - let name = this.flvs[index].match(/\d+-\d+(?:\d|-|hd)*\.(flv|mp4)/)[0]; - let item = await this.cache.getData(name); - if (!item) return; - return this.flvsBlob[index] = item.data; - } - - async loadPartialFLVFromCache(index) { - if (!this.cache) return; - if (!this.flvs) throw 'BiliMonkey: info uninitialized'; - let name = this.flvs[index].match(/\d+-\d+(?:\d|-|hd)*\.(flv|mp4)/)[0]; - name = 'PC_' + name; - let item = await this.cache.getData(name); - if (!item) return; - return item.data; - } - - async loadAllFLVFromCache() { - if (!this.cache) return; - if (!this.flvs) throw 'BiliMonkey: info uninitialized'; - - let promises = []; - for (let i = 0; i < this.flvs.length; i++) promises.push(this.loadFLVFromCache(i)); - - return Promise.all(promises); - } - - async saveFLVToCache(index, blob) { - if (!this.cache) return; - if (!this.flvs) throw 'BiliMonkey: info uninitialized'; - let name = this.flvs[index].match(/\d+-\d+(?:\d|-|hd)*\.(flv|mp4)/)[0]; - return this.cache.addData({ name, data: blob }); - } - - async savePartialFLVToCache(index, blob) { - if (!this.cache) return; - if (!this.flvs) throw 'BiliMonkey: info uninitialized'; - let name = this.flvs[index].match(/\d+-\d+(?:\d|-|hd)*\.(flv|mp4)/)[0]; - name = 'PC_' + name; - return this.cache.putData({ name, data: blob }); - } - - async cleanPartialFLVInCache(index) { - if (!this.cache) return; - if (!this.flvs) throw 'BiliMonkey: info uninitialized'; - let name = this.flvs[index].match(/\d+-\d+(?:\d|-|hd)*\.(flv|mp4)/)[0]; - name = 'PC_' + name; - return this.cache.deleteData(name); - } - - async getFLV(index, progressHandler) { - if (this.flvsBlob[index]) return this.flvsBlob[index]; - - if (!this.flvs) throw 'BiliMonkey: info uninitialized'; - this.flvsBlob[index] = (async () => { - let cache = await this.loadFLVFromCache(index); - if (cache) return this.flvsBlob[index] = cache; - let partialFLVFromCache = await this.loadPartialFLVFromCache(index); - - let burl = this.flvs[index]; - if (partialFLVFromCache) burl += `&bstart=${partialFLVFromCache.size}`; - let opt = { - fetch: this.playerWin.fetch, - method: 'GET', - mode: 'cors', - cache: 'default', - referrerPolicy: 'no-referrer-when-downgrade', - cacheLoaded: partialFLVFromCache ? partialFLVFromCache.size : 0, - headers: partialFLVFromCache && (!burl.includes('wsTime')) ? { Range: `bytes=${partialFLVFromCache.size}-` } : undefined - }; - opt.onprogress = progressHandler; - opt.onerror = opt.onabort = ({ target, type }) => { - let partialFLV = target.getPartialBlob(); - if (partialFLVFromCache) partialFLV = new Blob([partialFLVFromCache, partialFLV]); - this.savePartialFLVToCache(index, partialFLV); - }; - - let fch = new DetailedFetchBlob(burl, opt); - this.flvsDetailedFetch[index] = fch; - let fullFLV = await fch.getBlob(); - this.flvsDetailedFetch[index] = undefined; - if (partialFLVFromCache) { - fullFLV = new Blob([partialFLVFromCache, fullFLV]); - this.cleanPartialFLVInCache(index); - } - this.saveFLVToCache(index, fullFLV); - return (this.flvsBlob[index] = fullFLV); - })(); - return this.flvsBlob[index]; - } - - async abortFLV(index) { - if (this.flvsDetailedFetch[index]) return this.flvsDetailedFetch[index].abort(); - } - - async getAllFLVs(progressHandler) { - if (!this.flvs) throw 'BiliMonkey: info uninitialized'; - let promises = []; - for (let i = 0; i < this.flvs.length; i++) promises.push(this.getFLV(i, progressHandler)); - return Promise.all(promises); - } - - async cleanAllFLVsInCache() { - if (!this.cache) return; - if (!this.flvs) throw 'BiliMonkey: info uninitialized'; - - let ret = []; - for (let flv of this.flvs) { - let name = flv.match(/\d+-\d+(?:\d|-|hd)*\.(flv|mp4)/)[0]; - ret.push(await this.cache.deleteData(name)); - ret.push(await this.cache.deleteData('PC_' + name)); - } - - return ret; - } - - async setupProxy(res, onsuccess) { - if (!this.setupProxy._fetch) { - const _fetch = this.setupProxy._fetch = this.playerWin.fetch; - this.playerWin.fetch = function (input, init) { - if (!input.slice || input.slice(0, 5) != 'blob:') { - return _fetch(input, init); - } - let bstart = input.indexOf('?bstart='); - if (bstart < 0) { - return _fetch(input, init); - } - if (!init.headers instanceof Headers) init.headers = new Headers(init.headers || {}); - init.headers.set('Range', `bytes=${input.slice(bstart + 8)}-`); - return _fetch(input.slice(0, bstart), init) - }; - this.destroy.addCallback(() => this.playerWin.fetch = _fetch); - } - - await this.loadAllFLVFromCache(); - let resProxy = Object.assign({}, res); - for (let i = 0; i < this.flvsBlob.length; i++) { - if (this.flvsBlob[i]) resProxy.durl[i].url = this.playerWin.URL.createObjectURL(this.flvsBlob[i]); - } - return onsuccess(resProxy); - } - - static async fetchDanmaku(cid) { - return ASSConverter.parseXML( - await new Promise((resolve, reject) => { - const e = new XMLHttpRequest(); - e.onload = () => resolve(e.responseText); - e.onerror = reject; - // fix CORS issue - e.open('get', `https://cors.xmader.com/?url=${encodeURIComponent(`https://comment.bilibili.com/${cid}.xml`)}`); - e.send(); - }) - ); - } - - static async getAllPageDefaultFormats(playerWin = top, monkey) { - /** @returns {Promise<{cid: number; page: number; part?: string; }[]>} */ - const getPartsList = async () => { - const r = await fetch(`https://api.bilibili.com/x/player/pagelist?aid=${aid}`); - const json = await r.json(); - return json.data - }; - - const list = await getPartsList(); - - const queryInfoMutex = new Mutex(); - - const _getDataList = () => { - const _zc = playerWin.Gc || playerWin.zc || - Object.values(playerWin).filter( - x => typeof x == "string" && x.includes("[Info]") - )[0]; - return _zc.split("\n").filter( - x => x.startsWith("{") - ) - }; - - // from the first page - playerWin.player.next(1); - const initialDataSize = new Set(_getDataList()).size; - - const retPromises = list.map((x, n) => (async () => { - await queryInfoMutex.lock(); - - const cid = x.cid; - const danmuku = await new ASSConverter().genASSBlob( - await BiliMonkey.fetchDanmaku(cid), top.document.title, top.location.href - ); - - const isBangumi = location.pathname.includes("bangumi") || location.hostname.includes("bangumi"); - const apiPath = isBangumi ? "/pgc/player/web/playurl" : "/x/player/playurl"; - - const qn = (monkey.option.enableVideoMaxResolution && monkey.option.videoMaxResolution) || "120"; - const api_url = `https://api.bilibili.com${apiPath}?avid=${aid}&cid=${cid}&otype=json&fourk=1&qn=${qn}`; - const r = await fetch(api_url, { credentials: 'include' }); - - const apiJson = await r.json(); - const res = apiJson.data || apiJson.result; - - if (!res.durl) { - await new Promise(resolve => { - const i = setInterval(() => { - const dataSize = new Set( - _getDataList() - ).size; - - if (list.length == 1 || dataSize == n + initialDataSize + 1) { - clearInterval(i); - resolve(); - } - }, 100); - }); - - const data = JSON.parse( - _getDataList().pop() - ); - - const _data_X = data.Y || data.X || - Object.values(data).filter( - x => typeof x == "object" && Object.prototype.toString.call(x) == "[object Object]" - )[0]; - - res.durl = _data_X.segments || [_data_X]; - } - - queryInfoMutex.unlock(); - playerWin.player.next(); - - return ({ - durl: res.durl.map(({ url }) => url.replace('http:', playerWin.location.protocol)), - danmuku, - name: x.part || x.index || playerWin.document.title.replace("_哔哩哔哩 (゜-゜)つロ 干杯~-bilibili", ""), - outputName: res.durl[0].url.match(/\d+-\d+(?:\d|-|hd)*(?=\.flv)/) ? - /*** - * see #28 - * Firefox lookbehind assertion not implemented https://bugzilla.mozilla.org/show_bug.cgi?id=1225665 - * try replace /-\d+(?=(?:\d|-|hd)*\.flv)/ => /(?<=\d+)-\d+(?=(?:\d|-|hd)*\.flv)/ in the future - */ - res.durl[0].url.match(/\d+-\d+(?:\d|-|hd)*(?=\.flv)/)[0].replace(/-\d+(?=(?:\d|-|hd)*\.flv)/, '') - : res.durl[0].url.match(/\d(?:\d|-|hd)*(?=\.mp4)/) ? - res.durl[0].url.match(/\d(?:\d|-|hd)*(?=\.mp4)/)[0] - : cid, - cid, - res, - }); - })()); - - const ret = await Promise.all(retPromises); - - return ret; - } - - static async getBiliShortVideoInfo() { - const video_id = location.pathname.match(/\/video\/(\d+)/)[1]; - const api_url = `https://api.vc.bilibili.com/clip/v1/video/detail?video_id=${video_id}&need_playurl=1`; - - const req = await fetch(api_url, { credentials: 'include' }); - const data = (await req.json()).data; - const { video_playurl, first_pic: cover_img } = data.item; - - return { video_playurl: video_playurl.replace("http://", "https://"), cover_img } - } - - static formatToValue(format) { - if (format == 'does_not_exist') throw `formatToValue: cannot lookup does_not_exist`; - if (typeof BiliMonkey.formatToValue.dict == 'undefined') BiliMonkey.formatToValue.dict = { - 'hdflv2': '120', - 'flv_p60': '116', - 'flv720_p60': '74', - 'flv': '80', - 'flv720': '64', - 'flv480': '32', - 'flv360': '15', - - // legacy - late 2017 - 'hdflv2': '112', - 'hdmp4': '64', // data-value is still '64' instead of '48'. '48', - 'mp4': '16', - }; - return BiliMonkey.formatToValue.dict[format] || null; - } - - static valueToFormat(value) { - if (typeof BiliMonkey.valueToFormat.dict == 'undefined') BiliMonkey.valueToFormat.dict = { - '120': 'hdflv2', - '116': 'flv_p60', - '74': 'flv720_p60', - '80': 'flv', - '64': 'flv720', - '32': 'flv480', - '15': 'flv360', - - // legacy - late 2017 - '112': 'hdflv2', - '48': 'hdmp4', - '16': 'mp4', - - // legacy - early 2017 - '3': 'flv', - '2': 'hdmp4', - '1': 'mp4', - }; - return BiliMonkey.valueToFormat.dict[value] || null; - } - - static get optionDescriptions() { - return [ - // 1. cache - ['cache', '关标签页不清缓存:保留完全下载好的分段到缓存,忘记另存为也没关系。'], - ['partial', '断点续传:点击“取消”保留部分下载的分段到缓存,忘记点击会弹窗确认。'], - ['proxy', '用缓存加速播放器:如果缓存里有完全下载好的分段,直接喂给网页播放器,不重新访问网络。小水管利器,播放只需500k流量。如果实在搞不清怎么播放ASS弹幕,也可以就这样用。'], - - // 2. customizing - ['blocker', '弹幕过滤:在网页播放器里设置的屏蔽词也对下载的弹幕生效。'], - ['font', '自定义字体:在网页播放器里设置的字体、大小、加粗、透明度也对下载的弹幕生效。'], - ['resolution', '(测)自定义弹幕画布分辨率:仅对下载的弹幕生效。(默认值: 560 x 420)'], - ]; - } - - static get resolutionPreferenceOptions() { - return [ - ['超清 4K (大会员)', '120'], - ['高清 1080P60 (大会员)', '116'], - ['高清 1080P+ (大会员)', '112'], - ['高清 720P60 (大会员)', '74'], - ['高清 1080P', '80'], - ['高清 720P', '64'], - ['清晰 480P', '32'], - ['流畅 360P', '16'], - ] - } - - static get optionDefaults() { - return { - // 1. automation - autoDefault: true, - autoFLV: false, - autoMP4: false, - - // 2. cache - cache: true, - partial: true, - proxy: true, - - // 3. customizing - blocker: true, - font: true, - resolution: false, - resolutionX: 560, - resolutionY: 420, - videoMaxResolution: "120", - enableVideoMaxResolution: false, - } - } - - static _UNIT_TEST() { - return (async () => { - let playerWin = await BiliUserJS.getPlayerWin(); - window.m = new BiliMonkey(playerWin); - - console.warn('data race test'); - m.queryInfo('video'); - console.log(m.queryInfo('video')); - - //location.reload(); - })(); - } -} - /*** - * BiliPolyfill + * BiliMonkey * A bilibili user script * Copyright (C) 2018 Qli5. All Rights Reserved. * @@ -2254,26 +1622,679 @@ class BiliMonkey { * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * The FLV merge utility is a Javascript translation of + * https://github.com/grepmusic/flvmerge + * by grepmusic + * + * The ASS convert utility is a fork of + * https://github.com/tiansh/ass-danmaku + * by tiansh + * + * The FLV demuxer is from + * https://github.com/Bilibili/flv.js/ + * by zheng qian + * + * The EMBL builder is from + * + * by ryiwamoto */ -class BiliPolyfill { - /*** - * Assumption: aid, cid, pageno does not change during lifecycle - * Create a new BiliPolyfill if assumption breaks - */ - constructor(playerWin, option = BiliPolyfill.optionDefaults, hintInfo = () => { }) { +class BiliMonkey { + constructor(playerWin, option = BiliMonkey.optionDefaults) { this.playerWin = playerWin; + this.protocol = playerWin.location.protocol; + this.cid = null; + this.flvs = null; + this.mp4 = null; + this.ass = null; + this.flvFormatName = null; + this.mp4FormatName = null; + this.fallbackFormatName = null; + this.cidAsyncContainer = new AsyncContainer(); + this.cidAsyncContainer.then(cid => { this.cid = cid; /** this.ass = this.getASS(); */ }); + if (typeof top.cid === 'string') this.cidAsyncContainer.resolve(top.cid); + + /*** + * cache + proxy = Service Worker + * Hope bilibili will have a SW as soon as possible. + * partial = Stream + * Hope the fetch API will be stabilized as soon as possible. + * If you are using your grandpa's browser, do not enable these functions. + */ + this.cache = option.cache; + this.partial = option.partial; + this.proxy = option.proxy; + this.blocker = option.blocker; + this.font = option.font; this.option = option; - this.hintInfo = hintInfo; + if (this.cache && (!(this.cache instanceof CacheDB))) this.cache = new CacheDB('biliMonkey', 'flv', 'name'); - this.video = null; + this.flvsDetailedFetch = []; + this.flvsBlob = []; - this.series = []; - this.userdata = { oped: {}, restore: {} }; + this.defaultFormatPromise = null; + this.queryInfoMutex = new Mutex(); this.destroy = new HookedFunction(); - this.playerWin.addEventListener('beforeunload', this.destroy); - this.destroy.addCallback(() => this.playerWin.removeEventListener('beforeunload', this.destroy)); + } + + /*** + * Guide: for ease of debug, please use format name(flv720) instead of format value(64) unless necessary + * Guide: for ease of html concat, please use string format value('64') instead of number(parseInt('64')) + */ + lockFormat(format) { + // null => uninitialized + // async pending => another one is working on it + // async resolve => that guy just finished work + // sync value => someone already finished work + const toast = this.playerWin.document.getElementsByClassName('bilibili-player-video-toast-top')[0]; + if (toast) toast.style.visibility = 'hidden'; + if (format == this.fallbackFormatName) return null; + switch (format) { + // Single writer is not a must. + // Plus, if one writer fail, others should be able to overwrite its garbage. + case 'flv_p60': + case 'flv720_p60': + case 'hdflv2': + case 'flv': + case 'flv720': + case 'flv480': + case 'flv360': + //if (this.flvs) return this.flvs; + return this.flvs = new AsyncContainer(); + case 'hdmp4': + case 'mp4': + //if (this.mp4) return this.mp4; + return this.mp4 = new AsyncContainer(); + default: + throw `lockFormat error: ${format} is a unrecognizable format`; + } + } + + resolveFormat(res, shouldBe) { + const toast = this.playerWin.document.getElementsByClassName('bilibili-player-video-toast-top')[0]; + if (toast) { + toast.style.visibility = ''; + if (toast.children.length) toast.children[0].style.visibility = 'hidden'; + const video = this.playerWin.document.getElementsByTagName('video')[0]; + if (video) { + const h = () => { + if (toast.children.length) toast.children[0].style.visibility = 'hidden'; + }; + video.addEventListener('emptied', h, { once: true }); + setTimeout(() => video.removeEventListener('emptied', h), 500); + } + + } + if (res.format == this.fallbackFormatName) return null; + switch (res.format) { + case 'flv_p60': + case 'flv720_p60': + case 'hdflv2': + case 'flv': + case 'flv720': + case 'flv480': + case 'flv360': + if (shouldBe && shouldBe != res.format) { + this.flvs = null; + throw `URL interface error: response is not ${shouldBe}`; + } + return this.flvs = this.flvs.resolve(res.durl.map(e => e.url.replace('http:', this.protocol))); + case 'hdmp4': + case 'mp4': + if (shouldBe && shouldBe != res.format) { + this.mp4 = null; + throw `URL interface error: response is not ${shouldBe}`; + } + return this.mp4 = this.mp4.resolve(res.durl[0].url.replace('http:', this.protocol)); + default: + throw `resolveFormat error: ${res.format} is a unrecognizable format`; + } + } + + getVIPStatus() { + const data = this.playerWin.sessionStorage.getItem('bili_login_status'); + try { + return JSON.parse(data).some(e => e instanceof Object && e.vipStatus); + } + catch (e) { + return false; + } + } + + async getASS() { + if (this.ass) return this.ass; + this.ass = await new Promise(async resolve => { + // 1. cid + if (!this.cid) this.cid = this.playerWin.cid; + + // 2. options + const bilibili_player_settings = this.playerWin.localStorage.bilibili_player_settings && JSON.parse(this.playerWin.localStorage.bilibili_player_settings); + + // 2.1 blocker + let danmaku = await BiliMonkey.fetchDanmaku(this.cid); + if (bilibili_player_settings && this.blocker) { + const i = bilibili_player_settings.block.list.map(e => e.v).join('|'); + if (i) { + const regexp = new RegExp(i); + danmaku = danmaku.filter(e => !regexp.test(e.text) && !regexp.test(e.sender)); + } + } + + // 2.2 font + const option = bilibili_player_settings && this.font && { + 'fontFamily': bilibili_player_settings.setting_config['fontfamily'] != 'custom' ? bilibili_player_settings.setting_config['fontfamily'].split(/, ?/) : bilibili_player_settings.setting_config['fontfamilycustom'].split(/, ?/), + 'fontSize': parseFloat(bilibili_player_settings.setting_config['fontsize']), + 'textOpacity': parseFloat(bilibili_player_settings.setting_config['opacity']), + 'bold': bilibili_player_settings.setting_config['bold'] ? 1 : 0, + } || undefined; + + // 2.3 resolution + if (this.option.resolution) { + Object.assign(option, { + 'resolutionX': +this.option.resolutionX || 560, + 'resolutionY': +this.option.resolutionY || 420 + }); + } + + // 3. generate + const data = await new ASSConverter(option).genASSBlob( + danmaku, top.document.title, top.location.href + ); + resolve(top.URL.createObjectURL(data)); + }); + return this.ass; + } + + async queryInfo(format) { + return this.queryInfoMutex.lockAndAwait(async () => { + switch (format) { + case 'video': + if (this.flvs) + return this.video_format; + + const isBangumi = location.pathname.includes("bangumi") || location.hostname.includes("bangumi"); + const apiPath = isBangumi ? "/pgc/player/web/playurl" : "/x/player/playurl"; + + const qn = (this.option.enableVideoMaxResolution && this.option.videoMaxResolution) || "120"; + const api_url = `https://api.bilibili.com${apiPath}?avid=${aid}&cid=${cid}&otype=json&fourk=1&qn=${qn}`; + + // requir to enableVideoMaxResolution and set videoMaxResolution to 125 (HDR) + if (qn == 125) { + // check if video supports hdr + const dash_api_url = api_url + "&fnver=0&fnval=80"; + const dash_re = await fetch(dash_api_url, { credentials: 'include' }); + const dash_api_json = await dash_re.json(); + + var dash_data = dash_api_json.data || dash_api_json.result; + + if (dash_data && dash_data.quality == 125) { + // using dash urls for hdr video only + let dash_urls = [dash_data.dash.video[0].base_url, dash_data.dash.audio[0].base_url]; + this.flvs = dash_urls; + let video_format = dash_data.format && (dash_data.format.match(/mp4|flv/) || [])[0]; + this.video_format = video_format; + return video_format; + } + // otherwise fallback to normal prosedure + } + + const re = await fetch(api_url, { credentials: 'include' }); + const apiJson = await re.json(); + + let data = apiJson.data || apiJson.result; + // console.log(data) + let durls = data && data.durl; + + if (!durls) { + const _zc = window.Gc || window.zc || + Object.values(window).filter( + x => typeof x == "string" && x.includes("[Info]") + )[0]; + + data = JSON.parse( + _zc.split("\n").filter( + x => x.startsWith("{") + ).pop() + ); + + const _data_X = data.Y || data.X || + Object.values(data).filter( + x => typeof x == "object" && Object.prototype.toString.call(x) == "[object Object]" + )[0]; + + durls = _data_X.segments || [_data_X]; + } + + // console.log(data) + + let flvs = durls.map(url_obj => url_obj.url.replace("http://", "https://")); + + this.flvs = flvs; + + let video_format = data.format && (data.format.match(/mp4|flv/) || [])[0]; + + this.video_format = video_format; + + return video_format + case 'ass': + if (this.ass) + return this.ass; + else + return this.getASS(this.flvFormatName); + default: + throw `Bilimonkey: What is format ${format}?`; + } + }); + } + + hangPlayer() { + this.playerWin.document.getElementsByTagName('video')[0].src = "data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAsxtZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE0OCByMjY0MyA1YzY1NzA0IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNSAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTEgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTI1IHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAADmWIhABf/qcv4FM6/0nHAAAC7G1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAoAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAIWdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAoAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAQAAAAEAAAAAAAJGVkdHMAAAAcZWxzdAAAAAAAAAABAAAAKAAAAAAAAQAAAAABjm1kaWEAAAAgbWRoZAAAAAAAAAAAAAAAAAAAMgAAAAIAFccAAAAAAC1oZGxyAAAAAAAAAAB2aWRlAAAAAAAAAAAAAAAAVmlkZW9IYW5kbGVyAAAAATltaW5mAAAAFHZtaGQAAAABAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAAD5c3RibAAAAJVzdHNkAAAAAAAAAAEAAACFYXZjMQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAQABAASAAAAEgAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABj//wAAAC9hdmNDAWQACv/hABZnZAAKrNlehAAAAwAEAAADAMg8SJZYAQAGaOvjyyLAAAAAGHN0dHMAAAAAAAAAAQAAAAEAAAIAAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAABAAAAAQAAABRzdHN6AAAAAAAAAsQAAAABAAAAFHN0Y28AAAAAAAAAAQAAADAAAABidWR0YQAAAFptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAAC1pbHN0AAAAJal0b28AAAAdZGF0YQAAAAEAAAAATGF2ZjU2LjQwLjEwMQ=="; + } + + async loadFLVFromCache(index) { + if (!this.cache) return; + if (!this.flvs) throw 'BiliMonkey: info uninitialized'; + let name = this.flvs[index].match(/\d+-\d+(?:\d|-|hd)*\.(flv|mp4)/)[0]; + let item = await this.cache.getData(name); + if (!item) return; + return this.flvsBlob[index] = item.data; + } + + async loadPartialFLVFromCache(index) { + if (!this.cache) return; + if (!this.flvs) throw 'BiliMonkey: info uninitialized'; + let name = this.flvs[index].match(/\d+-\d+(?:\d|-|hd)*\.(flv|mp4)/)[0]; + name = 'PC_' + name; + let item = await this.cache.getData(name); + if (!item) return; + return item.data; + } + + async loadAllFLVFromCache() { + if (!this.cache) return; + if (!this.flvs) throw 'BiliMonkey: info uninitialized'; + + let promises = []; + for (let i = 0; i < this.flvs.length; i++) promises.push(this.loadFLVFromCache(i)); + + return Promise.all(promises); + } + + async saveFLVToCache(index, blob) { + if (!this.cache) return; + if (!this.flvs) throw 'BiliMonkey: info uninitialized'; + let name = this.flvs[index].match(/\d+-\d+(?:\d|-|hd)*\.(flv|mp4)/)[0]; + return this.cache.addData({ name, data: blob }); + } + + async savePartialFLVToCache(index, blob) { + if (!this.cache) return; + if (!this.flvs) throw 'BiliMonkey: info uninitialized'; + let name = this.flvs[index].match(/\d+-\d+(?:\d|-|hd)*\.(flv|mp4)/)[0]; + name = 'PC_' + name; + return this.cache.putData({ name, data: blob }); + } + + async cleanPartialFLVInCache(index) { + if (!this.cache) return; + if (!this.flvs) throw 'BiliMonkey: info uninitialized'; + let name = this.flvs[index].match(/\d+-\d+(?:\d|-|hd)*\.(flv|mp4)/)[0]; + name = 'PC_' + name; + return this.cache.deleteData(name); + } + + async getFLV(index, progressHandler) { + if (this.flvsBlob[index]) return this.flvsBlob[index]; + + if (!this.flvs) throw 'BiliMonkey: info uninitialized'; + this.flvsBlob[index] = (async () => { + let cache = await this.loadFLVFromCache(index); + if (cache) return this.flvsBlob[index] = cache; + let partialFLVFromCache = await this.loadPartialFLVFromCache(index); + + let burl = this.flvs[index]; + if (partialFLVFromCache) burl += `&bstart=${partialFLVFromCache.size}`; + let opt = { + fetch: this.playerWin.fetch, + method: 'GET', + mode: 'cors', + cache: 'default', + referrerPolicy: 'no-referrer-when-downgrade', + cacheLoaded: partialFLVFromCache ? partialFLVFromCache.size : 0, + headers: partialFLVFromCache && (!burl.includes('wsTime')) ? { Range: `bytes=${partialFLVFromCache.size}-` } : undefined + }; + opt.onprogress = progressHandler; + opt.onerror = opt.onabort = ({ target, type }) => { + let partialFLV = target.getPartialBlob(); + if (partialFLVFromCache) partialFLV = new Blob([partialFLVFromCache, partialFLV]); + this.savePartialFLVToCache(index, partialFLV); + }; + + let fch = new DetailedFetchBlob(burl, opt); + this.flvsDetailedFetch[index] = fch; + let fullFLV = await fch.getBlob(); + this.flvsDetailedFetch[index] = undefined; + if (partialFLVFromCache) { + fullFLV = new Blob([partialFLVFromCache, fullFLV]); + this.cleanPartialFLVInCache(index); + } + this.saveFLVToCache(index, fullFLV); + return (this.flvsBlob[index] = fullFLV); + })(); + return this.flvsBlob[index]; + } + + async abortFLV(index) { + if (this.flvsDetailedFetch[index]) return this.flvsDetailedFetch[index].abort(); + } + + async getAllFLVs(progressHandler) { + if (!this.flvs) throw 'BiliMonkey: info uninitialized'; + let promises = []; + for (let i = 0; i < this.flvs.length; i++) promises.push(this.getFLV(i, progressHandler)); + return Promise.all(promises); + } + + async cleanAllFLVsInCache() { + if (!this.cache) return; + if (!this.flvs) throw 'BiliMonkey: info uninitialized'; + + let ret = []; + for (let flv of this.flvs) { + let name = flv.match(/\d+-\d+(?:\d|-|hd)*\.(flv|mp4)/)[0]; + ret.push(await this.cache.deleteData(name)); + ret.push(await this.cache.deleteData('PC_' + name)); + } + + return ret; + } + + async setupProxy(res, onsuccess) { + if (!this.setupProxy._fetch) { + const _fetch = this.setupProxy._fetch = this.playerWin.fetch; + this.playerWin.fetch = function (input, init) { + if (!input.slice || input.slice(0, 5) != 'blob:') { + return _fetch(input, init); + } + let bstart = input.indexOf('?bstart='); + if (bstart < 0) { + return _fetch(input, init); + } + if (!init.headers instanceof Headers) init.headers = new Headers(init.headers || {}); + init.headers.set('Range', `bytes=${input.slice(bstart + 8)}-`); + return _fetch(input.slice(0, bstart), init) + }; + this.destroy.addCallback(() => this.playerWin.fetch = _fetch); + } + + await this.loadAllFLVFromCache(); + let resProxy = Object.assign({}, res); + for (let i = 0; i < this.flvsBlob.length; i++) { + if (this.flvsBlob[i]) resProxy.durl[i].url = this.playerWin.URL.createObjectURL(this.flvsBlob[i]); + } + return onsuccess(resProxy); + } + + static async fetchDanmaku(cid) { + return ASSConverter.parseXML( + await new Promise((resolve, reject) => { + const e = new XMLHttpRequest(); + e.onload = () => resolve(e.responseText); + e.onerror = reject; + // fix CORS issue + e.open('get', `https://cors.xmader.com/?url=${encodeURIComponent(`https://comment.bilibili.com/${cid}.xml`)}`); + e.send(); + }) + ); + } + + static async getAllPageDefaultFormats(playerWin = top, monkey) { + /** @returns {Promise<{cid: number; page: number; part?: string; }[]>} */ + const getPartsList = async () => { + const r = await fetch(`https://api.bilibili.com/x/player/pagelist?aid=${aid}`); + const json = await r.json(); + return json.data + }; + + const list = await getPartsList(); + + const queryInfoMutex = new Mutex(); + + const _getDataList = () => { + const _zc = playerWin.Gc || playerWin.zc || + Object.values(playerWin).filter( + x => typeof x == "string" && x.includes("[Info]") + )[0]; + return _zc.split("\n").filter( + x => x.startsWith("{") + ) + }; + + // from the first page + playerWin.player.next(1); + const initialDataSize = new Set(_getDataList()).size; + + const retPromises = list.map((x, n) => (async () => { + await queryInfoMutex.lock(); + + const cid = x.cid; + const danmuku = await new ASSConverter().genASSBlob( + await BiliMonkey.fetchDanmaku(cid), top.document.title, top.location.href + ); + + const isBangumi = location.pathname.includes("bangumi") || location.hostname.includes("bangumi"); + const apiPath = isBangumi ? "/pgc/player/web/playurl" : "/x/player/playurl"; + + const qn = (monkey.option.enableVideoMaxResolution && monkey.option.videoMaxResolution) || "120"; + const api_url = `https://api.bilibili.com${apiPath}?avid=${aid}&cid=${cid}&otype=json&fourk=1&qn=${qn}`; + const r = await fetch(api_url, { credentials: 'include' }); + + const apiJson = await r.json(); + const res = apiJson.data || apiJson.result; + + if (!res.durl) { + await new Promise(resolve => { + const i = setInterval(() => { + const dataSize = new Set( + _getDataList() + ).size; + + if (list.length == 1 || dataSize == n + initialDataSize + 1) { + clearInterval(i); + resolve(); + } + }, 100); + }); + + const data = JSON.parse( + _getDataList().pop() + ); + + const _data_X = data.Y || data.X || + Object.values(data).filter( + x => typeof x == "object" && Object.prototype.toString.call(x) == "[object Object]" + )[0]; + + res.durl = _data_X.segments || [_data_X]; + } + + queryInfoMutex.unlock(); + playerWin.player.next(); + + return ({ + durl: res.durl.map(({ url }) => url.replace('http:', playerWin.location.protocol)), + danmuku, + name: x.part || x.index || playerWin.document.title.replace("_哔哩哔哩 (゜-゜)つロ 干杯~-bilibili", ""), + outputName: res.durl[0].url.match(/\d+-\d+(?:\d|-|hd)*(?=\.flv)/) ? + /*** + * see #28 + * Firefox lookbehind assertion not implemented https://bugzilla.mozilla.org/show_bug.cgi?id=1225665 + * try replace /-\d+(?=(?:\d|-|hd)*\.flv)/ => /(?<=\d+)-\d+(?=(?:\d|-|hd)*\.flv)/ in the future + */ + res.durl[0].url.match(/\d+-\d+(?:\d|-|hd)*(?=\.flv)/)[0].replace(/-\d+(?=(?:\d|-|hd)*\.flv)/, '') + : res.durl[0].url.match(/\d(?:\d|-|hd)*(?=\.mp4)/) ? + res.durl[0].url.match(/\d(?:\d|-|hd)*(?=\.mp4)/)[0] + : cid, + cid, + res, + }); + })()); + + const ret = await Promise.all(retPromises); + + return ret; + } + + static async getBiliShortVideoInfo() { + const video_id = location.pathname.match(/\/video\/(\d+)/)[1]; + const api_url = `https://api.vc.bilibili.com/clip/v1/video/detail?video_id=${video_id}&need_playurl=1`; + + const req = await fetch(api_url, { credentials: 'include' }); + const data = (await req.json()).data; + const { video_playurl, first_pic: cover_img } = data.item; + + return { video_playurl: video_playurl.replace("http://", "https://"), cover_img } + } + + static formatToValue(format) { + if (format == 'does_not_exist') throw `formatToValue: cannot lookup does_not_exist`; + if (typeof BiliMonkey.formatToValue.dict == 'undefined') BiliMonkey.formatToValue.dict = { + 'hdflv2': '120', + 'flv_p60': '116', + 'flv720_p60': '74', + 'flv': '80', + 'flv720': '64', + 'flv480': '32', + 'flv360': '15', + + // legacy - late 2017 + 'hdflv2': '112', + 'hdmp4': '64', // data-value is still '64' instead of '48'. '48', + 'mp4': '16', + }; + return BiliMonkey.formatToValue.dict[format] || null; + } + + static valueToFormat(value) { + if (typeof BiliMonkey.valueToFormat.dict == 'undefined') BiliMonkey.valueToFormat.dict = { + '120': 'hdflv2', + '116': 'flv_p60', + '74': 'flv720_p60', + '80': 'flv', + '64': 'flv720', + '32': 'flv480', + '15': 'flv360', + + // legacy - late 2017 + '112': 'hdflv2', + '48': 'hdmp4', + '16': 'mp4', + + // legacy - early 2017 + '3': 'flv', + '2': 'hdmp4', + '1': 'mp4', + }; + return BiliMonkey.valueToFormat.dict[value] || null; + } + + static get optionDescriptions() { + return [ + // 1. cache + ['cache', '关标签页不清缓存:保留完全下载好的分段到缓存,忘记另存为也没关系。'], + ['partial', '断点续传:点击“取消”保留部分下载的分段到缓存,忘记点击会弹窗确认。'], + ['proxy', '用缓存加速播放器:如果缓存里有完全下载好的分段,直接喂给网页播放器,不重新访问网络。小水管利器,播放只需500k流量。如果实在搞不清怎么播放ASS弹幕,也可以就这样用。'], + + // 2. customizing + ['blocker', '弹幕过滤:在网页播放器里设置的屏蔽词也对下载的弹幕生效。'], + ['font', '自定义字体:在网页播放器里设置的字体、大小、加粗、透明度也对下载的弹幕生效。'], + ['resolution', '(测)自定义弹幕画布分辨率:仅对下载的弹幕生效。(默认值: 560 x 420)'], + ]; + } + + static get resolutionPreferenceOptions() { + return [ + ['HDR 真彩 (大会员)', '125'], + ['超清 4K (大会员)', '120'], + ['高清 1080P60 (大会员)', '116'], + ['高清 1080P+ (大会员)', '112'], + ['高清 720P60 (大会员)', '74'], + ['高清 1080P', '80'], + ['高清 720P', '64'], + ['清晰 480P', '32'], + ['流畅 360P', '16'], + ] + } + + static get optionDefaults() { + return { + // 1. automation + autoDefault: true, + autoFLV: false, + autoMP4: false, + + // 2. cache + cache: true, + partial: true, + proxy: true, + + // 3. customizing + blocker: true, + font: true, + resolution: false, + resolutionX: 560, + resolutionY: 420, + videoMaxResolution: "120", + enableVideoMaxResolution: false, + } + } + + static _UNIT_TEST() { + return (async () => { + let playerWin = await BiliUserJS.getPlayerWin(); + window.m = new BiliMonkey(playerWin); + + console.warn('data race test'); + m.queryInfo('video'); + console.log(m.queryInfo('video')); + + //location.reload(); + })(); + } +} + +/*** + * BiliPolyfill + * A bilibili user script + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +class BiliPolyfill { + /*** + * Assumption: aid, cid, pageno does not change during lifecycle + * Create a new BiliPolyfill if assumption breaks + */ + constructor(playerWin, option = BiliPolyfill.optionDefaults, hintInfo = () => { }) { + this.playerWin = playerWin; + this.option = option; + this.hintInfo = hintInfo; + + this.video = null; + + this.series = []; + this.userdata = { oped: {}, restore: {} }; + + this.destroy = new HookedFunction(); + this.playerWin.addEventListener('beforeunload', this.destroy); + this.destroy.addCallback(() => this.playerWin.removeEventListener('beforeunload', this.destroy)); this.BiliDanmakuSettings = class BiliDanmakuSettings { @@ -3248,78 +3269,78 @@ class BiliPolyfill { } } -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. -*/ - -class Exporter { - static exportIDM(urls, referrer = top.location.origin) { - return urls.map(url => `<\r\n${url}\r\nreferer: ${referrer}\r\n>\r\n`).join(''); - } - - static exportM3U8(urls, referrer = top.location.origin, userAgent = top.navigator.userAgent) { - return '#EXTM3U\n' + urls.map(url => `#EXTVLCOPT:http-referrer=${referrer}\n#EXTVLCOPT:http-user-agent=${userAgent}\n#EXTINF:-1\n${url}\n`).join(''); - } - - static exportAria2(urls, referrer = top.location.origin) { - return urls.map(url => `${url}\r\n referer=${referrer}\r\n`).join(''); - } - - static async sendToAria2RPC(urls, referrer = top.location.origin, target = 'http://127.0.0.1:6800/jsonrpc') { - const h = 'referer'; - const method = 'POST'; - - while (1) { - const token_array = target.match(/\/\/((.+)@)/); - const body = JSON.stringify(urls.map((url, id) => { - const params = [ - [url], - { [h]: referrer } - ]; - - if (token_array) { - params.unshift(token_array[2]); - target = target.replace(token_array[1], ""); - } - - return ({ - id, - jsonrpc: 2, - method: "aria2.addUri", - params - }) - })); - - try { - const res = await (await fetch(target, { method, body })).json(); - if (res.error || res[0].error) { - throw new Error((res.error || res[0].error).message) - } - else { - return res; - } - } - catch (e) { - target = top.prompt(`Aria2 connection failed${!e.message.includes("fetch") ? `: ${e.message}.\n` : ". "}Please provide a valid server address:`, target); - if (!target) return null; - } - } - } - - static copyToClipboard(text) { - const textarea = document.createElement('textarea'); - document.body.appendChild(textarea); - textarea.value = text; - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - } +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +class Exporter { + static exportIDM(urls, referrer = top.location.origin) { + return urls.map(url => `<\r\n${url}\r\nreferer: ${referrer}\r\n>\r\n`).join(''); + } + + static exportM3U8(urls, referrer = top.location.origin, userAgent = top.navigator.userAgent) { + return '#EXTM3U\n' + urls.map(url => `#EXTVLCOPT:http-referrer=${referrer}\n#EXTVLCOPT:http-user-agent=${userAgent}\n#EXTINF:-1\n${url}\n`).join(''); + } + + static exportAria2(urls, referrer = top.location.origin) { + return urls.map(url => `${url}\r\n referer=${referrer}\r\n`).join(''); + } + + static async sendToAria2RPC(urls, referrer = top.location.origin, target = 'http://127.0.0.1:6800/jsonrpc') { + const h = 'referer'; + const method = 'POST'; + + while (1) { + const token_array = target.match(/\/\/((.+)@)/); + const body = JSON.stringify(urls.map((url, id) => { + const params = [ + [url], + { [h]: referrer } + ]; + + if (token_array) { + params.unshift(token_array[2]); + target = target.replace(token_array[1], ""); + } + + return ({ + id, + jsonrpc: 2, + method: "aria2.addUri", + params + }) + })); + + try { + const res = await (await fetch(target, { method, body })).json(); + if (res.error || res[0].error) { + throw new Error((res.error || res[0].error).message) + } + else { + return res; + } + } + catch (e) { + target = top.prompt(`Aria2 connection failed${!e.message.includes("fetch") ? `: ${e.message}.\n` : ". "}Please provide a valid server address:`, target); + if (!target) return null; + } + } + } + + static copyToClipboard(text) { + const textarea = document.createElement('textarea'); + document.body.appendChild(textarea); + textarea.value = text; + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } } /*** @@ -3620,2724 +3641,2724 @@ class FLV { } } -var embeddedHTML = ` - - -

- 加载文件…… loading files... - -

-

- 构建mkv…… building mkv... - -

-

- merged.mkv -

-
- author qli5 <goodlq11[at](163|gmail).com> -
+var embeddedHTML = ` + + +

+ 加载文件…… loading files... + +

+

+ 构建mkv…… building mkv... + +

+

+ merged.mkv +

+
+ author qli5 <goodlq11[at](163|gmail).com> +
+ + + + +`; + +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +class MKVTransmuxer { + constructor(option) { + this.workerWin = null; + this.option = option; + } + + /** + * FLV + ASS => MKV entry point + * @param {Blob|string|ArrayBuffer} flv + * @param {Blob|string|ArrayBuffer} ass + * @param {string=} name + * @param {Node} target + * @param {{ name: string; file: (Blob|string|ArrayBuffer); }[]=} subtitleAssList + */ + exec(flv, ass, name, target, subtitleAssList = []) { + if (target.textContent != "另存为MKV") { + target.textContent = "打包中"; + + // 1. Allocate for a new window + if (!this.workerWin) this.workerWin = top.open('', undefined, ' '); + + // 2. Inject scripts + this.workerWin.document.write(embeddedHTML); + this.workerWin.document.close(); + + // 3. Invoke exec + if (!(this.option instanceof Object)) this.option = null; + this.workerWin.exec(Object.assign({}, this.option, { flv, ass, name, subtitleAssList }), target); + URL.revokeObjectURL(flv); + URL.revokeObjectURL(ass); + + // 4. Free parent window + // if (top.confirm('MKV打包中……要关掉这个窗口,释放内存吗?')) { + // top.location = 'about:blank'; + // } + } + } +} + +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +const _navigator = typeof navigator === 'object' && navigator || { userAgent: 'chrome' }; + +const _TextDecoder = typeof TextDecoder === 'function' && TextDecoder || class extends require('string_decoder').StringDecoder { + /** + * @param {ArrayBuffer} chunk + * @returns {string} + */ + decode(chunk) { + return this.end(Buffer.from(chunk)); + } +}; + +/*** + * The FLV demuxer is from flv.js + * + * Copyright (C) 2016 Bilibili. All Rights Reserved. + * + * @author zheng qian + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// import FLVDemuxer from 'flv.js/src/demux/flv-demuxer.js'; +// ..import Log from '../utils/logger.js'; +const Log = { + e: console.error.bind(console), + w: console.warn.bind(console), + i: console.log.bind(console), + v: console.log.bind(console), +}; + +// ..import AMF from './amf-parser.js'; +// ....import Log from '../utils/logger.js'; +// ....import decodeUTF8 from '../utils/utf8-conv.js'; +function checkContinuation(uint8array, start, checkLength) { + let array = uint8array; + if (start + checkLength < array.length) { + while (checkLength--) { + if ((array[++start] & 0xC0) !== 0x80) + return false; + } + return true; + } else { + return false; + } +} + +function decodeUTF8(uint8array) { + let out = []; + let input = uint8array; + let i = 0; + let length = uint8array.length; + + while (i < length) { + if (input[i] < 0x80) { + out.push(String.fromCharCode(input[i])); + ++i; + continue; + } else if (input[i] < 0xC0) { + // fallthrough + } else if (input[i] < 0xE0) { + if (checkContinuation(input, i, 1)) { + let ucs4 = (input[i] & 0x1F) << 6 | (input[i + 1] & 0x3F); + if (ucs4 >= 0x80) { + out.push(String.fromCharCode(ucs4 & 0xFFFF)); + i += 2; + continue; + } + } + } else if (input[i] < 0xF0) { + if (checkContinuation(input, i, 2)) { + let ucs4 = (input[i] & 0xF) << 12 | (input[i + 1] & 0x3F) << 6 | input[i + 2] & 0x3F; + if (ucs4 >= 0x800 && (ucs4 & 0xF800) !== 0xD800) { + out.push(String.fromCharCode(ucs4 & 0xFFFF)); + i += 3; + continue; + } + } + } else if (input[i] < 0xF8) { + if (checkContinuation(input, i, 3)) { + let ucs4 = (input[i] & 0x7) << 18 | (input[i + 1] & 0x3F) << 12 + | (input[i + 2] & 0x3F) << 6 | (input[i + 3] & 0x3F); + if (ucs4 > 0x10000 && ucs4 < 0x110000) { + ucs4 -= 0x10000; + out.push(String.fromCharCode((ucs4 >>> 10) | 0xD800)); + out.push(String.fromCharCode((ucs4 & 0x3FF) | 0xDC00)); + i += 4; + continue; + } + } + } + out.push(String.fromCharCode(0xFFFD)); + ++i; + } + + return out.join(''); +} + +// ....import {IllegalStateException} from '../utils/exception.js'; +class IllegalStateException extends Error { } + +let le = (function () { + let buf = new ArrayBuffer(2); + (new DataView(buf)).setInt16(0, 256, true); // little-endian write + return (new Int16Array(buf))[0] === 256; // platform-spec read, if equal then LE +})(); + +class AMF { + + static parseScriptData(arrayBuffer, dataOffset, dataSize) { + let data = {}; + + try { + let name = AMF.parseValue(arrayBuffer, dataOffset, dataSize); + let value = AMF.parseValue(arrayBuffer, dataOffset + name.size, dataSize - name.size); + + data[name.data] = value.data; + } catch (e) { + Log.e('AMF', e.toString()); + } + + return data; + } + + static parseObject(arrayBuffer, dataOffset, dataSize) { + if (dataSize < 3) { + throw new IllegalStateException('Data not enough when parse ScriptDataObject'); + } + let name = AMF.parseString(arrayBuffer, dataOffset, dataSize); + let value = AMF.parseValue(arrayBuffer, dataOffset + name.size, dataSize - name.size); + let isObjectEnd = value.objectEnd; + + return { + data: { + name: name.data, + value: value.data + }, + size: name.size + value.size, + objectEnd: isObjectEnd + }; + } + + static parseVariable(arrayBuffer, dataOffset, dataSize) { + return AMF.parseObject(arrayBuffer, dataOffset, dataSize); + } + + static parseString(arrayBuffer, dataOffset, dataSize) { + if (dataSize < 2) { + throw new IllegalStateException('Data not enough when parse String'); + } + let v = new DataView(arrayBuffer, dataOffset, dataSize); + let length = v.getUint16(0, !le); + + let str; + if (length > 0) { + str = decodeUTF8(new Uint8Array(arrayBuffer, dataOffset + 2, length)); + } else { + str = ''; + } + + return { + data: str, + size: 2 + length + }; + } + + static parseLongString(arrayBuffer, dataOffset, dataSize) { + if (dataSize < 4) { + throw new IllegalStateException('Data not enough when parse LongString'); + } + let v = new DataView(arrayBuffer, dataOffset, dataSize); + let length = v.getUint32(0, !le); + + let str; + if (length > 0) { + str = decodeUTF8(new Uint8Array(arrayBuffer, dataOffset + 4, length)); + } else { + str = ''; + } + + return { + data: str, + size: 4 + length + }; + } + + static parseDate(arrayBuffer, dataOffset, dataSize) { + if (dataSize < 10) { + throw new IllegalStateException('Data size invalid when parse Date'); + } + let v = new DataView(arrayBuffer, dataOffset, dataSize); + let timestamp = v.getFloat64(0, !le); + let localTimeOffset = v.getInt16(8, !le); + timestamp += localTimeOffset * 60 * 1000; // get UTC time + + return { + data: new Date(timestamp), + size: 8 + 2 + }; + } + + static parseValue(arrayBuffer, dataOffset, dataSize) { + if (dataSize < 1) { + throw new IllegalStateException('Data not enough when parse Value'); + } + + let v = new DataView(arrayBuffer, dataOffset, dataSize); + + let offset = 1; + let type = v.getUint8(0); + let value; + let objectEnd = false; + + try { + switch (type) { + case 0: // Number(Double) type + value = v.getFloat64(1, !le); + offset += 8; + break; + case 1: { // Boolean type + let b = v.getUint8(1); + value = b ? true : false; + offset += 1; + break; + } + case 2: { // String type + let amfstr = AMF.parseString(arrayBuffer, dataOffset + 1, dataSize - 1); + value = amfstr.data; + offset += amfstr.size; + break; + } + case 3: { // Object(s) type + value = {}; + let terminal = 0; // workaround for malformed Objects which has missing ScriptDataObjectEnd + if ((v.getUint32(dataSize - 4, !le) & 0x00FFFFFF) === 9) { + terminal = 3; + } + while (offset < dataSize - 4) { // 4 === type(UI8) + ScriptDataObjectEnd(UI24) + let amfobj = AMF.parseObject(arrayBuffer, dataOffset + offset, dataSize - offset - terminal); + if (amfobj.objectEnd) + break; + value[amfobj.data.name] = amfobj.data.value; + offset += amfobj.size; + } + if (offset <= dataSize - 3) { + let marker = v.getUint32(offset - 1, !le) & 0x00FFFFFF; + if (marker === 9) { + offset += 3; + } + } + break; + } + case 8: { // ECMA array type (Mixed array) + value = {}; + offset += 4; // ECMAArrayLength(UI32) + let terminal = 0; // workaround for malformed MixedArrays which has missing ScriptDataObjectEnd + if ((v.getUint32(dataSize - 4, !le) & 0x00FFFFFF) === 9) { + terminal = 3; + } + while (offset < dataSize - 8) { // 8 === type(UI8) + ECMAArrayLength(UI32) + ScriptDataVariableEnd(UI24) + let amfvar = AMF.parseVariable(arrayBuffer, dataOffset + offset, dataSize - offset - terminal); + if (amfvar.objectEnd) + break; + value[amfvar.data.name] = amfvar.data.value; + offset += amfvar.size; + } + if (offset <= dataSize - 3) { + let marker = v.getUint32(offset - 1, !le) & 0x00FFFFFF; + if (marker === 9) { + offset += 3; + } + } + break; + } + case 9: // ScriptDataObjectEnd + value = undefined; + offset = 1; + objectEnd = true; + break; + case 10: { // Strict array type + // ScriptDataValue[n]. NOTE: according to video_file_format_spec_v10_1.pdf + value = []; + let strictArrayLength = v.getUint32(1, !le); + offset += 4; + for (let i = 0; i < strictArrayLength; i++) { + let val = AMF.parseValue(arrayBuffer, dataOffset + offset, dataSize - offset); + value.push(val.data); + offset += val.size; + } + break; + } + case 11: { // Date type + let date = AMF.parseDate(arrayBuffer, dataOffset + 1, dataSize - 1); + value = date.data; + offset += date.size; + break; + } + case 12: { // Long string type + let amfLongStr = AMF.parseString(arrayBuffer, dataOffset + 1, dataSize - 1); + value = amfLongStr.data; + offset += amfLongStr.size; + break; + } + default: + // ignore and skip + offset = dataSize; + Log.w('AMF', 'Unsupported AMF value type ' + type); + } + } catch (e) { + Log.e('AMF', e.toString()); + } + + return { + data: value, + size: offset, + objectEnd: objectEnd + }; + } + +} + +// ..import SPSParser from './sps-parser.js'; +// ....import ExpGolomb from './exp-golomb.js'; +// ......import {IllegalStateException, InvalidArgumentException} from '../utils/exception.js'; +class InvalidArgumentException extends Error { } + +class ExpGolomb { + + constructor(uint8array) { + this.TAG = 'ExpGolomb'; + + this._buffer = uint8array; + this._buffer_index = 0; + this._total_bytes = uint8array.byteLength; + this._total_bits = uint8array.byteLength * 8; + this._current_word = 0; + this._current_word_bits_left = 0; + } + + destroy() { + this._buffer = null; + } + + _fillCurrentWord() { + let buffer_bytes_left = this._total_bytes - this._buffer_index; + if (buffer_bytes_left <= 0) + throw new IllegalStateException('ExpGolomb: _fillCurrentWord() but no bytes available'); + + let bytes_read = Math.min(4, buffer_bytes_left); + let word = new Uint8Array(4); + word.set(this._buffer.subarray(this._buffer_index, this._buffer_index + bytes_read)); + this._current_word = new DataView(word.buffer).getUint32(0, false); + + this._buffer_index += bytes_read; + this._current_word_bits_left = bytes_read * 8; + } + + readBits(bits) { + if (bits > 32) + throw new InvalidArgumentException('ExpGolomb: readBits() bits exceeded max 32bits!'); + + if (bits <= this._current_word_bits_left) { + let result = this._current_word >>> (32 - bits); + this._current_word <<= bits; + this._current_word_bits_left -= bits; + return result; + } + + let result = this._current_word_bits_left ? this._current_word : 0; + result = result >>> (32 - this._current_word_bits_left); + let bits_need_left = bits - this._current_word_bits_left; + + this._fillCurrentWord(); + let bits_read_next = Math.min(bits_need_left, this._current_word_bits_left); + + let result2 = this._current_word >>> (32 - bits_read_next); + this._current_word <<= bits_read_next; + this._current_word_bits_left -= bits_read_next; + + result = (result << bits_read_next) | result2; + return result; + } + + readBool() { + return this.readBits(1) === 1; + } + + readByte() { + return this.readBits(8); + } + + _skipLeadingZero() { + let zero_count; + for (zero_count = 0; zero_count < this._current_word_bits_left; zero_count++) { + if (0 !== (this._current_word & (0x80000000 >>> zero_count))) { + this._current_word <<= zero_count; + this._current_word_bits_left -= zero_count; + return zero_count; + } + } + this._fillCurrentWord(); + return zero_count + this._skipLeadingZero(); + } + + readUEG() { // unsigned exponential golomb + let leading_zeros = this._skipLeadingZero(); + return this.readBits(leading_zeros + 1) - 1; + } + + readSEG() { // signed exponential golomb + let value = this.readUEG(); + if (value & 0x01) { + return (value + 1) >>> 1; + } else { + return -1 * (value >>> 1); + } + } + +} + +class SPSParser { + + static _ebsp2rbsp(uint8array) { + let src = uint8array; + let src_length = src.byteLength; + let dst = new Uint8Array(src_length); + let dst_idx = 0; + + for (let i = 0; i < src_length; i++) { + if (i >= 2) { + // Unescape: Skip 0x03 after 00 00 + if (src[i] === 0x03 && src[i - 1] === 0x00 && src[i - 2] === 0x00) { + continue; + } + } + dst[dst_idx] = src[i]; + dst_idx++; + } + + return new Uint8Array(dst.buffer, 0, dst_idx); + } + + static parseSPS(uint8array) { + let rbsp = SPSParser._ebsp2rbsp(uint8array); + let gb = new ExpGolomb(rbsp); + + gb.readByte(); + let profile_idc = gb.readByte(); // profile_idc + gb.readByte(); // constraint_set_flags[5] + reserved_zero[3] + let level_idc = gb.readByte(); // level_idc + gb.readUEG(); // seq_parameter_set_id + + let profile_string = SPSParser.getProfileString(profile_idc); + let level_string = SPSParser.getLevelString(level_idc); + let chroma_format_idc = 1; + let chroma_format = 420; + let chroma_format_table = [0, 420, 422, 444]; + let bit_depth = 8; + + if (profile_idc === 100 || profile_idc === 110 || profile_idc === 122 || + profile_idc === 244 || profile_idc === 44 || profile_idc === 83 || + profile_idc === 86 || profile_idc === 118 || profile_idc === 128 || + profile_idc === 138 || profile_idc === 144) { + + chroma_format_idc = gb.readUEG(); + if (chroma_format_idc === 3) { + gb.readBits(1); // separate_colour_plane_flag + } + if (chroma_format_idc <= 3) { + chroma_format = chroma_format_table[chroma_format_idc]; + } + + bit_depth = gb.readUEG() + 8; // bit_depth_luma_minus8 + gb.readUEG(); // bit_depth_chroma_minus8 + gb.readBits(1); // qpprime_y_zero_transform_bypass_flag + if (gb.readBool()) { // seq_scaling_matrix_present_flag + let scaling_list_count = (chroma_format_idc !== 3) ? 8 : 12; + for (let i = 0; i < scaling_list_count; i++) { + if (gb.readBool()) { // seq_scaling_list_present_flag + if (i < 6) { + SPSParser._skipScalingList(gb, 16); + } else { + SPSParser._skipScalingList(gb, 64); + } + } + } + } + } + gb.readUEG(); // log2_max_frame_num_minus4 + let pic_order_cnt_type = gb.readUEG(); + if (pic_order_cnt_type === 0) { + gb.readUEG(); // log2_max_pic_order_cnt_lsb_minus_4 + } else if (pic_order_cnt_type === 1) { + gb.readBits(1); // delta_pic_order_always_zero_flag + gb.readSEG(); // offset_for_non_ref_pic + gb.readSEG(); // offset_for_top_to_bottom_field + let num_ref_frames_in_pic_order_cnt_cycle = gb.readUEG(); + for (let i = 0; i < num_ref_frames_in_pic_order_cnt_cycle; i++) { + gb.readSEG(); // offset_for_ref_frame + } + } + gb.readUEG(); // max_num_ref_frames + gb.readBits(1); // gaps_in_frame_num_value_allowed_flag + + let pic_width_in_mbs_minus1 = gb.readUEG(); + let pic_height_in_map_units_minus1 = gb.readUEG(); + + let frame_mbs_only_flag = gb.readBits(1); + if (frame_mbs_only_flag === 0) { + gb.readBits(1); // mb_adaptive_frame_field_flag + } + gb.readBits(1); // direct_8x8_inference_flag + + let frame_crop_left_offset = 0; + let frame_crop_right_offset = 0; + let frame_crop_top_offset = 0; + let frame_crop_bottom_offset = 0; + + let frame_cropping_flag = gb.readBool(); + if (frame_cropping_flag) { + frame_crop_left_offset = gb.readUEG(); + frame_crop_right_offset = gb.readUEG(); + frame_crop_top_offset = gb.readUEG(); + frame_crop_bottom_offset = gb.readUEG(); + } + + let sar_width = 1, sar_height = 1; + let fps = 0, fps_fixed = true, fps_num = 0, fps_den = 0; + + let vui_parameters_present_flag = gb.readBool(); + if (vui_parameters_present_flag) { + if (gb.readBool()) { // aspect_ratio_info_present_flag + let aspect_ratio_idc = gb.readByte(); + let sar_w_table = [1, 12, 10, 16, 40, 24, 20, 32, 80, 18, 15, 64, 160, 4, 3, 2]; + let sar_h_table = [1, 11, 11, 11, 33, 11, 11, 11, 33, 11, 11, 33, 99, 3, 2, 1]; + + if (aspect_ratio_idc > 0 && aspect_ratio_idc < 16) { + sar_width = sar_w_table[aspect_ratio_idc - 1]; + sar_height = sar_h_table[aspect_ratio_idc - 1]; + } else if (aspect_ratio_idc === 255) { + sar_width = gb.readByte() << 8 | gb.readByte(); + sar_height = gb.readByte() << 8 | gb.readByte(); + } + } + + if (gb.readBool()) { // overscan_info_present_flag + gb.readBool(); // overscan_appropriate_flag + } + if (gb.readBool()) { // video_signal_type_present_flag + gb.readBits(4); // video_format & video_full_range_flag + if (gb.readBool()) { // colour_description_present_flag + gb.readBits(24); // colour_primaries & transfer_characteristics & matrix_coefficients + } + } + if (gb.readBool()) { // chroma_loc_info_present_flag + gb.readUEG(); // chroma_sample_loc_type_top_field + gb.readUEG(); // chroma_sample_loc_type_bottom_field + } + if (gb.readBool()) { // timing_info_present_flag + let num_units_in_tick = gb.readBits(32); + let time_scale = gb.readBits(32); + fps_fixed = gb.readBool(); // fixed_frame_rate_flag + + fps_num = time_scale; + fps_den = num_units_in_tick * 2; + fps = fps_num / fps_den; + } + } + + let sarScale = 1; + if (sar_width !== 1 || sar_height !== 1) { + sarScale = sar_width / sar_height; + } + + let crop_unit_x = 0, crop_unit_y = 0; + if (chroma_format_idc === 0) { + crop_unit_x = 1; + crop_unit_y = 2 - frame_mbs_only_flag; + } else { + let sub_wc = (chroma_format_idc === 3) ? 1 : 2; + let sub_hc = (chroma_format_idc === 1) ? 2 : 1; + crop_unit_x = sub_wc; + crop_unit_y = sub_hc * (2 - frame_mbs_only_flag); + } + + let codec_width = (pic_width_in_mbs_minus1 + 1) * 16; + let codec_height = (2 - frame_mbs_only_flag) * ((pic_height_in_map_units_minus1 + 1) * 16); + + codec_width -= (frame_crop_left_offset + frame_crop_right_offset) * crop_unit_x; + codec_height -= (frame_crop_top_offset + frame_crop_bottom_offset) * crop_unit_y; + + let present_width = Math.ceil(codec_width * sarScale); + + gb.destroy(); + gb = null; + + return { + profile_string: profile_string, // baseline, high, high10, ... + level_string: level_string, // 3, 3.1, 4, 4.1, 5, 5.1, ... + bit_depth: bit_depth, // 8bit, 10bit, ... + chroma_format: chroma_format, // 4:2:0, 4:2:2, ... + chroma_format_string: SPSParser.getChromaFormatString(chroma_format), + + frame_rate: { + fixed: fps_fixed, + fps: fps, + fps_den: fps_den, + fps_num: fps_num + }, + + sar_ratio: { + width: sar_width, + height: sar_height + }, + + codec_size: { + width: codec_width, + height: codec_height + }, + + present_size: { + width: present_width, + height: codec_height + } + }; + } + + static _skipScalingList(gb, count) { + let last_scale = 8, next_scale = 8; + let delta_scale = 0; + for (let i = 0; i < count; i++) { + if (next_scale !== 0) { + delta_scale = gb.readSEG(); + next_scale = (last_scale + delta_scale + 256) % 256; + } + last_scale = (next_scale === 0) ? last_scale : next_scale; + } + } + + static getProfileString(profile_idc) { + switch (profile_idc) { + case 66: + return 'Baseline'; + case 77: + return 'Main'; + case 88: + return 'Extended'; + case 100: + return 'High'; + case 110: + return 'High10'; + case 122: + return 'High422'; + case 244: + return 'High444'; + default: + return 'Unknown'; + } + } + + static getLevelString(level_idc) { + return (level_idc / 10).toFixed(1); + } + + static getChromaFormatString(chroma) { + switch (chroma) { + case 420: + return '4:2:0'; + case 422: + return '4:2:2'; + case 444: + return '4:4:4'; + default: + return 'Unknown'; + } + } + +} + +// ..import DemuxErrors from './demux-errors.js'; +const DemuxErrors = { + OK: 'OK', + FORMAT_ERROR: 'FormatError', + FORMAT_UNSUPPORTED: 'FormatUnsupported', + CODEC_UNSUPPORTED: 'CodecUnsupported' +}; + +// ..import MediaInfo from '../core/media-info.js'; +class MediaInfo { + + constructor() { + this.mimeType = null; + this.duration = null; + + this.hasAudio = null; + this.hasVideo = null; + this.audioCodec = null; + this.videoCodec = null; + this.audioDataRate = null; + this.videoDataRate = null; + + this.audioSampleRate = null; + this.audioChannelCount = null; + + this.width = null; + this.height = null; + this.fps = null; + this.profile = null; + this.level = null; + this.chromaFormat = null; + this.sarNum = null; + this.sarDen = null; + + this.metadata = null; + this.segments = null; // MediaInfo[] + this.segmentCount = null; + this.hasKeyframesIndex = null; + this.keyframesIndex = null; + } + + isComplete() { + let audioInfoComplete = (this.hasAudio === false) || + (this.hasAudio === true && + this.audioCodec != null && + this.audioSampleRate != null && + this.audioChannelCount != null); + + let videoInfoComplete = (this.hasVideo === false) || + (this.hasVideo === true && + this.videoCodec != null && + this.width != null && + this.height != null && + this.fps != null && + this.profile != null && + this.level != null && + this.chromaFormat != null && + this.sarNum != null && + this.sarDen != null); + + // keyframesIndex may not be present + return this.mimeType != null && + this.duration != null && + this.metadata != null && + this.hasKeyframesIndex != null && + audioInfoComplete && + videoInfoComplete; + } + + isSeekable() { + return this.hasKeyframesIndex === true; + } + + getNearestKeyframe(milliseconds) { + if (this.keyframesIndex == null) { + return null; + } + + let table = this.keyframesIndex; + let keyframeIdx = this._search(table.times, milliseconds); + + return { + index: keyframeIdx, + milliseconds: table.times[keyframeIdx], + fileposition: table.filepositions[keyframeIdx] + }; + } + + _search(list, value) { + let idx = 0; + + let last = list.length - 1; + let mid = 0; + let lbound = 0; + let ubound = last; + + if (value < list[0]) { + idx = 0; + lbound = ubound + 1; // skip search + } + + while (lbound <= ubound) { + mid = lbound + Math.floor((ubound - lbound) / 2); + if (mid === last || (value >= list[mid] && value < list[mid + 1])) { + idx = mid; + break; + } else if (list[mid] < value) { + lbound = mid + 1; + } else { + ubound = mid - 1; + } + } + + return idx; + } + +} + +function ReadBig32(array, index) { + return ((array[index] << 24) | + (array[index + 1] << 16) | + (array[index + 2] << 8) | + (array[index + 3])); +} + +class FLVDemuxer { + + /** + * Create a new FLV demuxer + * @param {Object} probeData + * @param {boolean} probeData.match + * @param {number} probeData.consumed + * @param {number} probeData.dataOffset + * @param {boolean} probeData.hasAudioTrack + * @param {boolean} probeData.hasVideoTrack + */ + constructor(probeData) { + this.TAG = 'FLVDemuxer'; + + this._onError = null; + this._onMediaInfo = null; + this._onTrackMetadata = null; + this._onDataAvailable = null; + + this._dataOffset = probeData.dataOffset; + this._firstParse = true; + this._dispatch = false; + + this._hasAudio = probeData.hasAudioTrack; + this._hasVideo = probeData.hasVideoTrack; + + this._hasAudioFlagOverrided = false; + this._hasVideoFlagOverrided = false; + + this._audioInitialMetadataDispatched = false; + this._videoInitialMetadataDispatched = false; + + this._mediaInfo = new MediaInfo(); + this._mediaInfo.hasAudio = this._hasAudio; + this._mediaInfo.hasVideo = this._hasVideo; + this._metadata = null; + this._audioMetadata = null; + this._videoMetadata = null; + + this._naluLengthSize = 4; + this._timestampBase = 0; // int32, in milliseconds + this._timescale = 1000; + this._duration = 0; // int32, in milliseconds + this._durationOverrided = false; + this._referenceFrameRate = { + fixed: true, + fps: 23.976, + fps_num: 23976, + fps_den: 1000 + }; + + this._flvSoundRateTable = [5500, 11025, 22050, 44100, 48000]; + + this._mpegSamplingRates = [ + 96000, 88200, 64000, 48000, 44100, 32000, + 24000, 22050, 16000, 12000, 11025, 8000, 7350 + ]; + + this._mpegAudioV10SampleRateTable = [44100, 48000, 32000, 0]; + this._mpegAudioV20SampleRateTable = [22050, 24000, 16000, 0]; + this._mpegAudioV25SampleRateTable = [11025, 12000, 8000, 0]; + + this._mpegAudioL1BitRateTable = [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, -1]; + this._mpegAudioL2BitRateTable = [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, -1]; + this._mpegAudioL3BitRateTable = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, -1]; + + this._videoTrack = { type: 'video', id: 1, sequenceNumber: 0, samples: [], length: 0 }; + this._audioTrack = { type: 'audio', id: 2, sequenceNumber: 0, samples: [], length: 0 }; + + this._littleEndian = (function () { + let buf = new ArrayBuffer(2); + (new DataView(buf)).setInt16(0, 256, true); // little-endian write + return (new Int16Array(buf))[0] === 256; // platform-spec read, if equal then LE + })(); + } + + destroy() { + this._mediaInfo = null; + this._metadata = null; + this._audioMetadata = null; + this._videoMetadata = null; + this._videoTrack = null; + this._audioTrack = null; + + this._onError = null; + this._onMediaInfo = null; + this._onTrackMetadata = null; + this._onDataAvailable = null; + } + + /** + * Probe the flv data + * @param {ArrayBuffer} buffer + * @returns {Object} - probeData to be feed into constructor + */ + static probe(buffer) { + let data = new Uint8Array(buffer); + let mismatch = { match: false }; + + if (data[0] !== 0x46 || data[1] !== 0x4C || data[2] !== 0x56 || data[3] !== 0x01) { + return mismatch; + } + + let hasAudio = ((data[4] & 4) >>> 2) !== 0; + let hasVideo = (data[4] & 1) !== 0; + + let offset = ReadBig32(data, 5); + + if (offset < 9) { + return mismatch; + } + + return { + match: true, + consumed: offset, + dataOffset: offset, + hasAudioTrack: hasAudio, + hasVideoTrack: hasVideo + }; + } + + bindDataSource(loader) { + loader.onDataArrival = this.parseChunks.bind(this); + return this; + } + + // prototype: function(type: string, metadata: any): void + get onTrackMetadata() { + return this._onTrackMetadata; + } + + set onTrackMetadata(callback) { + this._onTrackMetadata = callback; + } + + // prototype: function(mediaInfo: MediaInfo): void + get onMediaInfo() { + return this._onMediaInfo; + } + + set onMediaInfo(callback) { + this._onMediaInfo = callback; + } + + // prototype: function(type: number, info: string): void + get onError() { + return this._onError; + } + + set onError(callback) { + this._onError = callback; + } + + // prototype: function(videoTrack: any, audioTrack: any): void + get onDataAvailable() { + return this._onDataAvailable; + } + + set onDataAvailable(callback) { + this._onDataAvailable = callback; + } + + // timestamp base for output samples, must be in milliseconds + get timestampBase() { + return this._timestampBase; + } + + set timestampBase(base) { + this._timestampBase = base; + } + + get overridedDuration() { + return this._duration; + } + + // Force-override media duration. Must be in milliseconds, int32 + set overridedDuration(duration) { + this._durationOverrided = true; + this._duration = duration; + this._mediaInfo.duration = duration; + } + + // Force-override audio track present flag, boolean + set overridedHasAudio(hasAudio) { + this._hasAudioFlagOverrided = true; + this._hasAudio = hasAudio; + this._mediaInfo.hasAudio = hasAudio; + } + + // Force-override video track present flag, boolean + set overridedHasVideo(hasVideo) { + this._hasVideoFlagOverrided = true; + this._hasVideo = hasVideo; + this._mediaInfo.hasVideo = hasVideo; + } + + resetMediaInfo() { + this._mediaInfo = new MediaInfo(); + } + + _isInitialMetadataDispatched() { + if (this._hasAudio && this._hasVideo) { // both audio & video + return this._audioInitialMetadataDispatched && this._videoInitialMetadataDispatched; + } + if (this._hasAudio && !this._hasVideo) { // audio only + return this._audioInitialMetadataDispatched; + } + if (!this._hasAudio && this._hasVideo) { // video only + return this._videoInitialMetadataDispatched; + } + return false; + } + + // function parseChunks(chunk: ArrayBuffer, byteStart: number): number; + parseChunks(chunk, byteStart) { + if (!this._onError || !this._onMediaInfo || !this._onTrackMetadata || !this._onDataAvailable) { + throw new IllegalStateException('Flv: onError & onMediaInfo & onTrackMetadata & onDataAvailable callback must be specified'); + } + + // qli5: fix nonzero byteStart + let offset = byteStart || 0; + let le = this._littleEndian; + + if (byteStart === 0) { // buffer with FLV header + if (chunk.byteLength > 13) { + let probeData = FLVDemuxer.probe(chunk); + offset = probeData.dataOffset; + } else { + return 0; + } + } + + if (this._firstParse) { // handle PreviousTagSize0 before Tag1 + this._firstParse = false; + if (offset !== this._dataOffset) { + Log.w(this.TAG, 'First time parsing but chunk byteStart invalid!'); + } + + let v = new DataView(chunk, offset); + let prevTagSize0 = v.getUint32(0, !le); + if (prevTagSize0 !== 0) { + Log.w(this.TAG, 'PrevTagSize0 !== 0 !!!'); + } + offset += 4; + } + + while (offset < chunk.byteLength) { + this._dispatch = true; + + let v = new DataView(chunk, offset); + + if (offset + 11 + 4 > chunk.byteLength) { + // data not enough for parsing an flv tag + break; + } + + let tagType = v.getUint8(0); + let dataSize = v.getUint32(0, !le) & 0x00FFFFFF; + + if (offset + 11 + dataSize + 4 > chunk.byteLength) { + // data not enough for parsing actual data body + break; + } + + if (tagType !== 8 && tagType !== 9 && tagType !== 18) { + Log.w(this.TAG, `Unsupported tag type ${tagType}, skipped`); + // consume the whole tag (skip it) + offset += 11 + dataSize + 4; + continue; + } + + let ts2 = v.getUint8(4); + let ts1 = v.getUint8(5); + let ts0 = v.getUint8(6); + let ts3 = v.getUint8(7); + + let timestamp = ts0 | (ts1 << 8) | (ts2 << 16) | (ts3 << 24); + + let streamId = v.getUint32(7, !le) & 0x00FFFFFF; + if (streamId !== 0) { + Log.w(this.TAG, 'Meet tag which has StreamID != 0!'); + } + + let dataOffset = offset + 11; + + switch (tagType) { + case 8: // Audio + this._parseAudioData(chunk, dataOffset, dataSize, timestamp); + break; + case 9: // Video + this._parseVideoData(chunk, dataOffset, dataSize, timestamp, byteStart + offset); + break; + case 18: // ScriptDataObject + this._parseScriptData(chunk, dataOffset, dataSize); + break; + } + + let prevTagSize = v.getUint32(11 + dataSize, !le); + if (prevTagSize !== 11 + dataSize) { + Log.w(this.TAG, `Invalid PrevTagSize ${prevTagSize}`); + } + + offset += 11 + dataSize + 4; // tagBody + dataSize + prevTagSize + } + + // dispatch parsed frames to consumer (typically, the remuxer) + if (this._isInitialMetadataDispatched()) { + if (this._dispatch && (this._audioTrack.length || this._videoTrack.length)) { + this._onDataAvailable(this._audioTrack, this._videoTrack); + } + } + + return offset; // consumed bytes, just equals latest offset index + } + + _parseScriptData(arrayBuffer, dataOffset, dataSize) { + let scriptData = AMF.parseScriptData(arrayBuffer, dataOffset, dataSize); + + if (scriptData.hasOwnProperty('onMetaData')) { + if (scriptData.onMetaData == null || typeof scriptData.onMetaData !== 'object') { + Log.w(this.TAG, 'Invalid onMetaData structure!'); + return; + } + if (this._metadata) { + Log.w(this.TAG, 'Found another onMetaData tag!'); + } + this._metadata = scriptData; + let onMetaData = this._metadata.onMetaData; + + if (typeof onMetaData.hasAudio === 'boolean') { // hasAudio + if (this._hasAudioFlagOverrided === false) { + this._hasAudio = onMetaData.hasAudio; + this._mediaInfo.hasAudio = this._hasAudio; + } + } + if (typeof onMetaData.hasVideo === 'boolean') { // hasVideo + if (this._hasVideoFlagOverrided === false) { + this._hasVideo = onMetaData.hasVideo; + this._mediaInfo.hasVideo = this._hasVideo; + } + } + if (typeof onMetaData.audiodatarate === 'number') { // audiodatarate + this._mediaInfo.audioDataRate = onMetaData.audiodatarate; + } + if (typeof onMetaData.videodatarate === 'number') { // videodatarate + this._mediaInfo.videoDataRate = onMetaData.videodatarate; + } + if (typeof onMetaData.width === 'number') { // width + this._mediaInfo.width = onMetaData.width; + } + if (typeof onMetaData.height === 'number') { // height + this._mediaInfo.height = onMetaData.height; + } + if (typeof onMetaData.duration === 'number') { // duration + if (!this._durationOverrided) { + let duration = Math.floor(onMetaData.duration * this._timescale); + this._duration = duration; + this._mediaInfo.duration = duration; + } + } else { + this._mediaInfo.duration = 0; + } + if (typeof onMetaData.framerate === 'number') { // framerate + let fps_num = Math.floor(onMetaData.framerate * 1000); + if (fps_num > 0) { + let fps = fps_num / 1000; + this._referenceFrameRate.fixed = true; + this._referenceFrameRate.fps = fps; + this._referenceFrameRate.fps_num = fps_num; + this._referenceFrameRate.fps_den = 1000; + this._mediaInfo.fps = fps; + } + } + if (typeof onMetaData.keyframes === 'object') { // keyframes + this._mediaInfo.hasKeyframesIndex = true; + let keyframes = onMetaData.keyframes; + this._mediaInfo.keyframesIndex = this._parseKeyframesIndex(keyframes); + onMetaData.keyframes = null; // keyframes has been extracted, remove it + } else { + this._mediaInfo.hasKeyframesIndex = false; + } + this._dispatch = false; + this._mediaInfo.metadata = onMetaData; + Log.v(this.TAG, 'Parsed onMetaData'); + if (this._mediaInfo.isComplete()) { + this._onMediaInfo(this._mediaInfo); + } + } + } + + _parseKeyframesIndex(keyframes) { + let times = []; + let filepositions = []; + + // ignore first keyframe which is actually AVC Sequence Header (AVCDecoderConfigurationRecord) + for (let i = 1; i < keyframes.times.length; i++) { + let time = this._timestampBase + Math.floor(keyframes.times[i] * 1000); + times.push(time); + filepositions.push(keyframes.filepositions[i]); + } + + return { + times: times, + filepositions: filepositions + }; + } + + _parseAudioData(arrayBuffer, dataOffset, dataSize, tagTimestamp) { + if (dataSize <= 1) { + Log.w(this.TAG, 'Flv: Invalid audio packet, missing SoundData payload!'); + return; + } + + if (this._hasAudioFlagOverrided === true && this._hasAudio === false) { + // If hasAudio: false indicated explicitly in MediaDataSource, + // Ignore all the audio packets + return; + } + + let le = this._littleEndian; + let v = new DataView(arrayBuffer, dataOffset, dataSize); + + let soundSpec = v.getUint8(0); + + let soundFormat = soundSpec >>> 4; + if (soundFormat !== 2 && soundFormat !== 10) { // MP3 or AAC + this._onError(DemuxErrors.CODEC_UNSUPPORTED, 'Flv: Unsupported audio codec idx: ' + soundFormat); + return; + } + + let soundRate = 0; + let soundRateIndex = (soundSpec & 12) >>> 2; + if (soundRateIndex >= 0 && soundRateIndex <= 4) { + soundRate = this._flvSoundRateTable[soundRateIndex]; + } else { + this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: Invalid audio sample rate idx: ' + soundRateIndex); + return; + } + let soundType = (soundSpec & 1); + + + let meta = this._audioMetadata; + let track = this._audioTrack; + + if (!meta) { + if (this._hasAudio === false && this._hasAudioFlagOverrided === false) { + this._hasAudio = true; + this._mediaInfo.hasAudio = true; + } + + // initial metadata + meta = this._audioMetadata = {}; + meta.type = 'audio'; + meta.id = track.id; + meta.timescale = this._timescale; + meta.duration = this._duration; + meta.audioSampleRate = soundRate; + meta.channelCount = (soundType === 0 ? 1 : 2); + } + + if (soundFormat === 10) { // AAC + let aacData = this._parseAACAudioData(arrayBuffer, dataOffset + 1, dataSize - 1); + + if (aacData == undefined) { + return; + } + + if (aacData.packetType === 0) { // AAC sequence header (AudioSpecificConfig) + if (meta.config) { + Log.w(this.TAG, 'Found another AudioSpecificConfig!'); + } + let misc = aacData.data; + meta.audioSampleRate = misc.samplingRate; + meta.channelCount = misc.channelCount; + meta.codec = misc.codec; + meta.originalCodec = misc.originalCodec; + meta.config = misc.config; + // added by qli5 + meta.configRaw = misc.configRaw; + // added by Xmader + meta.audioObjectType = misc.audioObjectType; + meta.samplingFrequencyIndex = misc.samplingIndex; + meta.channelConfig = misc.channelCount; + // The decode result of an aac sample is 1024 PCM samples + meta.refSampleDuration = 1024 / meta.audioSampleRate * meta.timescale; + Log.v(this.TAG, 'Parsed AudioSpecificConfig'); + + if (this._isInitialMetadataDispatched()) { + // Non-initial metadata, force dispatch (or flush) parsed frames to remuxer + if (this._dispatch && (this._audioTrack.length || this._videoTrack.length)) { + this._onDataAvailable(this._audioTrack, this._videoTrack); + } + } else { + this._audioInitialMetadataDispatched = true; + } + // then notify new metadata + this._dispatch = false; + this._onTrackMetadata('audio', meta); + + let mi = this._mediaInfo; + mi.audioCodec = meta.originalCodec; + mi.audioSampleRate = meta.audioSampleRate; + mi.audioChannelCount = meta.channelCount; + if (mi.hasVideo) { + if (mi.videoCodec != null) { + mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + ',' + mi.audioCodec + '"'; + } + } else { + mi.mimeType = 'video/x-flv; codecs="' + mi.audioCodec + '"'; + } + if (mi.isComplete()) { + this._onMediaInfo(mi); + } + } else if (aacData.packetType === 1) { // AAC raw frame data + let dts = this._timestampBase + tagTimestamp; + let aacSample = { unit: aacData.data, length: aacData.data.byteLength, dts: dts, pts: dts }; + track.samples.push(aacSample); + track.length += aacData.data.length; + } else { + Log.e(this.TAG, `Flv: Unsupported AAC data type ${aacData.packetType}`); + } + } else if (soundFormat === 2) { // MP3 + if (!meta.codec) { + // We need metadata for mp3 audio track, extract info from frame header + let misc = this._parseMP3AudioData(arrayBuffer, dataOffset + 1, dataSize - 1, true); + if (misc == undefined) { + return; + } + meta.audioSampleRate = misc.samplingRate; + meta.channelCount = misc.channelCount; + meta.codec = misc.codec; + meta.originalCodec = misc.originalCodec; + // The decode result of an mp3 sample is 1152 PCM samples + meta.refSampleDuration = 1152 / meta.audioSampleRate * meta.timescale; + Log.v(this.TAG, 'Parsed MPEG Audio Frame Header'); + + this._audioInitialMetadataDispatched = true; + this._onTrackMetadata('audio', meta); + + let mi = this._mediaInfo; + mi.audioCodec = meta.codec; + mi.audioSampleRate = meta.audioSampleRate; + mi.audioChannelCount = meta.channelCount; + mi.audioDataRate = misc.bitRate; + if (mi.hasVideo) { + if (mi.videoCodec != null) { + mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + ',' + mi.audioCodec + '"'; + } + } else { + mi.mimeType = 'video/x-flv; codecs="' + mi.audioCodec + '"'; + } + if (mi.isComplete()) { + this._onMediaInfo(mi); + } + } + + // This packet is always a valid audio packet, extract it + let data = this._parseMP3AudioData(arrayBuffer, dataOffset + 1, dataSize - 1, false); + if (data == undefined) { + return; + } + let dts = this._timestampBase + tagTimestamp; + let mp3Sample = { unit: data, length: data.byteLength, dts: dts, pts: dts }; + track.samples.push(mp3Sample); + track.length += data.length; + } + } + + _parseAACAudioData(arrayBuffer, dataOffset, dataSize) { + if (dataSize <= 1) { + Log.w(this.TAG, 'Flv: Invalid AAC packet, missing AACPacketType or/and Data!'); + return; + } + + let result = {}; + let array = new Uint8Array(arrayBuffer, dataOffset, dataSize); + + result.packetType = array[0]; + + if (array[0] === 0) { + result.data = this._parseAACAudioSpecificConfig(arrayBuffer, dataOffset + 1, dataSize - 1); + } else { + result.data = array.subarray(1); + } + + return result; + } + + _parseAACAudioSpecificConfig(arrayBuffer, dataOffset, dataSize) { + let array = new Uint8Array(arrayBuffer, dataOffset, dataSize); + let config = null; + + /* Audio Object Type: + 0: Null + 1: AAC Main + 2: AAC LC + 3: AAC SSR (Scalable Sample Rate) + 4: AAC LTP (Long Term Prediction) + 5: HE-AAC / SBR (Spectral Band Replication) + 6: AAC Scalable + */ + + let audioObjectType = 0; + let originalAudioObjectType = 0; + let samplingIndex = 0; + let extensionSamplingIndex = null; + + // 5 bits + audioObjectType = originalAudioObjectType = array[0] >>> 3; + // 4 bits + samplingIndex = ((array[0] & 0x07) << 1) | (array[1] >>> 7); + if (samplingIndex < 0 || samplingIndex >= this._mpegSamplingRates.length) { + this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: AAC invalid sampling frequency index!'); + return; + } + + let samplingFrequence = this._mpegSamplingRates[samplingIndex]; + + // 4 bits + let channelConfig = (array[1] & 0x78) >>> 3; + if (channelConfig < 0 || channelConfig >= 8) { + this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: AAC invalid channel configuration'); + return; + } + + if (audioObjectType === 5) { // HE-AAC? + // 4 bits + extensionSamplingIndex = ((array[1] & 0x07) << 1) | (array[2] >>> 7); + } + + // workarounds for various browsers + let userAgent = _navigator.userAgent.toLowerCase(); + + if (userAgent.indexOf('firefox') !== -1) { + // firefox: use SBR (HE-AAC) if freq less than 24kHz + if (samplingIndex >= 6) { + audioObjectType = 5; + config = new Array(4); + extensionSamplingIndex = samplingIndex - 3; + } else { // use LC-AAC + audioObjectType = 2; + config = new Array(2); + extensionSamplingIndex = samplingIndex; + } + } else if (userAgent.indexOf('android') !== -1) { + // android: always use LC-AAC + audioObjectType = 2; + config = new Array(2); + extensionSamplingIndex = samplingIndex; + } else { + // for other browsers, e.g. chrome... + // Always use HE-AAC to make it easier to switch aac codec profile + audioObjectType = 5; + extensionSamplingIndex = samplingIndex; + config = new Array(4); + + if (samplingIndex >= 6) { + extensionSamplingIndex = samplingIndex - 3; + } else if (channelConfig === 1) { // Mono channel + audioObjectType = 2; + config = new Array(2); + extensionSamplingIndex = samplingIndex; + } + } + + config[0] = audioObjectType << 3; + config[0] |= (samplingIndex & 0x0F) >>> 1; + config[1] = (samplingIndex & 0x0F) << 7; + config[1] |= (channelConfig & 0x0F) << 3; + if (audioObjectType === 5) { + config[1] |= ((extensionSamplingIndex & 0x0F) >>> 1); + config[2] = (extensionSamplingIndex & 0x01) << 7; + // extended audio object type: force to 2 (LC-AAC) + config[2] |= (2 << 2); + config[3] = 0; + } + + return { + audioObjectType, // audio_object_type, added by Xmader + samplingIndex, // sampling_frequency_index, added by Xmader + configRaw: array, // added by qli5 + config: config, + samplingRate: samplingFrequence, + channelCount: channelConfig, // channel_config + codec: 'mp4a.40.' + audioObjectType, + originalCodec: 'mp4a.40.' + originalAudioObjectType + }; + } + + _parseMP3AudioData(arrayBuffer, dataOffset, dataSize, requestHeader) { + if (dataSize < 4) { + Log.w(this.TAG, 'Flv: Invalid MP3 packet, header missing!'); + return; + } + + let le = this._littleEndian; + let array = new Uint8Array(arrayBuffer, dataOffset, dataSize); + let result = null; + + if (requestHeader) { + if (array[0] !== 0xFF) { + return; + } + let ver = (array[1] >>> 3) & 0x03; + let layer = (array[1] & 0x06) >> 1; + + let bitrate_index = (array[2] & 0xF0) >>> 4; + let sampling_freq_index = (array[2] & 0x0C) >>> 2; + + let channel_mode = (array[3] >>> 6) & 0x03; + let channel_count = channel_mode !== 3 ? 2 : 1; + + let sample_rate = 0; + let bit_rate = 0; + + let codec = 'mp3'; + + switch (ver) { + case 0: // MPEG 2.5 + sample_rate = this._mpegAudioV25SampleRateTable[sampling_freq_index]; + break; + case 2: // MPEG 2 + sample_rate = this._mpegAudioV20SampleRateTable[sampling_freq_index]; + break; + case 3: // MPEG 1 + sample_rate = this._mpegAudioV10SampleRateTable[sampling_freq_index]; + break; + } + + switch (layer) { + case 1: // Layer 3 + if (bitrate_index < this._mpegAudioL3BitRateTable.length) { + bit_rate = this._mpegAudioL3BitRateTable[bitrate_index]; + } + break; + case 2: // Layer 2 + if (bitrate_index < this._mpegAudioL2BitRateTable.length) { + bit_rate = this._mpegAudioL2BitRateTable[bitrate_index]; + } + break; + case 3: // Layer 1 + if (bitrate_index < this._mpegAudioL1BitRateTable.length) { + bit_rate = this._mpegAudioL1BitRateTable[bitrate_index]; + } + break; + } + + result = { + bitRate: bit_rate, + samplingRate: sample_rate, + channelCount: channel_count, + codec: codec, + originalCodec: codec + }; + } else { + result = array; + } + + return result; + } + + _parseVideoData(arrayBuffer, dataOffset, dataSize, tagTimestamp, tagPosition) { + if (dataSize <= 1) { + Log.w(this.TAG, 'Flv: Invalid video packet, missing VideoData payload!'); + return; + } + + if (this._hasVideoFlagOverrided === true && this._hasVideo === false) { + // If hasVideo: false indicated explicitly in MediaDataSource, + // Ignore all the video packets + return; + } + + let spec = (new Uint8Array(arrayBuffer, dataOffset, dataSize))[0]; + + let frameType = (spec & 240) >>> 4; + let codecId = spec & 15; + + if (codecId !== 7) { + this._onError(DemuxErrors.CODEC_UNSUPPORTED, `Flv: Unsupported codec in video frame: ${codecId}`); + return; + } + + this._parseAVCVideoPacket(arrayBuffer, dataOffset + 1, dataSize - 1, tagTimestamp, tagPosition, frameType); + } + + _parseAVCVideoPacket(arrayBuffer, dataOffset, dataSize, tagTimestamp, tagPosition, frameType) { + if (dataSize < 4) { + Log.w(this.TAG, 'Flv: Invalid AVC packet, missing AVCPacketType or/and CompositionTime'); + return; + } + + let le = this._littleEndian; + let v = new DataView(arrayBuffer, dataOffset, dataSize); + + let packetType = v.getUint8(0); + let cts = v.getUint32(0, !le) & 0x00FFFFFF; + + if (packetType === 0) { // AVCDecoderConfigurationRecord + this._parseAVCDecoderConfigurationRecord(arrayBuffer, dataOffset + 4, dataSize - 4); + } else if (packetType === 1) { // One or more Nalus + this._parseAVCVideoData(arrayBuffer, dataOffset + 4, dataSize - 4, tagTimestamp, tagPosition, frameType, cts); + } else if (packetType === 2) { + // empty, AVC end of sequence + } else { + this._onError(DemuxErrors.FORMAT_ERROR, `Flv: Invalid video packet type ${packetType}`); + return; + } + } + + _parseAVCDecoderConfigurationRecord(arrayBuffer, dataOffset, dataSize) { + if (dataSize < 7) { + Log.w(this.TAG, 'Flv: Invalid AVCDecoderConfigurationRecord, lack of data!'); + return; + } + + let meta = this._videoMetadata; + let track = this._videoTrack; + let le = this._littleEndian; + let v = new DataView(arrayBuffer, dataOffset, dataSize); + + if (!meta) { + if (this._hasVideo === false && this._hasVideoFlagOverrided === false) { + this._hasVideo = true; + this._mediaInfo.hasVideo = true; + } + + meta = this._videoMetadata = {}; + meta.type = 'video'; + meta.id = track.id; + meta.timescale = this._timescale; + meta.duration = this._duration; + } else { + if (typeof meta.avcc !== 'undefined') { + Log.w(this.TAG, 'Found another AVCDecoderConfigurationRecord!'); + } + } + + let version = v.getUint8(0); // configurationVersion + let avcProfile = v.getUint8(1); // avcProfileIndication + let profileCompatibility = v.getUint8(2); // profile_compatibility + let avcLevel = v.getUint8(3); // AVCLevelIndication + + if (version !== 1 || avcProfile === 0) { + this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: Invalid AVCDecoderConfigurationRecord'); + return; + } + + this._naluLengthSize = (v.getUint8(4) & 3) + 1; // lengthSizeMinusOne + if (this._naluLengthSize !== 3 && this._naluLengthSize !== 4) { // holy shit!!! + this._onError(DemuxErrors.FORMAT_ERROR, `Flv: Strange NaluLengthSizeMinusOne: ${this._naluLengthSize - 1}`); + return; + } + + let spsCount = v.getUint8(5) & 31; // numOfSequenceParameterSets + if (spsCount === 0) { + this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: Invalid AVCDecoderConfigurationRecord: No SPS'); + return; + } else if (spsCount > 1) { + Log.w(this.TAG, `Flv: Strange AVCDecoderConfigurationRecord: SPS Count = ${spsCount}`); + } + + let offset = 6; + + for (let i = 0; i < spsCount; i++) { + let len = v.getUint16(offset, !le); // sequenceParameterSetLength + offset += 2; + + if (len === 0) { + continue; + } + + // Notice: Nalu without startcode header (00 00 00 01) + let sps = new Uint8Array(arrayBuffer, dataOffset + offset, len); + offset += len; + + let config = SPSParser.parseSPS(sps); + if (i !== 0) { + // ignore other sps's config + continue; + } + + meta.codecWidth = config.codec_size.width; + meta.codecHeight = config.codec_size.height; + meta.presentWidth = config.present_size.width; + meta.presentHeight = config.present_size.height; + + meta.profile = config.profile_string; + meta.level = config.level_string; + meta.bitDepth = config.bit_depth; + meta.chromaFormat = config.chroma_format; + meta.sarRatio = config.sar_ratio; + meta.frameRate = config.frame_rate; + + if (config.frame_rate.fixed === false || + config.frame_rate.fps_num === 0 || + config.frame_rate.fps_den === 0) { + meta.frameRate = this._referenceFrameRate; + } + + let fps_den = meta.frameRate.fps_den; + let fps_num = meta.frameRate.fps_num; + meta.refSampleDuration = meta.timescale * (fps_den / fps_num); + + let codecArray = sps.subarray(1, 4); + let codecString = 'avc1.'; + for (let j = 0; j < 3; j++) { + let h = codecArray[j].toString(16); + if (h.length < 2) { + h = '0' + h; + } + codecString += h; + } + meta.codec = codecString; + + let mi = this._mediaInfo; + mi.width = meta.codecWidth; + mi.height = meta.codecHeight; + mi.fps = meta.frameRate.fps; + mi.profile = meta.profile; + mi.level = meta.level; + mi.chromaFormat = config.chroma_format_string; + mi.sarNum = meta.sarRatio.width; + mi.sarDen = meta.sarRatio.height; + mi.videoCodec = codecString; + + if (mi.hasAudio) { + if (mi.audioCodec != null) { + mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + ',' + mi.audioCodec + '"'; + } + } else { + mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + '"'; + } + if (mi.isComplete()) { + this._onMediaInfo(mi); + } + } + + let ppsCount = v.getUint8(offset); // numOfPictureParameterSets + if (ppsCount === 0) { + this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: Invalid AVCDecoderConfigurationRecord: No PPS'); + return; + } else if (ppsCount > 1) { + Log.w(this.TAG, `Flv: Strange AVCDecoderConfigurationRecord: PPS Count = ${ppsCount}`); + } + + offset++; + + for (let i = 0; i < ppsCount; i++) { + let len = v.getUint16(offset, !le); // pictureParameterSetLength + offset += 2; + + if (len === 0) { + continue; + } + + // pps is useless for extracting video information + offset += len; + } + + meta.avcc = new Uint8Array(dataSize); + meta.avcc.set(new Uint8Array(arrayBuffer, dataOffset, dataSize), 0); + Log.v(this.TAG, 'Parsed AVCDecoderConfigurationRecord'); + + if (this._isInitialMetadataDispatched()) { + // flush parsed frames + if (this._dispatch && (this._audioTrack.length || this._videoTrack.length)) { + this._onDataAvailable(this._audioTrack, this._videoTrack); + } + } else { + this._videoInitialMetadataDispatched = true; + } + // notify new metadata + this._dispatch = false; + this._onTrackMetadata('video', meta); + } + + _parseAVCVideoData(arrayBuffer, dataOffset, dataSize, tagTimestamp, tagPosition, frameType, cts) { + let le = this._littleEndian; + let v = new DataView(arrayBuffer, dataOffset, dataSize); + + let units = [], length = 0; + + let offset = 0; + const lengthSize = this._naluLengthSize; + let dts = this._timestampBase + tagTimestamp; + let keyframe = (frameType === 1); // from FLV Frame Type constants + let refIdc = 1; // added by qli5 + + while (offset < dataSize) { + if (offset + 4 >= dataSize) { + Log.w(this.TAG, `Malformed Nalu near timestamp ${dts}, offset = ${offset}, dataSize = ${dataSize}`); + break; // data not enough for next Nalu + } + // Nalu with length-header (AVC1) + let naluSize = v.getUint32(offset, !le); // Big-Endian read + if (lengthSize === 3) { + naluSize >>>= 8; + } + if (naluSize > dataSize - lengthSize) { + Log.w(this.TAG, `Malformed Nalus near timestamp ${dts}, NaluSize > DataSize!`); + return; + } + + let unitType = v.getUint8(offset + lengthSize) & 0x1F; + // added by qli5 + refIdc = v.getUint8(offset + lengthSize) & 0x60; + + if (unitType === 5) { // IDR + keyframe = true; + } + + let data = new Uint8Array(arrayBuffer, dataOffset + offset, lengthSize + naluSize); + let unit = { type: unitType, data: data }; + units.push(unit); + length += data.byteLength; + + offset += lengthSize + naluSize; + } + + if (units.length) { + let track = this._videoTrack; + let avcSample = { + units: units, + length: length, + isKeyframe: keyframe, + refIdc: refIdc, + dts: dts, + cts: cts, + pts: (dts + cts) + }; + if (keyframe) { + avcSample.fileposition = tagPosition; + } + track.samples.push(avcSample); + track.length += length; + } + } + +} + +/** + * Copyright (C) 2018 Xmader. + * @author Xmader + */ + +/** + * 计算adts头部 + * @see https://blog.jianchihu.net/flv-aac-add-adtsheader.html + * @typedef {Object} AdtsHeadersInit + * @property {number} audioObjectType + * @property {number} samplingFrequencyIndex + * @property {number} channelConfig + * @property {number} adtsLen + * @param {AdtsHeadersInit} init + */ +const getAdtsHeaders = (init) => { + const { audioObjectType, samplingFrequencyIndex, channelConfig, adtsLen } = init; + const headers = new Uint8Array(7); + + headers[0] = 0xff; // syncword:0xfff 高8bits + headers[1] = 0xf0; // syncword:0xfff 低4bits + headers[1] |= (0 << 3); // MPEG Version:0 for MPEG-4,1 for MPEG-2 1bit + headers[1] |= (0 << 1); // Layer:0 2bits + headers[1] |= 1; // protection absent:1 1bit + + headers[2] = (audioObjectType - 1) << 6; // profile:audio_object_type - 1 2bits + headers[2] |= (samplingFrequencyIndex & 0x0f) << 2; // sampling frequency index:sampling_frequency_index 4bits + headers[2] |= (0 << 1); // private bit:0 1bit + headers[2] |= (channelConfig & 0x04) >> 2; // channel configuration:channel_config 高1bit + + headers[3] = (channelConfig & 0x03) << 6; // channel configuration:channel_config 低2bits + headers[3] |= (0 << 5); // original:0 1bit + headers[3] |= (0 << 4); // home:0 1bit + headers[3] |= (0 << 3); // copyright id bit:0 1bit + headers[3] |= (0 << 2); // copyright id start:0 1bit + + headers[3] |= (adtsLen & 0x1800) >> 11; // frame length:value 高2bits + headers[4] = (adtsLen & 0x7f8) >> 3; // frame length:value 中间8bits + headers[5] = (adtsLen & 0x7) << 5; // frame length:value 低3bits + headers[5] |= 0x1f; // buffer fullness:0x7ff 高5bits + headers[6] = 0xfc; + + return headers +}; + +/** + * Copyright (C) 2018 Xmader. + * @author Xmader + */ + +/** + * Demux FLV into H264 + AAC stream into line stream then + * remux it into a AAC file. + * @param {Blob|Buffer|ArrayBuffer|string} flv + */ +const FLV2AAC = async (flv) => { + + // load flv as arraybuffer + /** @type {ArrayBuffer} */ + const flvArrayBuffer = await new Promise((r, j) => { + if ((typeof Blob != "undefined") && (flv instanceof Blob)) { + const reader = new FileReader(); + reader.onload = () => { + /** @type {ArrayBuffer} */ + // @ts-ignore + const result = reader.result; + r(result); + }; + reader.onerror = j; + reader.readAsArrayBuffer(flv); + } else if ((typeof Buffer != "undefined") && (flv instanceof Buffer)) { + r(new Uint8Array(flv).buffer); + } else if (flv instanceof ArrayBuffer) { + r(flv); + } else if (typeof flv == 'string') { + const req = new XMLHttpRequest(); + req.responseType = "arraybuffer"; + req.onload = () => r(req.response); + req.onerror = j; + req.open('get', flv); + req.send(); + } else { + j(new TypeError("@type {Blob|Buffer|ArrayBuffer} flv")); + } + }); + + const flvProbeData = FLVDemuxer.probe(flvArrayBuffer); + const flvDemuxer = new FLVDemuxer(flvProbeData); + + // 只解析音频 + flvDemuxer.overridedHasVideo = false; + + /** + * @typedef {Object} Sample + * @property {Uint8Array} unit + * @property {number} length + * @property {number} dts + * @property {number} pts + */ - if (cache.has(key)) { - return cache.get(key); + /** @type {{ type: "audio"; id: number; sequenceNumber: number; length: number; samples: Sample[]; }} */ + let aac = null; + let metadata = null; + + flvDemuxer.onTrackMetadata = (type, _metaData) => { + if (type == "audio") { + metadata = _metaData; } - var result = func.apply(this, args); - memoized.cache = cache.set(key, result) || cache; - return result; - }; - memoized.cache = new (memoize.Cache || MapCache); - return memoized; - } + }; - // Expose \`MapCache\`. - memoize.Cache = MapCache; + flvDemuxer.onMediaInfo = () => { }; - const numberToByteArray = (num, byteLength = getNumberByteLength(num)) => { - var byteArray; - if (byteLength == 1) { - byteArray = new DataView(new ArrayBuffer(1)); - byteArray.setUint8(0, num); - } - else if (byteLength == 2) { - byteArray = new DataView(new ArrayBuffer(2)); - byteArray.setUint16(0, num); - } - else if (byteLength == 3) { - byteArray = new DataView(new ArrayBuffer(3)); - byteArray.setUint8(0, num >> 16); - byteArray.setUint16(1, num & 0xffff); - } - else if (byteLength == 4) { - byteArray = new DataView(new ArrayBuffer(4)); - byteArray.setUint32(0, num); - } - else if (num < 0xffffffff) { - byteArray = new DataView(new ArrayBuffer(5)); - byteArray.setUint32(1, num); - } - else if (byteLength == 5) { - byteArray = new DataView(new ArrayBuffer(5)); - byteArray.setUint8(0, num / 0x100000000 | 0); - byteArray.setUint32(1, num % 0x100000000); - } - else if (byteLength == 6) { - byteArray = new DataView(new ArrayBuffer(6)); - byteArray.setUint16(0, num / 0x100000000 | 0); - byteArray.setUint32(2, num % 0x100000000); - } - else if (byteLength == 7) { - byteArray = new DataView(new ArrayBuffer(7)); - byteArray.setUint8(0, num / 0x1000000000000 | 0); - byteArray.setUint16(1, num / 0x100000000 & 0xffff); - byteArray.setUint32(3, num % 0x100000000); - } - else if (byteLength == 8) { - byteArray = new DataView(new ArrayBuffer(8)); - byteArray.setUint32(0, num / 0x100000000 | 0); - byteArray.setUint32(4, num % 0x100000000); - } - else { - throw new Error("EBML.typedArrayUtils.numberToByteArray: byte length must be less than or equal to 8"); - } - return new Uint8Array(byteArray.buffer); - }; - const stringToByteArray = memoize((str) => { - return Uint8Array.from(Array.from(str).map(_ => _.codePointAt(0))); - }); - function getNumberByteLength(num) { - if (num < 0) { - throw new Error("EBML.typedArrayUtils.getNumberByteLength: negative number not implemented"); - } - else if (num < 0x100) { - return 1; - } - else if (num < 0x10000) { - return 2; - } - else if (num < 0x1000000) { - return 3; - } - else if (num < 0x100000000) { - return 4; - } - else if (num < 0x10000000000) { - return 5; - } - else if (num < 0x1000000000000) { - return 6; - } - else if (num < 0x20000000000000) { - return 7; - } - else { - throw new Error("EBML.typedArrayUtils.getNumberByteLength: number exceeds Number.MAX_SAFE_INTEGER"); - } - } - const int16Bit = memoize((num) => { - const ab = new ArrayBuffer(2); - new DataView(ab).setInt16(0, num); - return new Uint8Array(ab); - }); - const float32bit = memoize((num) => { - const ab = new ArrayBuffer(4); - new DataView(ab).setFloat32(0, num); - return new Uint8Array(ab); - }); - const dumpBytes = (b) => { - return Array.from(new Uint8Array(b)).map(_ => \`0x\${_.toString(16)}\`).join(", "); + flvDemuxer.onError = (e) => { + throw new Error(e) }; - class Value { - constructor(bytes) { - this.bytes = bytes; - } - write(buf, pos) { - buf.set(this.bytes, pos); - return pos + this.bytes.length; - } - countSize() { - return this.bytes.length; - } - } - class Element { - constructor(id, children, isSizeUnknown) { - this.id = id; - this.children = children; - const bodySize = this.children.reduce((p, c) => p + c.countSize(), 0); - this.sizeMetaData = isSizeUnknown ? - UNKNOWN_SIZE : - vintEncode(numberToByteArray(bodySize, getEBMLByteLength(bodySize))); - this.size = this.id.length + this.sizeMetaData.length + bodySize; - } - write(buf, pos) { - buf.set(this.id, pos); - buf.set(this.sizeMetaData, pos + this.id.length); - return this.children.reduce((p, c) => c.write(buf, p), pos + this.id.length + this.sizeMetaData.length); - } - countSize() { - return this.size; - } - } - const bytes = memoize((data) => { - return new Value(data); - }); - const number = memoize((num) => { - return bytes(numberToByteArray(num)); - }); - const vintEncodedNumber = memoize((num) => { - return bytes(vintEncode(numberToByteArray(num, getEBMLByteLength(num)))); - }); - const int16 = memoize((num) => { - return bytes(int16Bit(num)); - }); - const float = memoize((num) => { - return bytes(float32bit(num)); - }); - const string = memoize((str) => { - return bytes(stringToByteArray(str)); - }); - const element = (id, child) => { - return new Element(id, Array.isArray(child) ? child : [child], false); - }; - const unknownSizeElement = (id, child) => { - return new Element(id, Array.isArray(child) ? child : [child], true); - }; - const build = (v) => { - const b = new Uint8Array(v.countSize()); - v.write(b, 0); - return b; - }; - const getEBMLByteLength = (num) => { - if (num < 0x7f) { - return 1; - } - else if (num < 0x3fff) { - return 2; - } - else if (num < 0x1fffff) { - return 3; - } - else if (num < 0xfffffff) { - return 4; - } - else if (num < 0x7ffffffff) { - return 5; - } - else if (num < 0x3ffffffffff) { - return 6; - } - else if (num < 0x1ffffffffffff) { - return 7; - } - else if (num < 0x20000000000000) { - return 8; - } - else if (num < 0xffffffffffffff) { - throw new Error("EBMLgetEBMLByteLength: number exceeds Number.MAX_SAFE_INTEGER"); - } - else { - throw new Error("EBMLgetEBMLByteLength: data size must be less than or equal to " + (Math.pow(2, 56) - 2)); - } - }; - const UNKNOWN_SIZE = new Uint8Array([0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); - const vintEncode = (byteArray) => { - byteArray[0] = getSizeMask(byteArray.length) | byteArray[0]; - return byteArray; - }; - const getSizeMask = (byteLength) => { - return 0x80 >> (byteLength - 1); + flvDemuxer.onDataAvailable = (...args) => { + args.forEach(data => { + if (data.type == "audio") { + aac = data; + } + }); }; - /** - * @see https://www.matroska.org/technical/specs/index.html - */ - const ID = { - EBML: Uint8Array.of(0x1A, 0x45, 0xDF, 0xA3), - EBMLVersion: Uint8Array.of(0x42, 0x86), - EBMLReadVersion: Uint8Array.of(0x42, 0xF7), - EBMLMaxIDLength: Uint8Array.of(0x42, 0xF2), - EBMLMaxSizeLength: Uint8Array.of(0x42, 0xF3), - DocType: Uint8Array.of(0x42, 0x82), - DocTypeVersion: Uint8Array.of(0x42, 0x87), - DocTypeReadVersion: Uint8Array.of(0x42, 0x85), - Void: Uint8Array.of(0xEC), - CRC32: Uint8Array.of(0xBF), - Segment: Uint8Array.of(0x18, 0x53, 0x80, 0x67), - SeekHead: Uint8Array.of(0x11, 0x4D, 0x9B, 0x74), - Seek: Uint8Array.of(0x4D, 0xBB), - SeekID: Uint8Array.of(0x53, 0xAB), - SeekPosition: Uint8Array.of(0x53, 0xAC), - Info: Uint8Array.of(0x15, 0x49, 0xA9, 0x66), - SegmentUID: Uint8Array.of(0x73, 0xA4), - SegmentFilename: Uint8Array.of(0x73, 0x84), - PrevUID: Uint8Array.of(0x3C, 0xB9, 0x23), - PrevFilename: Uint8Array.of(0x3C, 0x83, 0xAB), - NextUID: Uint8Array.of(0x3E, 0xB9, 0x23), - NextFilename: Uint8Array.of(0x3E, 0x83, 0xBB), - SegmentFamily: Uint8Array.of(0x44, 0x44), - ChapterTranslate: Uint8Array.of(0x69, 0x24), - ChapterTranslateEditionUID: Uint8Array.of(0x69, 0xFC), - ChapterTranslateCodec: Uint8Array.of(0x69, 0xBF), - ChapterTranslateID: Uint8Array.of(0x69, 0xA5), - TimecodeScale: Uint8Array.of(0x2A, 0xD7, 0xB1), - Duration: Uint8Array.of(0x44, 0x89), - DateUTC: Uint8Array.of(0x44, 0x61), - Title: Uint8Array.of(0x7B, 0xA9), - MuxingApp: Uint8Array.of(0x4D, 0x80), - WritingApp: Uint8Array.of(0x57, 0x41), - Cluster: Uint8Array.of(0x1F, 0x43, 0xB6, 0x75), - Timecode: Uint8Array.of(0xE7), - SilentTracks: Uint8Array.of(0x58, 0x54), - SilentTrackNumber: Uint8Array.of(0x58, 0xD7), - Position: Uint8Array.of(0xA7), - PrevSize: Uint8Array.of(0xAB), - SimpleBlock: Uint8Array.of(0xA3), - BlockGroup: Uint8Array.of(0xA0), - Block: Uint8Array.of(0xA1), - BlockAdditions: Uint8Array.of(0x75, 0xA1), - BlockMore: Uint8Array.of(0xA6), - BlockAddID: Uint8Array.of(0xEE), - BlockAdditional: Uint8Array.of(0xA5), - BlockDuration: Uint8Array.of(0x9B), - ReferencePriority: Uint8Array.of(0xFA), - ReferenceBlock: Uint8Array.of(0xFB), - CodecState: Uint8Array.of(0xA4), - DiscardPadding: Uint8Array.of(0x75, 0xA2), - Slices: Uint8Array.of(0x8E), - TimeSlice: Uint8Array.of(0xE8), - LaceNumber: Uint8Array.of(0xCC), - Tracks: Uint8Array.of(0x16, 0x54, 0xAE, 0x6B), - TrackEntry: Uint8Array.of(0xAE), - TrackNumber: Uint8Array.of(0xD7), - TrackUID: Uint8Array.of(0x73, 0xC5), - TrackType: Uint8Array.of(0x83), - FlagEnabled: Uint8Array.of(0xB9), - FlagDefault: Uint8Array.of(0x88), - FlagForced: Uint8Array.of(0x55, 0xAA), - FlagLacing: Uint8Array.of(0x9C), - MinCache: Uint8Array.of(0x6D, 0xE7), - MaxCache: Uint8Array.of(0x6D, 0xF8), - DefaultDuration: Uint8Array.of(0x23, 0xE3, 0x83), - DefaultDecodedFieldDuration: Uint8Array.of(0x23, 0x4E, 0x7A), - MaxBlockAdditionID: Uint8Array.of(0x55, 0xEE), - Name: Uint8Array.of(0x53, 0x6E), - Language: Uint8Array.of(0x22, 0xB5, 0x9C), - CodecID: Uint8Array.of(0x86), - CodecPrivate: Uint8Array.of(0x63, 0xA2), - CodecName: Uint8Array.of(0x25, 0x86, 0x88), - AttachmentLink: Uint8Array.of(0x74, 0x46), - CodecDecodeAll: Uint8Array.of(0xAA), - TrackOverlay: Uint8Array.of(0x6F, 0xAB), - CodecDelay: Uint8Array.of(0x56, 0xAA), - SeekPreRoll: Uint8Array.of(0x56, 0xBB), - TrackTranslate: Uint8Array.of(0x66, 0x24), - TrackTranslateEditionUID: Uint8Array.of(0x66, 0xFC), - TrackTranslateCodec: Uint8Array.of(0x66, 0xBF), - TrackTranslateTrackID: Uint8Array.of(0x66, 0xA5), - Video: Uint8Array.of(0xE0), - FlagInterlaced: Uint8Array.of(0x9A), - FieldOrder: Uint8Array.of(0x9D), - StereoMode: Uint8Array.of(0x53, 0xB8), - AlphaMode: Uint8Array.of(0x53, 0xC0), - PixelWidth: Uint8Array.of(0xB0), - PixelHeight: Uint8Array.of(0xBA), - PixelCropBottom: Uint8Array.of(0x54, 0xAA), - PixelCropTop: Uint8Array.of(0x54, 0xBB), - PixelCropLeft: Uint8Array.of(0x54, 0xCC), - PixelCropRight: Uint8Array.of(0x54, 0xDD), - DisplayWidth: Uint8Array.of(0x54, 0xB0), - DisplayHeight: Uint8Array.of(0x54, 0xBA), - DisplayUnit: Uint8Array.of(0x54, 0xB2), - AspectRatioType: Uint8Array.of(0x54, 0xB3), - ColourSpace: Uint8Array.of(0x2E, 0xB5, 0x24), - Colour: Uint8Array.of(0x55, 0xB0), - MatrixCoefficients: Uint8Array.of(0x55, 0xB1), - BitsPerChannel: Uint8Array.of(0x55, 0xB2), - ChromaSubsamplingHorz: Uint8Array.of(0x55, 0xB3), - ChromaSubsamplingVert: Uint8Array.of(0x55, 0xB4), - CbSubsamplingHorz: Uint8Array.of(0x55, 0xB5), - CbSubsamplingVert: Uint8Array.of(0x55, 0xB6), - ChromaSitingHorz: Uint8Array.of(0x55, 0xB7), - ChromaSitingVert: Uint8Array.of(0x55, 0xB8), - Range: Uint8Array.of(0x55, 0xB9), - TransferCharacteristics: Uint8Array.of(0x55, 0xBA), - Primaries: Uint8Array.of(0x55, 0xBB), - MaxCLL: Uint8Array.of(0x55, 0xBC), - MaxFALL: Uint8Array.of(0x55, 0xBD), - MasteringMetadata: Uint8Array.of(0x55, 0xD0), - PrimaryRChromaticityX: Uint8Array.of(0x55, 0xD1), - PrimaryRChromaticityY: Uint8Array.of(0x55, 0xD2), - PrimaryGChromaticityX: Uint8Array.of(0x55, 0xD3), - PrimaryGChromaticityY: Uint8Array.of(0x55, 0xD4), - PrimaryBChromaticityX: Uint8Array.of(0x55, 0xD5), - PrimaryBChromaticityY: Uint8Array.of(0x55, 0xD6), - WhitePointChromaticityX: Uint8Array.of(0x55, 0xD7), - WhitePointChromaticityY: Uint8Array.of(0x55, 0xD8), - LuminanceMax: Uint8Array.of(0x55, 0xD9), - LuminanceMin: Uint8Array.of(0x55, 0xDA), - Audio: Uint8Array.of(0xE1), - SamplingFrequency: Uint8Array.of(0xB5), - OutputSamplingFrequency: Uint8Array.of(0x78, 0xB5), - Channels: Uint8Array.of(0x9F), - BitDepth: Uint8Array.of(0x62, 0x64), - TrackOperation: Uint8Array.of(0xE2), - TrackCombinePlanes: Uint8Array.of(0xE3), - TrackPlane: Uint8Array.of(0xE4), - TrackPlaneUID: Uint8Array.of(0xE5), - TrackPlaneType: Uint8Array.of(0xE6), - TrackJoinBlocks: Uint8Array.of(0xE9), - TrackJoinUID: Uint8Array.of(0xED), - ContentEncodings: Uint8Array.of(0x6D, 0x80), - ContentEncoding: Uint8Array.of(0x62, 0x40), - ContentEncodingOrder: Uint8Array.of(0x50, 0x31), - ContentEncodingScope: Uint8Array.of(0x50, 0x32), - ContentEncodingType: Uint8Array.of(0x50, 0x33), - ContentCompression: Uint8Array.of(0x50, 0x34), - ContentCompAlgo: Uint8Array.of(0x42, 0x54), - ContentCompSettings: Uint8Array.of(0x42, 0x55), - ContentEncryption: Uint8Array.of(0x50, 0x35), - ContentEncAlgo: Uint8Array.of(0x47, 0xE1), - ContentEncKeyID: Uint8Array.of(0x47, 0xE2), - ContentSignature: Uint8Array.of(0x47, 0xE3), - ContentSigKeyID: Uint8Array.of(0x47, 0xE4), - ContentSigAlgo: Uint8Array.of(0x47, 0xE5), - ContentSigHashAlgo: Uint8Array.of(0x47, 0xE6), - Cues: Uint8Array.of(0x1C, 0x53, 0xBB, 0x6B), - CuePoint: Uint8Array.of(0xBB), - CueTime: Uint8Array.of(0xB3), - CueTrackPositions: Uint8Array.of(0xB7), - CueTrack: Uint8Array.of(0xF7), - CueClusterPosition: Uint8Array.of(0xF1), - CueRelativePosition: Uint8Array.of(0xF0), - CueDuration: Uint8Array.of(0xB2), - CueBlockNumber: Uint8Array.of(0x53, 0x78), - CueCodecState: Uint8Array.of(0xEA), - CueReference: Uint8Array.of(0xDB), - CueRefTime: Uint8Array.of(0x96), - Attachments: Uint8Array.of(0x19, 0x41, 0xA4, 0x69), - AttachedFile: Uint8Array.of(0x61, 0xA7), - FileDescription: Uint8Array.of(0x46, 0x7E), - FileName: Uint8Array.of(0x46, 0x6E), - FileMimeType: Uint8Array.of(0x46, 0x60), - FileData: Uint8Array.of(0x46, 0x5C), - FileUID: Uint8Array.of(0x46, 0xAE), - Chapters: Uint8Array.of(0x10, 0x43, 0xA7, 0x70), - EditionEntry: Uint8Array.of(0x45, 0xB9), - EditionUID: Uint8Array.of(0x45, 0xBC), - EditionFlagHidden: Uint8Array.of(0x45, 0xBD), - EditionFlagDefault: Uint8Array.of(0x45, 0xDB), - EditionFlagOrdered: Uint8Array.of(0x45, 0xDD), - ChapterAtom: Uint8Array.of(0xB6), - ChapterUID: Uint8Array.of(0x73, 0xC4), - ChapterStringUID: Uint8Array.of(0x56, 0x54), - ChapterTimeStart: Uint8Array.of(0x91), - ChapterTimeEnd: Uint8Array.of(0x92), - ChapterFlagHidden: Uint8Array.of(0x98), - ChapterFlagEnabled: Uint8Array.of(0x45, 0x98), - ChapterSegmentUID: Uint8Array.of(0x6E, 0x67), - ChapterSegmentEditionUID: Uint8Array.of(0x6E, 0xBC), - ChapterPhysicalEquiv: Uint8Array.of(0x63, 0xC3), - ChapterTrack: Uint8Array.of(0x8F), - ChapterTrackNumber: Uint8Array.of(0x89), - ChapterDisplay: Uint8Array.of(0x80), - ChapString: Uint8Array.of(0x85), - ChapLanguage: Uint8Array.of(0x43, 0x7C), - ChapCountry: Uint8Array.of(0x43, 0x7E), - ChapProcess: Uint8Array.of(0x69, 0x44), - ChapProcessCodecID: Uint8Array.of(0x69, 0x55), - ChapProcessPrivate: Uint8Array.of(0x45, 0x0D), - ChapProcessCommand: Uint8Array.of(0x69, 0x11), - ChapProcessTime: Uint8Array.of(0x69, 0x22), - ChapProcessData: Uint8Array.of(0x69, 0x33), - Tags: Uint8Array.of(0x12, 0x54, 0xC3, 0x67), - Tag: Uint8Array.of(0x73, 0x73), - Targets: Uint8Array.of(0x63, 0xC0), - TargetTypeValue: Uint8Array.of(0x68, 0xCA), - TargetType: Uint8Array.of(0x63, 0xCA), - TagTrackUID: Uint8Array.of(0x63, 0xC5), - TagEditionUID: Uint8Array.of(0x63, 0xC9), - TagChapterUID: Uint8Array.of(0x63, 0xC4), - TagAttachmentUID: Uint8Array.of(0x63, 0xC6), - SimpleTag: Uint8Array.of(0x67, 0xC8), - TagName: Uint8Array.of(0x45, 0xA3), - TagLanguage: Uint8Array.of(0x44, 0x7A), - TagDefault: Uint8Array.of(0x44, 0x84), - TagString: Uint8Array.of(0x44, 0x87), - TagBinary: Uint8Array.of(0x44, 0x85), - }; + const finalOffset = flvDemuxer.parseChunks(flvArrayBuffer, flvProbeData.dataOffset); + if (finalOffset != flvArrayBuffer.byteLength) { + throw new Error("FLVDemuxer: unexpected EOF") + } + const { + audioObjectType, + samplingFrequencyIndex, + channelCount: channelConfig + } = metadata; + /** @type {number[]} */ + let output = []; - var EBML = /*#__PURE__*/Object.freeze({ - Value: Value, - Element: Element, - bytes: bytes, - number: number, - vintEncodedNumber: vintEncodedNumber, - int16: int16, - float: float, - string: string, - element: element, - unknownSizeElement: unknownSizeElement, - build: build, - getEBMLByteLength: getEBMLByteLength, - UNKNOWN_SIZE: UNKNOWN_SIZE, - vintEncode: vintEncode, - getSizeMask: getSizeMask, - ID: ID, - numberToByteArray: numberToByteArray, - stringToByteArray: stringToByteArray, - getNumberByteLength: getNumberByteLength, - int16Bit: int16Bit, - float32bit: float32bit, - dumpBytes: dumpBytes + aac.samples.forEach((sample) => { + const headers = getAdtsHeaders({ + audioObjectType, + samplingFrequencyIndex, + channelConfig, + adtsLen: sample.length + 7 + }); + output.push(...headers, ...sample.unit); }); - /*** - * The EMBL builder is from simple-ebml-builder - * - * Copyright 2017 ryiwamoto - * - * @author ryiwamoto, qli5 - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the - * "Software"), to deal in the Software without restriction, including - * without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to - * permit persons to whom the Software is furnished to do so, subject - * to the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL - * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR - * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, - * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - * DEALINGS IN THE SOFTWARE. - */ + return new Uint8Array(output) +}; + +/*** + * Copyright (C) 2018 Xmader. All Rights Reserved. + * + * @author Xmader + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +class WebWorker extends Worker { + constructor(stringUrl) { + super(stringUrl); - /*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - - /** - * @typedef {Object} AssBlock - * @property {number} track - * @property {Uint8Array} frame - * @property {number} timestamp - * @property {number} duration - */ - - class MKV { - constructor(config) { - this.min = true; - this.onprogress = null; - Object.assign(this, config); - this.segmentUID = MKV.randomBytes(16); - this.trackUIDBase = Math.trunc(Math.random() * 2 ** 16); - this.trackMetadata = { h264: null, aac: null, assList: [] }; - this.duration = 0; - /** @type {{ h264: any[]; aac: any[]; assList: AssBlock[][]; }} */ - this.blocks = { h264: [], aac: [], assList: [] }; - } - - static randomBytes(num) { - return Array.from(new Array(num), () => Math.trunc(Math.random() * 256)); - } - - static textToMS(str) { - const [, h, mm, ss, ms10] = str.match(/(\\d+):(\\d+):(\\d+).(\\d+)/); - return h * 3600000 + mm * 60000 + ss * 1000 + ms10 * 10; - } - - static mimeToCodecID(str) { - if (str.startsWith('avc1')) { - return 'V_MPEG4/ISO/AVC'; - } - else if (str.startsWith('mp4a')) { - return 'A_AAC'; - } - else { - throw new Error(\`MKVRemuxer: unknown codec \${str}\`); - } - } - - static uint8ArrayConcat(...array) { - // if (Array.isArray(array[0])) array = array[0]; - if (array.length == 1) return array[0]; - if (typeof Buffer != 'undefined') return Buffer.concat(array); - const ret = new Uint8Array(array.reduce((i, j) => i + j.byteLength, 0)); - let length = 0; - for (let e of array) { - ret.set(e, length); - length += e.byteLength; - } - return ret; - } - - addH264Metadata(h264) { - this.trackMetadata.h264 = { - codecId: MKV.mimeToCodecID(h264.codec), - codecPrivate: h264.avcc, - defaultDuration: h264.refSampleDuration * 1000000, - pixelWidth: h264.codecWidth, - pixelHeight: h264.codecHeight, - displayWidth: h264.presentWidth, - displayHeight: h264.presentHeight - }; - this.duration = Math.max(this.duration, h264.duration); - } - - addAACMetadata(aac) { - this.trackMetadata.aac = { - codecId: MKV.mimeToCodecID(aac.originalCodec), - codecPrivate: aac.configRaw, - defaultDuration: aac.refSampleDuration * 1000000, - samplingFrequence: aac.audioSampleRate, - channels: aac.channelCount - }; - this.duration = Math.max(this.duration, aac.duration); - } - - /** - * @param {import("../demuxer/ass").ASS} ass - * @param {string} name - */ - addASSMetadata(ass, name = "") { - this.trackMetadata.assList.push({ - codecId: 'S_TEXT/ASS', - codecPrivate: new _TextEncoder().encode(ass.header), - name, - _info: ass.info, - _styles: ass.styles, - }); - } - - addH264Stream(h264) { - this.blocks.h264 = this.blocks.h264.concat(h264.samples.map(e => ({ - track: 1, - frame: MKV.uint8ArrayConcat(...e.units.map(i => i.data)), - isKeyframe: e.isKeyframe, - discardable: Boolean(e.refIdc), - timestamp: e.pts, - simple: true, - }))); - } - - addAACStream(aac) { - this.blocks.aac = this.blocks.aac.concat(aac.samples.map(e => ({ - track: 2, - frame: e.unit, - timestamp: e.pts, - simple: true, - }))); - } - - /** - * @param {import("../demuxer/ass").ASS} ass - */ - addASSStream(ass) { - const n = this.blocks.assList.length; - const lineBlocks = ass.lines.map((e, i) => ({ - track: 3 + n, - frame: new _TextEncoder().encode(\`\${i},\${e['Layer'] || ''},\${e['Style'] || ''},\${e['Name'] || ''},\${e['MarginL'] || ''},\${e['MarginR'] || ''},\${e['MarginV'] || ''},\${e['Effect'] || ''},\${e['Text'] || ''}\`), - timestamp: MKV.textToMS(e['Start']), - duration: MKV.textToMS(e['End']) - MKV.textToMS(e['Start']), - })); - this.blocks.assList.push(lineBlocks); - } - - combineSubtitles() { - const [firstB, ...restB] = this.blocks.assList; - const l = Math.min(this.blocks.assList.length, this.trackMetadata.assList.length); - /** - * @param {AssBlock} a - * @param {AssBlock} b - */ - const sortFn = (a, b) => { - return a.timestamp - b.timestamp - }; - restB.forEach((a, n) => { - this.blocks.assList.push( - a.concat(firstB).sort(sortFn).map((x) => { - return { - track: 3 + l + n, - frame: x.frame, - timestamp: x.timestamp, - duration: x.duration, - } - }) - ); - }); - const [firstM, ...restM] = this.trackMetadata.assList; - restM.forEach((a) => { - const name = \`\${firstM.name} + \${a.name}\`; - const info = firstM._info.replace(/^(Title:.+)\$/m, \`\$1 \${name}\`); - const firstStyles = firstM._styles.split(/\\r?\\n+/).filter(x => !!x); - const aStyles = a._styles.split(/\\r?\\n+/).slice(2); - const styles = firstStyles.concat(aStyles).join("\\r\\n"); - const header = info + styles; - this.trackMetadata.assList.push({ - name: name, - codecId: 'S_TEXT/ASS', - codecPrivate: new _TextEncoder().encode(header), - }); - }); - } - - build() { - return new _Blob([ - this.buildHeader(), - this.buildBody() - ]); - } - - buildHeader() { - return new _Blob([EBML.build(EBML.element(EBML.ID.EBML, [ - EBML.element(EBML.ID.EBMLVersion, EBML.number(1)), - EBML.element(EBML.ID.EBMLReadVersion, EBML.number(1)), - EBML.element(EBML.ID.EBMLMaxIDLength, EBML.number(4)), - EBML.element(EBML.ID.EBMLMaxSizeLength, EBML.number(8)), - EBML.element(EBML.ID.DocType, EBML.string('matroska')), - EBML.element(EBML.ID.DocTypeVersion, EBML.number(4)), - EBML.element(EBML.ID.DocTypeReadVersion, EBML.number(2)), - ]))]); - } - - buildBody() { - if (this.min) { - return new _Blob([EBML.build(EBML.element(EBML.ID.Segment, [ - this.getSegmentInfo(), - this.getTracks(), - ...this.getClusterArray() - ]))]); - } - else { - return new _Blob([EBML.build(EBML.element(EBML.ID.Segment, [ - this.getSeekHead(), - this.getVoid(4100), - this.getSegmentInfo(), - this.getTracks(), - this.getVoid(1100), - ...this.getClusterArray() - ]))]); - } - } - - getSeekHead() { - return EBML.element(EBML.ID.SeekHead, [ - EBML.element(EBML.ID.Seek, [ - EBML.element(EBML.ID.SeekID, EBML.bytes(EBML.ID.Info)), - EBML.element(EBML.ID.SeekPosition, EBML.number(4050)) - ]), - EBML.element(EBML.ID.Seek, [ - EBML.element(EBML.ID.SeekID, EBML.bytes(EBML.ID.Tracks)), - EBML.element(EBML.ID.SeekPosition, EBML.number(4200)) - ]), - ]); - } - - getVoid(length = 2000) { - return EBML.element(EBML.ID.Void, EBML.bytes(new Uint8Array(length))); - } - - getSegmentInfo() { - return EBML.element(EBML.ID.Info, [ - EBML.element(EBML.ID.TimecodeScale, EBML.number(1000000)), - EBML.element(EBML.ID.MuxingApp, EBML.string('flv.js + assparser_qli5 -> simple-ebml-builder')), - EBML.element(EBML.ID.WritingApp, EBML.string('flvass2mkv.js by qli5')), - EBML.element(EBML.ID.Duration, EBML.float(this.duration)), - EBML.element(EBML.ID.SegmentUID, EBML.bytes(this.segmentUID)), - ]); - } - - getTracks() { - return EBML.element(EBML.ID.Tracks, [ - this.getVideoTrackEntry(), - this.getAudioTrackEntry(), - ...this.getSubtitleTrackEntry(), - ]); - } - - getVideoTrackEntry() { - return EBML.element(EBML.ID.TrackEntry, [ - EBML.element(EBML.ID.TrackNumber, EBML.number(1)), - EBML.element(EBML.ID.TrackUID, EBML.number(this.trackUIDBase + 1)), - EBML.element(EBML.ID.TrackType, EBML.number(0x01)), - EBML.element(EBML.ID.FlagLacing, EBML.number(0x00)), - EBML.element(EBML.ID.CodecID, EBML.string(this.trackMetadata.h264.codecId)), - EBML.element(EBML.ID.CodecPrivate, EBML.bytes(this.trackMetadata.h264.codecPrivate)), - EBML.element(EBML.ID.DefaultDuration, EBML.number(this.trackMetadata.h264.defaultDuration)), - EBML.element(EBML.ID.Language, EBML.string('und')), - EBML.element(EBML.ID.Video, [ - EBML.element(EBML.ID.PixelWidth, EBML.number(this.trackMetadata.h264.pixelWidth)), - EBML.element(EBML.ID.PixelHeight, EBML.number(this.trackMetadata.h264.pixelHeight)), - EBML.element(EBML.ID.DisplayWidth, EBML.number(this.trackMetadata.h264.displayWidth)), - EBML.element(EBML.ID.DisplayHeight, EBML.number(this.trackMetadata.h264.displayHeight)), - ]), - ]); - } - - getAudioTrackEntry() { - return EBML.element(EBML.ID.TrackEntry, [ - EBML.element(EBML.ID.TrackNumber, EBML.number(2)), - EBML.element(EBML.ID.TrackUID, EBML.number(this.trackUIDBase + 2)), - EBML.element(EBML.ID.TrackType, EBML.number(0x02)), - EBML.element(EBML.ID.FlagLacing, EBML.number(0x00)), - EBML.element(EBML.ID.CodecID, EBML.string(this.trackMetadata.aac.codecId)), - EBML.element(EBML.ID.CodecPrivate, EBML.bytes(this.trackMetadata.aac.codecPrivate)), - EBML.element(EBML.ID.DefaultDuration, EBML.number(this.trackMetadata.aac.defaultDuration)), - EBML.element(EBML.ID.Language, EBML.string('und')), - EBML.element(EBML.ID.Audio, [ - EBML.element(EBML.ID.SamplingFrequency, EBML.float(this.trackMetadata.aac.samplingFrequence)), - EBML.element(EBML.ID.Channels, EBML.number(this.trackMetadata.aac.channels)), - ]), - ]); - } - - getSubtitleTrackEntry() { - return this.trackMetadata.assList.map((ass, i) => { - return EBML.element(EBML.ID.TrackEntry, [ - EBML.element(EBML.ID.TrackNumber, EBML.number(3 + i)), - EBML.element(EBML.ID.TrackUID, EBML.number(this.trackUIDBase + 3 + i)), - EBML.element(EBML.ID.TrackType, EBML.number(0x11)), - EBML.element(EBML.ID.FlagLacing, EBML.number(0x00)), - EBML.element(EBML.ID.CodecID, EBML.string(ass.codecId)), - EBML.element(EBML.ID.CodecPrivate, EBML.bytes(ass.codecPrivate)), - EBML.element(EBML.ID.Language, EBML.string('und')), - ass.name && EBML.element(EBML.ID.Name, EBML.bytes(new _TextEncoder().encode(ass.name))), - ].filter(x => !!x)); - }); - } - - getClusterArray() { - // H264 codecState - this.blocks.h264[0].simple = false; - this.blocks.h264[0].codecState = this.trackMetadata.h264.codecPrivate; - - let i = 0; - let j = 0; - let k = Array.from({ length: this.blocks.assList.length }).fill(0); - let clusterTimeCode = 0; - let clusterContent = [EBML.element(EBML.ID.Timecode, EBML.number(clusterTimeCode))]; - let ret = [clusterContent]; - const progressThrottler = Math.pow(2, Math.floor(Math.log(this.blocks.h264.length >> 7) / Math.log(2))) - 1; - for (i = 0; i < this.blocks.h264.length; i++) { - const e = this.blocks.h264[i]; - for (; j < this.blocks.aac.length; j++) { - if (this.blocks.aac[j].timestamp < e.timestamp) { - clusterContent.push(this.getBlocks(this.blocks.aac[j], clusterTimeCode)); - } - else { - break; - } - } - this.blocks.assList.forEach((ass, n) => { - for (; k[n] < ass.length; k[n]++) { - if (ass[k[n]].timestamp < e.timestamp) { - clusterContent.push(this.getBlocks(ass[k[n]], clusterTimeCode)); - } - else { - break; - } - } - }); - if (e.isKeyframe/* || clusterContent.length > 72 */) { - // start new cluster - clusterTimeCode = e.timestamp; - clusterContent = [EBML.element(EBML.ID.Timecode, EBML.number(clusterTimeCode))]; - ret.push(clusterContent); - } - clusterContent.push(this.getBlocks(e, clusterTimeCode)); - if (this.onprogress && !(i & progressThrottler)) this.onprogress({ loaded: i, total: this.blocks.h264.length }); - } - for (; j < this.blocks.aac.length; j++) clusterContent.push(this.getBlocks(this.blocks.aac[j], clusterTimeCode)); - this.blocks.assList.forEach((ass, n) => { - for (; k[n] < ass.length; k[n]++) clusterContent.push(this.getBlocks(ass[k[n]], clusterTimeCode)); - }); - if (this.onprogress) this.onprogress({ loaded: i, total: this.blocks.h264.length }); - if (ret[0].length == 1) ret.shift(); - ret = ret.map(clusterContent => EBML.element(EBML.ID.Cluster, clusterContent)); - - return ret; - } - - getBlocks(e, clusterTimeCode) { - if (e.simple) { - return EBML.element(EBML.ID.SimpleBlock, [ - EBML.vintEncodedNumber(e.track), - EBML.int16(e.timestamp - clusterTimeCode), - EBML.bytes(e.isKeyframe ? [128] : [0]), - EBML.bytes(e.frame) - ]); - } - else { - let blockGroupContent = [EBML.element(EBML.ID.Block, [ - EBML.vintEncodedNumber(e.track), - EBML.int16(e.timestamp - clusterTimeCode), - EBML.bytes([0]), - EBML.bytes(e.frame) - ])]; - if (typeof e.duration != 'undefined') { - blockGroupContent.push(EBML.element(EBML.ID.BlockDuration, EBML.number(e.duration))); - } - if (typeof e.codecState != 'undefined') { - blockGroupContent.push(EBML.element(EBML.ID.CodecState, EBML.bytes(e.codecState))); - } - return EBML.element(EBML.ID.BlockGroup, blockGroupContent); - } - } - } - - /*** - * FLV + ASS => MKV transmuxer - * Demux FLV into H264 + AAC stream and ASS into line stream; then - * remux them into a MKV file. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * The FLV demuxer is from flv.js - * by zheng qian , licensed under Apache 2.0. - * - * The EMBL builder is from simple-ebml-builder - * by ryiwamoto, - * licensed under MIT. - */ - - /** - * @param {Blob|string|ArrayBuffer} x - */ - const getArrayBuffer = (x) => { - return new Promise((resolve, reject) => { - if (x instanceof _Blob) { - const e = new FileReader(); - e.onload = () => resolve(e.result); - e.onerror = reject; - e.readAsArrayBuffer(x); - } - else if (typeof x == 'string') { - const e = new XMLHttpRequest(); - e.responseType = 'arraybuffer'; - e.onload = () => resolve(e.response); - e.onerror = reject; - e.open('get', x); - e.send(); - } - else if (x instanceof ArrayBuffer) { - resolve(x); - } - else { - reject(new TypeError('flvass2mkv: getArrayBuffer {Blob|string|ArrayBuffer}')); - } - }) - }; - - const FLVASS2MKV = class { - constructor(config = {}) { - this.onflvprogress = null; - this.onfileload = null; - this.onmkvprogress = null; - this.onload = null; - Object.assign(this, config); - this.mkvConfig = { onprogress: this.onmkvprogress }; - Object.assign(this.mkvConfig, config.mkvConfig); - } - - /** - * Demux FLV into H264 + AAC stream and ASS into line stream; then - * remux them into a MKV file. - * @typedef {Blob|string|ArrayBuffer} F - * @param {F} flv - * @param {F} ass - * @param {{ name: string; file: F; }[]} subtitleAssList - */ - async build(flv = './samples/gen_case.flv', ass = './samples/gen_case.ass', subtitleAssList) { - // load flv and ass as arraybuffer - await Promise.all([ - (async () => { - flv = await getArrayBuffer(flv); - })(), - (async () => { - ass = await getArrayBuffer(ass); - })(), - (async () => { - subtitleAssList = await Promise.all( - subtitleAssList.map(async ({ name, file }) => { - return { name, file: await getArrayBuffer(file) } - }) - ); - })(), - ]); - - if (this.onfileload) this.onfileload(); - - const mkv = new MKV(this.mkvConfig); - - const assParser = new ASS(); - const assData = assParser.parseFile(ass); - mkv.addASSMetadata(assData, "弹幕"); - mkv.addASSStream(assData); - - subtitleAssList.forEach(({ name, file }) => { - const subAssData = assParser.parseFile(file); - mkv.addASSMetadata(subAssData, name); - mkv.addASSStream(subAssData); - }); - - if (subtitleAssList.length > 0) { - mkv.combineSubtitles(); - } - - const flvProbeData = FLVDemuxer.probe(flv); - const flvDemuxer = new FLVDemuxer(flvProbeData); - let mediaInfo = null; - let h264 = null; - let aac = null; - flvDemuxer.onDataAvailable = (...array) => { - array.forEach(e => { - if (e.type == 'video') h264 = e; - else if (e.type == 'audio') aac = e; - else throw new Error(\`MKVRemuxer: unrecoginzed data type \${e.type}\`); - }); - }; - flvDemuxer.onMediaInfo = i => mediaInfo = i; - flvDemuxer.onTrackMetadata = (i, e) => { - if (i == 'video') mkv.addH264Metadata(e); - else if (i == 'audio') mkv.addAACMetadata(e); - else throw new Error(\`MKVRemuxer: unrecoginzed metadata type \${i}\`); - }; - flvDemuxer.onError = e => { throw new Error(e); }; - const finalOffset = flvDemuxer.parseChunks(flv, flvProbeData.dataOffset); - if (finalOffset != flv.byteLength) throw new Error('FLVDemuxer: unexpected EOF'); - mkv.addH264Stream(h264); - mkv.addAACStream(aac); - - const ret = mkv.build(); - if (this.onload) this.onload(ret); - return ret; - } - }; - - // if nodejs then test - if (typeof window == 'undefined') { - if (require.main == module) { - (async () => { - const fs = require('fs'); - const assFileName = process.argv.slice(1).find(e => e.includes('.ass')) || './samples/gen_case.ass'; - const flvFileName = process.argv.slice(1).find(e => e.includes('.flv')) || './samples/gen_case.flv'; - const assFile = fs.readFileSync(assFileName).buffer; - const flvFile = fs.readFileSync(flvFileName).buffer; - fs.writeFileSync('out.mkv', await new FLVASS2MKV({ onmkvprogress: console.log.bind(console) }).build(flvFile, assFile)); - })(); - } + this.importFnAsAScript(TwentyFourDataView); + this.importFnAsAScript(FLVTag); + this.importFnAsAScript(FLV); } - return FLVASS2MKV; + /** + * @param {string} method + * @param {*} data + */ + async getReturnValue(method, data) { + const callbackNum = window.crypto.getRandomValues(new Uint32Array(1))[0]; + + this.postMessage([ + method, + data, + callbackNum + ]); + + return await new Promise((resolve, reject) => { + this.addEventListener("message", (e) => { + const [_method, incomingData, _callbackNum] = e.data; + if (_callbackNum == callbackNum) { + if (_method == method) { + resolve(incomingData); + } else if (_method == "error") { + console.error(incomingData); + reject(new Error("Web Worker 内部错误")); + } + } + }); + }) + } -}()); -//# sourceMappingURL=index.js.map + async registerAllMethods() { + const methods = await this.getReturnValue("getAllMethods"); - - - - - -`; + methods.forEach(method => { + Object.defineProperty(this, method, { + value: (arg) => this.getReturnValue(method, arg) + }); + }); + } + + /** + * @param {Function | ClassDecorator} c + */ + importFnAsAScript(c) { + const blob = new Blob([c.toString()], { type: 'application/javascript' }); + return this.getReturnValue("importScripts", URL.createObjectURL(blob)) + } -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. -*/ - -class MKVTransmuxer { - constructor(option) { - this.workerWin = null; - this.option = option; - } - - /** - * FLV + ASS => MKV entry point - * @param {Blob|string|ArrayBuffer} flv - * @param {Blob|string|ArrayBuffer} ass - * @param {string=} name - * @param {Node} target - * @param {{ name: string; file: (Blob|string|ArrayBuffer); }[]=} subtitleAssList - */ - exec(flv, ass, name, target, subtitleAssList = []) { - if (target.textContent != "另存为MKV") { - target.textContent = "打包中"; - - // 1. Allocate for a new window - if (!this.workerWin) this.workerWin = top.open('', undefined, ' '); - - // 2. Inject scripts - this.workerWin.document.write(embeddedHTML); - this.workerWin.document.close(); - - // 3. Invoke exec - if (!(this.option instanceof Object)) this.option = null; - this.workerWin.exec(Object.assign({}, this.option, { flv, ass, name, subtitleAssList }), target); - URL.revokeObjectURL(flv); - URL.revokeObjectURL(ass); - - // 4. Free parent window - // if (top.confirm('MKV打包中……要关掉这个窗口,释放内存吗?')) { - // top.location = 'about:blank'; - // } - } - } + /** + * @param {() => void} fn + */ + static fromAFunction(fn) { + const blob = new Blob(['(' + fn.toString() + ')()'], { type: 'application/javascript' }); + return new WebWorker(URL.createObjectURL(blob)) + } } -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. -*/ - -const _navigator = typeof navigator === 'object' && navigator || { userAgent: 'chrome' }; - -const _TextDecoder = typeof TextDecoder === 'function' && TextDecoder || class extends require('string_decoder').StringDecoder { - /** - * @param {ArrayBuffer} chunk - * @returns {string} - */ - decode(chunk) { - return this.end(Buffer.from(chunk)); - } +// 用于批量下载的 Web Worker , 请将函数中的内容想象成一个独立的js文件 +const BatchDownloadWorkerFn = () => { + + class BatchDownloadWorker { + async mergeFLVFiles(files) { + return await FLV.mergeBlobs(files); + } + + /** + * 引入脚本与库 + * @param {string[]} scripts + */ + importScripts(...scripts) { + importScripts(...scripts); + } + + getAllMethods() { + return Object.getOwnPropertyNames(BatchDownloadWorker.prototype).slice(1, -1) + } + } + + const worker = new BatchDownloadWorker(); + + onmessage = async (e) => { + const [method, incomingData, callbackNum] = e.data; + + try { + const returnValue = await worker[method](incomingData); + if (returnValue) { + postMessage([ + method, + returnValue, + callbackNum + ]); + } + } catch (e) { + postMessage([ + "error", + e.message, + callbackNum + ]); + throw e + } + }; +}; + +// @ts-check + +/** + * @param {number} alpha 0~255 + */ +const formatColorChannel$1 = (alpha) => { + return (alpha & 255).toString(16).toUpperCase().padStart(2, '0') +}; + +/** + * @param {number} opacity 0 ~ 1 -> alpha 0 ~ 255 + */ +const formatOpacity = (opacity) => { + const alpha = 0xFF * (100 - +opacity * 100) / 100; + return formatColorChannel$1(alpha) }; -/*** - * The FLV demuxer is from flv.js - * - * Copyright (C) 2016 Bilibili. All Rights Reserved. - * - * @author zheng qian - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// import FLVDemuxer from 'flv.js/src/demux/flv-demuxer.js'; -// ..import Log from '../utils/logger.js'; -const Log = { - e: console.error.bind(console), - w: console.warn.bind(console), - i: console.log.bind(console), - v: console.log.bind(console), -}; - -// ..import AMF from './amf-parser.js'; -// ....import Log from '../utils/logger.js'; -// ....import decodeUTF8 from '../utils/utf8-conv.js'; -function checkContinuation(uint8array, start, checkLength) { - let array = uint8array; - if (start + checkLength < array.length) { - while (checkLength--) { - if ((array[++start] & 0xC0) !== 0x80) - return false; - } - return true; - } else { - return false; - } -} - -function decodeUTF8(uint8array) { - let out = []; - let input = uint8array; - let i = 0; - let length = uint8array.length; - - while (i < length) { - if (input[i] < 0x80) { - out.push(String.fromCharCode(input[i])); - ++i; - continue; - } else if (input[i] < 0xC0) { - // fallthrough - } else if (input[i] < 0xE0) { - if (checkContinuation(input, i, 1)) { - let ucs4 = (input[i] & 0x1F) << 6 | (input[i + 1] & 0x3F); - if (ucs4 >= 0x80) { - out.push(String.fromCharCode(ucs4 & 0xFFFF)); - i += 2; - continue; - } - } - } else if (input[i] < 0xF0) { - if (checkContinuation(input, i, 2)) { - let ucs4 = (input[i] & 0xF) << 12 | (input[i + 1] & 0x3F) << 6 | input[i + 2] & 0x3F; - if (ucs4 >= 0x800 && (ucs4 & 0xF800) !== 0xD800) { - out.push(String.fromCharCode(ucs4 & 0xFFFF)); - i += 3; - continue; - } - } - } else if (input[i] < 0xF8) { - if (checkContinuation(input, i, 3)) { - let ucs4 = (input[i] & 0x7) << 18 | (input[i + 1] & 0x3F) << 12 - | (input[i + 2] & 0x3F) << 6 | (input[i + 3] & 0x3F); - if (ucs4 > 0x10000 && ucs4 < 0x110000) { - ucs4 -= 0x10000; - out.push(String.fromCharCode((ucs4 >>> 10) | 0xD800)); - out.push(String.fromCharCode((ucs4 & 0x3FF) | 0xDC00)); - i += 4; - continue; - } - } - } - out.push(String.fromCharCode(0xFFFD)); - ++i; - } - - return out.join(''); -} - -// ....import {IllegalStateException} from '../utils/exception.js'; -class IllegalStateException extends Error { } - -let le = (function () { - let buf = new ArrayBuffer(2); - (new DataView(buf)).setInt16(0, 256, true); // little-endian write - return (new Int16Array(buf))[0] === 256; // platform-spec read, if equal then LE -})(); - -class AMF { - - static parseScriptData(arrayBuffer, dataOffset, dataSize) { - let data = {}; - - try { - let name = AMF.parseValue(arrayBuffer, dataOffset, dataSize); - let value = AMF.parseValue(arrayBuffer, dataOffset + name.size, dataSize - name.size); - - data[name.data] = value.data; - } catch (e) { - Log.e('AMF', e.toString()); - } - - return data; - } - - static parseObject(arrayBuffer, dataOffset, dataSize) { - if (dataSize < 3) { - throw new IllegalStateException('Data not enough when parse ScriptDataObject'); - } - let name = AMF.parseString(arrayBuffer, dataOffset, dataSize); - let value = AMF.parseValue(arrayBuffer, dataOffset + name.size, dataSize - name.size); - let isObjectEnd = value.objectEnd; - - return { - data: { - name: name.data, - value: value.data - }, - size: name.size + value.size, - objectEnd: isObjectEnd - }; - } - - static parseVariable(arrayBuffer, dataOffset, dataSize) { - return AMF.parseObject(arrayBuffer, dataOffset, dataSize); - } - - static parseString(arrayBuffer, dataOffset, dataSize) { - if (dataSize < 2) { - throw new IllegalStateException('Data not enough when parse String'); - } - let v = new DataView(arrayBuffer, dataOffset, dataSize); - let length = v.getUint16(0, !le); - - let str; - if (length > 0) { - str = decodeUTF8(new Uint8Array(arrayBuffer, dataOffset + 2, length)); - } else { - str = ''; - } - - return { - data: str, - size: 2 + length - }; - } - - static parseLongString(arrayBuffer, dataOffset, dataSize) { - if (dataSize < 4) { - throw new IllegalStateException('Data not enough when parse LongString'); - } - let v = new DataView(arrayBuffer, dataOffset, dataSize); - let length = v.getUint32(0, !le); - - let str; - if (length > 0) { - str = decodeUTF8(new Uint8Array(arrayBuffer, dataOffset + 4, length)); - } else { - str = ''; - } - - return { - data: str, - size: 4 + length - }; - } - - static parseDate(arrayBuffer, dataOffset, dataSize) { - if (dataSize < 10) { - throw new IllegalStateException('Data size invalid when parse Date'); - } - let v = new DataView(arrayBuffer, dataOffset, dataSize); - let timestamp = v.getFloat64(0, !le); - let localTimeOffset = v.getInt16(8, !le); - timestamp += localTimeOffset * 60 * 1000; // get UTC time - - return { - data: new Date(timestamp), - size: 8 + 2 - }; - } - - static parseValue(arrayBuffer, dataOffset, dataSize) { - if (dataSize < 1) { - throw new IllegalStateException('Data not enough when parse Value'); - } - - let v = new DataView(arrayBuffer, dataOffset, dataSize); - - let offset = 1; - let type = v.getUint8(0); - let value; - let objectEnd = false; - - try { - switch (type) { - case 0: // Number(Double) type - value = v.getFloat64(1, !le); - offset += 8; - break; - case 1: { // Boolean type - let b = v.getUint8(1); - value = b ? true : false; - offset += 1; - break; - } - case 2: { // String type - let amfstr = AMF.parseString(arrayBuffer, dataOffset + 1, dataSize - 1); - value = amfstr.data; - offset += amfstr.size; - break; - } - case 3: { // Object(s) type - value = {}; - let terminal = 0; // workaround for malformed Objects which has missing ScriptDataObjectEnd - if ((v.getUint32(dataSize - 4, !le) & 0x00FFFFFF) === 9) { - terminal = 3; - } - while (offset < dataSize - 4) { // 4 === type(UI8) + ScriptDataObjectEnd(UI24) - let amfobj = AMF.parseObject(arrayBuffer, dataOffset + offset, dataSize - offset - terminal); - if (amfobj.objectEnd) - break; - value[amfobj.data.name] = amfobj.data.value; - offset += amfobj.size; - } - if (offset <= dataSize - 3) { - let marker = v.getUint32(offset - 1, !le) & 0x00FFFFFF; - if (marker === 9) { - offset += 3; - } - } - break; - } - case 8: { // ECMA array type (Mixed array) - value = {}; - offset += 4; // ECMAArrayLength(UI32) - let terminal = 0; // workaround for malformed MixedArrays which has missing ScriptDataObjectEnd - if ((v.getUint32(dataSize - 4, !le) & 0x00FFFFFF) === 9) { - terminal = 3; - } - while (offset < dataSize - 8) { // 8 === type(UI8) + ECMAArrayLength(UI32) + ScriptDataVariableEnd(UI24) - let amfvar = AMF.parseVariable(arrayBuffer, dataOffset + offset, dataSize - offset - terminal); - if (amfvar.objectEnd) - break; - value[amfvar.data.name] = amfvar.data.value; - offset += amfvar.size; - } - if (offset <= dataSize - 3) { - let marker = v.getUint32(offset - 1, !le) & 0x00FFFFFF; - if (marker === 9) { - offset += 3; - } - } - break; - } - case 9: // ScriptDataObjectEnd - value = undefined; - offset = 1; - objectEnd = true; - break; - case 10: { // Strict array type - // ScriptDataValue[n]. NOTE: according to video_file_format_spec_v10_1.pdf - value = []; - let strictArrayLength = v.getUint32(1, !le); - offset += 4; - for (let i = 0; i < strictArrayLength; i++) { - let val = AMF.parseValue(arrayBuffer, dataOffset + offset, dataSize - offset); - value.push(val.data); - offset += val.size; - } - break; - } - case 11: { // Date type - let date = AMF.parseDate(arrayBuffer, dataOffset + 1, dataSize - 1); - value = date.data; - offset += date.size; - break; - } - case 12: { // Long string type - let amfLongStr = AMF.parseString(arrayBuffer, dataOffset + 1, dataSize - 1); - value = amfLongStr.data; - offset += amfLongStr.size; - break; - } - default: - // ignore and skip - offset = dataSize; - Log.w('AMF', 'Unsupported AMF value type ' + type); - } - } catch (e) { - Log.e('AMF', e.toString()); - } - - return { - data: value, - size: offset, - objectEnd: objectEnd - }; - } - -} - -// ..import SPSParser from './sps-parser.js'; -// ....import ExpGolomb from './exp-golomb.js'; -// ......import {IllegalStateException, InvalidArgumentException} from '../utils/exception.js'; -class InvalidArgumentException extends Error { } - -class ExpGolomb { - - constructor(uint8array) { - this.TAG = 'ExpGolomb'; - - this._buffer = uint8array; - this._buffer_index = 0; - this._total_bytes = uint8array.byteLength; - this._total_bits = uint8array.byteLength * 8; - this._current_word = 0; - this._current_word_bits_left = 0; - } - - destroy() { - this._buffer = null; - } - - _fillCurrentWord() { - let buffer_bytes_left = this._total_bytes - this._buffer_index; - if (buffer_bytes_left <= 0) - throw new IllegalStateException('ExpGolomb: _fillCurrentWord() but no bytes available'); - - let bytes_read = Math.min(4, buffer_bytes_left); - let word = new Uint8Array(4); - word.set(this._buffer.subarray(this._buffer_index, this._buffer_index + bytes_read)); - this._current_word = new DataView(word.buffer).getUint32(0, false); - - this._buffer_index += bytes_read; - this._current_word_bits_left = bytes_read * 8; - } - - readBits(bits) { - if (bits > 32) - throw new InvalidArgumentException('ExpGolomb: readBits() bits exceeded max 32bits!'); - - if (bits <= this._current_word_bits_left) { - let result = this._current_word >>> (32 - bits); - this._current_word <<= bits; - this._current_word_bits_left -= bits; - return result; - } - - let result = this._current_word_bits_left ? this._current_word : 0; - result = result >>> (32 - this._current_word_bits_left); - let bits_need_left = bits - this._current_word_bits_left; - - this._fillCurrentWord(); - let bits_read_next = Math.min(bits_need_left, this._current_word_bits_left); - - let result2 = this._current_word >>> (32 - bits_read_next); - this._current_word <<= bits_read_next; - this._current_word_bits_left -= bits_read_next; - - result = (result << bits_read_next) | result2; - return result; - } - - readBool() { - return this.readBits(1) === 1; - } - - readByte() { - return this.readBits(8); - } - - _skipLeadingZero() { - let zero_count; - for (zero_count = 0; zero_count < this._current_word_bits_left; zero_count++) { - if (0 !== (this._current_word & (0x80000000 >>> zero_count))) { - this._current_word <<= zero_count; - this._current_word_bits_left -= zero_count; - return zero_count; - } - } - this._fillCurrentWord(); - return zero_count + this._skipLeadingZero(); - } - - readUEG() { // unsigned exponential golomb - let leading_zeros = this._skipLeadingZero(); - return this.readBits(leading_zeros + 1) - 1; - } - - readSEG() { // signed exponential golomb - let value = this.readUEG(); - if (value & 0x01) { - return (value + 1) >>> 1; - } else { - return -1 * (value >>> 1); - } - } - -} - -class SPSParser { - - static _ebsp2rbsp(uint8array) { - let src = uint8array; - let src_length = src.byteLength; - let dst = new Uint8Array(src_length); - let dst_idx = 0; - - for (let i = 0; i < src_length; i++) { - if (i >= 2) { - // Unescape: Skip 0x03 after 00 00 - if (src[i] === 0x03 && src[i - 1] === 0x00 && src[i - 2] === 0x00) { - continue; - } - } - dst[dst_idx] = src[i]; - dst_idx++; - } - - return new Uint8Array(dst.buffer, 0, dst_idx); - } - - static parseSPS(uint8array) { - let rbsp = SPSParser._ebsp2rbsp(uint8array); - let gb = new ExpGolomb(rbsp); - - gb.readByte(); - let profile_idc = gb.readByte(); // profile_idc - gb.readByte(); // constraint_set_flags[5] + reserved_zero[3] - let level_idc = gb.readByte(); // level_idc - gb.readUEG(); // seq_parameter_set_id - - let profile_string = SPSParser.getProfileString(profile_idc); - let level_string = SPSParser.getLevelString(level_idc); - let chroma_format_idc = 1; - let chroma_format = 420; - let chroma_format_table = [0, 420, 422, 444]; - let bit_depth = 8; - - if (profile_idc === 100 || profile_idc === 110 || profile_idc === 122 || - profile_idc === 244 || profile_idc === 44 || profile_idc === 83 || - profile_idc === 86 || profile_idc === 118 || profile_idc === 128 || - profile_idc === 138 || profile_idc === 144) { - - chroma_format_idc = gb.readUEG(); - if (chroma_format_idc === 3) { - gb.readBits(1); // separate_colour_plane_flag - } - if (chroma_format_idc <= 3) { - chroma_format = chroma_format_table[chroma_format_idc]; - } - - bit_depth = gb.readUEG() + 8; // bit_depth_luma_minus8 - gb.readUEG(); // bit_depth_chroma_minus8 - gb.readBits(1); // qpprime_y_zero_transform_bypass_flag - if (gb.readBool()) { // seq_scaling_matrix_present_flag - let scaling_list_count = (chroma_format_idc !== 3) ? 8 : 12; - for (let i = 0; i < scaling_list_count; i++) { - if (gb.readBool()) { // seq_scaling_list_present_flag - if (i < 6) { - SPSParser._skipScalingList(gb, 16); - } else { - SPSParser._skipScalingList(gb, 64); - } - } - } - } - } - gb.readUEG(); // log2_max_frame_num_minus4 - let pic_order_cnt_type = gb.readUEG(); - if (pic_order_cnt_type === 0) { - gb.readUEG(); // log2_max_pic_order_cnt_lsb_minus_4 - } else if (pic_order_cnt_type === 1) { - gb.readBits(1); // delta_pic_order_always_zero_flag - gb.readSEG(); // offset_for_non_ref_pic - gb.readSEG(); // offset_for_top_to_bottom_field - let num_ref_frames_in_pic_order_cnt_cycle = gb.readUEG(); - for (let i = 0; i < num_ref_frames_in_pic_order_cnt_cycle; i++) { - gb.readSEG(); // offset_for_ref_frame - } - } - gb.readUEG(); // max_num_ref_frames - gb.readBits(1); // gaps_in_frame_num_value_allowed_flag - - let pic_width_in_mbs_minus1 = gb.readUEG(); - let pic_height_in_map_units_minus1 = gb.readUEG(); - - let frame_mbs_only_flag = gb.readBits(1); - if (frame_mbs_only_flag === 0) { - gb.readBits(1); // mb_adaptive_frame_field_flag - } - gb.readBits(1); // direct_8x8_inference_flag - - let frame_crop_left_offset = 0; - let frame_crop_right_offset = 0; - let frame_crop_top_offset = 0; - let frame_crop_bottom_offset = 0; - - let frame_cropping_flag = gb.readBool(); - if (frame_cropping_flag) { - frame_crop_left_offset = gb.readUEG(); - frame_crop_right_offset = gb.readUEG(); - frame_crop_top_offset = gb.readUEG(); - frame_crop_bottom_offset = gb.readUEG(); - } - - let sar_width = 1, sar_height = 1; - let fps = 0, fps_fixed = true, fps_num = 0, fps_den = 0; - - let vui_parameters_present_flag = gb.readBool(); - if (vui_parameters_present_flag) { - if (gb.readBool()) { // aspect_ratio_info_present_flag - let aspect_ratio_idc = gb.readByte(); - let sar_w_table = [1, 12, 10, 16, 40, 24, 20, 32, 80, 18, 15, 64, 160, 4, 3, 2]; - let sar_h_table = [1, 11, 11, 11, 33, 11, 11, 11, 33, 11, 11, 33, 99, 3, 2, 1]; - - if (aspect_ratio_idc > 0 && aspect_ratio_idc < 16) { - sar_width = sar_w_table[aspect_ratio_idc - 1]; - sar_height = sar_h_table[aspect_ratio_idc - 1]; - } else if (aspect_ratio_idc === 255) { - sar_width = gb.readByte() << 8 | gb.readByte(); - sar_height = gb.readByte() << 8 | gb.readByte(); - } - } - - if (gb.readBool()) { // overscan_info_present_flag - gb.readBool(); // overscan_appropriate_flag - } - if (gb.readBool()) { // video_signal_type_present_flag - gb.readBits(4); // video_format & video_full_range_flag - if (gb.readBool()) { // colour_description_present_flag - gb.readBits(24); // colour_primaries & transfer_characteristics & matrix_coefficients - } - } - if (gb.readBool()) { // chroma_loc_info_present_flag - gb.readUEG(); // chroma_sample_loc_type_top_field - gb.readUEG(); // chroma_sample_loc_type_bottom_field - } - if (gb.readBool()) { // timing_info_present_flag - let num_units_in_tick = gb.readBits(32); - let time_scale = gb.readBits(32); - fps_fixed = gb.readBool(); // fixed_frame_rate_flag - - fps_num = time_scale; - fps_den = num_units_in_tick * 2; - fps = fps_num / fps_den; - } - } - - let sarScale = 1; - if (sar_width !== 1 || sar_height !== 1) { - sarScale = sar_width / sar_height; - } - - let crop_unit_x = 0, crop_unit_y = 0; - if (chroma_format_idc === 0) { - crop_unit_x = 1; - crop_unit_y = 2 - frame_mbs_only_flag; - } else { - let sub_wc = (chroma_format_idc === 3) ? 1 : 2; - let sub_hc = (chroma_format_idc === 1) ? 2 : 1; - crop_unit_x = sub_wc; - crop_unit_y = sub_hc * (2 - frame_mbs_only_flag); - } - - let codec_width = (pic_width_in_mbs_minus1 + 1) * 16; - let codec_height = (2 - frame_mbs_only_flag) * ((pic_height_in_map_units_minus1 + 1) * 16); - - codec_width -= (frame_crop_left_offset + frame_crop_right_offset) * crop_unit_x; - codec_height -= (frame_crop_top_offset + frame_crop_bottom_offset) * crop_unit_y; - - let present_width = Math.ceil(codec_width * sarScale); - - gb.destroy(); - gb = null; - - return { - profile_string: profile_string, // baseline, high, high10, ... - level_string: level_string, // 3, 3.1, 4, 4.1, 5, 5.1, ... - bit_depth: bit_depth, // 8bit, 10bit, ... - chroma_format: chroma_format, // 4:2:0, 4:2:2, ... - chroma_format_string: SPSParser.getChromaFormatString(chroma_format), - - frame_rate: { - fixed: fps_fixed, - fps: fps, - fps_den: fps_den, - fps_num: fps_num - }, - - sar_ratio: { - width: sar_width, - height: sar_height - }, - - codec_size: { - width: codec_width, - height: codec_height - }, - - present_size: { - width: present_width, - height: codec_height - } - }; - } - - static _skipScalingList(gb, count) { - let last_scale = 8, next_scale = 8; - let delta_scale = 0; - for (let i = 0; i < count; i++) { - if (next_scale !== 0) { - delta_scale = gb.readSEG(); - next_scale = (last_scale + delta_scale + 256) % 256; - } - last_scale = (next_scale === 0) ? last_scale : next_scale; - } - } - - static getProfileString(profile_idc) { - switch (profile_idc) { - case 66: - return 'Baseline'; - case 77: - return 'Main'; - case 88: - return 'Extended'; - case 100: - return 'High'; - case 110: - return 'High10'; - case 122: - return 'High422'; - case 244: - return 'High444'; - default: - return 'Unknown'; - } - } - - static getLevelString(level_idc) { - return (level_idc / 10).toFixed(1); - } - - static getChromaFormatString(chroma) { - switch (chroma) { - case 420: - return '4:2:0'; - case 422: - return '4:2:2'; - case 444: - return '4:4:4'; - default: - return 'Unknown'; - } - } - -} - -// ..import DemuxErrors from './demux-errors.js'; -const DemuxErrors = { - OK: 'OK', - FORMAT_ERROR: 'FormatError', - FORMAT_UNSUPPORTED: 'FormatUnsupported', - CODEC_UNSUPPORTED: 'CodecUnsupported' -}; - -// ..import MediaInfo from '../core/media-info.js'; -class MediaInfo { - - constructor() { - this.mimeType = null; - this.duration = null; - - this.hasAudio = null; - this.hasVideo = null; - this.audioCodec = null; - this.videoCodec = null; - this.audioDataRate = null; - this.videoDataRate = null; - - this.audioSampleRate = null; - this.audioChannelCount = null; - - this.width = null; - this.height = null; - this.fps = null; - this.profile = null; - this.level = null; - this.chromaFormat = null; - this.sarNum = null; - this.sarDen = null; - - this.metadata = null; - this.segments = null; // MediaInfo[] - this.segmentCount = null; - this.hasKeyframesIndex = null; - this.keyframesIndex = null; - } - - isComplete() { - let audioInfoComplete = (this.hasAudio === false) || - (this.hasAudio === true && - this.audioCodec != null && - this.audioSampleRate != null && - this.audioChannelCount != null); - - let videoInfoComplete = (this.hasVideo === false) || - (this.hasVideo === true && - this.videoCodec != null && - this.width != null && - this.height != null && - this.fps != null && - this.profile != null && - this.level != null && - this.chromaFormat != null && - this.sarNum != null && - this.sarDen != null); - - // keyframesIndex may not be present - return this.mimeType != null && - this.duration != null && - this.metadata != null && - this.hasKeyframesIndex != null && - audioInfoComplete && - videoInfoComplete; - } - - isSeekable() { - return this.hasKeyframesIndex === true; - } - - getNearestKeyframe(milliseconds) { - if (this.keyframesIndex == null) { - return null; - } - - let table = this.keyframesIndex; - let keyframeIdx = this._search(table.times, milliseconds); - - return { - index: keyframeIdx, - milliseconds: table.times[keyframeIdx], - fileposition: table.filepositions[keyframeIdx] - }; - } - - _search(list, value) { - let idx = 0; - - let last = list.length - 1; - let mid = 0; - let lbound = 0; - let ubound = last; - - if (value < list[0]) { - idx = 0; - lbound = ubound + 1; // skip search - } - - while (lbound <= ubound) { - mid = lbound + Math.floor((ubound - lbound) / 2); - if (mid === last || (value >= list[mid] && value < list[mid + 1])) { - idx = mid; - break; - } else if (list[mid] < value) { - lbound = mid + 1; - } else { - ubound = mid - 1; - } - } - - return idx; - } - -} - -function ReadBig32(array, index) { - return ((array[index] << 24) | - (array[index + 1] << 16) | - (array[index + 2] << 8) | - (array[index + 3])); -} - -class FLVDemuxer { - - /** - * Create a new FLV demuxer - * @param {Object} probeData - * @param {boolean} probeData.match - * @param {number} probeData.consumed - * @param {number} probeData.dataOffset - * @param {boolean} probeData.hasAudioTrack - * @param {boolean} probeData.hasVideoTrack - */ - constructor(probeData) { - this.TAG = 'FLVDemuxer'; - - this._onError = null; - this._onMediaInfo = null; - this._onTrackMetadata = null; - this._onDataAvailable = null; - - this._dataOffset = probeData.dataOffset; - this._firstParse = true; - this._dispatch = false; - - this._hasAudio = probeData.hasAudioTrack; - this._hasVideo = probeData.hasVideoTrack; - - this._hasAudioFlagOverrided = false; - this._hasVideoFlagOverrided = false; - - this._audioInitialMetadataDispatched = false; - this._videoInitialMetadataDispatched = false; - - this._mediaInfo = new MediaInfo(); - this._mediaInfo.hasAudio = this._hasAudio; - this._mediaInfo.hasVideo = this._hasVideo; - this._metadata = null; - this._audioMetadata = null; - this._videoMetadata = null; - - this._naluLengthSize = 4; - this._timestampBase = 0; // int32, in milliseconds - this._timescale = 1000; - this._duration = 0; // int32, in milliseconds - this._durationOverrided = false; - this._referenceFrameRate = { - fixed: true, - fps: 23.976, - fps_num: 23976, - fps_den: 1000 - }; - - this._flvSoundRateTable = [5500, 11025, 22050, 44100, 48000]; - - this._mpegSamplingRates = [ - 96000, 88200, 64000, 48000, 44100, 32000, - 24000, 22050, 16000, 12000, 11025, 8000, 7350 - ]; - - this._mpegAudioV10SampleRateTable = [44100, 48000, 32000, 0]; - this._mpegAudioV20SampleRateTable = [22050, 24000, 16000, 0]; - this._mpegAudioV25SampleRateTable = [11025, 12000, 8000, 0]; - - this._mpegAudioL1BitRateTable = [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, -1]; - this._mpegAudioL2BitRateTable = [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, -1]; - this._mpegAudioL3BitRateTable = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, -1]; - - this._videoTrack = { type: 'video', id: 1, sequenceNumber: 0, samples: [], length: 0 }; - this._audioTrack = { type: 'audio', id: 2, sequenceNumber: 0, samples: [], length: 0 }; - - this._littleEndian = (function () { - let buf = new ArrayBuffer(2); - (new DataView(buf)).setInt16(0, 256, true); // little-endian write - return (new Int16Array(buf))[0] === 256; // platform-spec read, if equal then LE - })(); - } - - destroy() { - this._mediaInfo = null; - this._metadata = null; - this._audioMetadata = null; - this._videoMetadata = null; - this._videoTrack = null; - this._audioTrack = null; - - this._onError = null; - this._onMediaInfo = null; - this._onTrackMetadata = null; - this._onDataAvailable = null; - } - - /** - * Probe the flv data - * @param {ArrayBuffer} buffer - * @returns {Object} - probeData to be feed into constructor - */ - static probe(buffer) { - let data = new Uint8Array(buffer); - let mismatch = { match: false }; - - if (data[0] !== 0x46 || data[1] !== 0x4C || data[2] !== 0x56 || data[3] !== 0x01) { - return mismatch; - } - - let hasAudio = ((data[4] & 4) >>> 2) !== 0; - let hasVideo = (data[4] & 1) !== 0; - - let offset = ReadBig32(data, 5); - - if (offset < 9) { - return mismatch; - } - - return { - match: true, - consumed: offset, - dataOffset: offset, - hasAudioTrack: hasAudio, - hasVideoTrack: hasVideo - }; - } - - bindDataSource(loader) { - loader.onDataArrival = this.parseChunks.bind(this); - return this; - } - - // prototype: function(type: string, metadata: any): void - get onTrackMetadata() { - return this._onTrackMetadata; - } - - set onTrackMetadata(callback) { - this._onTrackMetadata = callback; - } - - // prototype: function(mediaInfo: MediaInfo): void - get onMediaInfo() { - return this._onMediaInfo; - } - - set onMediaInfo(callback) { - this._onMediaInfo = callback; - } - - // prototype: function(type: number, info: string): void - get onError() { - return this._onError; - } - - set onError(callback) { - this._onError = callback; - } - - // prototype: function(videoTrack: any, audioTrack: any): void - get onDataAvailable() { - return this._onDataAvailable; - } - - set onDataAvailable(callback) { - this._onDataAvailable = callback; - } - - // timestamp base for output samples, must be in milliseconds - get timestampBase() { - return this._timestampBase; - } - - set timestampBase(base) { - this._timestampBase = base; - } - - get overridedDuration() { - return this._duration; - } - - // Force-override media duration. Must be in milliseconds, int32 - set overridedDuration(duration) { - this._durationOverrided = true; - this._duration = duration; - this._mediaInfo.duration = duration; - } - - // Force-override audio track present flag, boolean - set overridedHasAudio(hasAudio) { - this._hasAudioFlagOverrided = true; - this._hasAudio = hasAudio; - this._mediaInfo.hasAudio = hasAudio; - } - - // Force-override video track present flag, boolean - set overridedHasVideo(hasVideo) { - this._hasVideoFlagOverrided = true; - this._hasVideo = hasVideo; - this._mediaInfo.hasVideo = hasVideo; - } - - resetMediaInfo() { - this._mediaInfo = new MediaInfo(); - } - - _isInitialMetadataDispatched() { - if (this._hasAudio && this._hasVideo) { // both audio & video - return this._audioInitialMetadataDispatched && this._videoInitialMetadataDispatched; - } - if (this._hasAudio && !this._hasVideo) { // audio only - return this._audioInitialMetadataDispatched; - } - if (!this._hasAudio && this._hasVideo) { // video only - return this._videoInitialMetadataDispatched; - } - return false; - } - - // function parseChunks(chunk: ArrayBuffer, byteStart: number): number; - parseChunks(chunk, byteStart) { - if (!this._onError || !this._onMediaInfo || !this._onTrackMetadata || !this._onDataAvailable) { - throw new IllegalStateException('Flv: onError & onMediaInfo & onTrackMetadata & onDataAvailable callback must be specified'); - } - - // qli5: fix nonzero byteStart - let offset = byteStart || 0; - let le = this._littleEndian; - - if (byteStart === 0) { // buffer with FLV header - if (chunk.byteLength > 13) { - let probeData = FLVDemuxer.probe(chunk); - offset = probeData.dataOffset; - } else { - return 0; - } - } - - if (this._firstParse) { // handle PreviousTagSize0 before Tag1 - this._firstParse = false; - if (offset !== this._dataOffset) { - Log.w(this.TAG, 'First time parsing but chunk byteStart invalid!'); - } - - let v = new DataView(chunk, offset); - let prevTagSize0 = v.getUint32(0, !le); - if (prevTagSize0 !== 0) { - Log.w(this.TAG, 'PrevTagSize0 !== 0 !!!'); - } - offset += 4; - } - - while (offset < chunk.byteLength) { - this._dispatch = true; - - let v = new DataView(chunk, offset); - - if (offset + 11 + 4 > chunk.byteLength) { - // data not enough for parsing an flv tag - break; - } - - let tagType = v.getUint8(0); - let dataSize = v.getUint32(0, !le) & 0x00FFFFFF; - - if (offset + 11 + dataSize + 4 > chunk.byteLength) { - // data not enough for parsing actual data body - break; - } - - if (tagType !== 8 && tagType !== 9 && tagType !== 18) { - Log.w(this.TAG, `Unsupported tag type ${tagType}, skipped`); - // consume the whole tag (skip it) - offset += 11 + dataSize + 4; - continue; - } - - let ts2 = v.getUint8(4); - let ts1 = v.getUint8(5); - let ts0 = v.getUint8(6); - let ts3 = v.getUint8(7); - - let timestamp = ts0 | (ts1 << 8) | (ts2 << 16) | (ts3 << 24); - - let streamId = v.getUint32(7, !le) & 0x00FFFFFF; - if (streamId !== 0) { - Log.w(this.TAG, 'Meet tag which has StreamID != 0!'); - } - - let dataOffset = offset + 11; - - switch (tagType) { - case 8: // Audio - this._parseAudioData(chunk, dataOffset, dataSize, timestamp); - break; - case 9: // Video - this._parseVideoData(chunk, dataOffset, dataSize, timestamp, byteStart + offset); - break; - case 18: // ScriptDataObject - this._parseScriptData(chunk, dataOffset, dataSize); - break; - } - - let prevTagSize = v.getUint32(11 + dataSize, !le); - if (prevTagSize !== 11 + dataSize) { - Log.w(this.TAG, `Invalid PrevTagSize ${prevTagSize}`); - } - - offset += 11 + dataSize + 4; // tagBody + dataSize + prevTagSize - } - - // dispatch parsed frames to consumer (typically, the remuxer) - if (this._isInitialMetadataDispatched()) { - if (this._dispatch && (this._audioTrack.length || this._videoTrack.length)) { - this._onDataAvailable(this._audioTrack, this._videoTrack); - } - } - - return offset; // consumed bytes, just equals latest offset index - } - - _parseScriptData(arrayBuffer, dataOffset, dataSize) { - let scriptData = AMF.parseScriptData(arrayBuffer, dataOffset, dataSize); - - if (scriptData.hasOwnProperty('onMetaData')) { - if (scriptData.onMetaData == null || typeof scriptData.onMetaData !== 'object') { - Log.w(this.TAG, 'Invalid onMetaData structure!'); - return; - } - if (this._metadata) { - Log.w(this.TAG, 'Found another onMetaData tag!'); - } - this._metadata = scriptData; - let onMetaData = this._metadata.onMetaData; - - if (typeof onMetaData.hasAudio === 'boolean') { // hasAudio - if (this._hasAudioFlagOverrided === false) { - this._hasAudio = onMetaData.hasAudio; - this._mediaInfo.hasAudio = this._hasAudio; - } - } - if (typeof onMetaData.hasVideo === 'boolean') { // hasVideo - if (this._hasVideoFlagOverrided === false) { - this._hasVideo = onMetaData.hasVideo; - this._mediaInfo.hasVideo = this._hasVideo; - } - } - if (typeof onMetaData.audiodatarate === 'number') { // audiodatarate - this._mediaInfo.audioDataRate = onMetaData.audiodatarate; - } - if (typeof onMetaData.videodatarate === 'number') { // videodatarate - this._mediaInfo.videoDataRate = onMetaData.videodatarate; - } - if (typeof onMetaData.width === 'number') { // width - this._mediaInfo.width = onMetaData.width; - } - if (typeof onMetaData.height === 'number') { // height - this._mediaInfo.height = onMetaData.height; - } - if (typeof onMetaData.duration === 'number') { // duration - if (!this._durationOverrided) { - let duration = Math.floor(onMetaData.duration * this._timescale); - this._duration = duration; - this._mediaInfo.duration = duration; - } - } else { - this._mediaInfo.duration = 0; - } - if (typeof onMetaData.framerate === 'number') { // framerate - let fps_num = Math.floor(onMetaData.framerate * 1000); - if (fps_num > 0) { - let fps = fps_num / 1000; - this._referenceFrameRate.fixed = true; - this._referenceFrameRate.fps = fps; - this._referenceFrameRate.fps_num = fps_num; - this._referenceFrameRate.fps_den = 1000; - this._mediaInfo.fps = fps; - } - } - if (typeof onMetaData.keyframes === 'object') { // keyframes - this._mediaInfo.hasKeyframesIndex = true; - let keyframes = onMetaData.keyframes; - this._mediaInfo.keyframesIndex = this._parseKeyframesIndex(keyframes); - onMetaData.keyframes = null; // keyframes has been extracted, remove it - } else { - this._mediaInfo.hasKeyframesIndex = false; - } - this._dispatch = false; - this._mediaInfo.metadata = onMetaData; - Log.v(this.TAG, 'Parsed onMetaData'); - if (this._mediaInfo.isComplete()) { - this._onMediaInfo(this._mediaInfo); - } - } - } - - _parseKeyframesIndex(keyframes) { - let times = []; - let filepositions = []; - - // ignore first keyframe which is actually AVC Sequence Header (AVCDecoderConfigurationRecord) - for (let i = 1; i < keyframes.times.length; i++) { - let time = this._timestampBase + Math.floor(keyframes.times[i] * 1000); - times.push(time); - filepositions.push(keyframes.filepositions[i]); - } - - return { - times: times, - filepositions: filepositions - }; - } - - _parseAudioData(arrayBuffer, dataOffset, dataSize, tagTimestamp) { - if (dataSize <= 1) { - Log.w(this.TAG, 'Flv: Invalid audio packet, missing SoundData payload!'); - return; - } - - if (this._hasAudioFlagOverrided === true && this._hasAudio === false) { - // If hasAudio: false indicated explicitly in MediaDataSource, - // Ignore all the audio packets - return; - } - - let le = this._littleEndian; - let v = new DataView(arrayBuffer, dataOffset, dataSize); - - let soundSpec = v.getUint8(0); - - let soundFormat = soundSpec >>> 4; - if (soundFormat !== 2 && soundFormat !== 10) { // MP3 or AAC - this._onError(DemuxErrors.CODEC_UNSUPPORTED, 'Flv: Unsupported audio codec idx: ' + soundFormat); - return; - } - - let soundRate = 0; - let soundRateIndex = (soundSpec & 12) >>> 2; - if (soundRateIndex >= 0 && soundRateIndex <= 4) { - soundRate = this._flvSoundRateTable[soundRateIndex]; - } else { - this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: Invalid audio sample rate idx: ' + soundRateIndex); - return; - } - let soundType = (soundSpec & 1); - - - let meta = this._audioMetadata; - let track = this._audioTrack; - - if (!meta) { - if (this._hasAudio === false && this._hasAudioFlagOverrided === false) { - this._hasAudio = true; - this._mediaInfo.hasAudio = true; - } - - // initial metadata - meta = this._audioMetadata = {}; - meta.type = 'audio'; - meta.id = track.id; - meta.timescale = this._timescale; - meta.duration = this._duration; - meta.audioSampleRate = soundRate; - meta.channelCount = (soundType === 0 ? 1 : 2); - } - - if (soundFormat === 10) { // AAC - let aacData = this._parseAACAudioData(arrayBuffer, dataOffset + 1, dataSize - 1); - - if (aacData == undefined) { - return; - } - - if (aacData.packetType === 0) { // AAC sequence header (AudioSpecificConfig) - if (meta.config) { - Log.w(this.TAG, 'Found another AudioSpecificConfig!'); - } - let misc = aacData.data; - meta.audioSampleRate = misc.samplingRate; - meta.channelCount = misc.channelCount; - meta.codec = misc.codec; - meta.originalCodec = misc.originalCodec; - meta.config = misc.config; - // added by qli5 - meta.configRaw = misc.configRaw; - // added by Xmader - meta.audioObjectType = misc.audioObjectType; - meta.samplingFrequencyIndex = misc.samplingIndex; - meta.channelConfig = misc.channelCount; - // The decode result of an aac sample is 1024 PCM samples - meta.refSampleDuration = 1024 / meta.audioSampleRate * meta.timescale; - Log.v(this.TAG, 'Parsed AudioSpecificConfig'); - - if (this._isInitialMetadataDispatched()) { - // Non-initial metadata, force dispatch (or flush) parsed frames to remuxer - if (this._dispatch && (this._audioTrack.length || this._videoTrack.length)) { - this._onDataAvailable(this._audioTrack, this._videoTrack); - } - } else { - this._audioInitialMetadataDispatched = true; - } - // then notify new metadata - this._dispatch = false; - this._onTrackMetadata('audio', meta); - - let mi = this._mediaInfo; - mi.audioCodec = meta.originalCodec; - mi.audioSampleRate = meta.audioSampleRate; - mi.audioChannelCount = meta.channelCount; - if (mi.hasVideo) { - if (mi.videoCodec != null) { - mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + ',' + mi.audioCodec + '"'; - } - } else { - mi.mimeType = 'video/x-flv; codecs="' + mi.audioCodec + '"'; - } - if (mi.isComplete()) { - this._onMediaInfo(mi); - } - } else if (aacData.packetType === 1) { // AAC raw frame data - let dts = this._timestampBase + tagTimestamp; - let aacSample = { unit: aacData.data, length: aacData.data.byteLength, dts: dts, pts: dts }; - track.samples.push(aacSample); - track.length += aacData.data.length; - } else { - Log.e(this.TAG, `Flv: Unsupported AAC data type ${aacData.packetType}`); - } - } else if (soundFormat === 2) { // MP3 - if (!meta.codec) { - // We need metadata for mp3 audio track, extract info from frame header - let misc = this._parseMP3AudioData(arrayBuffer, dataOffset + 1, dataSize - 1, true); - if (misc == undefined) { - return; - } - meta.audioSampleRate = misc.samplingRate; - meta.channelCount = misc.channelCount; - meta.codec = misc.codec; - meta.originalCodec = misc.originalCodec; - // The decode result of an mp3 sample is 1152 PCM samples - meta.refSampleDuration = 1152 / meta.audioSampleRate * meta.timescale; - Log.v(this.TAG, 'Parsed MPEG Audio Frame Header'); - - this._audioInitialMetadataDispatched = true; - this._onTrackMetadata('audio', meta); - - let mi = this._mediaInfo; - mi.audioCodec = meta.codec; - mi.audioSampleRate = meta.audioSampleRate; - mi.audioChannelCount = meta.channelCount; - mi.audioDataRate = misc.bitRate; - if (mi.hasVideo) { - if (mi.videoCodec != null) { - mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + ',' + mi.audioCodec + '"'; - } - } else { - mi.mimeType = 'video/x-flv; codecs="' + mi.audioCodec + '"'; - } - if (mi.isComplete()) { - this._onMediaInfo(mi); - } - } - - // This packet is always a valid audio packet, extract it - let data = this._parseMP3AudioData(arrayBuffer, dataOffset + 1, dataSize - 1, false); - if (data == undefined) { - return; - } - let dts = this._timestampBase + tagTimestamp; - let mp3Sample = { unit: data, length: data.byteLength, dts: dts, pts: dts }; - track.samples.push(mp3Sample); - track.length += data.length; - } - } - - _parseAACAudioData(arrayBuffer, dataOffset, dataSize) { - if (dataSize <= 1) { - Log.w(this.TAG, 'Flv: Invalid AAC packet, missing AACPacketType or/and Data!'); - return; - } - - let result = {}; - let array = new Uint8Array(arrayBuffer, dataOffset, dataSize); - - result.packetType = array[0]; - - if (array[0] === 0) { - result.data = this._parseAACAudioSpecificConfig(arrayBuffer, dataOffset + 1, dataSize - 1); - } else { - result.data = array.subarray(1); - } - - return result; - } - - _parseAACAudioSpecificConfig(arrayBuffer, dataOffset, dataSize) { - let array = new Uint8Array(arrayBuffer, dataOffset, dataSize); - let config = null; - - /* Audio Object Type: - 0: Null - 1: AAC Main - 2: AAC LC - 3: AAC SSR (Scalable Sample Rate) - 4: AAC LTP (Long Term Prediction) - 5: HE-AAC / SBR (Spectral Band Replication) - 6: AAC Scalable - */ - - let audioObjectType = 0; - let originalAudioObjectType = 0; - let samplingIndex = 0; - let extensionSamplingIndex = null; - - // 5 bits - audioObjectType = originalAudioObjectType = array[0] >>> 3; - // 4 bits - samplingIndex = ((array[0] & 0x07) << 1) | (array[1] >>> 7); - if (samplingIndex < 0 || samplingIndex >= this._mpegSamplingRates.length) { - this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: AAC invalid sampling frequency index!'); - return; - } - - let samplingFrequence = this._mpegSamplingRates[samplingIndex]; - - // 4 bits - let channelConfig = (array[1] & 0x78) >>> 3; - if (channelConfig < 0 || channelConfig >= 8) { - this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: AAC invalid channel configuration'); - return; - } - - if (audioObjectType === 5) { // HE-AAC? - // 4 bits - extensionSamplingIndex = ((array[1] & 0x07) << 1) | (array[2] >>> 7); - } - - // workarounds for various browsers - let userAgent = _navigator.userAgent.toLowerCase(); - - if (userAgent.indexOf('firefox') !== -1) { - // firefox: use SBR (HE-AAC) if freq less than 24kHz - if (samplingIndex >= 6) { - audioObjectType = 5; - config = new Array(4); - extensionSamplingIndex = samplingIndex - 3; - } else { // use LC-AAC - audioObjectType = 2; - config = new Array(2); - extensionSamplingIndex = samplingIndex; - } - } else if (userAgent.indexOf('android') !== -1) { - // android: always use LC-AAC - audioObjectType = 2; - config = new Array(2); - extensionSamplingIndex = samplingIndex; - } else { - // for other browsers, e.g. chrome... - // Always use HE-AAC to make it easier to switch aac codec profile - audioObjectType = 5; - extensionSamplingIndex = samplingIndex; - config = new Array(4); - - if (samplingIndex >= 6) { - extensionSamplingIndex = samplingIndex - 3; - } else if (channelConfig === 1) { // Mono channel - audioObjectType = 2; - config = new Array(2); - extensionSamplingIndex = samplingIndex; - } - } - - config[0] = audioObjectType << 3; - config[0] |= (samplingIndex & 0x0F) >>> 1; - config[1] = (samplingIndex & 0x0F) << 7; - config[1] |= (channelConfig & 0x0F) << 3; - if (audioObjectType === 5) { - config[1] |= ((extensionSamplingIndex & 0x0F) >>> 1); - config[2] = (extensionSamplingIndex & 0x01) << 7; - // extended audio object type: force to 2 (LC-AAC) - config[2] |= (2 << 2); - config[3] = 0; - } - - return { - audioObjectType, // audio_object_type, added by Xmader - samplingIndex, // sampling_frequency_index, added by Xmader - configRaw: array, // added by qli5 - config: config, - samplingRate: samplingFrequence, - channelCount: channelConfig, // channel_config - codec: 'mp4a.40.' + audioObjectType, - originalCodec: 'mp4a.40.' + originalAudioObjectType - }; - } - - _parseMP3AudioData(arrayBuffer, dataOffset, dataSize, requestHeader) { - if (dataSize < 4) { - Log.w(this.TAG, 'Flv: Invalid MP3 packet, header missing!'); - return; - } - - let le = this._littleEndian; - let array = new Uint8Array(arrayBuffer, dataOffset, dataSize); - let result = null; - - if (requestHeader) { - if (array[0] !== 0xFF) { - return; - } - let ver = (array[1] >>> 3) & 0x03; - let layer = (array[1] & 0x06) >> 1; - - let bitrate_index = (array[2] & 0xF0) >>> 4; - let sampling_freq_index = (array[2] & 0x0C) >>> 2; - - let channel_mode = (array[3] >>> 6) & 0x03; - let channel_count = channel_mode !== 3 ? 2 : 1; - - let sample_rate = 0; - let bit_rate = 0; - - let codec = 'mp3'; - - switch (ver) { - case 0: // MPEG 2.5 - sample_rate = this._mpegAudioV25SampleRateTable[sampling_freq_index]; - break; - case 2: // MPEG 2 - sample_rate = this._mpegAudioV20SampleRateTable[sampling_freq_index]; - break; - case 3: // MPEG 1 - sample_rate = this._mpegAudioV10SampleRateTable[sampling_freq_index]; - break; - } - - switch (layer) { - case 1: // Layer 3 - if (bitrate_index < this._mpegAudioL3BitRateTable.length) { - bit_rate = this._mpegAudioL3BitRateTable[bitrate_index]; - } - break; - case 2: // Layer 2 - if (bitrate_index < this._mpegAudioL2BitRateTable.length) { - bit_rate = this._mpegAudioL2BitRateTable[bitrate_index]; - } - break; - case 3: // Layer 1 - if (bitrate_index < this._mpegAudioL1BitRateTable.length) { - bit_rate = this._mpegAudioL1BitRateTable[bitrate_index]; - } - break; - } - - result = { - bitRate: bit_rate, - samplingRate: sample_rate, - channelCount: channel_count, - codec: codec, - originalCodec: codec - }; - } else { - result = array; - } - - return result; - } - - _parseVideoData(arrayBuffer, dataOffset, dataSize, tagTimestamp, tagPosition) { - if (dataSize <= 1) { - Log.w(this.TAG, 'Flv: Invalid video packet, missing VideoData payload!'); - return; - } - - if (this._hasVideoFlagOverrided === true && this._hasVideo === false) { - // If hasVideo: false indicated explicitly in MediaDataSource, - // Ignore all the video packets - return; - } - - let spec = (new Uint8Array(arrayBuffer, dataOffset, dataSize))[0]; - - let frameType = (spec & 240) >>> 4; - let codecId = spec & 15; - - if (codecId !== 7) { - this._onError(DemuxErrors.CODEC_UNSUPPORTED, `Flv: Unsupported codec in video frame: ${codecId}`); - return; - } - - this._parseAVCVideoPacket(arrayBuffer, dataOffset + 1, dataSize - 1, tagTimestamp, tagPosition, frameType); - } - - _parseAVCVideoPacket(arrayBuffer, dataOffset, dataSize, tagTimestamp, tagPosition, frameType) { - if (dataSize < 4) { - Log.w(this.TAG, 'Flv: Invalid AVC packet, missing AVCPacketType or/and CompositionTime'); - return; - } - - let le = this._littleEndian; - let v = new DataView(arrayBuffer, dataOffset, dataSize); - - let packetType = v.getUint8(0); - let cts = v.getUint32(0, !le) & 0x00FFFFFF; - - if (packetType === 0) { // AVCDecoderConfigurationRecord - this._parseAVCDecoderConfigurationRecord(arrayBuffer, dataOffset + 4, dataSize - 4); - } else if (packetType === 1) { // One or more Nalus - this._parseAVCVideoData(arrayBuffer, dataOffset + 4, dataSize - 4, tagTimestamp, tagPosition, frameType, cts); - } else if (packetType === 2) { - // empty, AVC end of sequence - } else { - this._onError(DemuxErrors.FORMAT_ERROR, `Flv: Invalid video packet type ${packetType}`); - return; - } - } - - _parseAVCDecoderConfigurationRecord(arrayBuffer, dataOffset, dataSize) { - if (dataSize < 7) { - Log.w(this.TAG, 'Flv: Invalid AVCDecoderConfigurationRecord, lack of data!'); - return; - } - - let meta = this._videoMetadata; - let track = this._videoTrack; - let le = this._littleEndian; - let v = new DataView(arrayBuffer, dataOffset, dataSize); - - if (!meta) { - if (this._hasVideo === false && this._hasVideoFlagOverrided === false) { - this._hasVideo = true; - this._mediaInfo.hasVideo = true; - } - - meta = this._videoMetadata = {}; - meta.type = 'video'; - meta.id = track.id; - meta.timescale = this._timescale; - meta.duration = this._duration; - } else { - if (typeof meta.avcc !== 'undefined') { - Log.w(this.TAG, 'Found another AVCDecoderConfigurationRecord!'); - } - } - - let version = v.getUint8(0); // configurationVersion - let avcProfile = v.getUint8(1); // avcProfileIndication - let profileCompatibility = v.getUint8(2); // profile_compatibility - let avcLevel = v.getUint8(3); // AVCLevelIndication - - if (version !== 1 || avcProfile === 0) { - this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: Invalid AVCDecoderConfigurationRecord'); - return; - } - - this._naluLengthSize = (v.getUint8(4) & 3) + 1; // lengthSizeMinusOne - if (this._naluLengthSize !== 3 && this._naluLengthSize !== 4) { // holy shit!!! - this._onError(DemuxErrors.FORMAT_ERROR, `Flv: Strange NaluLengthSizeMinusOne: ${this._naluLengthSize - 1}`); - return; - } - - let spsCount = v.getUint8(5) & 31; // numOfSequenceParameterSets - if (spsCount === 0) { - this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: Invalid AVCDecoderConfigurationRecord: No SPS'); - return; - } else if (spsCount > 1) { - Log.w(this.TAG, `Flv: Strange AVCDecoderConfigurationRecord: SPS Count = ${spsCount}`); - } - - let offset = 6; - - for (let i = 0; i < spsCount; i++) { - let len = v.getUint16(offset, !le); // sequenceParameterSetLength - offset += 2; - - if (len === 0) { - continue; - } - - // Notice: Nalu without startcode header (00 00 00 01) - let sps = new Uint8Array(arrayBuffer, dataOffset + offset, len); - offset += len; - - let config = SPSParser.parseSPS(sps); - if (i !== 0) { - // ignore other sps's config - continue; - } - - meta.codecWidth = config.codec_size.width; - meta.codecHeight = config.codec_size.height; - meta.presentWidth = config.present_size.width; - meta.presentHeight = config.present_size.height; - - meta.profile = config.profile_string; - meta.level = config.level_string; - meta.bitDepth = config.bit_depth; - meta.chromaFormat = config.chroma_format; - meta.sarRatio = config.sar_ratio; - meta.frameRate = config.frame_rate; - - if (config.frame_rate.fixed === false || - config.frame_rate.fps_num === 0 || - config.frame_rate.fps_den === 0) { - meta.frameRate = this._referenceFrameRate; - } - - let fps_den = meta.frameRate.fps_den; - let fps_num = meta.frameRate.fps_num; - meta.refSampleDuration = meta.timescale * (fps_den / fps_num); - - let codecArray = sps.subarray(1, 4); - let codecString = 'avc1.'; - for (let j = 0; j < 3; j++) { - let h = codecArray[j].toString(16); - if (h.length < 2) { - h = '0' + h; - } - codecString += h; - } - meta.codec = codecString; - - let mi = this._mediaInfo; - mi.width = meta.codecWidth; - mi.height = meta.codecHeight; - mi.fps = meta.frameRate.fps; - mi.profile = meta.profile; - mi.level = meta.level; - mi.chromaFormat = config.chroma_format_string; - mi.sarNum = meta.sarRatio.width; - mi.sarDen = meta.sarRatio.height; - mi.videoCodec = codecString; - - if (mi.hasAudio) { - if (mi.audioCodec != null) { - mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + ',' + mi.audioCodec + '"'; - } - } else { - mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + '"'; - } - if (mi.isComplete()) { - this._onMediaInfo(mi); - } - } - - let ppsCount = v.getUint8(offset); // numOfPictureParameterSets - if (ppsCount === 0) { - this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: Invalid AVCDecoderConfigurationRecord: No PPS'); - return; - } else if (ppsCount > 1) { - Log.w(this.TAG, `Flv: Strange AVCDecoderConfigurationRecord: PPS Count = ${ppsCount}`); - } - - offset++; - - for (let i = 0; i < ppsCount; i++) { - let len = v.getUint16(offset, !le); // pictureParameterSetLength - offset += 2; - - if (len === 0) { - continue; - } - - // pps is useless for extracting video information - offset += len; - } - - meta.avcc = new Uint8Array(dataSize); - meta.avcc.set(new Uint8Array(arrayBuffer, dataOffset, dataSize), 0); - Log.v(this.TAG, 'Parsed AVCDecoderConfigurationRecord'); - - if (this._isInitialMetadataDispatched()) { - // flush parsed frames - if (this._dispatch && (this._audioTrack.length || this._videoTrack.length)) { - this._onDataAvailable(this._audioTrack, this._videoTrack); - } - } else { - this._videoInitialMetadataDispatched = true; - } - // notify new metadata - this._dispatch = false; - this._onTrackMetadata('video', meta); - } - - _parseAVCVideoData(arrayBuffer, dataOffset, dataSize, tagTimestamp, tagPosition, frameType, cts) { - let le = this._littleEndian; - let v = new DataView(arrayBuffer, dataOffset, dataSize); - - let units = [], length = 0; - - let offset = 0; - const lengthSize = this._naluLengthSize; - let dts = this._timestampBase + tagTimestamp; - let keyframe = (frameType === 1); // from FLV Frame Type constants - let refIdc = 1; // added by qli5 - - while (offset < dataSize) { - if (offset + 4 >= dataSize) { - Log.w(this.TAG, `Malformed Nalu near timestamp ${dts}, offset = ${offset}, dataSize = ${dataSize}`); - break; // data not enough for next Nalu - } - // Nalu with length-header (AVC1) - let naluSize = v.getUint32(offset, !le); // Big-Endian read - if (lengthSize === 3) { - naluSize >>>= 8; - } - if (naluSize > dataSize - lengthSize) { - Log.w(this.TAG, `Malformed Nalus near timestamp ${dts}, NaluSize > DataSize!`); - return; - } - - let unitType = v.getUint8(offset + lengthSize) & 0x1F; - // added by qli5 - refIdc = v.getUint8(offset + lengthSize) & 0x60; - - if (unitType === 5) { // IDR - keyframe = true; - } - - let data = new Uint8Array(arrayBuffer, dataOffset + offset, lengthSize + naluSize); - let unit = { type: unitType, data: data }; - units.push(unit); - length += data.byteLength; - - offset += lengthSize + naluSize; - } - - if (units.length) { - let track = this._videoTrack; - let avcSample = { - units: units, - length: length, - isKeyframe: keyframe, - refIdc: refIdc, - dts: dts, - cts: cts, - pts: (dts + cts) - }; - if (keyframe) { - avcSample.fileposition = tagPosition; - } - track.samples.push(avcSample); - track.length += length; - } - } - -} +/** + * "#xxxxxx" -> "xxxxxx" + * @param {string} colorStr + */ +const formatColor$1 = (colorStr) => { + colorStr = colorStr.toUpperCase(); + const m = colorStr.match(/^#?(\w{6})$/); + return m[1] +}; + +const buildHeader = ({ + title = "", + original = "", + fontFamily = "Arial", + bold = false, + textColor = "#FFFFFF", + bgColor = "#000000", + textOpacity = 1.0, + bgOpacity = 0.5, + fontsizeRatio = 0.4, + baseFontsize = 50, + playResX = 560, + playResY = 420, +}) => { + textColor = formatColor$1(textColor); + bgColor = formatColor$1(bgColor); + + const boldFlag = bold ? -1 : 0; + const fontSize = Math.round(fontsizeRatio * baseFontsize); + const textAlpha = formatOpacity(textOpacity); + const bgAlpha = formatOpacity(bgOpacity); + + return [ + "[Script Info]", + `Title: ${title}`, + `Original Script: ${original}`, + "ScriptType: v4.00+", + "Collisions: Normal", + `PlayResX: ${playResX}`, + `PlayResY: ${playResY}`, + "Timer: 100.0000", + "", + "[V4+ Styles]", + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding", + `Style: CC,${fontFamily},${fontSize},&H${textAlpha}${textColor},&H${textAlpha}${textColor},&H${textAlpha}000000,&H${bgAlpha}${bgColor},${boldFlag},0,0,0,100,100,0,0,1,2,0,2,20,20,2,0`, + "", + "[Events]", + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text", + ] +}; + +/** + * @param {number} time + */ +const formatTimestamp$1 = (time) => { + const value = Math.round(time * 100) * 10; + const rem = value % 3600000; + const hour = (value - rem) / 3600000; + const fHour = hour.toFixed(0).padStart(2, '0'); + const fRem = new Date(rem).toISOString().slice(-11, -2); + return fHour + fRem +}; + +/** + * @param {string} str + */ +const textEscape$1 = (str) => { + // VSFilter do not support escaped "{" or "}"; we use full-width version instead + return str.replace(/{/g, '{').replace(/}/g, '}').replace(/\s/g, ' ') +}; + +/** + * @param {import("./index").Dialogue} dialogue + */ +const buildLine = (dialogue) => { + const start = formatTimestamp$1(dialogue.from); + const end = formatTimestamp$1(dialogue.to); + const text = textEscape$1(dialogue.content); + return `Dialogue: 0,${start},${end},CC,,20,20,2,,${text}` +}; + +/** + * @param {import("./index").SubtitleData} subtitleData + * @param {string} languageDoc 字幕语言描述,例如 "英语(美国)" + */ +const buildAss = (subtitleData, languageDoc = "") => { + const pageTitle = top.document.title.replace(/_哔哩哔哩 \(゜-゜\)つロ 干杯~-bilibili$/, ""); + const title = `${pageTitle} ${languageDoc || ""}字幕`; + const url = top.location.href; + const original = `Generated by Xmader/bilitwin based on ${url}`; + + const header = buildHeader({ + title, + original, + fontsizeRatio: subtitleData.font_size, + textColor: subtitleData.font_color, + bgOpacity: subtitleData.background_alpha, + bgColor: subtitleData.background_color, + }); -/** - * Copyright (C) 2018 Xmader. - * @author Xmader - */ - -/** - * 计算adts头部 - * @see https://blog.jianchihu.net/flv-aac-add-adtsheader.html - * @typedef {Object} AdtsHeadersInit - * @property {number} audioObjectType - * @property {number} samplingFrequencyIndex - * @property {number} channelConfig - * @property {number} adtsLen - * @param {AdtsHeadersInit} init - */ -const getAdtsHeaders = (init) => { - const { audioObjectType, samplingFrequencyIndex, channelConfig, adtsLen } = init; - const headers = new Uint8Array(7); - - headers[0] = 0xff; // syncword:0xfff 高8bits - headers[1] = 0xf0; // syncword:0xfff 低4bits - headers[1] |= (0 << 3); // MPEG Version:0 for MPEG-4,1 for MPEG-2 1bit - headers[1] |= (0 << 1); // Layer:0 2bits - headers[1] |= 1; // protection absent:1 1bit - - headers[2] = (audioObjectType - 1) << 6; // profile:audio_object_type - 1 2bits - headers[2] |= (samplingFrequencyIndex & 0x0f) << 2; // sampling frequency index:sampling_frequency_index 4bits - headers[2] |= (0 << 1); // private bit:0 1bit - headers[2] |= (channelConfig & 0x04) >> 2; // channel configuration:channel_config 高1bit - - headers[3] = (channelConfig & 0x03) << 6; // channel configuration:channel_config 低2bits - headers[3] |= (0 << 5); // original:0 1bit - headers[3] |= (0 << 4); // home:0 1bit - headers[3] |= (0 << 3); // copyright id bit:0 1bit - headers[3] |= (0 << 2); // copyright id start:0 1bit - - headers[3] |= (adtsLen & 0x1800) >> 11; // frame length:value 高2bits - headers[4] = (adtsLen & 0x7f8) >> 3; // frame length:value 中间8bits - headers[5] = (adtsLen & 0x7) << 5; // frame length:value 低3bits - headers[5] |= 0x1f; // buffer fullness:0x7ff 高5bits - headers[6] = 0xfc; - - return headers + const lines = subtitleData.body.map(buildLine); + + return [ + ...header, + ...lines, + ].join('\r\n') }; -/** - * Copyright (C) 2018 Xmader. - * @author Xmader - */ - -/** - * Demux FLV into H264 + AAC stream into line stream then - * remux it into a AAC file. - * @param {Blob|Buffer|ArrayBuffer|string} flv - */ -const FLV2AAC = async (flv) => { - - // load flv as arraybuffer - /** @type {ArrayBuffer} */ - const flvArrayBuffer = await new Promise((r, j) => { - if ((typeof Blob != "undefined") && (flv instanceof Blob)) { - const reader = new FileReader(); - reader.onload = () => { - /** @type {ArrayBuffer} */ - // @ts-ignore - const result = reader.result; - r(result); - }; - reader.onerror = j; - reader.readAsArrayBuffer(flv); - } else if ((typeof Buffer != "undefined") && (flv instanceof Buffer)) { - r(new Uint8Array(flv).buffer); - } else if (flv instanceof ArrayBuffer) { - r(flv); - } else if (typeof flv == 'string') { - const req = new XMLHttpRequest(); - req.responseType = "arraybuffer"; - req.onload = () => r(req.response); - req.onerror = j; - req.open('get', flv); - req.send(); - } else { - j(new TypeError("@type {Blob|Buffer|ArrayBuffer} flv")); - } - }); - - const flvProbeData = FLVDemuxer.probe(flvArrayBuffer); - const flvDemuxer = new FLVDemuxer(flvProbeData); - - // 只解析音频 - flvDemuxer.overridedHasVideo = false; - - /** - * @typedef {Object} Sample - * @property {Uint8Array} unit - * @property {number} length - * @property {number} dts - * @property {number} pts - */ - - /** @type {{ type: "audio"; id: number; sequenceNumber: number; length: number; samples: Sample[]; }} */ - let aac = null; - let metadata = null; - - flvDemuxer.onTrackMetadata = (type, _metaData) => { - if (type == "audio") { - metadata = _metaData; - } - }; - - flvDemuxer.onMediaInfo = () => { }; - - flvDemuxer.onError = (e) => { - throw new Error(e) - }; - - flvDemuxer.onDataAvailable = (...args) => { - args.forEach(data => { - if (data.type == "audio") { - aac = data; - } - }); - }; - - const finalOffset = flvDemuxer.parseChunks(flvArrayBuffer, flvProbeData.dataOffset); - if (finalOffset != flvArrayBuffer.byteLength) { - throw new Error("FLVDemuxer: unexpected EOF") - } - - const { - audioObjectType, - samplingFrequencyIndex, - channelCount: channelConfig - } = metadata; - - /** @type {number[]} */ - let output = []; - - aac.samples.forEach((sample) => { - const headers = getAdtsHeaders({ - audioObjectType, - samplingFrequencyIndex, - channelConfig, - adtsLen: sample.length + 7 - }); - output.push(...headers, ...sample.unit); - }); - - return new Uint8Array(output) +// @ts-check + +/** + * 获取视频信息 + * @param {number} aid + * @param {number} cid + */ +const getVideoInfo = async (aid, cid) => { + const url = `https://api.bilibili.com/x/web-interface/view?aid=${aid}&cid=${cid}`; + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`${res.status} ${res.statusText}`) + } + + const json = await res.json(); + return json.data }; -/*** - * Copyright (C) 2018 Xmader. All Rights Reserved. - * - * @author Xmader - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. -*/ - -class WebWorker extends Worker { - constructor(stringUrl) { - super(stringUrl); - - this.importFnAsAScript(TwentyFourDataView); - this.importFnAsAScript(FLVTag); - this.importFnAsAScript(FLV); - } - - /** - * @param {string} method - * @param {*} data - */ - async getReturnValue(method, data) { - const callbackNum = window.crypto.getRandomValues(new Uint32Array(1))[0]; - - this.postMessage([ - method, - data, - callbackNum - ]); - - return await new Promise((resolve, reject) => { - this.addEventListener("message", (e) => { - const [_method, incomingData, _callbackNum] = e.data; - if (_callbackNum == callbackNum) { - if (_method == method) { - resolve(incomingData); - } else if (_method == "error") { - console.error(incomingData); - reject(new Error("Web Worker 内部错误")); - } - } - }); - }) - } - - async registerAllMethods() { - const methods = await this.getReturnValue("getAllMethods"); - - methods.forEach(method => { - Object.defineProperty(this, method, { - value: (arg) => this.getReturnValue(method, arg) - }); - }); - } - - /** - * @param {Function | ClassDecorator} c - */ - importFnAsAScript(c) { - const blob = new Blob([c.toString()], { type: 'application/javascript' }); - return this.getReturnValue("importScripts", URL.createObjectURL(blob)) - } - - /** - * @param {() => void} fn - */ - static fromAFunction(fn) { - const blob = new Blob(['(' + fn.toString() + ')()'], { type: 'application/javascript' }); - return new WebWorker(URL.createObjectURL(blob)) - } -} - -// 用于批量下载的 Web Worker , 请将函数中的内容想象成一个独立的js文件 -const BatchDownloadWorkerFn = () => { - - class BatchDownloadWorker { - async mergeFLVFiles(files) { - return await FLV.mergeBlobs(files); - } - - /** - * 引入脚本与库 - * @param {string[]} scripts - */ - importScripts(...scripts) { - importScripts(...scripts); - } - - getAllMethods() { - return Object.getOwnPropertyNames(BatchDownloadWorker.prototype).slice(1, -1) - } - } - - const worker = new BatchDownloadWorker(); - - onmessage = async (e) => { - const [method, incomingData, callbackNum] = e.data; - - try { - const returnValue = await worker[method](incomingData); - if (returnValue) { - postMessage([ - method, - returnValue, - callbackNum - ]); - } - } catch (e) { - postMessage([ - "error", - e.message, - callbackNum - ]); - throw e - } - }; +/** + * @typedef {Object} SubtitleInfo 字幕信息 + * @property {number} id + * @property {string} lan 字幕语言,例如 "en-US" + * @property {string} lan_doc 字幕语言描述,例如 "英语(美国)" + * @property {boolean} is_lock 是否字幕可以在视频上拖动 + * @property {string} subtitle_url 指向字幕数据 json 的 url + * @property {object} author 作者信息 + */ + +/** + * 获取字幕信息列表 + * @param {number} aid + * @param {number} cid + * @returns {Promise} + */ +const getSubtitleInfoList = async (aid, cid) => { + try { + const videoinfo = await getVideoInfo(aid, cid); + return videoinfo.subtitle.list + } catch (error) { + return [] + } }; -// @ts-check - -/** - * @param {number} alpha 0~255 - */ -const formatColorChannel$1 = (alpha) => { - return (alpha & 255).toString(16).toUpperCase().padStart(2, '0') -}; - -/** - * @param {number} opacity 0 ~ 1 -> alpha 0 ~ 255 - */ -const formatOpacity = (opacity) => { - const alpha = 0xFF * (100 - +opacity * 100) / 100; - return formatColorChannel$1(alpha) -}; - -/** - * "#xxxxxx" -> "xxxxxx" - * @param {string} colorStr - */ -const formatColor$1 = (colorStr) => { - colorStr = colorStr.toUpperCase(); - const m = colorStr.match(/^#?(\w{6})$/); - return m[1] -}; - -const buildHeader = ({ - title = "", - original = "", - fontFamily = "Arial", - bold = false, - textColor = "#FFFFFF", - bgColor = "#000000", - textOpacity = 1.0, - bgOpacity = 0.5, - fontsizeRatio = 0.4, - baseFontsize = 50, - playResX = 560, - playResY = 420, -}) => { - textColor = formatColor$1(textColor); - bgColor = formatColor$1(bgColor); - - const boldFlag = bold ? -1 : 0; - const fontSize = Math.round(fontsizeRatio * baseFontsize); - const textAlpha = formatOpacity(textOpacity); - const bgAlpha = formatOpacity(bgOpacity); - - return [ - "[Script Info]", - `Title: ${title}`, - `Original Script: ${original}`, - "ScriptType: v4.00+", - "Collisions: Normal", - `PlayResX: ${playResX}`, - `PlayResY: ${playResY}`, - "Timer: 100.0000", - "", - "[V4+ Styles]", - "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding", - `Style: CC,${fontFamily},${fontSize},&H${textAlpha}${textColor},&H${textAlpha}${textColor},&H${textAlpha}000000,&H${bgAlpha}${bgColor},${boldFlag},0,0,0,100,100,0,0,1,2,0,2,20,20,2,0`, - "", - "[Events]", - "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text", - ] -}; - -/** - * @param {number} time - */ -const formatTimestamp$1 = (time) => { - const value = Math.round(time * 100) * 10; - const rem = value % 3600000; - const hour = (value - rem) / 3600000; - const fHour = hour.toFixed(0).padStart(2, '0'); - const fRem = new Date(rem).toISOString().slice(-11, -2); - return fHour + fRem -}; - -/** - * @param {string} str - */ -const textEscape$1 = (str) => { - // VSFilter do not support escaped "{" or "}"; we use full-width version instead - return str.replace(/{/g, '{').replace(/}/g, '}').replace(/\s/g, ' ') -}; - -/** - * @param {import("./index").Dialogue} dialogue - */ -const buildLine = (dialogue) => { - const start = formatTimestamp$1(dialogue.from); - const end = formatTimestamp$1(dialogue.to); - const text = textEscape$1(dialogue.content); - return `Dialogue: 0,${start},${end},CC,,20,20,2,,${text}` -}; - -/** - * @param {import("./index").SubtitleData} subtitleData - * @param {string} languageDoc 字幕语言描述,例如 "英语(美国)" - */ -const buildAss = (subtitleData, languageDoc = "") => { - const pageTitle = top.document.title.replace(/_哔哩哔哩 \(゜-゜\)つロ 干杯~-bilibili$/, ""); - const title = `${pageTitle} ${languageDoc || ""}字幕`; - const url = top.location.href; - const original = `Generated by Xmader/bilitwin based on ${url}`; - - const header = buildHeader({ - title, - original, - fontsizeRatio: subtitleData.font_size, - textColor: subtitleData.font_color, - bgOpacity: subtitleData.background_alpha, - bgColor: subtitleData.background_color, - }); - - const lines = subtitleData.body.map(buildLine); - - return [ - ...header, - ...lines, - ].join('\r\n') +/** + * @typedef {Object} Dialogue + * @property {number} from 开始时间 + * @property {number} to 结束时间 + * @property {number} location 默认 2 + * @property {string} content 字幕内容 + */ + +/** + * @typedef {Object} SubtitleData 字幕数据 + * @property {number} font_size 默认 0.4 + * @property {string} font_color 默认 "#FFFFFF" + * @property {number} background_alpha 默认 0.5 + * @property {string} background_color 默认 "#9C27B0" + * @property {string} Stroke 默认 "none" + * @property {Dialogue[]} body + */ + +/** + * @param {string} subtitle_url 指向字幕数据 json 的 url + * @returns {Promise} + */ +const getSubtitleData = async (subtitle_url) => { + subtitle_url = subtitle_url.replace(/^http:/, "https:"); + + const res = await fetch(subtitle_url); + if (!res.ok) { + throw new Error(`${res.status} ${res.statusText}`) + } + + const data = await res.json(); + return data }; -// @ts-check - -/** - * 获取视频信息 - * @param {number} aid - * @param {number} cid - */ -const getVideoInfo = async (aid, cid) => { - const url = `https://api.bilibili.com/x/web-interface/view?aid=${aid}&cid=${cid}`; - - const res = await fetch(url); - if (!res.ok) { - throw new Error(`${res.status} ${res.statusText}`) - } - - const json = await res.json(); - return json.data -}; - -/** - * @typedef {Object} SubtitleInfo 字幕信息 - * @property {number} id - * @property {string} lan 字幕语言,例如 "en-US" - * @property {string} lan_doc 字幕语言描述,例如 "英语(美国)" - * @property {boolean} is_lock 是否字幕可以在视频上拖动 - * @property {string} subtitle_url 指向字幕数据 json 的 url - * @property {object} author 作者信息 - */ - -/** - * 获取字幕信息列表 - * @param {number} aid - * @param {number} cid - * @returns {Promise} - */ -const getSubtitleInfoList = async (aid, cid) => { - try { - const videoinfo = await getVideoInfo(aid, cid); - return videoinfo.subtitle.list - } catch (error) { - return [] - } -}; - -/** - * @typedef {Object} Dialogue - * @property {number} from 开始时间 - * @property {number} to 结束时间 - * @property {number} location 默认 2 - * @property {string} content 字幕内容 - */ - -/** - * @typedef {Object} SubtitleData 字幕数据 - * @property {number} font_size 默认 0.4 - * @property {string} font_color 默认 "#FFFFFF" - * @property {number} background_alpha 默认 0.5 - * @property {string} background_color 默认 "#9C27B0" - * @property {string} Stroke 默认 "none" - * @property {Dialogue[]} body - */ - -/** - * @param {string} subtitle_url 指向字幕数据 json 的 url - * @returns {Promise} - */ -const getSubtitleData = async (subtitle_url) => { - subtitle_url = subtitle_url.replace(/^http:/, "https:"); - - const res = await fetch(subtitle_url); - if (!res.ok) { - throw new Error(`${res.status} ${res.statusText}`) - } - - const data = await res.json(); - return data -}; - -/** - * @param {number} aid - * @param {number} cid - */ -const getSubtitles = async (aid, cid) => { - const list = await getSubtitleInfoList(aid, cid); - return await Promise.all( - list.map(async (info) => { - const subtitleData = await getSubtitleData(info.subtitle_url); - return { - language: info.lan, - language_doc: info.lan_doc, - url: info.subtitle_url, - data: subtitleData, - ass: buildAss(subtitleData, info.lan_doc), - } - }) - ) +/** + * @param {number} aid + * @param {number} cid + */ +const getSubtitles = async (aid, cid) => { + const list = await getSubtitleInfoList(aid, cid); + return await Promise.all( + list.map(async (info) => { + const subtitleData = await getSubtitleData(info.subtitle_url); + return { + language: info.lan, + language_doc: info.lan_doc, + url: info.subtitle_url, + data: subtitleData, + ass: buildAss(subtitleData, info.lan_doc), + } + }) + ) }; /*** @@ -11886,229 +11907,229 @@ class UI { } } -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. -*/ - -let debugOption = { debug: 1 }; - -class BiliTwin extends BiliUserJS { - static get debugOption() { - return debugOption; - } - - static set debugOption(option) { - debugOption = option; - } - - constructor(option = {}, ui) { - super(); - this.BiliMonkey = BiliMonkey; - this.BiliPolyfill = BiliPolyfill; - this.playerWin = null; - this.monkey = null; - this.polifill = null; - this.ui = ui || new UI(this); - this.option = option; - } - - async runCidSession() { - // 1. playerWin and option - try { - // you know what? it is a race, data race for jq! try not to yield to others! - this.playerWin = BiliUserJS.tryGetPlayerWinSync() || await BiliTwin.getPlayerWin(); - } - catch (e) { - if (e == 'Need H5 Player') UI.requestH5Player(); - throw e; - } - const href = location.href; - this.option = this.getOption(); - if (this.option.debug) { - if (top.console) top.console.clear(); - } - - // 2. monkey and polyfill - this.monkey = new BiliMonkey(this.playerWin, this.option); - this.polyfill = new BiliPolyfill(this.playerWin, this.option, t => UI.hintInfo(t, this.playerWin)); - - const cidRefresh = BiliTwin.getCidRefreshPromise(this.playerWin); - - /** - * @param {HTMLVideoElement} video - */ - const videoRightClick = (video) => { - let event = new MouseEvent('contextmenu', { - 'bubbles': true - }); - - video.dispatchEvent(event); - video.dispatchEvent(event); - }; - if (this.option.autoDisplayDownloadBtn) { - // 无需右键播放器就能显示下载按钮 - await new Promise(resolve => { - const observer = new MutationObserver(() => { - const el = this.playerWin.document.querySelector('.bilibili-player-dm-tip-wrap'); - if (el) { - const video = this.playerWin.document.querySelector("video"); - videoRightClick(video); - - observer.disconnect(); - resolve(); - } - }); - observer.observe(document, { childList: true, subtree: true }); - }); - } else { - const video = document.querySelector("video"); - if (video) { - video.addEventListener('play', () => videoRightClick(video), { once: true }); - } - } - - await this.polyfill.setFunctions(); - - // 3. async consistent => render UI - if (href == location.href) { - this.ui.option = this.option; - this.ui.cidSessionRender(); - - let videoA = this.ui.cidSessionDom.context_menu_videoA || this.ui.cidSessionDom.videoA; - if (videoA && videoA.onmouseover) videoA.onmouseover({ target: videoA.lastChild }); - } - else { - cidRefresh.resolve(); - } - - // 4. debug - if (this.option.debug) { - [(top.unsafeWindow || top).monkey, (top.unsafeWindow || top).polyfill] = [this.monkey, this.polyfill]; - } - - // 5. refresh => session expire - await cidRefresh; - this.monkey.destroy(); - this.polyfill.destroy(); - this.ui.cidSessionDestroy(); - } - - async mergeFLVFiles(files) { - return URL.createObjectURL(await FLV.mergeBlobs(files)); - } - - async clearCacheDB(cache) { - if (cache) return cache.deleteEntireDB(); - } - - resetOption(option = this.option) { - option.setStorage('BiliTwin', JSON.stringify({})); - return this.option = {}; - } - - getOption(playerWin = this.playerWin) { - let rawOption = null; - try { - rawOption = JSON.parse(playerWin.localStorage.getItem('BiliTwin')); - } - catch (e) { } - finally { - if (!rawOption) rawOption = {}; - rawOption.setStorage = (n, i) => playerWin.localStorage.setItem(n, i); - rawOption.getStorage = n => playerWin.localStorage.getItem(n); - return Object.assign( - {}, - BiliMonkey.optionDefaults, - BiliPolyfill.optionDefaults, - UI.optionDefaults, - rawOption, - BiliTwin.debugOption, - ); - } - } - - saveOption(option = this.option) { - return option.setStorage('BiliTwin', JSON.stringify(option)); - } - - async addUserScriptMenu() { - if (typeof GM !== 'object') return - if (typeof GM.registerMenuCommand !== 'function') return - - // see https://www.tampermonkey.net/documentation.php#GM_registerMenuCommand - await GM.registerMenuCommand('恢复默认设置并刷新', () => { - // 开启增强组件以后如不显示脚本,可以通过 Tampermonkey/Greasemonkey 的菜单重置设置 - this.resetOption() && top.location.reload(); - }); - } - - static async init() { - if (!document.body) return; - - if (location.hostname == "www.biligame.com") { - return BiliPolyfill.biligameInit(); - } - else if (location.pathname.startsWith("/bangumi/media/md")) { - return BiliPolyfill.showBangumiCoverImage(); - } - - BiliTwin.outdatedEngineClearance(); - BiliTwin.firefoxClearance(); - - const twin = new BiliTwin(); - twin.addUserScriptMenu(); - - if (location.hostname == "vc.bilibili.com") { - const vc_info = await BiliMonkey.getBiliShortVideoInfo(); - return twin.ui.appendShortVideoTitle(vc_info); - } - - while (1) { - await twin.runCidSession(); - } - } - - static outdatedEngineClearance() { - if (typeof Promise != 'function' || typeof MutationObserver != 'function') { - alert('这个浏览器实在太老了,脚本决定罢工。'); - throw 'BiliTwin: browser outdated: Promise or MutationObserver unsupported'; - } - } - - static firefoxClearance() { - if (navigator.userAgent.includes('Firefox')) { - BiliTwin.debugOption.proxy = false; - if (!window.navigator.temporaryStorage && !window.navigator.mozTemporaryStorage) window.navigator.temporaryStorage = { queryUsageAndQuota: func => func(-1048576, 10484711424) }; - } - } - - static xpcWrapperClearance() { - if (top.unsafeWindow) { - Object.defineProperty(window, 'cid', { - configurable: true, - get: () => String(unsafeWindow.cid) - }); - Object.defineProperty(window, 'player', { - configurable: true, - get: () => ({ destroy: unsafeWindow.player.destroy, reloadAccess: unsafeWindow.player.reloadAccess }) - }); - Object.defineProperty(window, 'jQuery', { - configurable: true, - get: () => unsafeWindow.jQuery, - }); - Object.defineProperty(window, 'fetch', { - configurable: true, - get: () => unsafeWindow.fetch.bind(unsafeWindow), - set: _fetch => unsafeWindow.fetch = _fetch.bind(unsafeWindow) - }); - } - } -} - +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +let debugOption = { debug: 1 }; + +class BiliTwin extends BiliUserJS { + static get debugOption() { + return debugOption; + } + + static set debugOption(option) { + debugOption = option; + } + + constructor(option = {}, ui) { + super(); + this.BiliMonkey = BiliMonkey; + this.BiliPolyfill = BiliPolyfill; + this.playerWin = null; + this.monkey = null; + this.polifill = null; + this.ui = ui || new UI(this); + this.option = option; + } + + async runCidSession() { + // 1. playerWin and option + try { + // you know what? it is a race, data race for jq! try not to yield to others! + this.playerWin = BiliUserJS.tryGetPlayerWinSync() || await BiliTwin.getPlayerWin(); + } + catch (e) { + if (e == 'Need H5 Player') UI.requestH5Player(); + throw e; + } + const href = location.href; + this.option = this.getOption(); + if (this.option.debug) { + if (top.console) top.console.clear(); + } + + // 2. monkey and polyfill + this.monkey = new BiliMonkey(this.playerWin, this.option); + this.polyfill = new BiliPolyfill(this.playerWin, this.option, t => UI.hintInfo(t, this.playerWin)); + + const cidRefresh = BiliTwin.getCidRefreshPromise(this.playerWin); + + /** + * @param {HTMLVideoElement} video + */ + const videoRightClick = (video) => { + let event = new MouseEvent('contextmenu', { + 'bubbles': true + }); + + video.dispatchEvent(event); + video.dispatchEvent(event); + }; + if (this.option.autoDisplayDownloadBtn) { + // 无需右键播放器就能显示下载按钮 + await new Promise(resolve => { + const observer = new MutationObserver(() => { + const el = this.playerWin.document.querySelector('.bilibili-player-dm-tip-wrap'); + if (el) { + const video = this.playerWin.document.querySelector("video"); + videoRightClick(video); + + observer.disconnect(); + resolve(); + } + }); + observer.observe(document, { childList: true, subtree: true }); + }); + } else { + const video = document.querySelector("video"); + if (video) { + video.addEventListener('play', () => videoRightClick(video), { once: true }); + } + } + + await this.polyfill.setFunctions(); + + // 3. async consistent => render UI + if (href == location.href) { + this.ui.option = this.option; + this.ui.cidSessionRender(); + + let videoA = this.ui.cidSessionDom.context_menu_videoA || this.ui.cidSessionDom.videoA; + if (videoA && videoA.onmouseover) videoA.onmouseover({ target: videoA.lastChild }); + } + else { + cidRefresh.resolve(); + } + + // 4. debug + if (this.option.debug) { + [(top.unsafeWindow || top).monkey, (top.unsafeWindow || top).polyfill] = [this.monkey, this.polyfill]; + } + + // 5. refresh => session expire + await cidRefresh; + this.monkey.destroy(); + this.polyfill.destroy(); + this.ui.cidSessionDestroy(); + } + + async mergeFLVFiles(files) { + return URL.createObjectURL(await FLV.mergeBlobs(files)); + } + + async clearCacheDB(cache) { + if (cache) return cache.deleteEntireDB(); + } + + resetOption(option = this.option) { + option.setStorage('BiliTwin', JSON.stringify({})); + return this.option = {}; + } + + getOption(playerWin = this.playerWin) { + let rawOption = null; + try { + rawOption = JSON.parse(playerWin.localStorage.getItem('BiliTwin')); + } + catch (e) { } + finally { + if (!rawOption) rawOption = {}; + rawOption.setStorage = (n, i) => playerWin.localStorage.setItem(n, i); + rawOption.getStorage = n => playerWin.localStorage.getItem(n); + return Object.assign( + {}, + BiliMonkey.optionDefaults, + BiliPolyfill.optionDefaults, + UI.optionDefaults, + rawOption, + BiliTwin.debugOption, + ); + } + } + + saveOption(option = this.option) { + return option.setStorage('BiliTwin', JSON.stringify(option)); + } + + async addUserScriptMenu() { + if (typeof GM !== 'object') return + if (typeof GM.registerMenuCommand !== 'function') return + + // see https://www.tampermonkey.net/documentation.php#GM_registerMenuCommand + await GM.registerMenuCommand('恢复默认设置并刷新', () => { + // 开启增强组件以后如不显示脚本,可以通过 Tampermonkey/Greasemonkey 的菜单重置设置 + this.resetOption() && top.location.reload(); + }); + } + + static async init() { + if (!document.body) return; + + if (location.hostname == "www.biligame.com") { + return BiliPolyfill.biligameInit(); + } + else if (location.pathname.startsWith("/bangumi/media/md")) { + return BiliPolyfill.showBangumiCoverImage(); + } + + BiliTwin.outdatedEngineClearance(); + BiliTwin.firefoxClearance(); + + const twin = new BiliTwin(); + twin.addUserScriptMenu(); + + if (location.hostname == "vc.bilibili.com") { + const vc_info = await BiliMonkey.getBiliShortVideoInfo(); + return twin.ui.appendShortVideoTitle(vc_info); + } + + while (1) { + await twin.runCidSession(); + } + } + + static outdatedEngineClearance() { + if (typeof Promise != 'function' || typeof MutationObserver != 'function') { + alert('这个浏览器实在太老了,脚本决定罢工。'); + throw 'BiliTwin: browser outdated: Promise or MutationObserver unsupported'; + } + } + + static firefoxClearance() { + if (navigator.userAgent.includes('Firefox')) { + BiliTwin.debugOption.proxy = false; + if (!window.navigator.temporaryStorage && !window.navigator.mozTemporaryStorage) window.navigator.temporaryStorage = { queryUsageAndQuota: func => func(-1048576, 10484711424) }; + } + } + + static xpcWrapperClearance() { + if (top.unsafeWindow) { + Object.defineProperty(window, 'cid', { + configurable: true, + get: () => String(unsafeWindow.cid) + }); + Object.defineProperty(window, 'player', { + configurable: true, + get: () => ({ destroy: unsafeWindow.player.destroy, reloadAccess: unsafeWindow.player.reloadAccess }) + }); + Object.defineProperty(window, 'jQuery', { + configurable: true, + get: () => unsafeWindow.jQuery, + }); + Object.defineProperty(window, 'fetch', { + configurable: true, + get: () => unsafeWindow.fetch.bind(unsafeWindow), + set: _fetch => unsafeWindow.fetch = _fetch.bind(unsafeWindow) + }); + } + } +} + BiliTwin.domContentLoadedThen(BiliTwin.init); diff --git a/biliTwinBabelCompiled.user.js b/biliTwinBabelCompiled.user.js index 07acf58..7bb70ca 100644 --- a/biliTwinBabelCompiled.user.js +++ b/biliTwinBabelCompiled.user.js @@ -81,69 +81,69 @@ var window = typeof unsafeWindow !== "undefined" && unsafeWindow || self var top = window.top // workaround - - -if (document.readyState == 'loading') { - var h = function () { - load(); - document.removeEventListener('DOMContentLoaded', h); - }; - document.addEventListener('DOMContentLoaded', h); -} -else { - load(); -} - -function load() { - if (typeof TextEncoder === 'undefined') { - top.TextEncoder = function () { - this.encoding = 'utf-8'; - this.encode = function (str) { - var binstr = unescape(encodeURIComponent(str)), - arr = new Uint8Array(binstr.length); - binstr.split('').forEach(function (char, i) { - arr[i] = char.charCodeAt(0); - }); - return arr; - }; - } - } - - if (typeof TextDecoder === 'undefined') { - top.TextDecoder = function () { - this.encoding = 'utf-8'; - this.decode = function (input) { - if (input instanceof ArrayBuffer) { - input = new Uint8Array(input); - } else { - input = new Uint8Array(input.buffer); - } - - var l = Array.prototype.map.call(input, function (x) { return String.fromCharCode(x) }).join(""); - return decodeURIComponent(escape(l)); - }; - } - } - - if (typeof _babelPolyfill === 'undefined') { - new Promise(function (resolve) { - var req = new XMLHttpRequest(); - req.onload = function () { resolve(req.responseText); }; - req.open('get', 'https://cdn.staticfile.org/babel-polyfill/7.7.0/polyfill.min.js'); - req.send(); - }).then(function (script) { - top.eval(script); - _babelPolyfill = false; - }).then(function () { - script(); - }); - } - else { - script(); - } -} - -function script() { + + +if (document.readyState == 'loading') { + var h = function () { + load(); + document.removeEventListener('DOMContentLoaded', h); + }; + document.addEventListener('DOMContentLoaded', h); +} +else { + load(); +} + +function load() { + if (typeof TextEncoder === 'undefined') { + top.TextEncoder = function () { + this.encoding = 'utf-8'; + this.encode = function (str) { + var binstr = unescape(encodeURIComponent(str)), + arr = new Uint8Array(binstr.length); + binstr.split('').forEach(function (char, i) { + arr[i] = char.charCodeAt(0); + }); + return arr; + }; + } + } + + if (typeof TextDecoder === 'undefined') { + top.TextDecoder = function () { + this.encoding = 'utf-8'; + this.decode = function (input) { + if (input instanceof ArrayBuffer) { + input = new Uint8Array(input); + } else { + input = new Uint8Array(input.buffer); + } + + var l = Array.prototype.map.call(input, function (x) { return String.fromCharCode(x) }).join(""); + return decodeURIComponent(escape(l)); + }; + } + } + + if (typeof _babelPolyfill === 'undefined') { + new Promise(function (resolve) { + var req = new XMLHttpRequest(); + req.onload = function () { resolve(req.responseText); }; + req.open('get', 'https://cdn.staticfile.org/babel-polyfill/7.7.0/polyfill.min.js'); + req.send(); + }).then(function (script) { + top.eval(script); + _babelPolyfill = false; + }).then(function () { + script(); + }); + } + else { + script(); + } +} + +function script() { 'use strict'; var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; @@ -273,26 +273,26 @@ function _classCallCheck(instance, Constructor) { if (!(instance instanceof Cons var window = typeof unsafeWindow !== "undefined" && unsafeWindow || self; var top = window.top; // workaround -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** - * Basically a Promise that exposes its resolve and reject callbacks +/** + * Basically a Promise that exposes its resolve and reject callbacks */ var AsyncContainer = function () { - /*** - * The thing is, if we cannot cancel a promise, we should at least be able to - * explicitly mark a promise as garbage collectible. - * - * Yes, this is something like cancelable Promise. But I insist they are different. + /*** + * The thing is, if we cannot cancel a promise, we should at least be able to + * explicitly mark a promise as garbage collectible. + * + * Yes, this is something like cancelable Promise. But I insist they are different. */ function AsyncContainer(callback) { var _this = this; @@ -341,36 +341,36 @@ var AsyncContainer = function () { if (typeof callback == 'function') callback(this.resolve, this.reject); } - /*** - * Memory leak notice: - * - * The V8 implementation of Promise requires - * 1. the resolve handler of a Promise - * 2. the reject handler of a Promise - * 3. !! the Promise object itself !! - * to be garbage collectible to correctly free Promise runtime contextes - * - * This piece of code will work - * void (async () => { - * const buf = new Uint8Array(1024 * 1024 * 1024); - * for (let i = 0; i < buf.length; i++) buf[i] = i; - * await new Promise(() => { }); - * return buf; - * })(); - * if (typeof gc == 'function') gc(); - * - * This piece of code will cause a Promise context mem leak - * const deadPromise = new Promise(() => { }); - * void (async () => { - * const buf = new Uint8Array(1024 * 1024 * 1024); - * for (let i = 0; i < buf.length; i++) buf[i] = i; - * await deadPromise; - * return buf; - * })(); - * if (typeof gc == 'function') gc(); - * - * In other words, do NOT directly inherit from promise. You will need to - * dereference it on destroying. + /*** + * Memory leak notice: + * + * The V8 implementation of Promise requires + * 1. the resolve handler of a Promise + * 2. the reject handler of a Promise + * 3. !! the Promise object itself !! + * to be garbage collectible to correctly free Promise runtime contextes + * + * This piece of code will work + * void (async () => { + * const buf = new Uint8Array(1024 * 1024 * 1024); + * for (let i = 0; i < buf.length; i++) buf[i] = i; + * await new Promise(() => { }); + * return buf; + * })(); + * if (typeof gc == 'function') gc(); + * + * This piece of code will cause a Promise context mem leak + * const deadPromise = new Promise(() => { }); + * void (async () => { + * const buf = new Uint8Array(1024 * 1024 * 1024); + * for (let i = 0; i < buf.length; i++) buf[i] = i; + * await deadPromise; + * return buf; + * })(); + * if (typeof gc == 'function') gc(); + * + * In other words, do NOT directly inherit from promise. You will need to + * dereference it on destroying. */ @@ -390,11 +390,11 @@ var AsyncContainer = function () { this.destroiedThen = function (f) { return f(); }; - /*** - * For ease of debug, do not dereference hangReturn - * - * If run from console, mysteriously this tiny symbol will help correct gc - * before a console.clear + /*** + * For ease of debug, do not dereference hangReturn + * + * If run from console, mysteriously this tiny symbol will help correct gc + * before a console.clear */ //this.hangReturn = null; } @@ -451,18 +451,18 @@ var AsyncContainer = function () { return AsyncContainer; }(); -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** - * Provides common util for all bilibili user scripts +/** + * Provides common util for all bilibili user scripts */ @@ -563,15 +563,15 @@ var BiliUserJS = function () { }, { key: 'getCidRefreshPromise', value: function getCidRefreshPromise(playerWin) { - /*********** - * !!!Race condition!!! - * We must finish everything within one microtask queue! - * - * bilibili script: - * videoElement.remove() -> setTimeout(0) -> [[microtask]] -> load playurl - * \- synchronous macrotask -/ || \- synchronous - * || - * the only position to inject monkey.sniffDefaultFormat + /*********** + * !!!Race condition!!! + * We must finish everything within one microtask queue! + * + * bilibili script: + * videoElement.remove() -> setTimeout(0) -> [[microtask]] -> load playurl + * \- synchronous macrotask -/ || \- synchronous + * || + * the only position to inject monkey.sniffDefaultFormat */ var cidRefresh = new AsyncContainer(); @@ -657,18 +657,18 @@ var setTimeoutDo = function setTimeoutDo(promise, ms) { return Promise.race([promise, t]); }; -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** - * A promisified indexedDB with large file(>100MB) support +/** + * A promisified indexedDB with large file(>100MB) support */ var CacheDB = function () { @@ -1658,28 +1658,28 @@ var Mutex = function () { return Mutex; }(); -/** - * @typedef DanmakuColor - * @property {number} r - * @property {number} g - * @property {number} b +/** + * @typedef DanmakuColor + * @property {number} r + * @property {number} g + * @property {number} b */ -/** - * @typedef Danmaku - * @property {string} text - * @property {number} time - * @property {string} mode - * @property {number} size - * @property {DanmakuColor} color - * @property {boolean} bottom - * @property {string=} sender +/** + * @typedef Danmaku + * @property {string} text + * @property {number} time + * @property {string} mode + * @property {number} size + * @property {DanmakuColor} color + * @property {boolean} bottom + * @property {string=} sender */ var parser = {}; -/** - * @param {Danmaku} danmaku - * @returns {boolean} +/** + * @param {Danmaku} danmaku + * @returns {boolean} */ var danmakuFilter = function danmakuFilter(danmaku) { if (!danmaku) return false; @@ -1732,9 +1732,9 @@ var parseNiconicoSize = function parseNiconicoSize(mail) { return 25; }; -/** - * @param {string|ArrayBuffer} content - * @return {{ cid: number, danmaku: Array }} +/** + * @param {string|ArrayBuffer} content + * @return {{ cid: number, danmaku: Array }} */ parser.bilibili = function (content) { var text = typeof content === 'string' ? content : new TextDecoder('utf-8').decode(content); @@ -1770,9 +1770,9 @@ parser.bilibili = function (content) { return { cid: cid, danmaku: danmaku }; }; -/** - * @param {string|ArrayBuffer} content - * @return {{ cid: number, danmaku: Array }} +/** + * @param {string|ArrayBuffer} content + * @return {{ cid: number, danmaku: Array }} */ parser.acfun = function (content) { var text = typeof content === 'string' ? content : new TextDecoder('utf-8').decode(content); @@ -1805,9 +1805,9 @@ parser.acfun = function (content) { return { danmaku: danmaku }; }; -/** - * @param {string|ArrayBuffer} content - * @return {{ cid: number, danmaku: Array }} +/** + * @param {string|ArrayBuffer} content + * @return {{ cid: number, danmaku: Array }} */ parser.niconico = function (content) { var text = typeof content === 'string' ? content : new TextDecoder('utf-8').decode(content); @@ -2408,38 +2408,38 @@ var convertToBlob = function convertToBlob(content) { return blob; }; -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** - * An API wrapper of tiansh/ass-danmaku for liqi0816/bilitwin +/** + * An API wrapper of tiansh/ass-danmaku for liqi0816/bilitwin */ var ASSConverter = function () { - /** - * @typedef {ExtOption} - * @property {number} resolutionX canvas width for drawing danmaku (px) - * @property {number} resolutionY canvas height for drawing danmaku (px) - * @property {number} bottomReserved reserved height at bottom for drawing danmaku (px) - * @property {string} fontFamily danmaku font family - * @property {number} fontSize danmaku font size (ratio) - * @property {number} textSpace space between danmaku (px) - * @property {number} rtlDuration duration of right to left moving danmaku appeared on screen (s) - * @property {number} fixDuration duration of keep bottom / top danmaku appeared on screen (s) - * @property {number} maxDelay // maxinum amount of allowed delay (s) - * @property {number} textOpacity // opacity of text, in range of [0, 1] - * @property {number} maxOverlap // maxinum layers of danmaku + /** + * @typedef {ExtOption} + * @property {number} resolutionX canvas width for drawing danmaku (px) + * @property {number} resolutionY canvas height for drawing danmaku (px) + * @property {number} bottomReserved reserved height at bottom for drawing danmaku (px) + * @property {string} fontFamily danmaku font family + * @property {number} fontSize danmaku font size (ratio) + * @property {number} textSpace space between danmaku (px) + * @property {number} rtlDuration duration of right to left moving danmaku appeared on screen (s) + * @property {number} fixDuration duration of keep bottom / top danmaku appeared on screen (s) + * @property {number} maxDelay // maxinum amount of allowed delay (s) + * @property {number} textOpacity // opacity of text, in range of [0, 1] + * @property {number} maxOverlap // maxinum layers of danmaku */ - /** - * @param {ExtOption} option tiansh/ass-danmaku compatible option + /** + * @param {ExtOption} option tiansh/ass-danmaku compatible option */ function ASSConverter() { var option = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -2453,10 +2453,10 @@ var ASSConverter = function () { key: 'genASS', - /** - * @param {Danmaku[]} danmaku use ASSConverter.parseXML - * @param {string} title - * @param {string} originalURL + /** + * @param {Danmaku[]} danmaku use ASSConverter.parseXML + * @param {string} title + * @param {string} originalURL */ value: function () { var _ref38 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee25(danmaku) { @@ -2529,27 +2529,27 @@ var ASSConverter = function () { return genASSBlob; }() - /** - * @typedef DanmakuColor - * @property {number} r - * @property {number} g - * @property {number} b + /** + * @typedef DanmakuColor + * @property {number} r + * @property {number} g + * @property {number} b */ - /** - * @typedef Danmaku - * @property {string} text - * @property {number} time - * @property {string} mode - * @property {number} size - * @property {DanmakuColor} color - * @property {boolean} bottom - * @property {string=} sender + /** + * @typedef Danmaku + * @property {string} text + * @property {number} time + * @property {string} mode + * @property {number} size + * @property {DanmakuColor} color + * @property {boolean} bottom + * @property {string=} sender */ - /** - * @param {string} xml bilibili danmaku xml - * @returns {Danmaku[]} + /** + * @param {string} xml bilibili danmaku xml + * @returns {Danmaku[]} */ }, { @@ -2810,32 +2810,32 @@ var HookedFunction = function (_Function) { return HookedFunction; }(Function); -/*** - * BiliMonkey - * A bilibili user script - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * The FLV merge utility is a Javascript translation of - * https://github.com/grepmusic/flvmerge - * by grepmusic - * - * The ASS convert utility is a fork of - * https://github.com/tiansh/ass-danmaku - * by tiansh - * - * The FLV demuxer is from - * https://github.com/Bilibili/flv.js/ - * by zheng qian - * - * The EMBL builder is from - * - * by ryiwamoto +/*** + * BiliMonkey + * A bilibili user script + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * The FLV merge utility is a Javascript translation of + * https://github.com/grepmusic/flvmerge + * by grepmusic + * + * The ASS convert utility is a fork of + * https://github.com/tiansh/ass-danmaku + * by tiansh + * + * The FLV demuxer is from + * https://github.com/Bilibili/flv.js/ + * by zheng qian + * + * The EMBL builder is from + * + * by ryiwamoto */ var BiliMonkey = function () { @@ -2861,12 +2861,12 @@ var BiliMonkey = function () { }); if (typeof top.cid === 'string') this.cidAsyncContainer.resolve(top.cid); - /*** - * cache + proxy = Service Worker - * Hope bilibili will have a SW as soon as possible. - * partial = Stream - * Hope the fetch API will be stabilized as soon as possible. - * If you are using your grandpa's browser, do not enable these functions. + /*** + * cache + proxy = Service Worker + * Hope bilibili will have a SW as soon as possible. + * partial = Stream + * Hope the fetch API will be stabilized as soon as possible. + * If you are using your grandpa's browser, do not enable these functions. */ this.cache = option.cache; this.partial = option.partial; @@ -2885,9 +2885,9 @@ var BiliMonkey = function () { this.destroy = new HookedFunction(); } - /*** - * Guide: for ease of debug, please use format name(flv720) instead of format value(64) unless necessary - * Guide: for ease of html concat, please use string format value('64') instead of number(parseInt('64')) + /*** + * Guide: for ease of debug, please use format name(flv720) instead of format value(64) unless necessary + * Guide: for ease of html concat, please use string format value('64') instead of number(parseInt('64')) */ @@ -3102,14 +3102,14 @@ var BiliMonkey = function () { switch (_context30.prev = _context30.next) { case 0: return _context30.abrupt('return', this.queryInfoMutex.lockAndAwait(_asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee29() { - var isBangumi, apiPath, qn, api_url, re, apiJson, data, durls, _zc, _data_X, flvs, video_format; + var isBangumi, apiPath, qn, api_url, dash_api_url, dash_re, dash_api_json, dash_data, dash_urls, _video_format, re, apiJson, data, durls, _zc, _data_X, flvs, video_format; return regeneratorRuntime.wrap(function _callee29$(_context29) { while (1) { switch (_context29.prev = _context29.next) { case 0: _context29.t0 = format; - _context29.next = _context29.t0 === 'video' ? 3 : _context29.t0 === 'ass' ? 23 : 28; + _context29.next = _context29.t0 === 'video' ? 3 : _context29.t0 === 'ass' ? 38 : 43; break; case 3: @@ -3125,15 +3125,52 @@ var BiliMonkey = function () { apiPath = isBangumi ? "/pgc/player/web/playurl" : "/x/player/playurl"; qn = _this16.option.enableVideoMaxResolution && _this16.option.videoMaxResolution || "120"; api_url = 'https://api.bilibili.com' + apiPath + '?avid=' + aid + '&cid=' + cid + '&otype=json&fourk=1&qn=' + qn; - _context29.next = 11; + + // requir to enableVideoMaxResolution and set videoMaxResolution to 125 (HDR) + + if (!(qn == 125)) { + _context29.next = 24; + break; + } + + // check if video supports hdr + dash_api_url = api_url + "&fnver=0&fnval=80"; + _context29.next = 13; + return fetch(dash_api_url, { credentials: 'include' }); + + case 13: + dash_re = _context29.sent; + _context29.next = 16; + return dash_re.json(); + + case 16: + dash_api_json = _context29.sent; + dash_data = dash_api_json.data || dash_api_json.result; + + if (!(dash_data && dash_data.quality == 125)) { + _context29.next = 24; + break; + } + + // using dash urls for hdr video only + dash_urls = [dash_data.dash.video[0].base_url, dash_data.dash.audio[0].base_url]; + + _this16.flvs = dash_urls; + _video_format = dash_data.format && (dash_data.format.match(/mp4|flv/) || [])[0]; + + _this16.video_format = _video_format; + return _context29.abrupt('return', _video_format); + + case 24: + _context29.next = 26; return fetch(api_url, { credentials: 'include' }); - case 11: + case 26: re = _context29.sent; - _context29.next = 14; + _context29.next = 29; return re.json(); - case 14: + case 29: apiJson = _context29.sent; data = apiJson.data || apiJson.result; // console.log(data) @@ -3175,21 +3212,21 @@ var BiliMonkey = function () { return _context29.abrupt('return', video_format); - case 23: + case 38: if (!_this16.ass) { - _context29.next = 27; + _context29.next = 42; break; } return _context29.abrupt('return', _this16.ass); - case 27: + case 42: return _context29.abrupt('return', _this16.getASS(_this16.flvFormatName)); - case 28: + case 43: throw 'Bilimonkey: What is format ' + format + '?'; - case 29: + case 44: case 'end': return _context29.stop(); } @@ -4046,10 +4083,10 @@ var BiliMonkey = function () { danmuku: danmuku, name: x.part || x.index || playerWin.document.title.replace("_哔哩哔哩 (゜-゜)つロ 干杯~-bilibili", ""), outputName: res.durl[0].url.match(/\d+-\d+(?:\d|-|hd)*(?=\.flv)/) ? - /*** - * see #28 - * Firefox lookbehind assertion not implemented https://bugzilla.mozilla.org/show_bug.cgi?id=1225665 - * try replace /-\d+(?=(?:\d|-|hd)*\.flv)/ => /(?<=\d+)-\d+(?=(?:\d|-|hd)*\.flv)/ in the future + /*** + * see #28 + * Firefox lookbehind assertion not implemented https://bugzilla.mozilla.org/show_bug.cgi?id=1225665 + * try replace /-\d+(?=(?:\d|-|hd)*\.flv)/ => /(?<=\d+)-\d+(?=(?:\d|-|hd)*\.flv)/ in the future */ res.durl[0].url.match(/\d+-\d+(?:\d|-|hd)*(?=\.flv)/)[0].replace(/-\d+(?=(?:\d|-|hd)*\.flv)/, '') : res.durl[0].url.match(/\d(?:\d|-|hd)*(?=\.mp4)/) ? res.durl[0].url.match(/\d(?:\d|-|hd)*(?=\.mp4)/)[0] : cid, cid: cid, @@ -4212,7 +4249,7 @@ var BiliMonkey = function () { }, { key: 'resolutionPreferenceOptions', get: function get() { - return [['超清 4K (大会员)', '120'], ['高清 1080P60 (大会员)', '116'], ['高清 1080P+ (大会员)', '112'], ['高清 720P60 (大会员)', '74'], ['高清 1080P', '80'], ['高清 720P', '64'], ['清晰 480P', '32'], ['流畅 360P', '16']]; + return [['HDR 真彩 (大会员)', '125'], ['超清 4K (大会员)', '120'], ['高清 1080P60 (大会员)', '116'], ['高清 1080P+ (大会员)', '112'], ['高清 720P60 (大会员)', '74'], ['高清 1080P', '80'], ['高清 720P', '64'], ['清晰 480P', '32'], ['流畅 360P', '16']]; } }, { key: 'optionDefaults', @@ -5715,14 +5752,14 @@ var BiliPolyfill = function () { return BiliPolyfill; }(); -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ var Exporter = function () { @@ -6436,14 +6473,14 @@ var FLV = function () { var embeddedHTML = '\n\n\n

\n \u52A0\u8F7D\u6587\u4EF6\u2026\u2026 loading files...\n \n

\n

\n \u6784\u5EFAmkv\u2026\u2026 building mkv...\n \n

\n

\n merged.mkv\n

\n
\n author qli5 <goodlq11[at](163|gmail).com>\n
\n \n \n\n\n\n'; -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ var MKVTransmuxer = function () { @@ -6454,13 +6491,13 @@ var MKVTransmuxer = function () { this.option = option; } - /** - * FLV + ASS => MKV entry point - * @param {Blob|string|ArrayBuffer} flv - * @param {Blob|string|ArrayBuffer} ass - * @param {string=} name - * @param {Node} target - * @param {{ name: string; file: (Blob|string|ArrayBuffer); }[]=} subtitleAssList + /** + * FLV + ASS => MKV entry point + * @param {Blob|string|ArrayBuffer} flv + * @param {Blob|string|ArrayBuffer} ass + * @param {string=} name + * @param {Node} target + * @param {{ name: string; file: (Blob|string|ArrayBuffer); }[]=} subtitleAssList */ @@ -6496,14 +6533,14 @@ var MKVTransmuxer = function () { return MKVTransmuxer; }(); -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ var _navigator = (typeof navigator === 'undefined' ? 'undefined' : _typeof(navigator)) === 'object' && navigator || { userAgent: 'chrome' }; @@ -6520,9 +6557,9 @@ var _TextDecoder = typeof TextDecoder === 'function' && TextDecoder || function _createClass(_class, [{ key: 'decode', - /** - * @param {ArrayBuffer} chunk - * @returns {string} + /** + * @param {ArrayBuffer} chunk + * @returns {string} */ value: function decode(chunk) { return this.end(Buffer.from(chunk)); @@ -6532,24 +6569,24 @@ var _TextDecoder = typeof TextDecoder === 'function' && TextDecoder || function return _class; }(require('string_decoder').StringDecoder); -/*** - * The FLV demuxer is from flv.js - * - * Copyright (C) 2016 Bilibili. All Rights Reserved. - * - * @author zheng qian - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. +/*** + * The FLV demuxer is from flv.js + * + * Copyright (C) 2016 Bilibili. All Rights Reserved. + * + * @author zheng qian + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ // import FLVDemuxer from 'flv.js/src/demux/flv-demuxer.js'; @@ -7410,14 +7447,14 @@ function ReadBig32(array, index) { var FLVDemuxer = function () { - /** - * Create a new FLV demuxer - * @param {Object} probeData - * @param {boolean} probeData.match - * @param {number} probeData.consumed - * @param {number} probeData.dataOffset - * @param {boolean} probeData.hasAudioTrack - * @param {boolean} probeData.hasVideoTrack + /** + * Create a new FLV demuxer + * @param {Object} probeData + * @param {boolean} probeData.match + * @param {number} probeData.consumed + * @param {number} probeData.dataOffset + * @param {boolean} probeData.hasAudioTrack + * @param {boolean} probeData.hasVideoTrack */ function FLVDemuxer(probeData) { _classCallCheck(this, FLVDemuxer); @@ -7499,10 +7536,10 @@ var FLVDemuxer = function () { this._onDataAvailable = null; } - /** - * Probe the flv data - * @param {ArrayBuffer} buffer - * @returns {Object} - probeData to be feed into constructor + /** + * Probe the flv data + * @param {ArrayBuffer} buffer + * @returns {Object} - probeData to be feed into constructor */ }, { @@ -7942,14 +7979,14 @@ var FLVDemuxer = function () { var array = new Uint8Array(arrayBuffer, dataOffset, dataSize); var config = null; - /* Audio Object Type: - 0: Null - 1: AAC Main - 2: AAC LC - 3: AAC SSR (Scalable Sample Rate) - 4: AAC LTP (Long Term Prediction) - 5: HE-AAC / SBR (Spectral Band Replication) - 6: AAC Scalable + /* Audio Object Type: + 0: Null + 1: AAC Main + 2: AAC LC + 3: AAC SSR (Scalable Sample Rate) + 4: AAC LTP (Long Term Prediction) + 5: HE-AAC / SBR (Spectral Band Replication) + 6: AAC Scalable */ var audioObjectType = 0; @@ -8522,20 +8559,20 @@ var FLVDemuxer = function () { return FLVDemuxer; }(); -/** - * Copyright (C) 2018 Xmader. - * @author Xmader +/** + * Copyright (C) 2018 Xmader. + * @author Xmader */ -/** - * 计算adts头部 - * @see https://blog.jianchihu.net/flv-aac-add-adtsheader.html - * @typedef {Object} AdtsHeadersInit - * @property {number} audioObjectType - * @property {number} samplingFrequencyIndex - * @property {number} channelConfig - * @property {number} adtsLen - * @param {AdtsHeadersInit} init +/** + * 计算adts头部 + * @see https://blog.jianchihu.net/flv-aac-add-adtsheader.html + * @typedef {Object} AdtsHeadersInit + * @property {number} audioObjectType + * @property {number} samplingFrequencyIndex + * @property {number} channelConfig + * @property {number} adtsLen + * @param {AdtsHeadersInit} init */ @@ -8573,15 +8610,15 @@ var getAdtsHeaders = function getAdtsHeaders(init) { return headers; }; -/** - * Copyright (C) 2018 Xmader. - * @author Xmader +/** + * Copyright (C) 2018 Xmader. + * @author Xmader */ -/** - * Demux FLV into H264 + AAC stream into line stream then - * remux it into a AAC file. - * @param {Blob|Buffer|ArrayBuffer|string} flv +/** + * Demux FLV into H264 + AAC stream into line stream then + * remux it into a AAC file. + * @param {Blob|Buffer|ArrayBuffer|string} flv */ var FLV2AAC = function () { var _ref77 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee60(flv) { @@ -8630,12 +8667,12 @@ var FLV2AAC = function () { flvDemuxer.overridedHasVideo = false; - /** - * @typedef {Object} Sample - * @property {Uint8Array} unit - * @property {number} length - * @property {number} dts - * @property {number} pts + /** + * @typedef {Object} Sample + * @property {Uint8Array} unit + * @property {number} length + * @property {number} dts + * @property {number} pts */ /** @type {{ type: "audio"; id: number; sequenceNumber: number; length: number; samples: Sample[]; }} */ @@ -8709,14 +8746,14 @@ var FLV2AAC = function () { }; }(); -/*** - * Copyright (C) 2018 Xmader. All Rights Reserved. - * - * @author Xmader - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. +/*** + * Copyright (C) 2018 Xmader. All Rights Reserved. + * + * @author Xmader + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ var WebWorker = function (_Worker) { @@ -8733,9 +8770,9 @@ var WebWorker = function (_Worker) { return _this46; } - /** - * @param {string} method - * @param {*} data + /** + * @param {string} method + * @param {*} data */ @@ -8832,8 +8869,8 @@ var WebWorker = function (_Worker) { return registerAllMethods; }() - /** - * @param {Function | ClassDecorator} c + /** + * @param {Function | ClassDecorator} c */ }, { @@ -8843,8 +8880,8 @@ var WebWorker = function (_Worker) { return this.getReturnValue("importScripts", URL.createObjectURL(blob)); } - /** - * @param {() => void} fn + /** + * @param {() => void} fn */ }], [{ @@ -8896,9 +8933,9 @@ var BatchDownloadWorkerFn = function BatchDownloadWorkerFn() { return mergeFLVFiles; }() - /** - * 引入脚本与库 - * @param {string[]} scripts + /** + * 引入脚本与库 + * @param {string[]} scripts */ }, { @@ -8973,24 +9010,24 @@ var BatchDownloadWorkerFn = function BatchDownloadWorkerFn() { // @ts-check -/** - * @param {number} alpha 0~255 +/** + * @param {number} alpha 0~255 */ var formatColorChannel$1 = function formatColorChannel$1(alpha) { return (alpha & 255).toString(16).toUpperCase().padStart(2, '0'); }; -/** - * @param {number} opacity 0 ~ 1 -> alpha 0 ~ 255 +/** + * @param {number} opacity 0 ~ 1 -> alpha 0 ~ 255 */ var formatOpacity = function formatOpacity(opacity) { var alpha = 0xFF * (100 - +opacity * 100) / 100; return formatColorChannel$1(alpha); }; -/** - * "#xxxxxx" -> "xxxxxx" - * @param {string} colorStr +/** + * "#xxxxxx" -> "xxxxxx" + * @param {string} colorStr */ var formatColor$1 = function formatColor$1(colorStr) { colorStr = colorStr.toUpperCase(); @@ -9035,8 +9072,8 @@ var buildHeader = function buildHeader(_ref82) { return ["[Script Info]", 'Title: ' + title, 'Original Script: ' + original, "ScriptType: v4.00+", "Collisions: Normal", 'PlayResX: ' + playResX, 'PlayResY: ' + playResY, "Timer: 100.0000", "", "[V4+ Styles]", "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding", 'Style: CC,' + fontFamily + ',' + fontSize + ',&H' + textAlpha + textColor + ',&H' + textAlpha + textColor + ',&H' + textAlpha + '000000,&H' + bgAlpha + bgColor + ',' + boldFlag + ',0,0,0,100,100,0,0,1,2,0,2,20,20,2,0', "", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"]; }; -/** - * @param {number} time +/** + * @param {number} time */ var formatTimestamp$1 = function formatTimestamp$1(time) { var value = Math.round(time * 100) * 10; @@ -9047,16 +9084,16 @@ var formatTimestamp$1 = function formatTimestamp$1(time) { return fHour + fRem; }; -/** - * @param {string} str +/** + * @param {string} str */ var textEscape$1 = function textEscape$1(str) { // VSFilter do not support escaped "{" or "}"; we use full-width version instead return str.replace(/{/g, '{').replace(/}/g, '}').replace(/\s/g, ' '); }; -/** - * @param {import("./index").Dialogue} dialogue +/** + * @param {import("./index").Dialogue} dialogue */ var buildLine = function buildLine(dialogue) { var start = formatTimestamp$1(dialogue.from); @@ -9065,9 +9102,9 @@ var buildLine = function buildLine(dialogue) { return 'Dialogue: 0,' + start + ',' + end + ',CC,,20,20,2,,' + text; }; -/** - * @param {import("./index").SubtitleData} subtitleData - * @param {string} languageDoc 字幕语言描述,例如 "英语(美国)" +/** + * @param {import("./index").SubtitleData} subtitleData + * @param {string} languageDoc 字幕语言描述,例如 "英语(美国)" */ var buildAss = function buildAss(subtitleData) { var languageDoc = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ""; @@ -9093,10 +9130,10 @@ var buildAss = function buildAss(subtitleData) { // @ts-check -/** - * 获取视频信息 - * @param {number} aid - * @param {number} cid +/** + * 获取视频信息 + * @param {number} aid + * @param {number} cid */ var getVideoInfo = function () { var _ref83 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee65(aid, cid) { @@ -9140,21 +9177,21 @@ var getVideoInfo = function () { }; }(); -/** - * @typedef {Object} SubtitleInfo 字幕信息 - * @property {number} id - * @property {string} lan 字幕语言,例如 "en-US" - * @property {string} lan_doc 字幕语言描述,例如 "英语(美国)" - * @property {boolean} is_lock 是否字幕可以在视频上拖动 - * @property {string} subtitle_url 指向字幕数据 json 的 url - * @property {object} author 作者信息 +/** + * @typedef {Object} SubtitleInfo 字幕信息 + * @property {number} id + * @property {string} lan 字幕语言,例如 "en-US" + * @property {string} lan_doc 字幕语言描述,例如 "英语(美国)" + * @property {boolean} is_lock 是否字幕可以在视频上拖动 + * @property {string} subtitle_url 指向字幕数据 json 的 url + * @property {object} author 作者信息 */ -/** - * 获取字幕信息列表 - * @param {number} aid - * @param {number} cid - * @returns {Promise} +/** + * 获取字幕信息列表 + * @param {number} aid + * @param {number} cid + * @returns {Promise} */ var getSubtitleInfoList = function () { var _ref84 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee66(aid, cid) { @@ -9189,27 +9226,27 @@ var getSubtitleInfoList = function () { }; }(); -/** - * @typedef {Object} Dialogue - * @property {number} from 开始时间 - * @property {number} to 结束时间 - * @property {number} location 默认 2 - * @property {string} content 字幕内容 +/** + * @typedef {Object} Dialogue + * @property {number} from 开始时间 + * @property {number} to 结束时间 + * @property {number} location 默认 2 + * @property {string} content 字幕内容 */ -/** - * @typedef {Object} SubtitleData 字幕数据 - * @property {number} font_size 默认 0.4 - * @property {string} font_color 默认 "#FFFFFF" - * @property {number} background_alpha 默认 0.5 - * @property {string} background_color 默认 "#9C27B0" - * @property {string} Stroke 默认 "none" - * @property {Dialogue[]} body +/** + * @typedef {Object} SubtitleData 字幕数据 + * @property {number} font_size 默认 0.4 + * @property {string} font_color 默认 "#FFFFFF" + * @property {number} background_alpha 默认 0.5 + * @property {string} background_color 默认 "#9C27B0" + * @property {string} Stroke 默认 "none" + * @property {Dialogue[]} body */ -/** - * @param {string} subtitle_url 指向字幕数据 json 的 url - * @returns {Promise} +/** + * @param {string} subtitle_url 指向字幕数据 json 的 url + * @returns {Promise} */ var getSubtitleData = function () { var _ref85 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee67(subtitle_url) { @@ -9254,9 +9291,9 @@ var getSubtitleData = function () { }; }(); -/** - * @param {number} aid - * @param {number} cid +/** + * @param {number} aid + * @param {number} cid */ var getSubtitles = function () { var _ref86 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee69(aid, cid) { @@ -12133,14 +12170,14 @@ var UI = function () { return UI; }(); -/*** - * Copyright (C) 2018 Qli5. All Rights Reserved. - * - * @author qli5 - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. +/*** + * Copyright (C) 2018 Qli5. All Rights Reserved. + * + * @author qli5 + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ var debugOption = { debug: 1 }; @@ -12230,8 +12267,8 @@ var BiliTwin = function (_BiliUserJS) { cidRefresh = BiliTwin.getCidRefreshPromise(this.playerWin); - /** - * @param {HTMLVideoElement} video + /** + * @param {HTMLVideoElement} video */ videoRightClick = function videoRightClick(video) { @@ -12599,7 +12636,7 @@ var BiliTwin = function (_BiliUserJS) { return BiliTwin; }(BiliUserJS); -BiliTwin.domContentLoadedThen(BiliTwin.init); -} - - +BiliTwin.domContentLoadedThen(BiliTwin.init); +} + + diff --git a/src/biliuserjs/bilimonkey.js b/src/biliuserjs/bilimonkey.js index f1a86b3..5ee25f6 100644 --- a/src/biliuserjs/bilimonkey.js +++ b/src/biliuserjs/bilimonkey.js @@ -214,6 +214,26 @@ class BiliMonkey { const qn = (this.option.enableVideoMaxResolution && this.option.videoMaxResolution) || "120" const api_url = `https://api.bilibili.com${apiPath}?avid=${aid}&cid=${cid}&otype=json&fourk=1&qn=${qn}` + // requir to enableVideoMaxResolution and set videoMaxResolution to 125 (HDR) + if (qn == 125) { + // check if video supports hdr + const dash_api_url = api_url + "&fnver=0&fnval=80"; + const dash_re = await fetch(dash_api_url, { credentials: 'include' }); + const dash_api_json = await dash_re.json(); + + var dash_data = dash_api_json.data || dash_api_json.result; + + if (dash_data && dash_data.quality == 125) { + // using dash urls for hdr video only + let dash_urls = [dash_data.dash.video[0].base_url, dash_data.dash.audio[0].base_url]; + this.flvs = dash_urls; + let video_format = dash_data.format && (dash_data.format.match(/mp4|flv/) || [])[0]; + this.video_format = video_format; + return video_format; + } + // otherwise fallback to normal prosedure + } + const re = await fetch(api_url, { credentials: 'include' }) const apiJson = await re.json() @@ -590,6 +610,7 @@ class BiliMonkey { static get resolutionPreferenceOptions() { return [ + ['HDR 真彩 (大会员)', '125'], ['超清 4K (大会员)', '120'], ['高清 1080P60 (大会员)', '116'], ['高清 1080P+ (大会员)', '112'],