This commit is contained in:
Connor
2026-01-12 13:17:11 +08:00
parent 95851f8e69
commit 9600fc542c
132 changed files with 35734 additions and 5 deletions

218
web/src/utils/ffmpeg.ts Normal file
View File

@@ -0,0 +1,218 @@
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile, toBlobURL } from '@ffmpeg/util'
let ffmpegInstance: FFmpeg | null = null
let loadPromise: Promise<FFmpeg> | null = null
export interface VideoTrimOptions {
startTime: number
endTime: number
}
export interface VideoMergeOptions {
clips: Array<{
url: string
startTime?: number
endTime?: number
}>
}
export interface ProgressCallback {
(progress: number): void
}
async function getFFmpeg(): Promise<FFmpeg> {
if (ffmpegInstance) {
return ffmpegInstance
}
if (loadPromise) {
return loadPromise
}
loadPromise = (async () => {
const ffmpeg = new FFmpeg()
ffmpeg.on('log', ({ message }) => {
console.log('[FFmpeg]', message)
})
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd'
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm')
})
ffmpegInstance = ffmpeg
return ffmpeg
})()
return loadPromise
}
export async function trimVideo(
videoUrl: string,
options: VideoTrimOptions,
onProgress?: ProgressCallback
): Promise<Blob> {
const ffmpeg = await getFFmpeg()
if (onProgress) onProgress(10)
const inputFileName = 'input.mp4'
const outputFileName = 'output.mp4'
await ffmpeg.writeFile(inputFileName, await fetchFile(videoUrl))
if (onProgress) onProgress(30)
const args = [
'-i', inputFileName,
'-ss', options.startTime.toString(),
'-to', options.endTime.toString(),
'-c', 'copy',
'-avoid_negative_ts', '1',
outputFileName
]
await ffmpeg.exec(args)
if (onProgress) onProgress(80)
const data = await ffmpeg.readFile(outputFileName) as Uint8Array
await ffmpeg.deleteFile(inputFileName)
await ffmpeg.deleteFile(outputFileName)
if (onProgress) onProgress(100)
return new Blob([new Uint8Array(data)], { type: 'video/mp4' })
}
export async function mergeVideos(
options: VideoMergeOptions,
onProgress?: ProgressCallback
): Promise<Blob> {
const ffmpeg = await getFFmpeg()
if (onProgress) onProgress(5)
const tempFiles: string[] = []
for (let i = 0; i < options.clips.length; i++) {
const clip = options.clips[i]
const fileName = `clip_${i}.mp4`
await ffmpeg.writeFile(fileName, await fetchFile(clip.url))
tempFiles.push(fileName)
if (onProgress) {
onProgress(5 + (i + 1) / options.clips.length * 40)
}
}
const listContent = tempFiles.map(file => `file '${file}'`).join('\n')
await ffmpeg.writeFile('filelist.txt', new TextEncoder().encode(listContent))
if (onProgress) onProgress(50)
await ffmpeg.exec([
'-f', 'concat',
'-safe', '0',
'-i', 'filelist.txt',
'-c', 'copy',
'output.mp4'
])
if (onProgress) onProgress(90)
const data = await ffmpeg.readFile('output.mp4') as Uint8Array
for (const file of tempFiles) {
await ffmpeg.deleteFile(file)
}
await ffmpeg.deleteFile('filelist.txt')
await ffmpeg.deleteFile('output.mp4')
if (onProgress) onProgress(100)
return new Blob([new Uint8Array(data)], { type: 'video/mp4' })
}
export async function trimAndMergeVideos(
clips: Array<{
url: string
startTime: number
endTime: number
}>,
onProgress?: ProgressCallback
): Promise<Blob> {
const ffmpeg = await getFFmpeg()
if (onProgress) onProgress(5)
const trimmedFiles: string[] = []
for (let i = 0; i < clips.length; i++) {
const clip = clips[i]
const inputName = `input_${i}.mp4`
const outputName = `trimmed_${i}.mp4`
await ffmpeg.writeFile(inputName, await fetchFile(clip.url))
await ffmpeg.exec([
'-i', inputName,
'-ss', clip.startTime.toString(),
'-to', clip.endTime.toString(),
'-c', 'copy',
'-avoid_negative_ts', '1',
outputName
])
await ffmpeg.deleteFile(inputName)
trimmedFiles.push(outputName)
if (onProgress) {
onProgress(5 + (i + 1) / clips.length * 60)
}
}
const listContent = trimmedFiles.map(file => `file '${file}'`).join('\n')
await ffmpeg.writeFile('filelist.txt', new TextEncoder().encode(listContent))
if (onProgress) onProgress(70)
await ffmpeg.exec([
'-f', 'concat',
'-safe', '0',
'-i', 'filelist.txt',
'-c', 'copy',
'final.mp4'
])
if (onProgress) onProgress(95)
const data = await ffmpeg.readFile('final.mp4') as Uint8Array
for (const file of trimmedFiles) {
await ffmpeg.deleteFile(file)
}
await ffmpeg.deleteFile('filelist.txt')
await ffmpeg.deleteFile('final.mp4')
if (onProgress) onProgress(100)
return new Blob([new Uint8Array(data)], { type: 'video/mp4' })
}
export async function isFFmpegLoaded(): Promise<boolean> {
return ffmpegInstance !== null
}
export async function unloadFFmpeg(): Promise<void> {
if (ffmpegInstance) {
await ffmpegInstance.terminate()
ffmpegInstance = null
loadPromise = null
}
}

48
web/src/utils/request.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { AxiosError, AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'
import axios from 'axios'
import { ElMessage } from 'element-plus'
interface CustomAxiosInstance extends Omit<AxiosInstance, 'get' | 'post' | 'put' | 'patch' | 'delete'> {
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
}
const request = axios.create({
baseURL: '/api/v1',
timeout: 600000, // 10分钟超时匹配后端AI生成接口
headers: {
'Content-Type': 'application/json'
}
}) as CustomAxiosInstance
// 开源版本 - 无需认证token
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
return config
},
(error: AxiosError) => {
return Promise.reject(error)
}
)
request.interceptors.response.use(
(response) => {
const res = response.data
if (res.success) {
return res.data
} else {
// 不在这里显示错误提示,让业务代码自行处理
return Promise.reject(new Error(res.error?.message || '请求失败'))
}
},
(error: AxiosError<any>) => {
// 不在拦截器中自动显示错误提示,让业务代码根据具体情况处理
// 只抛出错误供调用者捕获
return Promise.reject(error)
}
)
export default request

View File

@@ -0,0 +1,328 @@
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile, toBlobURL } from '@ffmpeg/util'
export interface VideoClip {
url: string
startTime: number
endTime: number
duration: number
transition?: TransitionEffect
}
export type TransitionType = 'fade' | 'fadeblack' | 'fadewhite' | 'slideleft' | 'slideright' | 'slideup' | 'slidedown' | 'wipeleft' | 'wiperight' | 'circleopen' | 'circleclose' | 'none'
export interface TransitionEffect {
type: TransitionType
duration: number // 转场时长(秒)
}
export interface MergeProgress {
phase: 'loading' | 'processing' | 'encoding' | 'completed'
progress: number
message: string
}
class VideoMerger {
private ffmpeg: FFmpeg
private loaded: boolean = false
private onProgress?: (progress: MergeProgress) => void
constructor() {
this.ffmpeg = new FFmpeg()
}
async initialize(onProgress?: (progress: MergeProgress) => void) {
if (this.loaded) return
this.onProgress = onProgress
this.onProgress?.({
phase: 'loading',
progress: 0,
message: '正在加载FFmpeg引擎首次需要下载约30MB...'
})
// CDN列表优先使用国内CDN
const cdnList = [
'https://unpkg.zhimg.com/@ffmpeg/core@0.12.6/dist/esm', // 知乎CDN镜像国内
'https://npm.elemecdn.com/@ffmpeg/core@0.12.6/dist/esm', // 饿了么CDN国内
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm', // jsDelivr全球CDN国内可用
'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm', // unpkg国外
]
this.ffmpeg.on('log', ({ message }) => {
console.log('[FFmpeg]', message)
})
this.ffmpeg.on('progress', ({ progress, time }) => {
this.onProgress?.({
phase: 'encoding',
progress: Math.round(progress * 100),
message: `正在合并视频... ${Math.round(progress * 100)}%`
})
})
// 尝试多个CDN源
let lastError: Error | null = null
for (let i = 0; i < cdnList.length; i++) {
const baseURL = cdnList[i]
try {
this.onProgress?.({
phase: 'loading',
progress: (i / cdnList.length) * 50,
message: `正在从CDN ${i + 1}/${cdnList.length} 加载FFmpeg...`
})
// 添加超时控制
const loadPromise = this.ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
})
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('加载超时')), 60000) // 60秒超时
})
await Promise.race([loadPromise, timeoutPromise])
// 加载成功
this.loaded = true
this.onProgress?.({
phase: 'loading',
progress: 100,
message: 'FFmpeg加载完成'
})
return
} catch (error) {
console.error(`CDN ${i + 1} 加载失败:`, error)
lastError = error as Error
if (i < cdnList.length - 1) {
this.onProgress?.({
phase: 'loading',
progress: ((i + 1) / cdnList.length) * 50,
message: `CDN ${i + 1} 失败,尝试备用源...`
})
}
}
}
// 所有CDN都失败
throw new Error(`FFmpeg加载失败: ${lastError?.message || '未知错误'}。请检查网络连接或稍后重试。`)
}
async mergeVideos(clips: VideoClip[]): Promise<Blob> {
if (!this.loaded) {
await this.initialize(this.onProgress)
}
if (clips.length === 0) {
throw new Error('没有视频片段')
}
this.onProgress?.({
phase: 'processing',
progress: 0,
message: '正在下载视频片段...'
})
// 并行下载所有视频文件
this.onProgress?.({
phase: 'processing',
progress: 0,
message: `正在下载 ${clips.length} 个视频片段...`
})
const downloadPromises = clips.map((clip, i) =>
fetchFile(clip.url).then(data => ({ index: i, data }))
)
const downloads = await Promise.all(downloadPromises)
this.onProgress?.({
phase: 'processing',
progress: 30,
message: '下载完成,正在处理视频...'
})
// 写入文件系统并处理
const inputFiles: string[] = []
for (let i = 0; i < clips.length; i++) {
const clip = clips[i]
const download = downloads.find(d => d.index === i)!
const inputFileName = `input${i}.mp4`
const outputFileName = `clip${i}.mp4`
// 写入原始视频
await this.ffmpeg.writeFile(inputFileName, download.data)
// 如果需要裁剪,先裁剪视频
if (clip.startTime > 0 || clip.endTime < clip.duration) {
this.onProgress?.({
phase: 'processing',
progress: Math.round(30 + (i / clips.length) * 20),
message: `正在裁剪视频片段 ${i + 1}/${clips.length}...`
})
await this.ffmpeg.exec([
'-i', inputFileName,
'-ss', clip.startTime.toString(),
'-t', (clip.endTime - clip.startTime).toString(),
'-c', 'copy',
outputFileName
])
inputFiles.push(outputFileName)
await this.ffmpeg.deleteFile(inputFileName)
} else {
inputFiles.push(inputFileName)
}
}
this.onProgress?.({
phase: 'processing',
progress: 50,
message: '正在准备合并...'
})
// 检查是否有转场效果
const hasTransitions = clips.some(clip => clip.transition && clip.transition.type !== 'none')
if (!hasTransitions || clips.length === 1) {
// 没有转场效果使用简单的concat方式更快
const concatContent = inputFiles.map(f => `file '${f}'`).join('\n')
await this.ffmpeg.writeFile('concat.txt', concatContent)
this.onProgress?.({
phase: 'encoding',
progress: 0,
message: '正在合并视频...'
})
await this.ffmpeg.exec([
'-f', 'concat',
'-safe', '0',
'-i', 'concat.txt',
'-c', 'copy',
'-movflags', '+faststart',
'output.mp4'
])
} else {
// 有转场效果使用filter_complex需要重新编码
this.onProgress?.({
phase: 'encoding',
progress: 0,
message: '正在添加转场效果并合并视频(这需要较长时间)...'
})
await this.mergeWithTransitions(inputFiles, clips)
}
this.onProgress?.({
phase: 'completed',
progress: 90,
message: '正在生成最终文件...'
})
// 读取输出文件
const data = await this.ffmpeg.readFile('output.mp4')
const blob = new Blob([data], { type: 'video/mp4' })
// 清理临时文件
for (const file of inputFiles) {
await this.ffmpeg.deleteFile(file)
}
await this.ffmpeg.deleteFile('concat.txt')
await this.ffmpeg.deleteFile('output.mp4')
this.onProgress?.({
phase: 'completed',
progress: 100,
message: '合并完成!'
})
return blob
}
private async mergeWithTransitions(inputFiles: string[], clips: VideoClip[]) {
// 构建FFmpeg filter_complex命令
const filterParts: string[] = []
const inputs: string[] = []
// 为每个输入添加标签
for (let i = 0; i < inputFiles.length; i++) {
inputs.push('-i', inputFiles[i])
filterParts.push(`[${i}:v]setpts=PTS-STARTPTS[v${i}]`)
filterParts.push(`[${i}:a]asetpts=PTS-STARTPTS[a${i}]`)
}
// 构建转场链
let videoChain = 'v0'
let audioChain = 'a0'
for (let i = 1; i < clips.length; i++) {
const transition = clips[i].transition
const transType = transition?.type || 'fade'
const transDuration = transition?.duration || 1.0
const offset = clips.slice(0, i).reduce((sum, c) => sum + c.duration, 0) - transDuration
// 视频转场
const xfadeFilter = this.getXfadeFilter(transType, transDuration, offset)
filterParts.push(`[${videoChain}][v${i}]${xfadeFilter}[v${i}out]`)
videoChain = `v${i}out`
// 音频交叉淡入淡出
filterParts.push(`[${audioChain}][a${i}]acrossfade=d=${transDuration}:c1=tri:c2=tri[a${i}out]`)
audioChain = `a${i}out`
}
const filterComplex = filterParts.join(';')
// 执行FFmpeg命令
await this.ffmpeg.exec([
...inputs,
'-filter_complex', filterComplex,
'-map', `[${videoChain}]`,
'-map', `[${audioChain}]`,
'-c:v', 'libx264',
'-preset', 'ultrafast',
'-crf', '23',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
'output.mp4'
])
}
private getXfadeFilter(type: TransitionType, duration: number, offset: number): string {
const xfadeTypes: Record<string, string> = {
'fade': 'fade',
'fadeblack': 'fadeblack',
'fadewhite': 'fadewhite',
'slideleft': 'slideleft',
'slideright': 'slideright',
'slideup': 'slideup',
'slidedown': 'slidedown',
'wipeleft': 'wipeleft',
'wiperight': 'wiperight',
'circleopen': 'circleopen',
'circleclose': 'circleclose'
}
const xfadeType = xfadeTypes[type] || 'fade'
return `xfade=transition=${xfadeType}:duration=${duration}:offset=${offset}`
}
async terminate() {
if (this.loaded) {
this.ffmpeg.terminate()
this.loaded = false
}
}
}
export const videoMerger = new VideoMerger()