/** * 视频处理工具库 * * 提供视频转码、截图等功能 * 依赖:需要系统安装 ffmpeg */ import { exec } from 'child_process' import { promisify } from 'util' import { createReadStream, createWriteStream } from 'fs' import { unlink, stat } from 'fs/promises' import { tmpdir } from 'os' import { join, resolve, isAbsolute } from 'path' import { pipeline } from 'stream/promises' import { uploadStreamToS3, getS3ObjectStream, isS3Url } from './s3' import { Readable } from 'stream' const execAsync = promisify(exec) // 转码参数配置(可通过环境变量覆盖) export const TRANSCODE_CONFIG = { // 预览视频分辨率(默认 540P) previewResolution: parseInt(process.env.VIDEO_PREVIEW_RESOLUTION || '540', 10), // 预览片段时长(秒,默认 10 秒) clipDuration: parseInt(process.env.VIDEO_CLIP_DURATION || '10', 10), // 预览视频质量(CRF,默认 28,范围 0-51,越小质量越高) previewCrf: parseInt(process.env.VIDEO_PREVIEW_CRF || '28', 10), // 片段视频质量(CRF,默认 32,更低质量但文件更小) clipCrf: parseInt(process.env.VIDEO_CLIP_CRF || '32', 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 // 是否需要生成封面图(用户未上传时) } interface TranscodeResult { preview540Url: string | null // 540P 预览视频 URL clipUrl: string | null // 10秒片段 URL coverUrl: string | null // 封面图 URL duration: number | null // 视频时长(秒) } async function deleteLocalSourceIfNeeded(inputPath: string) { if (!inputPath || isS3Url(inputPath)) { return } const normalizedPath = inputPath.startsWith('file://') ? inputPath.slice(7) : inputPath.replace(/^\.\/+/, '') const absolutePath = isAbsolute(normalizedPath) ? normalizedPath : resolve(process.cwd(), normalizedPath) try { await stat(absolutePath) } catch (error) { if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { return } console.warn('[VideoProcessor] Failed to access local source file:', absolutePath, error) return } try { await unlink(absolutePath) console.log('[VideoProcessor] Deleted local source file:', absolutePath) } catch (error) { console.warn('[VideoProcessor] Failed to delete local source file:', absolutePath, error) } } /** * 检查 ffmpeg 是否可用 */ export async function checkFFmpegAvailable(): Promise { try { await execAsync('ffmpeg -version') return true } catch { return false } } /** * 从 S3 下载视频到临时文件 */ async function downloadVideoToTemp(s3Url: string, tempPath: string): Promise { if (!s3Url) { throw new Error('Invalid S3 path') } const stream = await getS3ObjectStream(s3Url) if (!stream.body) { throw new Error('Failed to get video stream from S3') } const readable = stream.body as Readable await pipeline(readable, createWriteStream(tempPath)) } /** * 获取视频时长(秒) */ 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}"` ) const duration = parseFloat(stdout.trim()) return isNaN(duration) ? 0 : Math.round(duration) } catch (error) { console.error('[VideoProcessor] Failed to get video duration:', error) return 0 } } /** * 执行带超时的 ffmpeg 命令 */ async function execFFmpegWithTimeout( command: string, timeout: number = TRANSCODE_CONFIG.timeout ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const childProcess = exec(command, (error, stdout, stderr) => { if (error) { reject(error) return } resolve({ stdout, stderr }) }) // 设置超时 const timeoutId = setTimeout(() => { childProcess.kill('SIGTERM') reject(new Error(`FFmpeg command timeout after ${timeout}ms`)) }, timeout) childProcess.on('exit', () => { clearTimeout(timeoutId) }) }) } /** * 转码为预览视频(分辨率可配置) */ async function transcodeToPreview( inputPath: string, outputPath: string ): Promise { const resolution = TRANSCODE_CONFIG.previewResolution const height = resolution /** * 使用等比缩放,最长边限制在配置分辨率(默认 540P),不再强制填充 16:9。 * 这样可以保留原始纵横比,避免为竖屏视频生成黑边。 * scale=-2:${height} 表示高度固定为目标分辨率,宽度自动按比例并保持偶数。 */ const command = `ffmpeg -i "${inputPath}" -vf "scale=-2:${height}:flags=lanczos" -c:v libx264 -preset medium -crf ${TRANSCODE_CONFIG.previewCrf} -c:a aac -b:a 128k -movflags +faststart -y "${outputPath}"` const { stdout, stderr } = await execFFmpegWithTimeout(command) // ffmpeg 通常将进度信息输出到 stderr,这是正常的 if (stderr && !stderr.includes('frame=') && !stderr.includes('Duration:')) { console.warn('[VideoProcessor] FFmpeg stderr output:', stderr.substring(0, 200)) } } /** * 生成超小预览片段(时长和分辨率可配置) */ async function generateClip( inputPath: string, outputPath: string ): Promise { const duration = TRANSCODE_CONFIG.clipDuration const height = 360 // ffmpeg 命令:截取指定时长,按最长边 360P 等比缩放,超低码率 // -preset ultrafast: 最快编码速度,适合小片段 // -crf: 较低质量但文件更小 // -b:a 64k: 低音频码率 const command = `ffmpeg -i "${inputPath}" -t ${duration} -vf "scale=-2:${height}:flags=lanczos" -c:v libx264 -preset ultrafast -crf ${TRANSCODE_CONFIG.clipCrf} -c:a aac -b:a 64k -movflags +faststart -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)) } } /** * 从视频中截取封面图(第一帧) * * 为了避免“封面尺寸”和“预览视频尺寸”不一致导致弹窗在点击播放时突然缩放, * 这里使用与预览视频一致的等比缩放策略:按最长边限制在 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 } = options // 检查 ffmpeg 是否可用 const ffmpegAvailable = await checkFFmpegAvailable() if (!ffmpegAvailable) { throw new Error('FFmpeg 不可用,无法执行视频转码') } 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. 下载视频到临时文件 console.log('[VideoProcessor] Downloading video from S3...') await downloadVideoToTemp(inputPath, tempInputPath) // 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) const preview540Stat = await stat(temp540Path) const preview540FileName = `video-preview-${TRANSCODE_CONFIG.previewResolution}p-${materialId}-${Date.now()}.mp4` const preview540Url = await uploadStreamToS3( createReadStream(temp540Path), preview540FileName, 'video/mp4', preview540Stat.size ) console.log(`[VideoProcessor] ${TRANSCODE_CONFIG.previewResolution}P preview uploaded:`, preview540Url) // 4. 生成预览片段(时长可配置) console.log(`[VideoProcessor] Generating ${TRANSCODE_CONFIG.clipDuration}s clip...`) await generateClip(tempInputPath, tempClipPath) const clipStat = await stat(tempClipPath) const clipFileName = `video-clip-${TRANSCODE_CONFIG.clipDuration}s-${materialId}-${Date.now()}.mp4` const clipUrl = await uploadStreamToS3( createReadStream(tempClipPath), clipFileName, 'video/mp4', clipStat.size ) console.log(`[VideoProcessor] ${TRANSCODE_CONFIG.clipDuration}s clip uploaded:`, clipUrl) // 5. 截取封面图(仅在需要时) let coverUrl: string | null = null if (needsCoverImage) { console.log('[VideoProcessor] Extracting cover image...') await extractCoverImage(tempInputPath, tempCoverPath) const coverStat = await stat(tempCoverPath) const coverFileName = `video-cover-${materialId}-${Date.now()}.jpg` coverUrl = await uploadStreamToS3( createReadStream(tempCoverPath), coverFileName, 'image/jpeg', coverStat.size ) console.log('[VideoProcessor] Cover image uploaded:', coverUrl) } else { console.log('[VideoProcessor] Skipping cover image extraction (user already uploaded)') } 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 { // 忽略错误 } } } }