204 lines
11 KiB
JavaScript
204 lines
11 KiB
JavaScript
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 }
|
|
};
|
|
}
|