feat(wasm-output): embed cover

This commit is contained in:
WakelessSloth56 2025-09-30 22:17:20 +08:00
parent 6cc2048405
commit 794d38a4bc
No known key found for this signature in database
GPG Key ID: A0564954AFEF3E8C
5 changed files with 155 additions and 37 deletions

View File

@ -205,7 +205,7 @@ interface Message {
data?: FFMessageData
}
interface ProgressEvent {
export interface ProgressEvent {
progress: number
time: number
}

View File

@ -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,

View File

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

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

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