719 lines
27 KiB
TypeScript
719 lines
27 KiB
TypeScript
/**
|
||
* 视频处理工具库
|
||
*
|
||
* 提供视频转码、截图等功能
|
||
* 依赖:需要系统安装 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
|
||
}
|
||
|
||
export 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)
|
||
})
|
||
}
|
||
}
|
||
|