Merge pull request #4521 from WakelessSloth56/preview-features

[新增插件] 使用 WASM 在浏览器中下载并混流音视频
This commit is contained in:
Grant Howard 2023-11-16 22:31:14 +08:00 committed by GitHub
commit be32ec65bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 338 additions and 0 deletions

View 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
}
}

View 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)
}

View 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)
}
}
},
})
})
},
}

View 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)
}