mirror of
https://github.com/the1812/Bilibili-Evolved.git
synced 2025-11-04 21:22:45 +08:00
Merge pull request #4521 from WakelessSloth56/preview-features
[新增插件] 使用 WASM 在浏览器中下载并混流音视频
This commit is contained in:
commit
be32ec65bb
192
registry/lib/plugins/video/download/wasm-output/ffmpeg.ts
Normal file
192
registry/lib/plugins/video/download/wasm-output/ffmpeg.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
/*
|
||||||
|
MIT License
|
||||||
|
Copyright (c) 2019 Jerome Wu
|
||||||
|
|
||||||
|
Modified 2023 WakelessSloth56
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
|
||||||
|
export const VERSION = '0.12.4'
|
||||||
|
|
||||||
|
const messageId = (() => {
|
||||||
|
let messageID = 0
|
||||||
|
return () => messageID++
|
||||||
|
})()
|
||||||
|
|
||||||
|
enum FFMessageType {
|
||||||
|
LOAD = 'LOAD',
|
||||||
|
EXEC = 'EXEC',
|
||||||
|
WRITE_FILE = 'WRITE_FILE',
|
||||||
|
READ_FILE = 'READ_FILE',
|
||||||
|
ERROR = 'ERROR',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FFmpeg {
|
||||||
|
#worker: Worker | null = null
|
||||||
|
#resolves: Callbacks = {}
|
||||||
|
#rejects: Callbacks = {}
|
||||||
|
|
||||||
|
public loaded = false
|
||||||
|
|
||||||
|
#registerHandlers = () => {
|
||||||
|
if (this.#worker) {
|
||||||
|
this.#worker.onmessage = ({ data: { id, type, data } }: FFMessageEventCallback) => {
|
||||||
|
switch (type) {
|
||||||
|
case FFMessageType.LOAD:
|
||||||
|
this.loaded = true
|
||||||
|
this.#resolves[id](data)
|
||||||
|
break
|
||||||
|
case FFMessageType.EXEC:
|
||||||
|
case FFMessageType.WRITE_FILE:
|
||||||
|
case FFMessageType.READ_FILE:
|
||||||
|
this.#resolves[id](data)
|
||||||
|
break
|
||||||
|
case FFMessageType.ERROR:
|
||||||
|
this.#rejects[id](data)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown FFmpeg message')
|
||||||
|
}
|
||||||
|
delete this.#resolves[id]
|
||||||
|
delete this.#rejects[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#send = (
|
||||||
|
{ type, data }: Message,
|
||||||
|
trans: Transferable[] = [],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<CallbackData> => {
|
||||||
|
if (!this.#worker) {
|
||||||
|
return Promise.reject(new Error('FFmpeg is not loaded'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = messageId()
|
||||||
|
this.#worker && this.#worker.postMessage({ id, type, data }, trans)
|
||||||
|
this.#resolves[id] = resolve
|
||||||
|
this.#rejects[id] = reject
|
||||||
|
|
||||||
|
signal?.addEventListener(
|
||||||
|
'abort',
|
||||||
|
() => {
|
||||||
|
reject(new DOMException(`Message # ${id} was aborted`, 'AbortError'))
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public load = (config: FFMessageLoadConfig, signal?: AbortSignal) => {
|
||||||
|
if (!this.#worker) {
|
||||||
|
this.#worker = new Worker(config.workerLoadURL, { type: 'classic' })
|
||||||
|
this.#registerHandlers()
|
||||||
|
}
|
||||||
|
return this.#send(
|
||||||
|
{
|
||||||
|
type: FFMessageType.LOAD,
|
||||||
|
data: config,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
) as Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
public exec = (args: string[], timeout = -1, signal?: AbortSignal) =>
|
||||||
|
this.#send(
|
||||||
|
{
|
||||||
|
type: FFMessageType.EXEC,
|
||||||
|
data: { args, timeout },
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
) as unknown as Promise<number>
|
||||||
|
|
||||||
|
public terminate = () => {
|
||||||
|
const ids = Object.keys(this.#rejects)
|
||||||
|
for (const id of ids) {
|
||||||
|
this.#rejects[id](new Error('FFmpeg terminated'))
|
||||||
|
delete this.#rejects[id]
|
||||||
|
delete this.#resolves[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#worker) {
|
||||||
|
this.#worker.terminate()
|
||||||
|
this.#worker = null
|
||||||
|
this.loaded = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public writeFile = (path: string, data: Uint8Array, signal?: AbortSignal) => {
|
||||||
|
const trans: Transferable[] = []
|
||||||
|
trans.push(data.buffer)
|
||||||
|
return this.#send(
|
||||||
|
{
|
||||||
|
type: FFMessageType.WRITE_FILE,
|
||||||
|
data: { path, data },
|
||||||
|
},
|
||||||
|
trans,
|
||||||
|
signal,
|
||||||
|
) as Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
public readFile = (path: string, signal?: AbortSignal) =>
|
||||||
|
this.#send(
|
||||||
|
{
|
||||||
|
type: FFMessageType.READ_FILE,
|
||||||
|
data: { path, encoding: 'binary' },
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
) as Promise<Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================== //
|
||||||
|
|
||||||
|
interface FFMessageLoadConfig {
|
||||||
|
workerLoadURL: string
|
||||||
|
coreURL: string
|
||||||
|
wasmURL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FFMessageExecData {
|
||||||
|
args: string[]
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FFMessageWriteFileData {
|
||||||
|
path: string
|
||||||
|
data: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FFMessageReadFileData {
|
||||||
|
path: string
|
||||||
|
encoding: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FFMessageData =
|
||||||
|
| FFMessageLoadConfig
|
||||||
|
| FFMessageExecData
|
||||||
|
| FFMessageWriteFileData
|
||||||
|
| FFMessageReadFileData
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
type: string
|
||||||
|
data?: FFMessageData
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallbackData = Uint8Array | string | boolean | Error | undefined
|
||||||
|
|
||||||
|
interface Callbacks {
|
||||||
|
[id: number | string]: (data: CallbackData) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FFMessageEventCallback {
|
||||||
|
data: {
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
data: CallbackData
|
||||||
|
}
|
||||||
|
}
|
||||||
56
registry/lib/plugins/video/download/wasm-output/handler.ts
Normal file
56
registry/lib/plugins/video/download/wasm-output/handler.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { CdnTypes } from '@/core/cdn-types'
|
||||||
|
import { DownloadPackage } from '@/core/download'
|
||||||
|
import { meta } from '@/core/meta'
|
||||||
|
import { Toast } from '@/core/toast'
|
||||||
|
import { FFmpeg, VERSION } from './ffmpeg'
|
||||||
|
import { httpget, toBlobUrl, toastProgress } from './utils'
|
||||||
|
|
||||||
|
const ffmpeg = new FFmpeg()
|
||||||
|
|
||||||
|
function cdnUrl(p: string, file: string) {
|
||||||
|
return `https://${
|
||||||
|
meta.compilationInfo.allCdns[CdnTypes.jsDelivr].host
|
||||||
|
}/npm/@ffmpeg/${p}@${VERSION}/dist/umd/${file}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(toast: Toast) {
|
||||||
|
await ffmpeg.load({
|
||||||
|
workerLoadURL: await toBlobUrl(
|
||||||
|
cdnUrl('ffmpeg', '814.ffmpeg.js'),
|
||||||
|
'text/javascript',
|
||||||
|
toastProgress(toast, '正在加载 FFmpeg Worker'),
|
||||||
|
),
|
||||||
|
coreURL: await toBlobUrl(
|
||||||
|
cdnUrl('core', 'ffmpeg-core.js'),
|
||||||
|
'text/javascript',
|
||||||
|
toastProgress(toast, '正在加载 FFmpeg Core'),
|
||||||
|
),
|
||||||
|
wasmURL: await toBlobUrl(
|
||||||
|
cdnUrl('core', 'ffmpeg-core.wasm'),
|
||||||
|
'application/wasm',
|
||||||
|
toastProgress(toast, '正在加载 FFmpeg WASM'),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export async function run(name: string, videoUrl: string, audioUrl: string, toast: Toast) {
|
||||||
|
if (!ffmpeg.loaded) {
|
||||||
|
await load(toast)
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg.writeFile('video', await httpget(videoUrl, toastProgress(toast, '正在下载视频流')))
|
||||||
|
ffmpeg.writeFile('audio', await httpget(audioUrl, toastProgress(toast, '正在下载音频流')))
|
||||||
|
|
||||||
|
toast.message = '混流中……'
|
||||||
|
|
||||||
|
await ffmpeg.exec(['-i', 'video', '-i', 'audio', '-c:v', 'copy', '-c:a', 'copy', 'output.mp4'])
|
||||||
|
|
||||||
|
const output = await ffmpeg.readFile('output.mp4')
|
||||||
|
const outputBlob = new Blob([output], {
|
||||||
|
type: 'video/mp4',
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.message = '完成!'
|
||||||
|
toast.duration = 1500
|
||||||
|
|
||||||
|
await DownloadPackage.single(name, outputBlob)
|
||||||
|
}
|
||||||
40
registry/lib/plugins/video/download/wasm-output/index.ts
Normal file
40
registry/lib/plugins/video/download/wasm-output/index.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Toast } from '@/core/toast'
|
||||||
|
import { PluginMetadata } from '@/plugins/plugin'
|
||||||
|
import { DownloadVideoOutput } from '../../../../components/video/download/types'
|
||||||
|
import { run } from './handler'
|
||||||
|
|
||||||
|
const title = 'WASM 混流输出'
|
||||||
|
const desc = '使用 WASM 在浏览器中下载并合并音视频'
|
||||||
|
|
||||||
|
export const plugin: PluginMetadata = {
|
||||||
|
name: 'downloadVideo.outputs.wasm',
|
||||||
|
displayName: `下载视频 - ${title}`,
|
||||||
|
description: desc,
|
||||||
|
author: {
|
||||||
|
name: 'WakelessSloth56',
|
||||||
|
link: 'https://github.com/WakelessSloth56',
|
||||||
|
},
|
||||||
|
setup: ({ addData }) => {
|
||||||
|
addData('downloadVideo.outputs', (outputs: DownloadVideoOutput[]) => {
|
||||||
|
outputs.push({
|
||||||
|
name: 'wasm',
|
||||||
|
displayName: 'WASM',
|
||||||
|
description: `${desc},运行过程中请勿关闭页面,初次使用或清除缓存后需要加载约 30 MB 的 WASM 文件`,
|
||||||
|
runAction: async action => {
|
||||||
|
const fragments = action.infos.flatMap(it => it.titledFragments)
|
||||||
|
if (fragments.length !== 2) {
|
||||||
|
Toast.error('仅支持 DASH 格式', title)
|
||||||
|
} else {
|
||||||
|
const toast = Toast.info('正在加载', title)
|
||||||
|
try {
|
||||||
|
await run(fragments[0].title, fragments[0].url, fragments[1].url, toast)
|
||||||
|
} catch (error) {
|
||||||
|
toast.close()
|
||||||
|
Toast.error(String(error), title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
50
registry/lib/plugins/video/download/wasm-output/utils.ts
Normal file
50
registry/lib/plugins/video/download/wasm-output/utils.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Toast } from '@/core/toast'
|
||||||
|
import { formatFileSize, formatPercent } from '@/core/utils/formatters'
|
||||||
|
|
||||||
|
type OnProgress = (received: number, length: number) => void
|
||||||
|
|
||||||
|
export function toastProgress(toast: Toast, message: string): OnProgress {
|
||||||
|
return (r, l) => {
|
||||||
|
toast.message = `${message}: ${formatFileSize(r)}${
|
||||||
|
l > 0 ? ` / ${formatFileSize(l)} @ ${formatPercent(r / l)}` : ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function httpget(url: string, onprogress: OnProgress) {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const length = parseInt(response.headers.get('Content-Length') || '0')
|
||||||
|
|
||||||
|
let received = 0
|
||||||
|
const chunks = []
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
chunks.push(value)
|
||||||
|
received += value.length
|
||||||
|
onprogress(received, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunksAll = new Uint8Array(received)
|
||||||
|
let position = 0
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
chunksAll.set(chunk, position)
|
||||||
|
position += chunk.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunksAll
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toBlobUrl(url: string, mimeType: string, onprogress: OnProgress) {
|
||||||
|
const buffer = await httpget(url, onprogress)
|
||||||
|
const blob = new Blob([buffer], { type: mimeType })
|
||||||
|
return URL.createObjectURL(blob)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user