gongxiangp2/lib/video-processor.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

719 lines
27 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
*
* 当前处理策略:
* - 使用 libx265 (H.265) 生成默认 720P 预览和 360P 短片
* - 数据库队列全局单并发,且同时限制 FFmpeg 解码与 x265 内部编码线程
* - 生成文件从磁盘流式上传,避免把大视频整体读入 Node.js 内存
* - canonical 源文件始终保留,只清理本次任务创建的临时文件
*/
import { execFile, spawn } from 'child_process'
import { promisify } from 'util'
import { access, mkdir, mkdtemp, readdir, rm, stat, unlink } from 'fs/promises'
import { createWriteStream } from 'fs'
import { randomUUID } from 'crypto'
import path from 'path'
import {
deleteFromS3,
uploadFileToS3,
getS3KeyFromUrl,
getS3ObjectStream,
isHttpUrl,
isS3PublicUrl,
} from './s3'
import { Readable, Transform } from 'stream'
import { pipeline } from 'stream/promises'
const execFileAsync = promisify(execFile)
// 转码参数配置(可通过环境变量覆盖)
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, 阶段
abortSignal?: AbortSignal // 数据库租约失效或部署排空时终止 ffmpeg
}
interface TranscodeResult {
preview540Url: string | null // 720P 预览视频 URL(字段名保持540Url以兼容数据库)
clipUrl: string | null // 10秒片段 URL
coverUrl: string | null // 封面图 URL
duration: number | null // 视频时长(秒)
}
/**
* 检查 ffmpeg 是否可用
*/
export async function checkFFmpegAvailable(): Promise<boolean> {
try {
await execFileAsync('ffmpeg', ['-version'], { timeout: 30000 })
return true
} catch {
return false
}
}
/**
* 从 S3 下载视频到临时文件
*/
function createByteLimitTransform(maxBytes: number): Transform {
let receivedBytes = 0
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
receivedBytes += chunk.length
if (receivedBytes > maxBytes) {
callback(new Error(`Remote video exceeded ${maxBytes} byte limit`))
return
}
callback(null, chunk)
},
})
}
/**
* processVideoTranscode 尚未把结果交给调用方时,这些 URL 不可能已经被本次
* 任务写入数据库。每个文件名包含 UUID,因此这里只清理本次调用生成的对象。
* 清理是 best-effort:不能让清理失败覆盖真正的转码/上传错误。
*/
export async function cleanupUploadedTranscodeObjects(uploadedUrls: readonly string[]): Promise<void> {
const cleanupResults = await Promise.allSettled(
Array.from(new Set(uploadedUrls.filter((url): url is string => Boolean(url?.trim()))))
.map((url) => deleteFromS3(url)),
)
for (const cleanupResult of cleanupResults) {
if (cleanupResult.status === 'rejected') {
console.error(
'[VideoProcessor] Failed to clean an unreturned transcode object; manual reconciliation is required:',
cleanupResult.reason,
)
}
}
}
async function streamToLimitedFile(
readable: Readable,
tempPath: string,
abortSignal?: AbortSignal,
): Promise<void> {
try {
const streams = [readable, createByteLimitTransform(TRANSCODE_CONFIG.maxTempFileSize), createWriteStream(tempPath)] as const
if (abortSignal) {
await pipeline(...streams, { signal: abortSignal })
} else {
await pipeline(...streams)
}
} catch (error) {
await unlink(tempPath).catch(() => {})
throw error
}
}
async function downloadRemoteToTemp(
remoteUrl: string,
tempPath: string,
abortSignal?: AbortSignal,
): 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
await streamToLimitedFile(readable, tempPath, abortSignal)
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, { signal: abortSignal })
if (!res.ok || !res.body) {
throw new Error(`HTTP download failed: ${res.status} ${res.statusText}`)
}
const declaredLength = Number(res.headers.get('content-length') || 0)
if (Number.isFinite(declaredLength) && declaredLength > TRANSCODE_CONFIG.maxTempFileSize) {
await res.body.cancel().catch(() => {})
throw new Error(
`Remote video too large: ${declaredLength} bytes (max ${TRANSCODE_CONFIG.maxTempFileSize})`
)
}
await streamToLimitedFile(Readable.fromWeb(res.body as any), tempPath, abortSignal)
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})`
)
}
}
function getAbortReason(abortSignal?: AbortSignal): Error {
return abortSignal?.reason instanceof Error
? abortSignal.reason
: new Error('视频转码已取消')
}
function appendTail(current: string, chunk: Buffer, maxLength: number): string {
return `${current}${chunk.toString()}`.slice(-maxLength)
}
function signalFFmpegProcess(
childPid: number | undefined,
signal: NodeJS.Signals,
childKill: (signal: NodeJS.Signals) => boolean,
): Error | null {
if (!childPid) return null
try {
if (process.platform === 'win32') {
if (!childKill(signal)) {
return new Error(`Unable to deliver ${signal} to FFmpeg process ${childPid}`)
}
} else {
// detached=true makes ffmpeg the process-group leader; kill the whole group so
// future ffmpeg helper children cannot outlive a lost database lease.
process.kill(-childPid, signal)
}
return null
} catch (error: any) {
if (error?.code === 'ESRCH') return null
return error instanceof Error ? error : new Error(String(error))
}
}
/**
* R14修复:获取视频时长(秒),添加30秒超时控制
*/
async function getVideoDuration(videoPath: string, abortSignal?: AbortSignal): Promise<number> {
try {
const { stdout } = await execFileAsync(
'ffprobe',
['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', videoPath],
{ timeout: 30000, encoding: 'utf8', signal: abortSignal } // 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 命令,支持进度回调
*/
export async function execFFmpegWithTimeout(
args: string[],
timeout: number = TRANSCODE_CONFIG.timeout,
onProgress?: (progress: number) => void, // progress: 0-100
abortSignal?: AbortSignal,
): Promise<{ stdout: string; stderr: string }> {
if (abortSignal?.aborted) {
throw getAbortReason(abortSignal)
}
return new Promise((resolve, reject) => {
const childProcess = spawn('ffmpeg', args, {
shell: false,
windowsHide: true,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
})
let settled = false
let terminationReason: Error | null = null
let stdoutBuffer = ''
let stderrBuffer = ''
let progressBuffer = ''
let duration: number | null = null
let lastProgress = 0
let forceKillTimer: NodeJS.Timeout | null = null
const cleanup = () => {
clearTimeout(timeoutTimer)
if (forceKillTimer) clearTimeout(forceKillTimer)
abortSignal?.removeEventListener('abort', onAbort)
}
const terminate = (reason: Error) => {
if (settled || terminationReason) return
terminationReason = reason
const termError = signalFFmpegProcess(childProcess.pid, 'SIGTERM', signal => childProcess.kill(signal))
if (termError) {
console.error('[VideoProcessor] Failed to send SIGTERM to FFmpeg:', termError)
}
forceKillTimer = setTimeout(() => {
if (settled) return
const killError = signalFFmpegProcess(childProcess.pid, 'SIGKILL', signal => childProcess.kill(signal))
if (killError) {
console.error('[VideoProcessor] Failed to send SIGKILL to FFmpeg:', killError)
}
}, 5000)
}
const onAbort = () => terminate(getAbortReason(abortSignal))
const timeoutTimer = setTimeout(() => {
terminate(new Error(`FFmpeg timed out after ${timeout}ms`))
}, timeout)
abortSignal?.addEventListener('abort', onAbort, { once: true })
if (abortSignal?.aborted) onAbort()
childProcess.stdout.on('data', (chunk: Buffer) => {
stdoutBuffer = appendTail(stdoutBuffer, chunk, 4 * 1024 * 1024)
})
childProcess.stderr.on('data', (chunk: Buffer) => {
stderrBuffer = appendTail(stderrBuffer, chunk, 4 * 1024 * 1024)
if (onProgress) {
progressBuffer = appendTail(progressBuffer, chunk, 64 * 1024)
// 提取视频时长
const durationMatch = progressBuffer.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(progressBuffer)) !== 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)
}
}
}
})
childProcess.once('error', (error) => {
if (settled) return
settled = true
cleanup()
reject(error)
})
childProcess.once('close', (code, signal) => {
if (settled) return
settled = true
cleanup()
if (terminationReason) {
reject(terminationReason)
return
}
if (code !== 0) {
const diagnostic = stderrBuffer.slice(-2000)
reject(new Error(`FFmpeg exited with code ${code}${signal ? ` (${signal})` : ''}: ${diagnostic}`))
return
}
resolve({ stdout: stdoutBuffer, stderr: stderrBuffer })
})
})
}
function x265ThreadParams(): string {
return `pools=${TRANSCODE_CONFIG.threads}:frame-threads=${TRANSCODE_CONFIG.threads}`
}
export function buildPreviewFFmpegArgs(inputPath: string, outputPath: string): string[] {
return [
'-threads', String(TRANSCODE_CONFIG.threads),
'-i', inputPath,
'-vf', `scale=-2:${TRANSCODE_CONFIG.previewResolution}:flags=lanczos`,
'-c:v', 'libx265',
'-threads', String(TRANSCODE_CONFIG.threads),
'-x265-params', x265ThreadParams(),
'-preset', 'medium',
'-crf', String(TRANSCODE_CONFIG.previewCrf),
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
'-y', outputPath,
]
}
export function buildClipFFmpegArgs(
inputPath: string,
outputPath: string,
duration: number = TRANSCODE_CONFIG.clipDuration,
height: number = 360,
): string[] {
return [
'-threads', String(TRANSCODE_CONFIG.threads),
'-i', inputPath,
'-t', String(duration),
'-vf', `scale=-2:${height}:flags=lanczos`,
'-c:v', 'libx265',
'-threads', String(TRANSCODE_CONFIG.threads),
'-x265-params', x265ThreadParams(),
'-preset', 'ultrafast',
'-crf', String(TRANSCODE_CONFIG.clipCrf),
'-an',
'-movflags', '+faststart',
'-y', outputPath,
]
}
export function buildCoverFFmpegArgs(videoPath: string, outputPath: string, height: number): string[] {
return [
'-threads', String(TRANSCODE_CONFIG.threads),
'-i', videoPath,
'-threads', String(TRANSCODE_CONFIG.threads),
'-vf', `scale=-2:${height}:flags=lanczos`,
'-frames:v', '1',
'-y', outputPath,
]
}
/**
* 转码为预览视频(分辨率可配置,使用 libx265)
*/
async function transcodeToPreview(
inputPath: string,
outputPath: string,
onProgress?: (progress: number) => void,
abortSignal?: AbortSignal,
): Promise<void> {
/**
* 使用等比缩放,把高度固定为配置分辨率(默认 720P),不再强制填充 16:9。
* 这样可以保留原始纵横比,避免为竖屏视频生成黑边。
* scale=-2:${height} 表示高度固定为目标分辨率,宽度自动按比例并保持偶数。
*
* 使用 libx265 (H.265) 编码器,相比 libx264 能以更低码率获得更好质量
* -preset medium: 平衡质量和速度(H.265 转码时间约为 H.264 的 3-5 倍)
* -crf: 可配置,默认 20(中等-高质量,适合 4K 源视频缩放到 720P)
*/
const args = buildPreviewFFmpegArgs(inputPath, outputPath)
const { stderr } = await execFFmpegWithTimeout(args, TRANSCODE_CONFIG.timeout, onProgress, abortSignal)
// 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,
abortSignal?: AbortSignal,
): Promise<void> {
const duration = TRANSCODE_CONFIG.clipDuration
const height = 360
/**
* ffmpeg 命令:截取指定时长,把高度固定为 360P 并等比计算偶数宽度
* -preset ultrafast: 最快编码速度,适合小片段
* -crf: 可配置,默认 24,保证卡片上的清晰度
* -an: 移除音轨(卡片不需要声音,节省文件大小)
* 使用 libx265 (H.265) 编码器,相比 libx264 能以更低码率获得更好质量
*/
const args = buildClipFFmpegArgs(inputPath, outputPath, duration, height)
const { stderr } = await execFFmpegWithTimeout(args, TRANSCODE_CONFIG.timeout, onProgress, abortSignal)
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,
abortSignal?: AbortSignal,
): Promise<void> {
const height = TRANSCODE_CONFIG.previewResolution
const args = buildCoverFFmpegArgs(videoPath, outputPath, height)
const { stderr } = await execFFmpegWithTimeout(args, TRANSCODE_CONFIG.timeout, undefined, abortSignal)
if (stderr && !stderr.includes('frame=') && !stderr.includes('Duration:')) {
console.warn('[VideoProcessor] FFmpeg stderr output:', stderr.substring(0, 200))
}
}
function isPathInside(rootPath: string, candidatePath: string): boolean {
const relative = path.relative(rootPath, candidatePath)
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
}
function configuredUploadRoot(): string {
return path.resolve(process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads'))
}
export function resolveTranscodeOutputRoot(outputDir: string): string {
const uploadRoot = configuredUploadRoot()
let candidate: string
if (path.isAbsolute(outputDir)) {
candidate = path.resolve(outputDir)
} else {
const segments = outputDir
.replace(/^[.][\\/]/, '')
.split(/[\\/]+/)
.filter(Boolean)
if (segments[0]?.toLowerCase() === 'uploads') segments.shift()
candidate = path.resolve(uploadRoot, ...segments)
}
if (!isPathInside(uploadRoot, candidate)) {
throw new Error('Video transcode output directory must stay inside UPLOAD_DIR')
}
return candidate
}
function resolveLocalVideoPath(inputPath: string): string {
const uploadRoot = configuredUploadRoot()
if (inputPath.startsWith('/uploads/')) {
const candidate = path.resolve(uploadRoot, inputPath.slice('/uploads/'.length))
if (!isPathInside(uploadRoot, candidate)) {
throw new Error('Local video path escapes UPLOAD_DIR')
}
return candidate
}
const normalized = inputPath.startsWith('file://') ? inputPath.slice(7) : inputPath
return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(process.cwd(), normalized)
}
async function ensureStrictTranscodeDiskSpace(workRoot: string): Promise<void> {
const { checkDiskSpace } = await import('./disk-space')
const space = await checkDiskSpace(workRoot)
if (!space) {
throw new Error('无法确认 uploads/NAS 转码工作目录的剩余空间,已停止本次转码')
}
// 远端源文件最大可占 maxTempFileSize,另给预览、短片和封面保留 2GB。
const requiredBytes = TRANSCODE_CONFIG.maxTempFileSize + (2 * 1024 * 1024 * 1024)
const reservedBytes = Math.max(space.total * 0.1, 5 * 1024 * 1024 * 1024)
if (space.available < requiredBytes + reservedBytes) {
throw new Error(
`uploads/NAS 空间不足,视频转码至少需要保留 ${requiredBytes + reservedBytes} 字节,当前仅 ${space.available} 字节`
)
}
}
async function cleanupStaleVideoWorkDirs(outputRoot: string): Promise<void> {
const staleBefore = Date.now() - (24 * 60 * 60 * 1000)
const entries = await readdir(outputRoot, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || !/^video-task-[a-zA-Z0-9_-]+$/.test(entry.name)) continue
const candidate = path.join(outputRoot, entry.name)
if (!isPathInside(outputRoot, candidate)) continue
const candidateStat = await stat(candidate).catch(() => null)
if (!candidateStat || candidateStat.mtimeMs >= staleBefore) continue
await rm(candidate, { recursive: true, force: true }).catch(error => {
console.warn('[VideoProcessor] Failed to clean stale task work directory:', candidate, error)
})
}
}
/**
* 处理视频转码任务
*
* @param options 转码选项
* @returns 转码结果(包含生成的视频和图片 URL)
*/
export async function processVideoTranscode(
options: VideoTranscodeOptions
): Promise<TranscodeResult> {
const { inputPath, outputDir, materialId, needsCoverImage = true, onProgress, abortSignal } = options
// 检查 ffmpeg 是否可用
const ffmpegAvailable = await checkFFmpegAvailable()
if (!ffmpegAvailable) {
// 不能把“没有生成任何预览”误报为成功,否则素材会永久停在 done 且没有封面。
throw new Error('FFmpeg not available; video transcode cannot produce required previews')
}
const outputRoot = resolveTranscodeOutputRoot(outputDir)
await mkdir(outputRoot, { recursive: true })
await cleanupStaleVideoWorkDirs(outputRoot)
await ensureStrictTranscodeDiskSpace(outputRoot)
const workDir = await mkdtemp(path.join(outputRoot, 'video-task-'))
const remoteInputPath = path.join(workDir, 'remote-source')
const previewPath = path.join(workDir, 'preview.mp4')
const clipPath = path.join(workDir, 'clip.mp4')
const coverPath = path.join(workDir, 'cover.jpg')
const outputIdentity = randomUUID()
const uploadedUrls: string[] = []
try {
let sourcePath: string
// 1. 处理输入视频(S3 URL 或本地路径)
if (isHttpUrl(inputPath)) {
console.log('[VideoProcessor] Downloading remote video...')
onProgress?.(5, 'preview')
await downloadRemoteToTemp(inputPath, remoteInputPath, abortSignal)
sourcePath = remoteInputPath
onProgress?.(10, 'preview')
} else {
// canonical 本地源文件直接以只读输入交给 FFmpeg,不复制、不移动、不删除。
const localFilePath = resolveLocalVideoPath(inputPath)
console.log('[VideoProcessor] Checking local video file:', localFilePath)
try {
await access(localFilePath)
const sourceStat = await stat(localFilePath)
if (!sourceStat.isFile()) throw new Error('Local video source is not a file')
sourcePath = localFilePath
console.log('[VideoProcessor] Local canonical source opened read-only')
onProgress?.(10, 'preview')
} catch (fileError) {
console.warn('[VideoProcessor] Local file not found:', localFilePath)
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(sourcePath, abortSignal)
// 3. 转码为预览视频(分辨率可配置)
console.log(`[VideoProcessor] Transcoding to ${TRANSCODE_CONFIG.previewResolution}P preview...`)
await transcodeToPreview(sourcePath, previewPath, (progress) => {
// 预览转码进度:10% - 50%
onProgress?.(10 + Math.round(progress * 0.4), 'preview')
}, abortSignal)
const preview540FileName = `video-preview-${TRANSCODE_CONFIG.previewResolution}p-${materialId}-${outputIdentity}.mp4`
onProgress?.(55, 'preview') // 上传开始
const preview540Url = await uploadFileToS3(previewPath, preview540FileName, 'video/mp4')
uploadedUrls.push(preview540Url)
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(sourcePath, clipPath, (progress) => {
// 片段转码进度:60% - 80%
onProgress?.(60 + Math.round(progress * 0.2), 'clip')
}, abortSignal)
const clipFileName = `video-clip-${TRANSCODE_CONFIG.clipDuration}s-${materialId}-${outputIdentity}.mp4`
onProgress?.(85, 'clip') // 上传开始
const clipUrl = await uploadFileToS3(clipPath, clipFileName, 'video/mp4')
uploadedUrls.push(clipUrl)
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(sourcePath, coverPath, abortSignal)
const coverFileName = `video-cover-${materialId}-${outputIdentity}.jpg`
onProgress?.(95, 'cover') // 上传开始
coverUrl = await uploadFileToS3(coverPath, coverFileName, 'image/jpeg')
uploadedUrls.push(coverUrl)
console.log('[VideoProcessor] Cover image uploaded:', coverUrl)
} else {
console.log('[VideoProcessor] Skipping cover image extraction (user already uploaded)')
}
onProgress?.(100, 'cover') // 完成
// canonical 源文件是用户上传的唯一原件,转码成功后也必须保留。
// 这里只清理 uploads/NAS 内本次任务创建的唯一工作目录(见 finally)。
return {
preview540Url,
clipUrl,
coverUrl,
duration,
}
} catch (error) {
console.error('[VideoProcessor] Transcode error:', error)
await cleanupUploadedTranscodeObjects(uploadedUrls)
throw error
} finally {
await rm(workDir, { recursive: true, force: true }).catch(error => {
console.warn('[VideoProcessor] Failed to clean task work directory:', error)
})
}
}