/** * 视频处理工具库 * * 提供视频转码、截图等功能 * 依赖:需要系统安装 ffmpeg * * 优化说明: * - 使用 libx264 (H.264) 编码器,比 libx265 快 3-5 倍 * - 异步转码,不阻塞上传流程 * - 只转码预览版本(540P),源文件保持原样 * - 生成超小预览片段(10秒,360P)用于首页卡片 */ import { exec } from 'child_process' import { promisify } from 'util' import { readFile, unlink, writeFile, stat } from 'fs/promises' import { createWriteStream } from 'fs' import { tmpdir } from 'os' import path from 'path' const { join, resolve, isAbsolute } = path import { uploadToS3, getS3ObjectStream, isHttpUrl, isS3PublicUrl } from './s3' import { Readable } from 'stream' 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), } 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 { try { await execAsync('ffmpeg -version') return true } catch { return false } } /** * 从 S3 下载视频到临时文件 */ async function downloadRemoteToTemp(remoteUrl: string, tempPath: string): 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(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 { const chunks: Buffer[] = [] const readable = stream.body as Readable console.log(`[VideoProcessor] Reading stream chunks...`) for await (const chunk of readable) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) } const totalSize = Buffer.concat(chunks).length console.log(`[VideoProcessor] Stream read complete, total size: ${totalSize} bytes`) await writeFile(tempPath, Buffer.concat(chunks)) console.log(`[VideoProcessor] File written to: ${tempPath}`) const { stat } = await import('fs/promises') const fileStat = await stat(tempPath) console.log(`[VideoProcessor] File verified, 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((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 { 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: 10 * 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) => { stderrBuffer += chunk.toString() // 提取视频时长 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 { 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 -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 { const duration = TRANSCODE_CONFIG.clipDuration const height = 360 /** * ffmpeg 命令:截取指定时长,按最长边 360P 等比缩放 * -preset ultrafast: 最快编码速度,适合小片段 * -crf 24: 提升清晰度,保证卡片上的质量 * -an: 移除音轨(卡片不需要声音,节省文件大小) * 使用 libx265 (H.265) 编码器,相比 libx264 能以更低码率获得更好质量 */ const command = `ffmpeg -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 { const height = TRANSCODE_CONFIG.previewResolution const command = `ffmpeg -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 { 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 { // 忽略错误 } } } }