gongxiangp2/lib/video-transcode-task.ts
chenpei 5ef70bce4e fix: 转码按正式目录找源文件,失败只给用户可重试提示
把 /uploads 网址路径落到 UPLOAD_DIR,避免误判文件不存在。
页面和接口不再回传路径或堆栈,只提示稍后重试。
2026-08-17 15:24:13 +08:00

498 lines
17 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.

// 视频转码任务封装,供上传接口和转码API复用
import { prisma } from '@/lib/prisma'
import { transcodeQueue } from '@/lib/video-transcode-queue'
import { processVideoTranscode, resolveLocalVideoPath } from '@/lib/video-processor'
import { access } from 'fs/promises'
export const USER_TRANSCODE_FAILURE_MESSAGE = '预览还没做好,请稍后重试'
import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'
const MAX_TRANSCODE_RETRIES = 3
type CurrentLeaseResult<T> =
| { current: true; value: T }
| { current: false }
type LockedLeaseMaterial = {
videoCoverUrl: string | null
previewUrl: string
}
type TranscodeResult = Awaited<ReturnType<typeof processVideoTranscode>>
type CommittedTranscodeSnapshot = {
videoProcessingStatus: 'done'
videoPreview540Url: string
videoPreviewClipUrl: string
videoCoverUrl: string | null
previewUrl: string
videoDuration: number | null
transcodeLastError: null
transcodeAttempts: number
}
type ObservedTranscodeSnapshot = {
videoProcessingStatus: string | null
videoPreview540Url: string | null
videoPreviewClipUrl: string | null
videoCoverUrl: string | null
previewUrl: string
videoDuration: number | null
transcodeLastError: string | null
transcodeAttempts: number | null
}
type TranscodeFinalizeResult =
| { state: 'committed' }
| { state: 'not_committed'; error: unknown }
| { state: 'unknown'; error: unknown; observed?: ObservedTranscodeSnapshot | null }
/**
* 所有任务状态写入都以 PostgreSQL 的 clock_timestamp() 判定租约,不能使用
* 应用容器的本地时间。FOR UPDATE 同时把“校验 token”和后续写入收在一个事务里。
*/
async function withCurrentDatabaseLease<T>(
materialId: string,
leaseToken: string,
action: (tx: any, material: LockedLeaseMaterial) => Promise<T>,
): Promise<CurrentLeaseResult<T>> {
return prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Array<LockedLeaseMaterial>>`
SELECT "videoCoverUrl", "previewUrl"
FROM "materials"
WHERE "id" = ${materialId}
AND "transcode_lease_token" = ${leaseToken}
AND "transcode_lease_expires_at" > clock_timestamp()
FOR UPDATE
`
const material = rows[0]
if (!material) {
return { current: false } as const
}
return { current: true, value: await action(tx, material) } as const
})
}
class NonRetryableTranscodeError extends Error {
constructor(message: string) {
super(message)
this.name = 'NonRetryableTranscodeError'
}
}
export class TranscodeCommitStateUnknownError extends Error {
readonly originalError: unknown
constructor(materialId: string, originalError: unknown) {
super(`视频转码结果的数据库提交状态无法确认,已保留生成物等待恢复或人工对账: ${materialId}`)
this.name = 'TranscodeCommitStateUnknownError'
this.originalError = originalError
}
}
function extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'string') {
return error
}
try {
return JSON.stringify(error)
} catch {
return String(error)
}
}
function isNonRetryableTranscodeError(error: unknown): boolean {
if (error instanceof NonRetryableTranscodeError) {
return true
}
const code = (error as { code?: string } | undefined)?.code
if (code && code.toUpperCase() === 'ENOENT') {
return true
}
const message = extractErrorMessage(error)
return message.includes('FFmpeg not available')
}
function generatedOutputUrls(result: TranscodeResult): string[] {
return Array.from(new Set([
result.preview540Url,
result.clipUrl,
result.coverUrl,
].filter((url): url is string => Boolean(url?.trim()))))
}
/**
* 任务拿到 processor 结果后,只有在明确未提交时才会调用这里。仍通过全库引用
* 复核再删,防止租约切换期间另一个任务或用户操作已经引用了相同 URL。
*/
export async function cleanupUncommittedTranscodeOutputs(
materialId: string,
result: TranscodeResult,
): Promise<void> {
const cleanupResults = await Promise.allSettled(
generatedOutputUrls(result).map((url) => deleteFileIfUnreferenced(url)),
)
for (const cleanupResult of cleanupResults) {
if (cleanupResult.status === 'rejected') {
console.error(
`[Transcode] Failed to clean an uncommitted output for material ${materialId}; manual reconciliation is required:`,
cleanupResult.reason,
)
} else if (!cleanupResult.value.success) {
console.error(
`[Transcode] Uncommitted output cleanup did not succeed for material ${materialId}:`,
cleanupResult.value.error,
)
}
}
}
function snapshotMatches(
observed: ObservedTranscodeSnapshot,
expected: CommittedTranscodeSnapshot,
): boolean {
return observed.videoProcessingStatus === expected.videoProcessingStatus
&& observed.videoPreview540Url === expected.videoPreview540Url
&& observed.videoPreviewClipUrl === expected.videoPreviewClipUrl
&& observed.videoCoverUrl === expected.videoCoverUrl
&& observed.previewUrl === expected.previewUrl
&& observed.videoDuration === expected.videoDuration
&& observed.transcodeLastError === expected.transcodeLastError
&& observed.transcodeAttempts === expected.transcodeAttempts
}
function observedReferencesGeneratedOutput(
observed: ObservedTranscodeSnapshot,
result: TranscodeResult,
): boolean {
const generatedUrls = new Set(generatedOutputUrls(result))
return [
observed.videoPreview540Url,
observed.videoPreviewClipUrl,
observed.videoCoverUrl,
observed.previewUrl,
].some((url) => Boolean(url && generatedUrls.has(url)))
}
async function readCommittedTranscodeSnapshot(
materialId: string,
): Promise<ObservedTranscodeSnapshot | null> {
return prisma.material.findUnique({
where: { id: materialId },
select: {
videoProcessingStatus: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
previewUrl: true,
videoDuration: true,
transcodeLastError: true,
transcodeAttempts: true,
},
})
}
async function finalizeTranscodeResult(
materialId: string,
leaseToken: string,
attempt: number,
result: TranscodeResult,
): Promise<TranscodeFinalizeResult> {
let expectedSnapshot: CommittedTranscodeSnapshot | null = null
try {
const completed = await withCurrentDatabaseLease<{ count: number }>(materialId, leaseToken, async (tx, lockedMaterial) => {
const currentCoverUrl = lockedMaterial.videoCoverUrl?.trim()
? lockedMaterial.videoCoverUrl
: null
const currentPreviewUrl = lockedMaterial.previewUrl?.trim()
? lockedMaterial.previewUrl
: null
const automaticCoverUrl = result.coverUrl || null
expectedSnapshot = {
videoProcessingStatus: 'done',
videoPreview540Url: result.preview540Url!,
videoPreviewClipUrl: result.clipUrl!,
videoCoverUrl: currentCoverUrl || automaticCoverUrl,
previewUrl: currentPreviewUrl || currentCoverUrl || automaticCoverUrl || '',
videoDuration: result.duration,
transcodeLastError: null,
transcodeAttempts: attempt,
}
return tx.material.updateMany({
where: { id: materialId, transcodeLeaseToken: leaseToken },
data: {
videoProcessingStatus: 'done',
videoPreview540Url: result.preview540Url,
videoPreviewClipUrl: result.clipUrl,
// 只填空值;事务锁保证判断与写入之间不会被并发更新穿透。
videoCoverUrl: currentCoverUrl ? undefined : automaticCoverUrl || undefined,
videoDuration: result.duration,
transcodeLastError: null,
transcodeAttempts: attempt,
previewUrl: currentPreviewUrl
? undefined
: currentCoverUrl || automaticCoverUrl || undefined,
},
})
})
if (!completed.current || completed.value.count !== 1) {
return {
state: 'not_committed',
error: new StaleTranscodeLeaseError(materialId),
}
}
return { state: 'committed' }
} catch (commitError) {
let observed: ObservedTranscodeSnapshot | null
try {
observed = await readCommittedTranscodeSnapshot(materialId)
} catch (verificationError) {
console.error(
`[Transcode] Cannot verify completion after database error for material ${materialId}; preserving outputs:`,
verificationError,
)
return { state: 'unknown', error: commitError }
}
if (expectedSnapshot && observed && snapshotMatches(observed, expectedSnapshot)) {
console.warn(
`[Transcode] Completion acknowledgement was lost for material ${materialId}, but the exact committed state was verified`,
)
return { state: 'committed' }
}
if (observed && observedReferencesGeneratedOutput(observed, result)) {
console.error(
`[Transcode] Completion state is partial or changed for material ${materialId}; preserving outputs for reconciliation`,
)
return { state: 'unknown', error: commitError, observed }
}
// PostgreSQL 事务已返回失败,且主库回读确认没有任何本次唯一 URL 被引用。
// 这是“明确未提交”;实际物理删除仍由 deleteFileIfUnreferenced 二次复核。
return { state: 'not_committed', error: commitError }
}
}
/**
* 实际的转码任务处理函数
*
* 内部包含最多 3 次重试逻辑,队列外部无需处理 retry。
*/
export async function executeTranscodeTask(
materialId: string,
fallbackVideoSourceUrl: string,
needsCoverImage: boolean,
leaseToken: string,
abortSignal: AbortSignal = new AbortController().signal,
): Promise<void> {
let attempt = 0
const throwIfInterrupted = () => {
if (abortSignal.aborted) {
// 心跳、租约或 active marker 的状态无法继续证明时,当前执行结果必须
// 视为陈旧。这个分支绝不消耗重试次数,也绝不把可恢复任务写成 failed。
throw new StaleTranscodeLeaseError(materialId)
}
}
while (attempt < MAX_TRANSCODE_RETRIES) {
throwIfInterrupted()
attempt += 1
let attemptResult: TranscodeResult | null = null
let outputDisposition: 'none' | 'pending' | 'cleaned' | 'preserve' | 'committed' = 'none'
try {
console.log(`[Transcode] Starting task for material ${materialId} (attempt ${attempt}/${MAX_TRANSCODE_RETRIES})`)
const latestMaterial = await prisma.material.findUnique({
where: { id: materialId },
select: {
videoSourceUrl: true,
videoCoverUrl: true,
previewUrl: true,
},
})
const videoSourceUrl = latestMaterial?.videoSourceUrl || fallbackVideoSourceUrl
if (!videoSourceUrl) {
throw new NonRetryableTranscodeError(USER_TRANSCODE_FAILURE_MESSAGE)
}
// 入队时的 needsCoverImage 只是快照;真正开始处理时以数据库最新值为准,
// 避免排队期间用户已补手动封面却仍被自动截图覆盖。
const coverStillNeeded = !latestMaterial?.videoCoverUrl?.trim()
if (needsCoverImage !== coverStillNeeded) {
console.log(`[Transcode] Cover requirement changed while queued for material ${materialId}; using latest database value`)
}
const fileExists = await validateVideoFile(videoSourceUrl)
if (!fileExists) {
throw new Error(USER_TRANSCODE_FAILURE_MESSAGE)
}
const started = await withCurrentDatabaseLease<{ count: number }>(materialId, leaseToken, (tx) => tx.material.updateMany({
where: { id: materialId, transcodeLeaseToken: leaseToken },
data: { videoProcessingStatus: 'processing', transcodeLastError: null },
}))
if (!started.current || started.value.count !== 1) {
throw new StaleTranscodeLeaseError(materialId)
}
attemptResult = await processVideoTranscode({
inputPath: videoSourceUrl,
outputDir: './uploads/transcoded',
materialId,
needsCoverImage: coverStillNeeded,
abortSignal,
onProgress: (progress, stage) => {
console.log(`[Transcode] Material ${materialId} - ${stage}: ${progress}%`)
void transcodeQueue.updateProgress(materialId, leaseToken, progress, stage)
.catch((error) => console.error(`[Transcode] Failed to persist progress for ${materialId}:`, error))
},
})
outputDisposition = 'pending'
const result = attemptResult
throwIfInterrupted()
if (!result.preview540Url || !result.clipUrl || (coverStillNeeded && !result.coverUrl)) {
throw new NonRetryableTranscodeError('视频转码输出不完整,拒绝标记为完成')
}
// 锁住当前素材后只填空封面/首页预览。若事务响应丢失,必须精确回读全部
// 结果字段;只有 exact match 才当作成功,部分匹配一律保留对象等待对账。
const finalized = await finalizeTranscodeResult(materialId, leaseToken, attempt, result)
if (finalized.state === 'unknown') {
outputDisposition = 'preserve'
throw new TranscodeCommitStateUnknownError(materialId, finalized.error)
}
if (finalized.state === 'not_committed') {
await cleanupUncommittedTranscodeOutputs(materialId, result)
outputDisposition = 'cleaned'
throw finalized.error
}
outputDisposition = 'committed'
console.log(`[Transcode] Task completed for material ${materialId}`)
return
} catch (error) {
if (attemptResult && outputDisposition === 'pending') {
// processor 已成功返回,而完成事务尚未被确认;所有进入 unknown 的分支
// 都会先把 disposition 改为 preserve,因此这里仅覆盖明确未提交阶段。
await cleanupUncommittedTranscodeOutputs(materialId, attemptResult)
outputDisposition = 'cleaned'
}
if (error instanceof TranscodeCommitStateUnknownError) {
// 不能再写 failed 或重跑 FFmpeg;原事务可能已经提交。
throw error
}
if (abortSignal.aborted) {
throw new StaleTranscodeLeaseError(materialId)
}
if (error instanceof StaleTranscodeLeaseError) {
// 蓝绿切换或租约过期后,旧进程不得把成功/失败结果覆盖到新持有者。
throw error
}
const errorMsg = extractErrorMessage(error)
console.error(`[Transcode] Task attempt ${attempt} failed for material ${materialId}:`, errorMsg)
const shouldRetry = attempt < MAX_TRANSCODE_RETRIES && !isNonRetryableTranscodeError(error)
if (!shouldRetry) {
const failed = await withCurrentDatabaseLease<{ count: number }>(materialId, leaseToken, (tx) => tx.material.updateMany({
where: { id: materialId, transcodeLeaseToken: leaseToken },
data: {
videoProcessingStatus: 'failed',
transcodeLastError: USER_TRANSCODE_FAILURE_MESSAGE,
transcodeAttempts: attempt,
},
}))
if (!failed.current || failed.value.count !== 1) {
throw new StaleTranscodeLeaseError(materialId)
}
const finalError = error instanceof Error ? error : new Error(errorMsg)
throw finalError
}
const retryRecorded = await withCurrentDatabaseLease<{ count: number }>(materialId, leaseToken, (tx) => tx.material.updateMany({
where: { id: materialId, transcodeLeaseToken: leaseToken },
data: {
transcodeLastError: USER_TRANSCODE_FAILURE_MESSAGE,
transcodeAttempts: attempt,
},
}))
if (!retryRecorded.current || retryRecorded.value.count !== 1) {
throw new StaleTranscodeLeaseError(materialId)
}
console.warn(`[Transcode] Retrying material ${materialId} (${attempt}/${MAX_TRANSCODE_RETRIES})`)
}
}
}
export class StaleTranscodeLeaseError extends Error {
constructor(materialId: string) {
super(`视频转码租约已失效,拒绝写入旧进程结果: ${materialId}`)
this.name = 'StaleTranscodeLeaseError'
}
}
/**
* 验证视频文件是否存在
*/
async function validateVideoFile(videoSourceUrl: string): Promise<boolean> {
try {
if (videoSourceUrl.startsWith('http://') || videoSourceUrl.startsWith('https://')) {
return true
}
const localFilePath = resolveLocalVideoPath(videoSourceUrl)
await access(localFilePath)
console.log(`[TranscodeTask] Video source is ready: ${localFilePath}`)
return true
} catch (error) {
console.error('[TranscodeTask] Video source is not readable', {
videoSourceUrl,
error,
})
return false
}
}
/**
* 入队一个视频转码任务
*/
export async function enqueueVideoTranscodeTask(
materialId: string,
videoSourceUrl: string,
needsCoverImage: boolean
): Promise<void> {
// 验证视频文件是否存在
const fileExists = await validateVideoFile(videoSourceUrl)
if (!fileExists) {
throw new Error(USER_TRANSCODE_FAILURE_MESSAGE)
}
console.log(`[TranscodeTask] 📝 Enqueuing transcode task: materialId=${materialId}, videoSourceUrl=${videoSourceUrl}, needsCoverImage=${needsCoverImage}`)
// 只等待数据库持久化入队,不等待 ffmpeg;失败需要反馈给调用方,不能静默丢任务。
await transcodeQueue.add(materialId, videoSourceUrl, needsCoverImage, executeTranscodeTask)
console.log(`[TranscodeTask] ✅ Transcode task enqueued successfully (fire-and-forget): materialId=${materialId}`)
}