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