mirror of
https://github.com/the1812/Bilibili-Evolved.git
synced 2025-11-04 21:22:45 +08:00
feat(wasm-output): embed cover
This commit is contained in:
parent
6cc2048405
commit
794d38a4bc
@ -205,7 +205,7 @@ interface Message {
|
||||
data?: FFMessageData
|
||||
}
|
||||
|
||||
interface ProgressEvent {
|
||||
export interface ProgressEvent {
|
||||
progress: number
|
||||
time: number
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ import { title as pluginTitle } from '.'
|
||||
import type { Options } from '../../../../components/video/download'
|
||||
import { DownloadVideoAction } from '../../../../components/video/download/types'
|
||||
import { FFmpeg } from './ffmpeg'
|
||||
import { mux } from './muxer'
|
||||
import { OutputType } from './types'
|
||||
import { getCacheOrFetch, getContentLength, httpGet, toastProgress, toBlobUrl } from './utils'
|
||||
|
||||
const ffmpeg = new FFmpeg()
|
||||
@ -47,8 +49,10 @@ async function single(
|
||||
name: string,
|
||||
videoUrl: string,
|
||||
audioUrl: string,
|
||||
coverUrl: string,
|
||||
ffmetadata: string,
|
||||
outputMkv: boolean,
|
||||
outputType: OutputType,
|
||||
isFlac: boolean,
|
||||
pageIndex = 1,
|
||||
totalPages = 1,
|
||||
) {
|
||||
@ -60,55 +64,42 @@ async function single(
|
||||
httpGet(audioUrl, progress(1, '正在下载音频流')),
|
||||
])
|
||||
|
||||
await ffmpeg.writeFile('video', video)
|
||||
await ffmpeg.writeFile('audio', audio)
|
||||
|
||||
const args = ['-i', 'video', '-i', 'audio']
|
||||
|
||||
if (ffmetadata) {
|
||||
await ffmpeg.writeFile('ffmetadata', new TextEncoder().encode(ffmetadata))
|
||||
args.push('-i', 'ffmetadata', '-map_metadata', '2')
|
||||
if (!outputMkv) {
|
||||
args.push('-movflags', '+use_metadata_tags')
|
||||
}
|
||||
let cover: Uint8Array
|
||||
if (coverUrl) {
|
||||
cover = await httpGet(coverUrl, progress(0, '正在下载封面'))
|
||||
}
|
||||
|
||||
args.push('-codec', 'copy', '-f', outputMkv ? 'matroska' : 'mp4', 'output')
|
||||
let metadata: Uint8Array
|
||||
if (ffmetadata) {
|
||||
metadata = new TextEncoder().encode(ffmetadata)
|
||||
}
|
||||
|
||||
console.debug('FFmpeg commandline args:', args.join(' '))
|
||||
|
||||
ffmpeg.onProgress(event => {
|
||||
const muxProgress = event => {
|
||||
toast.message = `混流中: ${formatPercent(event.progress)}`
|
||||
})
|
||||
await ffmpeg.exec(args)
|
||||
}
|
||||
|
||||
const output = await ffmpeg.readFile('output')
|
||||
const outputBlob = new Blob([output], {
|
||||
type: outputMkv ? 'video/x-matroska' : 'video/mp4',
|
||||
})
|
||||
const {
|
||||
data: output,
|
||||
format: { extension },
|
||||
} = await mux(ffmpeg, outputType, muxProgress, video, audio, isFlac, cover, metadata)
|
||||
|
||||
toast.message = '完成!'
|
||||
toast.duration = 1000
|
||||
|
||||
await Promise.all([
|
||||
ffmpeg.deleteFile('video'),
|
||||
ffmpeg.deleteFile('audio'),
|
||||
ffmpeg.deleteFile('output'),
|
||||
ffmetadata ? ffmpeg.deleteFile('ffmetadata') : Promise.resolve(),
|
||||
])
|
||||
|
||||
await DownloadPackage.single(
|
||||
name.replace(/.[^/.]+$/, `.${outputMkv ? 'mkv' : 'mp4'}`),
|
||||
outputBlob,
|
||||
)
|
||||
await DownloadPackage.single(name.replace(/.[^/.]+$/, `.${extension}`), output)
|
||||
}
|
||||
|
||||
export async function run(action: DownloadVideoAction, muxWithMetadata: boolean) {
|
||||
export async function run(
|
||||
action: DownloadVideoAction,
|
||||
outputType: OutputType,
|
||||
muxWithMetadata: boolean,
|
||||
attachCover: boolean,
|
||||
) {
|
||||
if (!ffmpeg.loaded) {
|
||||
await loadFFmpeg()
|
||||
}
|
||||
|
||||
const { infos: pages, extraAssets } = action
|
||||
const { infos: pages, extraAssets, extraOnlineAssets } = action
|
||||
|
||||
let ffmetadata: PackageEntry[]
|
||||
if (muxWithMetadata) {
|
||||
@ -123,6 +114,19 @@ export async function run(action: DownloadVideoAction, muxWithMetadata: boolean)
|
||||
action.extraAssets = extraAssetsForBrowser
|
||||
}
|
||||
|
||||
let cover: { name: string; url: string }[]
|
||||
if (attachCover) {
|
||||
const extraOnlineAssetsForBrowser = []
|
||||
for (const { asset, instance } of extraOnlineAssets) {
|
||||
if (!cover && asset.name === 'downloadCover' && instance.type === 'jpg') {
|
||||
cover = await asset.getUrls(pages, instance)
|
||||
} else {
|
||||
extraOnlineAssetsForBrowser.push({ asset, instance })
|
||||
}
|
||||
}
|
||||
action.extraOnlineAssets = extraOnlineAssetsForBrowser
|
||||
}
|
||||
|
||||
const { dashAudioExtension, dashFlacAudioExtension, dashVideoExtension } =
|
||||
getComponentSettings<Options>('downloadVideo').options
|
||||
|
||||
@ -153,7 +157,9 @@ export async function run(action: DownloadVideoAction, muxWithMetadata: boolean)
|
||||
video.title,
|
||||
video.url,
|
||||
audio.url,
|
||||
cover?.[i]?.url,
|
||||
<string>ffmetadata?.[i]?.data,
|
||||
outputType,
|
||||
audio.extension === dashFlacAudioExtension,
|
||||
i + 1,
|
||||
pages.length,
|
||||
|
||||
@ -22,7 +22,7 @@ export const plugin: PluginMetadata = {
|
||||
description: `${desc}。运行过程中请勿关闭页面,初次使用或清除缓存后需要加载约 30 MB 的 WASM 文件。由于浏览器限制,仅支持合并 2GB 以内的音视频。`,
|
||||
runAction: async (action, instance) => {
|
||||
try {
|
||||
await run(action, instance.muxWithMetadata)
|
||||
await run(action, 'auto', instance.muxWithMetadata, true) // TODO add options: outputType, attachCover
|
||||
} catch (error) {
|
||||
Toast.error(String(error), title)
|
||||
}
|
||||
|
||||
99
registry/lib/plugins/video/download/wasm-output/muxer.ts
Normal file
99
registry/lib/plugins/video/download/wasm-output/muxer.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { FFmpeg, ProgressEvent } from './ffmpeg'
|
||||
import { Format, OutputFormats, OutputType } from './types'
|
||||
|
||||
const mp4Format: Format = {
|
||||
extension: 'mp4',
|
||||
mime: 'video/mp4',
|
||||
muxArgs: (hasCover, hasMetadata, isFlac) => {
|
||||
const args = ['-i', 'video', '-i', 'audio']
|
||||
if (hasCover && hasMetadata) {
|
||||
args.push('-i', 'cover', '-i', 'metadata')
|
||||
args.push('-map', '0', '-map', '1', '-map', '2')
|
||||
args.push('-map_metadata', '3', '-disposition:2', 'attached_pic')
|
||||
} else if (hasCover && !hasMetadata) {
|
||||
args.push('-i', 'cover')
|
||||
args.push('-map', '0', '-map', '1', '-map', '2')
|
||||
args.push('-disposition:2', 'attached_pic')
|
||||
} else if (!hasCover && hasMetadata) {
|
||||
args.push('-i', 'metadata')
|
||||
args.push('-map_metadata', '2', '-movflags', '+use_metadata_tags')
|
||||
}
|
||||
args.push('-codec:v', 'copy')
|
||||
args.push('-codec:a', isFlac ? 'alac' : 'copy')
|
||||
args.push('-f', 'mp4')
|
||||
return args
|
||||
},
|
||||
}
|
||||
|
||||
const mkvFormat: Format = {
|
||||
extension: 'mkv',
|
||||
mime: 'video/x-matroska',
|
||||
muxArgs: (hasCover, hasMetadata) => {
|
||||
const args = ['-i', 'video', '-i', 'audio']
|
||||
if (hasCover && hasMetadata) {
|
||||
args.push('-i', 'metadata', '-attach', 'cover')
|
||||
args.push('-map', '0', '-map', '1')
|
||||
args.push('-map_metadata', '2')
|
||||
args.push('-metadata:s:t:0', 'mimetype=image/jpeg', '-metadata:s:t:0', 'filename=cover.jpg')
|
||||
} else if (hasCover && !hasMetadata) {
|
||||
args.push('-attach', 'cover')
|
||||
args.push('-metadata:s:t:0', 'mimetype=image/jpeg', '-metadata:s:t:0', 'filename=cover.jpg')
|
||||
} else if (!hasCover && hasMetadata) {
|
||||
args.push('-i', 'metadata', '-map_metadata', '2')
|
||||
}
|
||||
args.push('-codec', 'copy', '-f', 'matroska')
|
||||
return args
|
||||
},
|
||||
}
|
||||
|
||||
const outputFormats: OutputFormats = {
|
||||
mp4: mp4Format,
|
||||
matroska: mkvFormat,
|
||||
}
|
||||
|
||||
export async function mux(
|
||||
ffmpeg: FFmpeg,
|
||||
outputType: OutputType,
|
||||
callback: (event: ProgressEvent) => void,
|
||||
video: Uint8Array,
|
||||
audio: Uint8Array,
|
||||
isFlac: boolean,
|
||||
cover: Uint8Array,
|
||||
metadata: Uint8Array,
|
||||
) {
|
||||
if (outputType === 'auto') {
|
||||
outputType = isFlac ? 'matroska' : 'mp4'
|
||||
outputType = cover && metadata ? 'matroska' : outputType
|
||||
}
|
||||
|
||||
const format = outputFormats[outputType]
|
||||
|
||||
const args = format.muxArgs(!!cover, !!metadata, isFlac)
|
||||
args.push('output')
|
||||
console.debug('FFmpeg commandline args:', args.join(' '))
|
||||
|
||||
await ffmpeg.writeFile('video', video)
|
||||
await ffmpeg.writeFile('audio', audio)
|
||||
if (cover) {
|
||||
await ffmpeg.writeFile('cover', cover)
|
||||
}
|
||||
if (metadata) {
|
||||
await ffmpeg.writeFile('metadata', metadata)
|
||||
}
|
||||
|
||||
ffmpeg.onProgress(callback)
|
||||
await ffmpeg.exec(args)
|
||||
|
||||
const output = await ffmpeg.readFile('output')
|
||||
const outputBlob = new Blob([output], { type: format.mime })
|
||||
|
||||
await Promise.all([
|
||||
ffmpeg.deleteFile('video'),
|
||||
ffmpeg.deleteFile('audio'),
|
||||
ffmpeg.deleteFile('output'),
|
||||
metadata ? ffmpeg.deleteFile('metadata') : Promise.resolve(),
|
||||
cover ? ffmpeg.deleteFile('cover') : Promise.resolve(),
|
||||
])
|
||||
|
||||
return { data: outputBlob, format }
|
||||
}
|
||||
13
registry/lib/plugins/video/download/wasm-output/types.ts
Normal file
13
registry/lib/plugins/video/download/wasm-output/types.ts
Normal file
@ -0,0 +1,13 @@
|
||||
type Auto = 'auto'
|
||||
|
||||
export type OutputType = Auto | 'mp4' | 'matroska'
|
||||
|
||||
export interface Format {
|
||||
extension: string
|
||||
mime: string
|
||||
muxArgs: (hasCover: boolean, hasMetadata: boolean, isFlac?: boolean) => string[]
|
||||
}
|
||||
|
||||
export type OutputFormats = {
|
||||
[x in Exclude<OutputType, Auto>]: Format
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user