import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { createReadStream } from "node:fs"; import { access, stat } from "node:fs/promises"; import { promisify } from "node:util"; import { resolve } from "node:path"; import { dbAll, dbGet } from "./db.mjs"; const execFileAsync = promisify(execFile); const projectRoot = resolve(import.meta.dirname, ".."); const ffmpegBinary = process.env.FFMPEG_BIN || "ffmpeg"; const ffprobeBinary = process.env.FFPROBE_BIN || "ffprobe"; function parseJson(value, fallback) { try { return JSON.parse(value); } catch { return fallback; } } function safeStoragePath(value) { const relative = String(value || "").trim(); if (!relative || relative.startsWith("/") || relative.includes("..") || !relative.startsWith("storage/")) return null; const absolute = resolve(projectRoot, relative); if (!absolute.startsWith(`${projectRoot}/storage/`)) return null; return { relative, absolute }; } function mediaKind(pathname, mimeType = "") { const value = `${pathname} ${mimeType}`.toLowerCase(); if (value.includes("audio") || /\.(wav|mp3|m4a|aac|flac|ogg)$/.test(value)) return "audio"; if (value.includes("video") || /\.(mp4|mov|webm|mkv|avi)$/.test(value)) return "video"; if (value.includes("image") || /\.(png|jpg|jpeg|webp|gif)$/.test(value)) return "image"; return "unknown"; } async function fileSha256(pathname) { return new Promise((resolveHash, reject) => { const hash = createHash("sha256"); const stream = createReadStream(pathname); stream.on("data", (chunk) => hash.update(chunk)); stream.on("error", reject); stream.on("end", () => resolveHash(hash.digest("hex"))); }); } async function probeMedia(pathname) { try { const result = await execFileAsync(ffprobeBinary, [ "-v", "error", "-show_streams", "-show_format", "-of", "json", pathname ], { timeout: 12_000, maxBuffer: 2 * 1024 * 1024 }); return JSON.parse(String(result.stdout || "{}")); } catch (error) { return { error: String(error.stderr || error.message || error).slice(0, 500) }; } } async function averageFrame(pathname, kind, position = "first") { try { const args = ["-v", "error"]; if (kind === "video" && position === "last") args.push("-sseof", "-0.1"); args.push("-i", pathname, "-vf", "scale=1:1,format=rgb24", "-frames:v", "1", "-f", "rawvideo", "pipe:1"); const result = await execFileAsync(ffmpegBinary, args, { timeout: 12_000, maxBuffer: 1024, encoding: "buffer" }); const bytes = Buffer.from(result.stdout || []); if (bytes.length < 3) return null; return [bytes[0], bytes[1], bytes[2]]; } catch { return null; } } function colorDistance(left, right) { if (!left || !right) return null; const distance = Math.sqrt(left.reduce((sum, value, index) => sum + ((value - right[index]) ** 2), 0)) / (255 * Math.sqrt(3)); return Number(distance.toFixed(4)); } export async function inspectStoragePath(value, options = {}) { const target = safeStoragePath(value); if (!target) return { path: String(value || ""), status: "unverified", reason: "路径不是 storage/ 下的安全相对路径" }; try { await access(target.absolute); const info = await stat(target.absolute); const kind = mediaKind(target.relative, options.mimeType); const probe = await probeMedia(target.absolute); const streams = Array.isArray(probe.streams) ? probe.streams : []; const video = streams.find((stream) => stream.codec_type === "video"); const audio = streams.find((stream) => stream.codec_type === "audio"); return { path: target.relative, status: probe.error ? "unverified" : "inspected", bytes: info.size, sha256: await fileSha256(target.absolute), kind, durationSec: Number(probe.format?.duration || video?.duration || audio?.duration || 0), width: Number(video?.width || 0), height: Number(video?.height || 0), hasVideo: Boolean(video), hasAudio: Boolean(audio), probeError: probe.error || "", firstFingerprint: kind === "image" || kind === "video" ? await averageFrame(target.absolute, kind, "first") : null, lastFingerprint: kind === "image" || kind === "video" ? await averageFrame(target.absolute, kind, "last") : null }; } catch (error) { return { path: target.relative, status: "missing", reason: String(error.message || error).slice(0, 300) }; } } function completedJobForShot(shotId, matcher) { const rows = dbAll("SELECT * FROM generation_jobs WHERE shot_id = ? AND status = 'completed' ORDER BY finished_at DESC, created_at DESC", [shotId]); return rows.find((row) => matcher(String(row.kind || "").toLowerCase())) || null; } function resultOutput(row) { if (!row) return ""; const result = parseJson(row.result_json, {}); return String(row.output_path || result.outputPath || result.output_path || result.path || "").trim(); } async function inspectShot(shot, previousShot) { const imageJob = completedJobForShot(shot.id, (kind) => kind.includes("图") || kind.includes("关键帧") || kind.includes("image")); const videoJob = completedJobForShot(shot.id, (kind) => kind.includes("视频") || kind.includes("i2v") || kind.includes("video")); const asrJob = completedJobForShot(shot.id, (kind) => kind.includes("asr") || kind.includes("字幕") || kind.includes("对齐")); const imageResult = parseJson(imageJob?.result_json, {}); const imageArrays = [imageResult.data, imageResult.images, imageResult.outputs].filter(Array.isArray); const videoPath = resultOutput(videoJob); const imageArtifacts = imageJob ? dbAll("SELECT * FROM media_artifacts WHERE job_id = ? ORDER BY created_at DESC", [imageJob.id]) : []; const videoArtifacts = videoJob ? dbAll("SELECT * FROM media_artifacts WHERE job_id = ? ORDER BY created_at DESC", [videoJob.id]) : []; const imageArtifactCount = imageArtifacts.filter((artifact) => artifact.kind === "image" && artifact.status === "inspected").length; const imageOutputCount = imageArtifactCount || (imageArrays.length ? imageArrays[0].length : null); const selectedVideoArtifact = videoArtifacts.find((artifact) => artifact.kind === "video") || null; const selectedVideoPath = selectedVideoArtifact?.path || videoPath; const video = selectedVideoPath ? await inspectStoragePath(selectedVideoPath, { mimeType: selectedVideoArtifact?.mime_type || "" }) : { status: "unverified", reason: "没有已完成的视频任务" }; const lastFrame = await inspectStoragePath(shot.lastFrame); const firstFrame = await inspectStoragePath(shot.firstFrame); const previousLastFrame = previousShot ? await inspectStoragePath(previousShot.lastFrame) : null; const currentFirstFingerprint = firstFrame?.firstFingerprint || firstFrame?.lastFingerprint; const previousLastFingerprint = previousLastFrame?.lastFingerprint || previousLastFrame?.firstFingerprint; const bridgeDistance = colorDistance(previousLastFingerprint, currentFirstFingerprint); const lines = Array.isArray(shot.voiceLines) ? shot.voiceLines : []; const voiceEvidence = await Promise.all(lines.map(async (line) => ({ ...line, media: line.audioFile ? await inspectStoragePath(line.audioFile, { mimeType: "audio/wav" }) : { status: "missing", reason: "对白没有音频路径" } }))); const lockedVoice = lines.length === 0 || voiceEvidence.every((line) => line.voiceId && line.media.status === "inspected"); const asrResult = parseJson(asrJob?.result_json, {}); const segments = Array.isArray(asrResult.segments) ? asrResult.segments : Array.isArray(asrResult.result?.segments) ? asrResult.result.segments : []; const hasAsrEvidence = segments.length > 0 || Boolean(asrJob?.result_json && asrJob.result_json !== "{}"); const targetDialogueSec = lines.reduce((sum, line) => sum + Number(line.targetDurationSec || 0), 0); const singleFrame = imageOutputCount === null ? { status: "pending", blockers: ["没有已完成的单画面图像任务,等待真实模型输出"], evidence: { imageJobId: imageJob?.id || null, imageOutputCount: null } } : { status: imageOutputCount === 1 ? "approved" : "changes_requested", blockers: imageOutputCount === 1 ? [] : [`图像输出数量为 ${imageOutputCount},要求恰好 1 张`], evidence: { imageJobId: imageJob.id, imageOutputCount } }; const continuity = lastFrame.status === "inspected" && (shot.shotNumber <= 1 || shot.transitionFromPrevious === "episode-start" || (bridgeDistance !== null && bridgeDistance <= 0.35)) ? { status: "approved", blockers: [], evidence: { lastFrame, firstFrame, bridgeDistance, source: "frame-inspection" } } : { status: "pending", blockers: [shot.shotNumber > 1 ? "无法确认上一段实际末帧与当前首帧的文件级衔接" : "当前镜头尚未产生可检查的实际末帧"], evidence: { lastFrame, firstFrame, previousLastFrame, bridgeDistance, source: "frame-inspection" } }; const voice = lockedVoice && (lines.length === 0 || (hasAsrEvidence && segments.length > 0)) ? { status: "approved", blockers: [], evidence: { lineCount: lines.length, lockedVoice, asrJobId: asrJob?.id || null, segmentCount: segments.length, targetDialogueSec, voiceEvidence } } : { status: "pending", blockers: [lockedVoice ? "缺少可验证的 ASR/字幕对齐证据" : "对白缺少固定 voiceId 或可读取的音频文件"], evidence: { lineCount: lines.length, lockedVoice, asrJobId: asrJob?.id || null, segmentCount: segments.length, targetDialogueSec, voiceEvidence } }; const clip = video.status === "inspected" && video.hasVideo && Number(video.durationSec || 0) > 0 ? { status: "approved", blockers: [], evidence: { videoJobId: videoJob?.id || null, video } } : { status: "pending", blockers: ["没有可验证的视频文件或 FFprobe 无法读取视频流"], evidence: { videoJobId: videoJob?.id || null, video } }; return { shotId: shot.id, gates: [ { lane: "single-frame", ...singleFrame }, { lane: "continuity-lock", ...continuity }, { lane: "voice-subtitle-asr", ...voice }, { lane: "clip-bridge", ...clip } ], media: { imageJobId: imageJob?.id || null, videoJobId: videoJob?.id || null, asrJobId: asrJob?.id || null, imageArtifacts, videoArtifact: selectedVideoArtifact, video, firstFrame, lastFrame, bridgeDistance } }; } export async function inspectMediaForProject(context, shots) { const reports = []; for (let index = 0; index < shots.length; index += 1) reports.push(await inspectShot(shots[index], shots[index - 1] || null)); const gates = reports.flatMap((report) => report.gates); return { reports, totals: { shots: reports.length, gates: gates.length, approved: gates.filter((gate) => gate.status === "approved").length, changesRequested: gates.filter((gate) => gate.status === "changes_requested").length, pending: gates.filter((gate) => gate.status === "pending").length }, checkedAt: new Date().toISOString(), tools: { ffmpeg: ffmpegBinary, ffprobe: ffprobeBinary }, scope: { organizationId: context.organization?.id, workspaceId: context.workspace?.id, projectId: context.project?.id } }; }