flnxi-Wu/lib/video-processor.ts
WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

311 lines
11 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
*/
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 {
// 忽略错误
}
}
}
}