feat: bootstrap commercial AI drama platform
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user