523 lines
21 KiB
TypeScript
523 lines
21 KiB
TypeScript
/**
|
||
* 视频处理工具库
|
||
*
|
||
* 提供视频转码、截图等功能
|
||
* 依赖:需要系统安装 ffmpeg
|
||
*
|
||
* 优化说明:
|
||
* - 使用 libx264 (H.264) 编码器,比 libx265 快 3-5 倍
|
||
* - 异步转码,不阻塞上传流程
|
||
* - 只转码预览版本(540P),源文件保持原样
|
||
* - 生成超小预览片段(10秒,360P)用于首页卡片
|
||
*/
|
||
|
||
import { exec } from 'child_process'
|
||
import { promisify } from 'util'
|
||
import { readFile, unlink, stat } from 'fs/promises'
|
||
import { createWriteStream } from 'fs'
|
||
import { tmpdir } from 'os'
|
||
import path from 'path'
|
||
const { join, resolve, isAbsolute } = path
|
||
import { uploadToS3, getS3KeyFromUrl, getS3ObjectStream, isHttpUrl, isS3PublicUrl } from './s3'
|
||
import { Readable } from 'stream'
|
||
import { pipeline } from 'stream/promises'
|
||
|
||
const execAsync = promisify(exec)
|
||
|
||
// 转码参数配置(可通过环境变量覆盖)
|
||
export const TRANSCODE_CONFIG = {
|
||
// 预览视频分辨率(默认 720P,从 4K 源视频缩放以保留细节)
|
||
previewResolution: parseInt(process.env.VIDEO_PREVIEW_RESOLUTION || '720', 10),
|
||
// 预览片段时长(秒,默认 10 秒)
|
||
clipDuration: parseInt(process.env.VIDEO_CLIP_DURATION || '10', 10),
|
||
// 预览视频质量(CRF,默认 20,范围 0-51,越小质量越高。20 = 高质量)
|
||
previewCrf: parseInt(process.env.VIDEO_PREVIEW_CRF || '20', 10),
|
||
// 片段视频质量(CRF,默认 24,提升清晰度)
|
||
clipCrf: parseInt(process.env.VIDEO_CLIP_CRF || '24', 10),
|
||
// 转码超时时间(毫秒,默认 1 小时)
|
||
timeout: parseInt(process.env.VIDEO_TRANSCODE_TIMEOUT || '3600000', 10),
|
||
// 临时文件大小限制(字节,默认 10GB)
|
||
maxTempFileSize: parseInt(process.env.VIDEO_MAX_TEMP_FILE_SIZE || '10737418240', 10),
|
||
// 与 Next.js 共用容器时限制 ffmpeg 线程,避免单个转码任务占满 CPU 和原生内存
|
||
threads: Math.max(1, Math.min(2, parseInt(process.env.VIDEO_TRANSCODE_THREADS || '1', 10) || 1)),
|
||
}
|
||
|
||
interface VideoTranscodeOptions {
|
||
inputPath: string // 输入视频路径(S3 URL 或本地路径)
|
||
outputDir: string // 输出目录
|
||
materialId: string // 素材ID,用于生成唯一文件名
|
||
needsCoverImage?: boolean // 是否需要生成封面图(用户未上传时)
|
||
onProgress?: (progress: number, stage: 'preview' | 'clip' | 'cover') => void // 进度回调:0-100, 阶段
|
||
}
|
||
|
||
interface TranscodeResult {
|
||
preview540Url: string | null // 720P 预览视频 URL(字段名保持540Url以兼容数据库)
|
||
clipUrl: string | null // 10秒片段 URL
|
||
coverUrl: string | null // 封面图 URL
|
||
duration: number | null // 视频时长(秒)
|
||
}
|
||
|
||
async function deleteLocalSourceIfNeeded(inputPath: string) {
|
||
if (!inputPath || isHttpUrl(inputPath)) {
|
||
return
|
||
}
|
||
|
||
const normalizedPath = inputPath.startsWith('file://') ? inputPath.slice(7) : inputPath.replace(/^\.\/+/, '')
|
||
const absolutePath = isAbsolute(normalizedPath) ? normalizedPath : resolve(process.cwd(), normalizedPath)
|
||
|
||
// 使用统一的文件删除工具
|
||
const { deleteLocalFile } = await import('./file-cleanup')
|
||
const result = await deleteLocalFile(absolutePath)
|
||
|
||
if (result.success) {
|
||
const fileType = result.isDirectory ? 'directory' : 'file'
|
||
console.log(`[VideoProcessor] Deleted local source ${fileType}:`, absolutePath)
|
||
} else if (result.error && !result.error.includes('ENOENT')) {
|
||
console.warn('[VideoProcessor] Failed to delete local source file:', absolutePath, result.error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查 ffmpeg 是否可用
|
||
*/
|
||
export async function checkFFmpegAvailable(): Promise<boolean> {
|
||
try {
|
||
await execAsync('ffmpeg -version')
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从 S3 下载视频到临时文件
|
||
*/
|
||
async function downloadRemoteToTemp(remoteUrl: string, tempPath: string): Promise<void> {
|
||
if (!isHttpUrl(remoteUrl)) {
|
||
throw new Error('Invalid remote URL')
|
||
}
|
||
|
||
if (isS3PublicUrl(remoteUrl)) {
|
||
let stream
|
||
try {
|
||
console.log(`[VideoProcessor] Fetching S3 object: ${remoteUrl}`)
|
||
stream = await getS3ObjectStream(getS3KeyFromUrl(remoteUrl))
|
||
if (!stream.body) {
|
||
throw new Error('Failed to get video stream from S3: stream body is null')
|
||
}
|
||
console.log(`[VideoProcessor] S3 stream obtained, contentLength: ${stream.contentLength}`)
|
||
} catch (error: any) {
|
||
const errorMsg = error?.message || String(error)
|
||
console.error(`[VideoProcessor] S3 download error: ${errorMsg}`)
|
||
if (errorMsg.toLowerCase().includes('key does not exist') || errorMsg.toLowerCase().includes('no such key') || errorMsg.toLowerCase().includes('notfound')) {
|
||
throw new Error('The specified key does not exist.')
|
||
}
|
||
if (errorMsg.toLowerCase().includes('no such file') || errorMsg.toLowerCase().includes('enoent')) {
|
||
throw new Error('The specified key does not exist.')
|
||
}
|
||
throw error
|
||
}
|
||
|
||
try {
|
||
if (stream.contentLength && stream.contentLength > TRANSCODE_CONFIG.maxTempFileSize) {
|
||
throw new Error(
|
||
`Remote video too large: ${stream.contentLength} bytes (max ${TRANSCODE_CONFIG.maxTempFileSize})`
|
||
)
|
||
}
|
||
|
||
const readable = stream.body as Readable
|
||
try {
|
||
await pipeline(readable, createWriteStream(tempPath))
|
||
} catch (error) {
|
||
await unlink(tempPath).catch(() => {})
|
||
throw error
|
||
}
|
||
|
||
const fileStat = await stat(tempPath)
|
||
if (fileStat.size > TRANSCODE_CONFIG.maxTempFileSize) {
|
||
await unlink(tempPath).catch(() => {})
|
||
throw new Error(
|
||
`Temp video file too large: ${fileStat.size} bytes (max ${TRANSCODE_CONFIG.maxTempFileSize})`
|
||
)
|
||
}
|
||
console.log(`[VideoProcessor] S3 file written to: ${tempPath}, size=${fileStat.size} bytes`)
|
||
} catch (error: any) {
|
||
const errorMsg = error?.message || String(error)
|
||
console.error(`[VideoProcessor] File write error: ${errorMsg}`)
|
||
if (errorMsg.toLowerCase().includes('no such file') || errorMsg.toLowerCase().includes('enoent')) {
|
||
throw new Error('The specified key does not exist.')
|
||
}
|
||
throw error
|
||
}
|
||
return
|
||
}
|
||
|
||
// 非 S3 的 HTTP(S) 直链
|
||
const res = await fetch(remoteUrl)
|
||
if (!res.ok || !res.body) {
|
||
throw new Error(`HTTP download failed: ${res.status} ${res.statusText}`)
|
||
}
|
||
await new Promise<void>((resolve, reject) => {
|
||
try {
|
||
const fileStream = createWriteStream(tempPath)
|
||
const readable = Readable.fromWeb(res.body as any)
|
||
readable.pipe(fileStream)
|
||
fileStream.on('finish', () => resolve())
|
||
fileStream.on('error', (err) => reject(err))
|
||
readable.on('error', (err) => reject(err))
|
||
} catch (err) {
|
||
reject(err as any)
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* R14修复:获取视频时长(秒),添加30秒超时控制
|
||
*/
|
||
async function getVideoDuration(videoPath: string): Promise<number> {
|
||
try {
|
||
const { stdout } = await execAsync(
|
||
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${videoPath}"`,
|
||
{ timeout: 30000 } // 30秒超时
|
||
)
|
||
const duration = parseFloat(stdout.trim())
|
||
return isNaN(duration) ? 0 : Math.round(duration)
|
||
} catch (error: any) {
|
||
if (error?.killed && error?.signal === 'SIGTERM') {
|
||
console.error('[VideoProcessor] FFprobe timeout after 30s')
|
||
} else {
|
||
console.error('[VideoProcessor] Failed to get video duration:', error)
|
||
}
|
||
return 0
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 执行带超时的 ffmpeg 命令,支持进度回调
|
||
*/
|
||
async function execFFmpegWithTimeout(
|
||
command: string,
|
||
timeout: number = TRANSCODE_CONFIG.timeout,
|
||
onProgress?: (progress: number) => void // progress: 0-100
|
||
): Promise<{ stdout: string; stderr: string }> {
|
||
return new Promise((resolve, reject) => {
|
||
const childProcess = exec(command, { maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
||
if (error) {
|
||
// FFmpeg 即使成功也可能返回非零退出码,需要检查 stderr 是否包含错误
|
||
if (stderr && !stderr.includes('frame=') && !stderr.includes('Duration:')) {
|
||
reject(error)
|
||
return
|
||
}
|
||
}
|
||
resolve({ stdout, stderr })
|
||
})
|
||
|
||
// 解析 FFmpeg 进度输出(从 stderr)
|
||
let duration: number | null = null
|
||
let lastProgress = 0
|
||
let stderrBuffer = ''
|
||
|
||
if (onProgress && childProcess.stderr) {
|
||
childProcess.stderr.on('data', (chunk: Buffer) => {
|
||
// 只保留最近一段日志;FFmpeg 长任务会持续输出进度,不能让字符串无限增长。
|
||
stderrBuffer = `${stderrBuffer}${chunk.toString()}`.slice(-64 * 1024)
|
||
|
||
// 提取视频时长
|
||
const durationMatch = stderrBuffer.match(/Duration:\s*(\d{2}):(\d{2}):(\d{2})\.(\d{2})/)
|
||
if (durationMatch && duration === null) {
|
||
const hours = parseInt(durationMatch[1], 10)
|
||
const minutes = parseInt(durationMatch[2], 10)
|
||
const seconds = parseInt(durationMatch[3], 10)
|
||
const centiseconds = parseInt(durationMatch[4], 10)
|
||
duration = hours * 3600 + minutes * 60 + seconds + centiseconds / 100
|
||
}
|
||
|
||
// 提取当前时间(匹配最新的 time=)
|
||
let latestTime: number | null = null
|
||
const timeRegex = /time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/g
|
||
let timeMatch: RegExpExecArray | null
|
||
while ((timeMatch = timeRegex.exec(stderrBuffer)) !== null) {
|
||
const hours = parseInt(timeMatch[1], 10)
|
||
const minutes = parseInt(timeMatch[2], 10)
|
||
const seconds = parseInt(timeMatch[3], 10)
|
||
const centiseconds = parseInt(timeMatch[4], 10)
|
||
latestTime = hours * 3600 + minutes * 60 + seconds + centiseconds / 100
|
||
}
|
||
|
||
if (latestTime !== null && duration !== null && duration > 0) {
|
||
const progress = Math.min(100, Math.max(0, Math.round((latestTime / duration) * 100)))
|
||
if (progress !== lastProgress && progress > lastProgress) {
|
||
lastProgress = progress
|
||
onProgress(progress)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// R14修复:改进的超时处理 - SIGTERM + 5秒后 SIGKILL
|
||
let timeoutCleared = false
|
||
const timeoutId = setTimeout(() => {
|
||
if (timeoutCleared) return
|
||
|
||
// 先尝试 SIGTERM(优雅退出)
|
||
childProcess.kill('SIGTERM')
|
||
|
||
// 如果进程在5秒后还在运行,使用 SIGKILL(强制退出)
|
||
const killTimeoutId = setTimeout(() => {
|
||
if (timeoutCleared) return
|
||
try {
|
||
childProcess.kill('SIGKILL')
|
||
} catch (e) {
|
||
// 进程可能已经退出,忽略错误
|
||
}
|
||
}, 5000)
|
||
|
||
// 保存 killTimeoutId 以便清理
|
||
;(childProcess as any).killTimeoutId = killTimeoutId
|
||
}, timeout)
|
||
|
||
childProcess.on('exit', () => {
|
||
timeoutCleared = true
|
||
clearTimeout(timeoutId)
|
||
if ((childProcess as any).killTimeoutId) {
|
||
clearTimeout((childProcess as any).killTimeoutId)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 转码为预览视频(分辨率可配置,使用 libx265)
|
||
*/
|
||
async function transcodeToPreview(
|
||
inputPath: string,
|
||
outputPath: string,
|
||
onProgress?: (progress: number) => void
|
||
): Promise<void> {
|
||
const resolution = TRANSCODE_CONFIG.previewResolution
|
||
const height = resolution
|
||
|
||
/**
|
||
* 使用等比缩放,最长边限制在配置分辨率(默认 720P),不再强制填充 16:9。
|
||
* 这样可以保留原始纵横比,避免为竖屏视频生成黑边。
|
||
* scale=-2:${height} 表示高度固定为目标分辨率,宽度自动按比例并保持偶数。
|
||
*
|
||
* 使用 libx265 (H.265) 编码器,相比 libx264 能以更低码率获得更好质量
|
||
* -preset medium: 平衡质量和速度(H.265 转码时间约为 H.264 的 3-5 倍)
|
||
* -crf 22: 预览视频质量(中等-高质量,适合 4K 源视频缩放到 720P)
|
||
*/
|
||
const command = `ffmpeg -threads ${TRANSCODE_CONFIG.threads} -i "${inputPath}" -vf "scale=-2:${height}:flags=lanczos" -c:v libx265 -preset medium -crf ${TRANSCODE_CONFIG.previewCrf} -c:a aac -b:a 128k -movflags +faststart -y "${outputPath}"`
|
||
|
||
const { stdout, stderr } = await execFFmpegWithTimeout(command, TRANSCODE_CONFIG.timeout, onProgress)
|
||
// ffmpeg 通常将进度信息输出到 stderr,这是正常的
|
||
if (stderr && !stderr.includes('frame=') && !stderr.includes('Duration:')) {
|
||
console.warn('[VideoProcessor] FFmpeg stderr output:', stderr.substring(0, 200))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成超小预览片段(时长和分辨率可配置,使用 libx265,无音轨)
|
||
*/
|
||
async function generateClip(
|
||
inputPath: string,
|
||
outputPath: string,
|
||
onProgress?: (progress: number) => void
|
||
): Promise<void> {
|
||
const duration = TRANSCODE_CONFIG.clipDuration
|
||
const height = 360
|
||
|
||
/**
|
||
* ffmpeg 命令:截取指定时长,按最长边 360P 等比缩放
|
||
* -preset ultrafast: 最快编码速度,适合小片段
|
||
* -crf 24: 提升清晰度,保证卡片上的质量
|
||
* -an: 移除音轨(卡片不需要声音,节省文件大小)
|
||
* 使用 libx265 (H.265) 编码器,相比 libx264 能以更低码率获得更好质量
|
||
*/
|
||
const command = `ffmpeg -threads ${TRANSCODE_CONFIG.threads} -i "${inputPath}" -t ${duration} -vf "scale=-2:${height}:flags=lanczos" -c:v libx265 -preset ultrafast -crf ${TRANSCODE_CONFIG.clipCrf} -an -movflags +faststart -y "${outputPath}"`
|
||
|
||
const { stdout, stderr } = await execFFmpegWithTimeout(command, TRANSCODE_CONFIG.timeout, onProgress)
|
||
if (stderr && !stderr.includes('frame=') && !stderr.includes('Duration:')) {
|
||
console.warn('[VideoProcessor] FFmpeg stderr output:', stderr.substring(0, 200))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从视频中截取封面图(第一帧)
|
||
*
|
||
* 为了避免"封面尺寸"和"预览视频尺寸"不一致导致弹窗在点击播放时突然缩放,
|
||
* 这里使用与预览视频一致的等比缩放策略:按最长边限制在 previewResolution。
|
||
*/
|
||
async function extractCoverImage(
|
||
videoPath: string,
|
||
outputPath: string
|
||
): Promise<void> {
|
||
const height = TRANSCODE_CONFIG.previewResolution
|
||
const command = `ffmpeg -threads ${TRANSCODE_CONFIG.threads} -i "${videoPath}" -vf "scale=-2:${height}:flags=lanczos" -frames:v 1 -y "${outputPath}"`
|
||
|
||
const { stdout, stderr } = await execFFmpegWithTimeout(command)
|
||
if (stderr && !stderr.includes('frame=') && !stderr.includes('Duration:')) {
|
||
console.warn('[VideoProcessor] FFmpeg stderr output:', stderr.substring(0, 200))
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理视频转码任务
|
||
*
|
||
* @param options 转码选项
|
||
* @returns 转码结果(包含生成的视频和图片 URL)
|
||
*/
|
||
export async function processVideoTranscode(
|
||
options: VideoTranscodeOptions
|
||
): Promise<TranscodeResult> {
|
||
const { inputPath, outputDir, materialId, needsCoverImage = true, onProgress } = options
|
||
|
||
// 检查 ffmpeg 是否可用
|
||
const ffmpegAvailable = await checkFFmpegAvailable()
|
||
if (!ffmpegAvailable) {
|
||
console.warn('[VideoProcessor] FFmpeg not available, skipping transcode')
|
||
return {
|
||
preview540Url: null,
|
||
clipUrl: null,
|
||
coverUrl: null,
|
||
duration: null,
|
||
}
|
||
}
|
||
|
||
// R38修复:检查磁盘空间(视频转码需要大量临时空间)
|
||
// 估算:源视频 + 预览视频(约为源1/5) + 片段(5MB) + 封面(1MB) + 安全余量
|
||
const estimatedTempSpace = 10 * 1024 * 1024 * 1024 // 默认预留10GB
|
||
try {
|
||
const { ensureEnoughDiskSpace } = await import('./disk-space')
|
||
await ensureEnoughDiskSpace(estimatedTempSpace, '视频转码')
|
||
} catch (diskError: any) {
|
||
console.error('[VideoProcessor] Disk space check failed:', diskError.message)
|
||
// 磁盘空间不足是严重错误,应该阻止转码
|
||
throw diskError
|
||
}
|
||
|
||
const tempDir = tmpdir()
|
||
const tempInputPath = join(tempDir, `video-input-${materialId}-${Date.now()}.mp4`)
|
||
const temp540Path = join(tempDir, `video-540p-${materialId}-${Date.now()}.mp4`)
|
||
const tempClipPath = join(tempDir, `video-clip-${materialId}-${Date.now()}.mp4`)
|
||
const tempCoverPath = join(tempDir, `video-cover-${materialId}-${Date.now()}.jpg`)
|
||
|
||
try {
|
||
// 1. 处理输入视频(S3 URL 或本地路径)
|
||
if (isHttpUrl(inputPath)) {
|
||
console.log('[VideoProcessor] Downloading remote video...')
|
||
onProgress?.(5, 'preview')
|
||
await downloadRemoteToTemp(inputPath, tempInputPath)
|
||
onProgress?.(10, 'preview')
|
||
} else {
|
||
// 本地路径:检查文件是否存在
|
||
const { access, constants, copyFile } = await import('fs/promises')
|
||
let localFilePath = inputPath
|
||
|
||
// 如果是相对路径(以 /uploads/ 开头),转换为绝对路径
|
||
if (inputPath.startsWith('/uploads/')) {
|
||
const uploadDir = process.env.UPLOAD_DIR || './uploads'
|
||
// 如果 uploadDir 是相对路径,需要基于 process.cwd();如果是绝对路径,直接使用
|
||
const absoluteUploadDir = path.isAbsolute(uploadDir) ? uploadDir : join(process.cwd(), uploadDir)
|
||
localFilePath = join(absoluteUploadDir, inputPath.replace('/uploads/', ''))
|
||
} else if (!path.isAbsolute(inputPath)) {
|
||
// 相对路径:基于当前工作目录
|
||
localFilePath = join(process.cwd(), inputPath)
|
||
}
|
||
|
||
console.log('[VideoProcessor] Checking local video file:', localFilePath)
|
||
|
||
try {
|
||
// 检查文件是否存在
|
||
await access(localFilePath, constants.F_OK)
|
||
console.log('[VideoProcessor] Local file exists, copying to temp:', tempInputPath)
|
||
// 如果文件存在,复制到临时路径(因为后续可能会删除原文件)
|
||
await copyFile(localFilePath, tempInputPath)
|
||
console.log('[VideoProcessor] Local file copied successfully')
|
||
onProgress?.(10, 'preview')
|
||
} catch (fileError) {
|
||
// 文件不存在,尝试从 S3 下载(如果 zipFilePath 是 S3 URL)
|
||
console.warn('[VideoProcessor] Local file not found:', localFilePath)
|
||
console.log('[VideoProcessor] Attempting to download from S3...')
|
||
|
||
// 尝试从 S3 下载:如果 inputPath 是 /uploads/xxx,尝试构造 S3 URL
|
||
// 注意:这里假设文件可能已经上传到 S3,使用 zipFilePath 或构造 S3 key
|
||
// 但由于当前函数没有 zipFilePath 参数,我们需要从数据库查询
|
||
// 暂时抛出更明确的错误
|
||
const errorMsg = `Local video file not found: ${localFilePath}. The file may have been moved or deleted. Please re-upload the video or ensure the file exists.`
|
||
console.error('[VideoProcessor]', errorMsg)
|
||
throw new Error(errorMsg)
|
||
}
|
||
}
|
||
|
||
// 2. 获取视频时长
|
||
console.log('[VideoProcessor] Getting video duration...')
|
||
const duration = await getVideoDuration(tempInputPath)
|
||
|
||
// 3. 转码为预览视频(分辨率可配置)
|
||
console.log(`[VideoProcessor] Transcoding to ${TRANSCODE_CONFIG.previewResolution}P preview...`)
|
||
await transcodeToPreview(tempInputPath, temp540Path, (progress) => {
|
||
// 预览转码进度:10% - 50%
|
||
onProgress?.(10 + Math.round(progress * 0.4), 'preview')
|
||
})
|
||
const preview540Buffer = await readFile(temp540Path)
|
||
const preview540FileName = `video-preview-${TRANSCODE_CONFIG.previewResolution}p-${materialId}-${Date.now()}.mp4`
|
||
onProgress?.(55, 'preview') // 上传开始
|
||
const preview540Url = await uploadToS3(preview540Buffer, preview540FileName, 'video/mp4')
|
||
console.log(`[VideoProcessor] ${TRANSCODE_CONFIG.previewResolution}P preview uploaded:`, preview540Url)
|
||
onProgress?.(60, 'clip') // 预览完成,开始片段
|
||
|
||
// 4. 生成预览片段(时长可配置)
|
||
console.log(`[VideoProcessor] Generating ${TRANSCODE_CONFIG.clipDuration}s clip...`)
|
||
await generateClip(tempInputPath, tempClipPath, (progress) => {
|
||
// 片段转码进度:60% - 80%
|
||
onProgress?.(60 + Math.round(progress * 0.2), 'clip')
|
||
})
|
||
const clipBuffer = await readFile(tempClipPath)
|
||
const clipFileName = `video-clip-${TRANSCODE_CONFIG.clipDuration}s-${materialId}-${Date.now()}.mp4`
|
||
onProgress?.(85, 'clip') // 上传开始
|
||
const clipUrl = await uploadToS3(clipBuffer, clipFileName, 'video/mp4')
|
||
console.log(`[VideoProcessor] ${TRANSCODE_CONFIG.clipDuration}s clip uploaded:`, clipUrl)
|
||
onProgress?.(90, 'cover') // 片段完成,开始封面
|
||
|
||
// 5. 截取封面图(仅在需要时)
|
||
let coverUrl: string | null = null
|
||
if (needsCoverImage) {
|
||
console.log('[VideoProcessor] Extracting cover image...')
|
||
await extractCoverImage(tempInputPath, tempCoverPath)
|
||
const coverBuffer = await readFile(tempCoverPath)
|
||
const coverFileName = `video-cover-${materialId}-${Date.now()}.jpg`
|
||
onProgress?.(95, 'cover') // 上传开始
|
||
coverUrl = await uploadToS3(coverBuffer, coverFileName, 'image/jpeg')
|
||
console.log('[VideoProcessor] Cover image uploaded:', coverUrl)
|
||
} else {
|
||
console.log('[VideoProcessor] Skipping cover image extraction (user already uploaded)')
|
||
}
|
||
onProgress?.(100, 'cover') // 完成
|
||
|
||
await deleteLocalSourceIfNeeded(inputPath)
|
||
|
||
return {
|
||
preview540Url,
|
||
clipUrl,
|
||
coverUrl,
|
||
duration,
|
||
}
|
||
} catch (error) {
|
||
console.error('[VideoProcessor] Transcode error:', error)
|
||
throw error
|
||
} finally {
|
||
// 清理临时文件
|
||
const tempFiles = [tempInputPath, temp540Path, tempClipPath, tempCoverPath]
|
||
for (const file of tempFiles) {
|
||
try {
|
||
await unlink(file).catch(() => {
|
||
// 忽略删除失败
|
||
})
|
||
} catch {
|
||
// 忽略错误
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|