Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[新增插件] 使用 WASM 在浏览器中下载并混流音视频 #4521

Merged
merged 1 commit into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions registry/lib/plugins/video/download/wasm-output/ffmpeg.ts
Original file line number Diff line number Diff line change
@@ -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')
WakelessSloth56 marked this conversation as resolved.
Show resolved Hide resolved
}
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 changes: 56 additions & 0 deletions registry/lib/plugins/video/download/wasm-output/handler.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
WakelessSloth56 marked this conversation as resolved.
Show resolved Hide resolved

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 changes: 40 additions & 0 deletions registry/lib/plugins/video/download/wasm-output/index.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 50 additions & 0 deletions registry/lib/plugins/video/download/wasm-output/utils.ts
Original file line number Diff line number Diff line change
@@ -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)}` : ''
WakelessSloth56 marked this conversation as resolved.
Show resolved Hide resolved
}`
}
}

export async function httpget(url: string, onprogress: OnProgress) {
WakelessSloth56 marked this conversation as resolved.
Show resolved Hide resolved
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