From 5f75019286f8cb546e0302cee32e50bf478c320c Mon Sep 17 00:00:00 2001 From: WakelessSloth56 Date: Tue, 14 Nov 2023 21:52:55 +0800 Subject: [PATCH] feat: download plugin - wasm muxing output Co-authored-by: lainio24 --- .../video/download/wasm-output/ffmpeg.ts | 192 ++++++++++++++++++ .../video/download/wasm-output/handler.ts | 56 +++++ .../video/download/wasm-output/index.ts | 40 ++++ .../video/download/wasm-output/utils.ts | 50 +++++ 4 files changed, 338 insertions(+) create mode 100644 registry/lib/plugins/video/download/wasm-output/ffmpeg.ts create mode 100644 registry/lib/plugins/video/download/wasm-output/handler.ts create mode 100644 registry/lib/plugins/video/download/wasm-output/index.ts create mode 100644 registry/lib/plugins/video/download/wasm-output/utils.ts diff --git a/registry/lib/plugins/video/download/wasm-output/ffmpeg.ts b/registry/lib/plugins/video/download/wasm-output/ffmpeg.ts new file mode 100644 index 0000000000..1eec43c85f --- /dev/null +++ b/registry/lib/plugins/video/download/wasm-output/ffmpeg.ts @@ -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 => { + 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 + } + + public exec = (args: string[], timeout = -1, signal?: AbortSignal) => + this.#send( + { + type: FFMessageType.EXEC, + data: { args, timeout }, + }, + undefined, + signal, + ) as unknown as Promise + + 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 + } + + public readFile = (path: string, signal?: AbortSignal) => + this.#send( + { + type: FFMessageType.READ_FILE, + data: { path, encoding: 'binary' }, + }, + undefined, + signal, + ) as Promise +} + +// ========================================================================== // + +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 + } +} diff --git a/registry/lib/plugins/video/download/wasm-output/handler.ts b/registry/lib/plugins/video/download/wasm-output/handler.ts new file mode 100644 index 0000000000..8e9473a6f1 --- /dev/null +++ b/registry/lib/plugins/video/download/wasm-output/handler.ts @@ -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) +} diff --git a/registry/lib/plugins/video/download/wasm-output/index.ts b/registry/lib/plugins/video/download/wasm-output/index.ts new file mode 100644 index 0000000000..2cb04ac880 --- /dev/null +++ b/registry/lib/plugins/video/download/wasm-output/index.ts @@ -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) + } + } + }, + }) + }) + }, +} diff --git a/registry/lib/plugins/video/download/wasm-output/utils.ts b/registry/lib/plugins/video/download/wasm-output/utils.ts new file mode 100644 index 0000000000..c957fb493b --- /dev/null +++ b/registry/lib/plugins/video/download/wasm-output/utils.ts @@ -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) +}