Files
ai-drama-platform/server/media-artifacts.mjs
T

292 lines
14 KiB
JavaScript

import { access, mkdir, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { resolve, extname } 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;
}
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);
}