344 lines
15 KiB
JavaScript
344 lines
15 KiB
JavaScript
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { createHash } from "node:crypto";
|
|
import { execFile } from "node:child_process";
|
|
import { resolve, extname, basename } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
|
import { inspectStoragePath } from "./media-qa.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const projectRoot = resolve(import.meta.dirname, "..");
|
|
const ffmpegBinary = process.env.FFMPEG_BIN || "ffmpeg";
|
|
|
|
function parseJson(value, fallback) {
|
|
try { return JSON.parse(value); } catch { return fallback; }
|
|
}
|
|
|
|
function now() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function makeId(prefix, value = "") {
|
|
const digest = createHash("sha1").update(String(value)).digest("hex").slice(0, 12);
|
|
return `${prefix}-${digest}`;
|
|
}
|
|
|
|
function kindForJob(job) {
|
|
const kind = String(job?.kind || "").toLowerCase();
|
|
if (kind.includes("视频") || kind.includes("i2v") || kind.includes("video")) return "video";
|
|
if (kind.includes("图") || kind.includes("关键帧") || kind.includes("image")) return "image";
|
|
if (kind.includes("tts") || kind.includes("配音") || kind.includes("声音") || kind.includes("audio")) return "audio";
|
|
if (kind.includes("asr") || kind.includes("字幕") || kind.includes("对齐")) return "json";
|
|
return "unknown";
|
|
}
|
|
|
|
function extensionFor(kind, mimeType = "") {
|
|
const value = `${mimeType} ${kind}`.toLowerCase();
|
|
if (value.includes("wav") || value.includes("audio")) return ".wav";
|
|
if (value.includes("mp4") || value.includes("video")) return ".mp4";
|
|
if (value.includes("webp")) return ".webp";
|
|
if (value.includes("jpeg") || value.includes("jpg")) return ".jpg";
|
|
if (value.includes("json")) return ".json";
|
|
return ".png";
|
|
}
|
|
|
|
function candidatePath(value) {
|
|
if (typeof value === "string") return value.trim();
|
|
if (!value || typeof value !== "object") return "";
|
|
return String(value.outputPath || value.output_path || value.path || value.file || value.url || "").trim();
|
|
}
|
|
|
|
function candidateMime(value, fallback = "") {
|
|
if (!value || typeof value !== "object") return fallback;
|
|
return String(value.mimeType || value.mime_type || value.contentType || value.content_type || fallback).trim();
|
|
}
|
|
|
|
function safeOutputPath(value) {
|
|
const path = String(value || "").trim();
|
|
if (!path || path.startsWith("/") || path.includes("..") || !path.startsWith("storage/")) return "";
|
|
return path;
|
|
}
|
|
|
|
function mimeForPath(pathname) {
|
|
const extension = extname(String(pathname || "")).toLowerCase();
|
|
return {
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".webp": "image/webp",
|
|
".gif": "image/gif",
|
|
".wav": "audio/wav",
|
|
".mp3": "audio/mpeg",
|
|
".m4a": "audio/mp4",
|
|
".mp4": "video/mp4",
|
|
".webm": "video/webm",
|
|
".json": "application/json",
|
|
".txt": "text/plain; charset=utf-8"
|
|
}[extension] || "application/octet-stream";
|
|
}
|
|
|
|
async function materializeBase64(job, value, index, kind, mimeType = "") {
|
|
const encoded = typeof value === "string" ? value : value?.b64_json || value?.base64 || "";
|
|
if (!encoded) return "";
|
|
const payload = encoded.includes(",") && encoded.startsWith("data:") ? encoded.split(",", 2)[1] : encoded;
|
|
let buffer;
|
|
try { buffer = Buffer.from(payload, "base64"); } catch { return ""; }
|
|
if (!buffer.length) return "";
|
|
const relative = `storage/jobs/${job.id}/outputs/${kind}-${index + 1}${extensionFor(kind, mimeType)}`;
|
|
await mkdir(resolve(projectRoot, `storage/jobs/${job.id}/outputs`), { recursive: true });
|
|
await writeFile(resolve(projectRoot, relative), buffer);
|
|
return relative;
|
|
}
|
|
|
|
async function extractVideoFrames(owner, inputPath, kind) {
|
|
if (kind !== "video") return { first: "", last: "" };
|
|
const prefix = owner.jobId ? `storage/jobs/${owner.jobId}` : `storage/compositions/${owner.compositionId}`;
|
|
if (!prefix || !inputPath) return { first: "", last: "" };
|
|
const frameDirectory = resolve(projectRoot, prefix, "frames");
|
|
await mkdir(frameDirectory, { recursive: true });
|
|
const first = `${prefix}/frames/first.jpg`;
|
|
const last = `${prefix}/frames/last.jpg`;
|
|
const jobs = [
|
|
["first", ["-y", "-v", "error", "-i", resolve(projectRoot, inputPath), "-frames:v", "1", "-q:v", "2", resolve(projectRoot, first)]],
|
|
["last", ["-y", "-v", "error", "-sseof", "-0.1", "-i", resolve(projectRoot, inputPath), "-frames:v", "1", "-q:v", "2", resolve(projectRoot, last)]]
|
|
];
|
|
const result = { first: "", last: "" };
|
|
for (const [name, args] of jobs) {
|
|
try {
|
|
await execFileAsync(ffmpegBinary, args, { timeout: 20_000, maxBuffer: 1024 * 1024 });
|
|
await access(resolve(projectRoot, name === "first" ? first : last));
|
|
result[name] = name === "first" ? first : last;
|
|
} catch {
|
|
result[name] = "";
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function artifactPayload(row) {
|
|
if (!row) return null;
|
|
return {
|
|
...row,
|
|
metadata: parseJson(row.metadata_json, {}),
|
|
fileSize: Number(row.file_size || 0),
|
|
durationSec: Number(row.duration_sec || 0),
|
|
width: Number(row.width || 0),
|
|
height: Number(row.height || 0),
|
|
hasVideo: Boolean(row.has_video),
|
|
hasAudio: Boolean(row.has_audio)
|
|
};
|
|
}
|
|
|
|
async function registerPath(context, descriptor) {
|
|
const path = safeOutputPath(descriptor.path);
|
|
if (!path) return null;
|
|
const inspected = await inspectStoragePath(path, { mimeType: descriptor.mimeType || "" });
|
|
const timestamp = now();
|
|
const identity = `${descriptor.jobId || "no-job"}:${descriptor.compositionId || "no-composition"}:${descriptor.role || "output"}:${path}`;
|
|
const id = makeId("artifact", identity);
|
|
const metadata = {
|
|
...(descriptor.metadata || {}),
|
|
inspectedAt: timestamp,
|
|
probeError: inspected.probeError || inspected.reason || ""
|
|
};
|
|
dbRun(
|
|
`INSERT INTO media_artifacts(
|
|
id, organization_id, workspace_id, project_id, episode_id, shot_id, job_id, composition_id,
|
|
kind, role, status, path, mime_type, file_size, sha256, duration_sec, width, height,
|
|
has_video, has_audio, first_frame_path, last_frame_path, metadata_json, created_by, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
status = excluded.status,
|
|
mime_type = excluded.mime_type,
|
|
file_size = excluded.file_size,
|
|
sha256 = excluded.sha256,
|
|
duration_sec = excluded.duration_sec,
|
|
width = excluded.width,
|
|
height = excluded.height,
|
|
has_video = excluded.has_video,
|
|
has_audio = excluded.has_audio,
|
|
first_frame_path = excluded.first_frame_path,
|
|
last_frame_path = excluded.last_frame_path,
|
|
metadata_json = excluded.metadata_json,
|
|
updated_at = excluded.updated_at`,
|
|
[
|
|
id,
|
|
context.organization.id,
|
|
context.workspace.id,
|
|
context.project.id,
|
|
descriptor.episodeId || null,
|
|
descriptor.shotId || null,
|
|
descriptor.jobId || null,
|
|
descriptor.compositionId || null,
|
|
descriptor.kind || "unknown",
|
|
descriptor.role || "output",
|
|
inspected.status,
|
|
path,
|
|
descriptor.mimeType || "",
|
|
Number(inspected.bytes || 0),
|
|
inspected.sha256 || "",
|
|
Number(inspected.durationSec || 0),
|
|
Number(inspected.width || 0),
|
|
Number(inspected.height || 0),
|
|
inspected.hasVideo ? 1 : 0,
|
|
inspected.hasAudio ? 1 : 0,
|
|
descriptor.firstFramePath || "",
|
|
descriptor.lastFramePath || "",
|
|
JSON.stringify(metadata),
|
|
descriptor.createdBy || context.user?.id || null,
|
|
timestamp,
|
|
timestamp
|
|
]
|
|
);
|
|
const row = dbGet("SELECT * FROM media_artifacts WHERE id = ?", [id]) || dbGet("SELECT * FROM media_artifacts WHERE job_id IS ? AND composition_id IS ? AND path = ? AND role = ? ORDER BY updated_at DESC LIMIT 1", [descriptor.jobId || null, descriptor.compositionId || null, path, descriptor.role || "output"]);
|
|
return { row, inspected };
|
|
}
|
|
|
|
export async function registerJobArtifacts(context, job, result = {}) {
|
|
const kind = kindForJob(job);
|
|
const candidates = [];
|
|
const seen = new Set();
|
|
const add = async (value, role = "output", mimeType = "") => {
|
|
const path = candidatePath(value);
|
|
if (path) {
|
|
const safe = safeOutputPath(path);
|
|
if (!safe || seen.has(`${role}:${safe}`)) return;
|
|
seen.add(`${role}:${safe}`);
|
|
candidates.push({ path: safe, role, mimeType: candidateMime(value, mimeType) });
|
|
return;
|
|
}
|
|
const encoded = typeof value === "string" ? value : value?.b64_json || value?.base64 || "";
|
|
if (!encoded) return;
|
|
const materialized = await materializeBase64(job, value, candidates.length, kind, candidateMime(value, mimeType));
|
|
if (materialized && !seen.has(`${role}:${materialized}`)) {
|
|
seen.add(`${role}:${materialized}`);
|
|
candidates.push({ path: materialized, role, mimeType: candidateMime(value, mimeType) });
|
|
}
|
|
};
|
|
|
|
const hasArrayOutputs = [result.data, result.images, result.outputs].some(Array.isArray);
|
|
const normalizedOutput = result.outputPath || result.output_path || result.path || result.file;
|
|
if (!hasArrayOutputs || normalizedOutput !== job.output_path) {
|
|
await add(normalizedOutput, "output", result.mimeType || result.contentType || "");
|
|
}
|
|
await add(result.audioPath || result.audio_path, "audio", "audio/wav");
|
|
await add(result.videoPath || result.video_path, "video", "video/mp4");
|
|
await add(result.imagePath || result.image_path, "image", "image/png");
|
|
for (const key of ["data", "images", "outputs"]) {
|
|
if (!Array.isArray(result[key])) continue;
|
|
for (const value of result[key]) await add(value, kind === "image" ? "image" : "output", candidateMime(value, kind === "image" ? "image/png" : ""));
|
|
}
|
|
if (!candidates.length && job.output_path) await add(job.output_path, "output", "");
|
|
|
|
const artifacts = [];
|
|
for (const candidate of candidates) {
|
|
const registered = await registerPath(context, {
|
|
...candidate,
|
|
kind,
|
|
jobId: job.id,
|
|
episodeId: job.episode_id,
|
|
shotId: job.shot_id,
|
|
createdBy: job.created_by,
|
|
metadata: { source: "generation-job", resultKeys: Object.keys(result || {}) }
|
|
});
|
|
if (!registered?.row) continue;
|
|
let row = registered.row;
|
|
if (kind === "video" && registered.inspected.status === "inspected") {
|
|
const frames = await extractVideoFrames({ jobId: job.id }, candidate.path, kind);
|
|
if (frames.first || frames.last) {
|
|
dbRun("UPDATE media_artifacts SET first_frame_path = ?, last_frame_path = ?, updated_at = ? WHERE id = ?", [frames.first, frames.last, now(), row.id]);
|
|
row = dbGet("SELECT * FROM media_artifacts WHERE id = ?", [row.id]);
|
|
if (job.shot_id && frames.last) {
|
|
const currentShot = dbGet("SELECT last_frame_path FROM shots WHERE id = ?", [job.shot_id]);
|
|
const currentLastFrame = await inspectStoragePath(currentShot?.last_frame_path || "");
|
|
const canKeepCurrentLastFrame = currentLastFrame.status === "inspected" && !/pending|auto_previous/i.test(currentShot?.last_frame_path || "");
|
|
if (!canKeepCurrentLastFrame) dbRun("UPDATE shots SET last_frame_path = ?, updated_at = ? WHERE id = ?", [frames.last, now(), job.shot_id]);
|
|
const nextShot = dbGet("SELECT next.id, next.first_frame_path FROM shots current_shot JOIN shots next ON next.episode_id = current_shot.episode_id AND next.shot_number = current_shot.shot_number + 1 WHERE current_shot.id = ?", [job.shot_id]);
|
|
const nextFirstFrame = await inspectStoragePath(nextShot?.first_frame_path || "");
|
|
if (nextShot && (nextFirstFrame.status !== "inspected" || /pending|auto_previous|actual-last-frame/i.test(nextShot.first_frame_path || ""))) {
|
|
dbRun("UPDATE shots SET first_frame_path = ?, updated_at = ? WHERE id = ?", [frames.last, now(), nextShot.id]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
artifacts.push(artifactPayload(row));
|
|
}
|
|
return artifacts;
|
|
}
|
|
|
|
export async function registerCompositionArtifact(context, composition, path) {
|
|
const registered = await registerPath(context, {
|
|
path,
|
|
kind: "video",
|
|
role: "composition-output",
|
|
compositionId: composition.id,
|
|
episodeId: composition.episode_id,
|
|
createdBy: composition.created_by,
|
|
metadata: { source: "ffmpeg-composition", version: composition.version }
|
|
});
|
|
if (!registered?.row) return null;
|
|
const frames = await extractVideoFrames({ compositionId: composition.id }, path, "video");
|
|
if (frames.first || frames.last) {
|
|
dbRun("UPDATE media_artifacts SET first_frame_path = ?, last_frame_path = ?, updated_at = ? WHERE id = ?", [frames.first, frames.last, now(), registered.row.id]);
|
|
}
|
|
return artifactPayload(dbGet("SELECT * FROM media_artifacts WHERE id = ?", [registered.row.id]));
|
|
}
|
|
|
|
export async function syncProjectJobArtifacts(context) {
|
|
const jobs = dbAll("SELECT * FROM generation_jobs WHERE organization_id = ? AND workspace_id = ? AND project_id = ? AND status = 'completed' ORDER BY finished_at DESC", [context.organization.id, context.workspace.id, context.project.id]);
|
|
const artifacts = [];
|
|
for (const job of jobs) {
|
|
const result = parseJson(job.result_json, {});
|
|
artifacts.push(...await registerJobArtifacts(context, job, result));
|
|
}
|
|
return artifacts;
|
|
}
|
|
|
|
export function listProjectArtifacts(context, options = {}) {
|
|
const params = [context.organization.id, context.workspace.id, context.project.id];
|
|
let where = "organization_id = ? AND workspace_id = ? AND project_id = ?";
|
|
if (options.shotId) { where += " AND shot_id = ?"; params.push(options.shotId); }
|
|
if (options.jobId) { where += " AND job_id = ?"; params.push(options.jobId); }
|
|
const limit = Math.max(1, Math.min(500, Number(options.limit || 200)));
|
|
return dbAll(`SELECT * FROM media_artifacts WHERE ${where} ORDER BY created_at DESC LIMIT ?`, [...params, limit]).map(artifactPayload);
|
|
}
|
|
|
|
export function latestArtifactForShot(context, shotId, kind = "video") {
|
|
const row = dbGet("SELECT * FROM media_artifacts WHERE organization_id = ? AND workspace_id = ? AND project_id = ? AND shot_id = ? AND kind = ? ORDER BY CASE WHEN status = 'inspected' THEN 0 ELSE 1 END, created_at DESC LIMIT 1", [context.organization.id, context.workspace.id, context.project.id, shotId, kind]);
|
|
return artifactPayload(row);
|
|
}
|
|
|
|
export async function readArtifactContent(context, artifactId, frame = "") {
|
|
const artifact = dbGet(
|
|
`SELECT * FROM media_artifacts
|
|
WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`,
|
|
[artifactId, context.organization.id, context.workspace.id, context.project.id]
|
|
);
|
|
if (!artifact) throw new Error("media_artifact_not_found");
|
|
const requestedFrame = frame === "first" ? artifact.first_frame_path : frame === "last" ? artifact.last_frame_path : "";
|
|
const relativePath = safeOutputPath(requestedFrame || artifact.path);
|
|
if (!relativePath) throw new Error("media_artifact_path_invalid");
|
|
let content;
|
|
try {
|
|
content = await readFile(resolve(projectRoot, relativePath));
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") {
|
|
const missing = new Error("media_artifact_content_missing");
|
|
missing.code = "ENOENT";
|
|
missing.path = relativePath;
|
|
throw missing;
|
|
}
|
|
throw error;
|
|
}
|
|
const contentSha256 = createHash("sha256").update(content).digest("hex");
|
|
const sourceName = basename(relativePath) || basename(artifact.path) || artifact.id;
|
|
return {
|
|
content,
|
|
contentType: frame ? "image/jpeg" : artifact.mime_type || mimeForPath(relativePath),
|
|
fileName: sourceName,
|
|
contentSha256,
|
|
artifact: artifactPayload(artifact),
|
|
frame: frame || "source"
|
|
};
|
|
}
|