311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
/**
|
||
* 视频处理工具库
|
||
*
|
||
* 提供视频转码、截图等功能
|
||
* 依赖:需要系统安装 ffmpeg
|
||
*/
|
||
|
||
import { exec } from 'child_process'
|
||
import { promisify } from 'util'
|
||
import { readFile, unlink, writeFile, stat } from 'fs/promises'
|
||
import { tmpdir } from 'os'
|
||
import { join, resolve, isAbsolute } from 'path'
|
||
import { uploadToS3, 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<boolean> {
|
||
try {
|
||
await execAsync('ffmpeg -version')
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从 S3 下载视频到临时文件
|
||
*/
|
||
async function downloadVideoToTemp(s3Url: string, tempPath: string): Promise<void> {
|
||
if (!isS3Url(s3Url)) {
|
||
throw new Error('Invalid S3 URL')
|
||
}
|
||
|
||
const stream = await getS3ObjectStream(s3Url)
|
||
if (!stream.body) {
|
||
throw new Error('Failed to get video stream from S3')
|
||
}
|
||
|
||
const chunks: Buffer[] = []
|
||
const readable = stream.body as Readable
|
||
|
||
for await (const chunk of readable) {
|
||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||
}
|
||
|
||
await writeFile(tempPath, Buffer.concat(chunks))
|
||
}
|
||
|
||
/**
|
||
* 获取视频时长(秒)
|
||
*/
|
||
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}"`
|
||
)
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<TranscodeResult> {
|
||
const { inputPath, outputDir, materialId, needsCoverImage = true } = 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,
|
||
}
|
||
}
|
||
|
||
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 preview540Buffer = await readFile(temp540Path)
|
||
const preview540FileName = `video-preview-${TRANSCODE_CONFIG.previewResolution}p-${materialId}-${Date.now()}.mp4`
|
||
const preview540Url = await uploadToS3(preview540Buffer, preview540FileName, 'video/mp4')
|
||
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 clipBuffer = await readFile(tempClipPath)
|
||
const clipFileName = `video-clip-${TRANSCODE_CONFIG.clipDuration}s-${materialId}-${Date.now()}.mp4`
|
||
const clipUrl = await uploadToS3(clipBuffer, clipFileName, 'video/mp4')
|
||
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 coverBuffer = await readFile(tempCoverPath)
|
||
const coverFileName = `video-cover-${materialId}-${Date.now()}.jpg`
|
||
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)')
|
||
}
|
||
|
||
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 {
|
||
// 忽略错误
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|