gongxiangp2/lib/video-processor.ts
2026-07-31 10:40:00 +08:00

523 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 视频处理工具库
*
* 提供视频转码、截图等功能
* 依赖:需要系统安装 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 {
// 忽略错误
}
}
}
}