/** * 视频处理工具库 * * 提供视频转码、截图等功能 * 依赖:需要系统安装 ffmpeg * * 当前处理策略: * - 使用 libx265 (H.265) 生成默认 720P 预览和 360P 短片 * - 数据库队列全局单并发,且同时限制 FFmpeg 解码与 x265 内部编码线程 * - 生成文件从磁盘流式上传,避免把大视频整体读入 Node.js 内存 * - canonical 源文件始终保留,只清理本次任务创建的临时文件 */ import { execFile, spawn } from 'child_process' import { promisify } from 'util' import { access, mkdir, mkdtemp, readdir, rm, stat, unlink } from 'fs/promises' import { createWriteStream } from 'fs' import { randomUUID } from 'crypto' import path from 'path' import { deleteFromS3, uploadFileToS3, getS3KeyFromUrl, getS3ObjectStream, isHttpUrl, isS3PublicUrl, } from './s3' import { Readable, Transform } from 'stream' import { pipeline } from 'stream/promises' const execFileAsync = promisify(execFile) // 转码参数配置(可通过环境变量覆盖) 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, 阶段 abortSignal?: AbortSignal // 数据库租约失效或部署排空时终止 ffmpeg } interface TranscodeResult { preview540Url: string | null // 720P 预览视频 URL(字段名保持540Url以兼容数据库) clipUrl: string | null // 10秒片段 URL coverUrl: string | null // 封面图 URL duration: number | null // 视频时长(秒) } /** * 检查 ffmpeg 是否可用 */ export async function checkFFmpegAvailable(): Promise { try { await execFileAsync('ffmpeg', ['-version'], { timeout: 30000 }) return true } catch { return false } } /** * 从 S3 下载视频到临时文件 */ function createByteLimitTransform(maxBytes: number): Transform { let receivedBytes = 0 return new Transform({ transform(chunk: Buffer, _encoding, callback) { receivedBytes += chunk.length if (receivedBytes > maxBytes) { callback(new Error(`Remote video exceeded ${maxBytes} byte limit`)) return } callback(null, chunk) }, }) } /** * processVideoTranscode 尚未把结果交给调用方时,这些 URL 不可能已经被本次 * 任务写入数据库。每个文件名包含 UUID,因此这里只清理本次调用生成的对象。 * 清理是 best-effort:不能让清理失败覆盖真正的转码/上传错误。 */ export async function cleanupUploadedTranscodeObjects(uploadedUrls: readonly string[]): Promise { const cleanupResults = await Promise.allSettled( Array.from(new Set(uploadedUrls.filter((url): url is string => Boolean(url?.trim())))) .map((url) => deleteFromS3(url)), ) for (const cleanupResult of cleanupResults) { if (cleanupResult.status === 'rejected') { console.error( '[VideoProcessor] Failed to clean an unreturned transcode object; manual reconciliation is required:', cleanupResult.reason, ) } } } async function streamToLimitedFile( readable: Readable, tempPath: string, abortSignal?: AbortSignal, ): Promise { try { const streams = [readable, createByteLimitTransform(TRANSCODE_CONFIG.maxTempFileSize), createWriteStream(tempPath)] as const if (abortSignal) { await pipeline(...streams, { signal: abortSignal }) } else { await pipeline(...streams) } } catch (error) { await unlink(tempPath).catch(() => {}) throw error } } async function downloadRemoteToTemp( remoteUrl: string, tempPath: string, abortSignal?: AbortSignal, ): Promise { 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 await streamToLimitedFile(readable, tempPath, abortSignal) 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, { signal: abortSignal }) if (!res.ok || !res.body) { throw new Error(`HTTP download failed: ${res.status} ${res.statusText}`) } const declaredLength = Number(res.headers.get('content-length') || 0) if (Number.isFinite(declaredLength) && declaredLength > TRANSCODE_CONFIG.maxTempFileSize) { await res.body.cancel().catch(() => {}) throw new Error( `Remote video too large: ${declaredLength} bytes (max ${TRANSCODE_CONFIG.maxTempFileSize})` ) } await streamToLimitedFile(Readable.fromWeb(res.body as any), tempPath, abortSignal) 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})` ) } } function getAbortReason(abortSignal?: AbortSignal): Error { return abortSignal?.reason instanceof Error ? abortSignal.reason : new Error('视频转码已取消') } function appendTail(current: string, chunk: Buffer, maxLength: number): string { return `${current}${chunk.toString()}`.slice(-maxLength) } function signalFFmpegProcess( childPid: number | undefined, signal: NodeJS.Signals, childKill: (signal: NodeJS.Signals) => boolean, ): Error | null { if (!childPid) return null try { if (process.platform === 'win32') { if (!childKill(signal)) { return new Error(`Unable to deliver ${signal} to FFmpeg process ${childPid}`) } } else { // detached=true makes ffmpeg the process-group leader; kill the whole group so // future ffmpeg helper children cannot outlive a lost database lease. process.kill(-childPid, signal) } return null } catch (error: any) { if (error?.code === 'ESRCH') return null return error instanceof Error ? error : new Error(String(error)) } } /** * R14修复:获取视频时长(秒),添加30秒超时控制 */ async function getVideoDuration(videoPath: string, abortSignal?: AbortSignal): Promise { try { const { stdout } = await execFileAsync( 'ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', videoPath], { timeout: 30000, encoding: 'utf8', signal: abortSignal } // 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 命令,支持进度回调 */ export async function execFFmpegWithTimeout( args: string[], timeout: number = TRANSCODE_CONFIG.timeout, onProgress?: (progress: number) => void, // progress: 0-100 abortSignal?: AbortSignal, ): Promise<{ stdout: string; stderr: string }> { if (abortSignal?.aborted) { throw getAbortReason(abortSignal) } return new Promise((resolve, reject) => { const childProcess = spawn('ffmpeg', args, { shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], }) let settled = false let terminationReason: Error | null = null let stdoutBuffer = '' let stderrBuffer = '' let progressBuffer = '' let duration: number | null = null let lastProgress = 0 let forceKillTimer: NodeJS.Timeout | null = null const cleanup = () => { clearTimeout(timeoutTimer) if (forceKillTimer) clearTimeout(forceKillTimer) abortSignal?.removeEventListener('abort', onAbort) } const terminate = (reason: Error) => { if (settled || terminationReason) return terminationReason = reason const termError = signalFFmpegProcess(childProcess.pid, 'SIGTERM', signal => childProcess.kill(signal)) if (termError) { console.error('[VideoProcessor] Failed to send SIGTERM to FFmpeg:', termError) } forceKillTimer = setTimeout(() => { if (settled) return const killError = signalFFmpegProcess(childProcess.pid, 'SIGKILL', signal => childProcess.kill(signal)) if (killError) { console.error('[VideoProcessor] Failed to send SIGKILL to FFmpeg:', killError) } }, 5000) } const onAbort = () => terminate(getAbortReason(abortSignal)) const timeoutTimer = setTimeout(() => { terminate(new Error(`FFmpeg timed out after ${timeout}ms`)) }, timeout) abortSignal?.addEventListener('abort', onAbort, { once: true }) if (abortSignal?.aborted) onAbort() childProcess.stdout.on('data', (chunk: Buffer) => { stdoutBuffer = appendTail(stdoutBuffer, chunk, 4 * 1024 * 1024) }) childProcess.stderr.on('data', (chunk: Buffer) => { stderrBuffer = appendTail(stderrBuffer, chunk, 4 * 1024 * 1024) if (onProgress) { progressBuffer = appendTail(progressBuffer, chunk, 64 * 1024) // 提取视频时长 const durationMatch = progressBuffer.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(progressBuffer)) !== 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) } } } }) childProcess.once('error', (error) => { if (settled) return settled = true cleanup() reject(error) }) childProcess.once('close', (code, signal) => { if (settled) return settled = true cleanup() if (terminationReason) { reject(terminationReason) return } if (code !== 0) { const diagnostic = stderrBuffer.slice(-2000) reject(new Error(`FFmpeg exited with code ${code}${signal ? ` (${signal})` : ''}: ${diagnostic}`)) return } resolve({ stdout: stdoutBuffer, stderr: stderrBuffer }) }) }) } function x265ThreadParams(): string { return `pools=${TRANSCODE_CONFIG.threads}:frame-threads=${TRANSCODE_CONFIG.threads}` } export function buildPreviewFFmpegArgs(inputPath: string, outputPath: string): string[] { return [ '-threads', String(TRANSCODE_CONFIG.threads), '-i', inputPath, '-vf', `scale=-2:${TRANSCODE_CONFIG.previewResolution}:flags=lanczos`, '-c:v', 'libx265', '-threads', String(TRANSCODE_CONFIG.threads), '-x265-params', x265ThreadParams(), '-preset', 'medium', '-crf', String(TRANSCODE_CONFIG.previewCrf), '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', '-y', outputPath, ] } export function buildClipFFmpegArgs( inputPath: string, outputPath: string, duration: number = TRANSCODE_CONFIG.clipDuration, height: number = 360, ): string[] { return [ '-threads', String(TRANSCODE_CONFIG.threads), '-i', inputPath, '-t', String(duration), '-vf', `scale=-2:${height}:flags=lanczos`, '-c:v', 'libx265', '-threads', String(TRANSCODE_CONFIG.threads), '-x265-params', x265ThreadParams(), '-preset', 'ultrafast', '-crf', String(TRANSCODE_CONFIG.clipCrf), '-an', '-movflags', '+faststart', '-y', outputPath, ] } export function buildCoverFFmpegArgs(videoPath: string, outputPath: string, height: number): string[] { return [ '-threads', String(TRANSCODE_CONFIG.threads), '-i', videoPath, '-threads', String(TRANSCODE_CONFIG.threads), '-vf', `scale=-2:${height}:flags=lanczos`, '-frames:v', '1', '-y', outputPath, ] } /** * 转码为预览视频(分辨率可配置,使用 libx265) */ async function transcodeToPreview( inputPath: string, outputPath: string, onProgress?: (progress: number) => void, abortSignal?: AbortSignal, ): Promise { /** * 使用等比缩放,把高度固定为配置分辨率(默认 720P),不再强制填充 16:9。 * 这样可以保留原始纵横比,避免为竖屏视频生成黑边。 * scale=-2:${height} 表示高度固定为目标分辨率,宽度自动按比例并保持偶数。 * * 使用 libx265 (H.265) 编码器,相比 libx264 能以更低码率获得更好质量 * -preset medium: 平衡质量和速度(H.265 转码时间约为 H.264 的 3-5 倍) * -crf: 可配置,默认 20(中等-高质量,适合 4K 源视频缩放到 720P) */ const args = buildPreviewFFmpegArgs(inputPath, outputPath) const { stderr } = await execFFmpegWithTimeout(args, TRANSCODE_CONFIG.timeout, onProgress, abortSignal) // 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, abortSignal?: AbortSignal, ): Promise { const duration = TRANSCODE_CONFIG.clipDuration const height = 360 /** * ffmpeg 命令:截取指定时长,把高度固定为 360P 并等比计算偶数宽度 * -preset ultrafast: 最快编码速度,适合小片段 * -crf: 可配置,默认 24,保证卡片上的清晰度 * -an: 移除音轨(卡片不需要声音,节省文件大小) * 使用 libx265 (H.265) 编码器,相比 libx264 能以更低码率获得更好质量 */ const args = buildClipFFmpegArgs(inputPath, outputPath, duration, height) const { stderr } = await execFFmpegWithTimeout(args, TRANSCODE_CONFIG.timeout, onProgress, abortSignal) 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, abortSignal?: AbortSignal, ): Promise { const height = TRANSCODE_CONFIG.previewResolution const args = buildCoverFFmpegArgs(videoPath, outputPath, height) const { stderr } = await execFFmpegWithTimeout(args, TRANSCODE_CONFIG.timeout, undefined, abortSignal) if (stderr && !stderr.includes('frame=') && !stderr.includes('Duration:')) { console.warn('[VideoProcessor] FFmpeg stderr output:', stderr.substring(0, 200)) } } function isPathInside(rootPath: string, candidatePath: string): boolean { const relative = path.relative(rootPath, candidatePath) return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) } function configuredUploadRoot(): string { return path.resolve(process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads')) } export function resolveTranscodeOutputRoot(outputDir: string): string { const uploadRoot = configuredUploadRoot() let candidate: string if (path.isAbsolute(outputDir)) { candidate = path.resolve(outputDir) } else { const segments = outputDir .replace(/^[.][\\/]/, '') .split(/[\\/]+/) .filter(Boolean) if (segments[0]?.toLowerCase() === 'uploads') segments.shift() candidate = path.resolve(uploadRoot, ...segments) } if (!isPathInside(uploadRoot, candidate)) { throw new Error('Video transcode output directory must stay inside UPLOAD_DIR') } return candidate } export function resolveLocalVideoPath(inputPath: string): string { const uploadRoot = configuredUploadRoot() if (inputPath.startsWith('/uploads/')) { const candidate = path.resolve(uploadRoot, inputPath.slice('/uploads/'.length)) if (!isPathInside(uploadRoot, candidate)) { throw new Error('Local video path escapes UPLOAD_DIR') } return candidate } const normalized = inputPath.startsWith('file://') ? inputPath.slice(7) : inputPath return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(process.cwd(), normalized) } async function ensureStrictTranscodeDiskSpace(workRoot: string): Promise { const { checkDiskSpace } = await import('./disk-space') const space = await checkDiskSpace(workRoot) if (!space) { throw new Error('无法确认 uploads/NAS 转码工作目录的剩余空间,已停止本次转码') } // 远端源文件最大可占 maxTempFileSize,另给预览、短片和封面保留 2GB。 const requiredBytes = TRANSCODE_CONFIG.maxTempFileSize + (2 * 1024 * 1024 * 1024) const reservedBytes = Math.max(space.total * 0.1, 5 * 1024 * 1024 * 1024) if (space.available < requiredBytes + reservedBytes) { throw new Error( `uploads/NAS 空间不足,视频转码至少需要保留 ${requiredBytes + reservedBytes} 字节,当前仅 ${space.available} 字节` ) } } async function cleanupStaleVideoWorkDirs(outputRoot: string): Promise { const staleBefore = Date.now() - (24 * 60 * 60 * 1000) const entries = await readdir(outputRoot, { withFileTypes: true }) for (const entry of entries) { if (!entry.isDirectory() || !/^video-task-[a-zA-Z0-9_-]+$/.test(entry.name)) continue const candidate = path.join(outputRoot, entry.name) if (!isPathInside(outputRoot, candidate)) continue const candidateStat = await stat(candidate).catch(() => null) if (!candidateStat || candidateStat.mtimeMs >= staleBefore) continue await rm(candidate, { recursive: true, force: true }).catch(error => { console.warn('[VideoProcessor] Failed to clean stale task work directory:', candidate, error) }) } } /** * 处理视频转码任务 * * @param options 转码选项 * @returns 转码结果(包含生成的视频和图片 URL) */ export async function processVideoTranscode( options: VideoTranscodeOptions ): Promise { const { inputPath, outputDir, materialId, needsCoverImage = true, onProgress, abortSignal } = options // 检查 ffmpeg 是否可用 const ffmpegAvailable = await checkFFmpegAvailable() if (!ffmpegAvailable) { // 不能把“没有生成任何预览”误报为成功,否则素材会永久停在 done 且没有封面。 throw new Error('FFmpeg not available; video transcode cannot produce required previews') } const outputRoot = resolveTranscodeOutputRoot(outputDir) await mkdir(outputRoot, { recursive: true }) await cleanupStaleVideoWorkDirs(outputRoot) await ensureStrictTranscodeDiskSpace(outputRoot) const workDir = await mkdtemp(path.join(outputRoot, 'video-task-')) const remoteInputPath = path.join(workDir, 'remote-source') const previewPath = path.join(workDir, 'preview.mp4') const clipPath = path.join(workDir, 'clip.mp4') const coverPath = path.join(workDir, 'cover.jpg') const outputIdentity = randomUUID() const uploadedUrls: string[] = [] try { let sourcePath: string // 1. 处理输入视频(S3 URL 或本地路径) if (isHttpUrl(inputPath)) { console.log('[VideoProcessor] Downloading remote video...') onProgress?.(5, 'preview') await downloadRemoteToTemp(inputPath, remoteInputPath, abortSignal) sourcePath = remoteInputPath onProgress?.(10, 'preview') } else { // canonical 本地源文件直接以只读输入交给 FFmpeg,不复制、不移动、不删除。 const localFilePath = resolveLocalVideoPath(inputPath) console.log('[VideoProcessor] Checking local video file:', localFilePath) try { await access(localFilePath) const sourceStat = await stat(localFilePath) if (!sourceStat.isFile()) throw new Error('Local video source is not a file') sourcePath = localFilePath console.log('[VideoProcessor] Local canonical source opened read-only') onProgress?.(10, 'preview') } catch (fileError) { console.warn('[VideoProcessor] Local file not found:', localFilePath) 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(sourcePath, abortSignal) // 3. 转码为预览视频(分辨率可配置) console.log(`[VideoProcessor] Transcoding to ${TRANSCODE_CONFIG.previewResolution}P preview...`) await transcodeToPreview(sourcePath, previewPath, (progress) => { // 预览转码进度:10% - 50% onProgress?.(10 + Math.round(progress * 0.4), 'preview') }, abortSignal) const preview540FileName = `video-preview-${TRANSCODE_CONFIG.previewResolution}p-${materialId}-${outputIdentity}.mp4` onProgress?.(55, 'preview') // 上传开始 const preview540Url = await uploadFileToS3(previewPath, preview540FileName, 'video/mp4') uploadedUrls.push(preview540Url) 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(sourcePath, clipPath, (progress) => { // 片段转码进度:60% - 80% onProgress?.(60 + Math.round(progress * 0.2), 'clip') }, abortSignal) const clipFileName = `video-clip-${TRANSCODE_CONFIG.clipDuration}s-${materialId}-${outputIdentity}.mp4` onProgress?.(85, 'clip') // 上传开始 const clipUrl = await uploadFileToS3(clipPath, clipFileName, 'video/mp4') uploadedUrls.push(clipUrl) 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(sourcePath, coverPath, abortSignal) const coverFileName = `video-cover-${materialId}-${outputIdentity}.jpg` onProgress?.(95, 'cover') // 上传开始 coverUrl = await uploadFileToS3(coverPath, coverFileName, 'image/jpeg') uploadedUrls.push(coverUrl) console.log('[VideoProcessor] Cover image uploaded:', coverUrl) } else { console.log('[VideoProcessor] Skipping cover image extraction (user already uploaded)') } onProgress?.(100, 'cover') // 完成 // canonical 源文件是用户上传的唯一原件,转码成功后也必须保留。 // 这里只清理 uploads/NAS 内本次任务创建的唯一工作目录(见 finally)。 return { preview540Url, clipUrl, coverUrl, duration, } } catch (error) { console.error('[VideoProcessor] Transcode error:', error) await cleanupUploadedTranscodeObjects(uploadedUrls) throw error } finally { await rm(workDir, { recursive: true, force: true }).catch(error => { console.warn('[VideoProcessor] Failed to clean task work directory:', error) }) } }