feat: bootstrap commercial AI drama platform

This commit is contained in:
xz
2026-08-24 10:24:34 +08:00
commit ffb27d845b
100 changed files with 35314 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { promisify } from "node:util";
import { resolve } from "node:path";
import { dbAll, dbGet, dbRun } from "./db.mjs";
import { addAudit, httpError, requirePermission } from "./tenant.mjs";
import { registerCompositionArtifact } from "./media-artifacts.mjs";
const execFileAsync = promisify(execFile);
const projectRoot = resolve(import.meta.dirname, "..");
const ffmpegBinary = process.env.FFMPEG_BIN || "ffmpeg";
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
function safeRelativePath(value, label) {
const relative = String(value || "").replace(/^[/\\]+/, "");
if (!relative || relative.includes("..") || !relative.startsWith("storage/")) throw httpError(400, "composition_path_invalid", `${label}必须是 storage/ 下的安全相对路径`);
const absolute = resolve(projectRoot, relative);
if (!absolute.startsWith(`${projectRoot}/storage/`)) throw httpError(400, "composition_path_invalid", `${label}越过了本地存储根目录`);
return { relative, absolute };
}
async function exists(filePath) {
try {
const info = await stat(filePath);
return info.isFile();
} catch {
return false;
}
}
function concatLine(pathname) {
return `file '${pathname.replaceAll("'", "'\\''")}'`;
}
function compositionPayload(row) {
if (!row) return null;
let source = {};
let result = {};
try { source = JSON.parse(row.source_json); } catch { source = {}; }
try { result = JSON.parse(row.result_json); } catch { result = {}; }
return { ...row, dryRun: Boolean(row.dry_run), source, result };
}
async function ffmpegVersion() {
try {
const result = await execFileAsync(ffmpegBinary, ["-version"], { timeout: 5000 });
return String(result.stdout || "").split("\n")[0] || "ffmpeg";
} catch (error) {
throw httpError(503, "ffmpeg_unavailable", `本地 FFmpeg 不可用:${String(error.message || error).slice(0, 300)}`);
}
}
function scopedEpisode(context, episodeId) {
if (!episodeId) return null;
const row = dbGet(
`SELECT e.id FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series sr ON sr.id = se.series_id
WHERE e.id = ? AND sr.project_id = ?`,
[episodeId, context.project.id]
);
if (!row) throw httpError(400, "episode_invalid", "合成分集不属于当前项目");
return row;
}
export async function composeProject(context, body = {}) {
requirePermission(context, "delivery:approve");
if (!context.project) throw httpError(400, "project_required", "本地合成必须绑定项目");
const clips = Array.isArray(body.clips) ? body.clips.map((item) => String(item || "").trim()).filter(Boolean) : [];
if (!clips.length || clips.length > 200) throw httpError(400, "composition_clips_invalid", "本地合成需要 1 到 200 个片段");
const clipFiles = clips.map((clip) => safeRelativePath(clip, "片段路径"));
const audio = body.audioPath ? safeRelativePath(body.audioPath, "音频路径") : null;
const version = String(body.version || `local-${new Date().toISOString().slice(0, 10)}-${Date.now()}`).trim();
const dryRun = Boolean(body.dryRun);
const compositionId = makeId("composition");
const compositionRoot = resolve(projectRoot, "storage", "compositions", compositionId);
const manifestPath = `storage/compositions/${compositionId}/concat.txt`;
const outputPath = safeRelativePath(body.outputPath || `storage/compositions/${compositionId}/final.mp4`, "输出路径");
const inputStatus = [];
for (const clip of clipFiles) inputStatus.push({ path: clip.relative, exists: await exists(clip.absolute) });
if (audio) inputStatus.push({ path: audio.relative, kind: "audio", exists: await exists(audio.absolute) });
const versionLabel = await ffmpegVersion();
const source = { clips: clipFiles.map((item) => item.relative), audioPath: audio?.relative || null, version, inputStatus };
const timestamp = new Date().toISOString();
dbRun("INSERT INTO media_compositions(id, organization_id, workspace_id, project_id, episode_id, version, status, tool, dry_run, output_path, manifest_path, source_json, result_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'ffmpeg', ?, ?, ?, ?, '{}', ?, ?, ?)", [compositionId, context.organization.id, context.workspace.id, context.project.id, scopedEpisode(context, body.episodeId)?.id || null, version, dryRun ? "planned" : "running", dryRun ? 1 : 0, outputPath.relative, manifestPath, JSON.stringify(source), context.user.id, timestamp, timestamp]);
if (dryRun) {
const result = { dryRun: true, tool: "ffmpeg", version: versionLabel, manifestPath, outputPath: outputPath.relative, command: [ffmpegBinary, "-f", "concat", "-safe", "0", "-i", manifestPath, "-c:v", "libx264", "-pix_fmt", "yuv420p", outputPath.relative], inputStatus };
dbRun("UPDATE media_compositions SET result_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(result), timestamp, compositionId]);
addAudit({ context, action: "media.composition.planned", targetType: "media_composition", targetId: compositionId, metadata: result });
return { composition: { ...compositionPayload(dbGet("SELECT * FROM media_compositions WHERE id = ?", [compositionId])), ...result } };
}
const missing = inputStatus.filter((item) => !item.exists);
if (missing.length) {
const message = `输入片段不存在:${missing.map((item) => item.path).join(", ")}`;
dbRun("UPDATE media_compositions SET status = 'failed', error_message = ?, updated_at = ? WHERE id = ?", [message, now(), compositionId]);
throw httpError(422, "composition_input_missing", message, { compositionId, missing });
}
await mkdir(compositionRoot, { recursive: true });
await mkdir(resolve(outputPath.absolute, ".."), { recursive: true });
await writeFile(resolve(projectRoot, manifestPath), `${clipFiles.map((item) => concatLine(item.absolute)).join("\n")}\n`, "utf8");
const args = ["-y", "-f", "concat", "-safe", "0", "-i", resolve(projectRoot, manifestPath)];
if (audio) args.push("-i", audio.absolute);
args.push("-map", "0:v:0", "-map", audio ? "1:a:0?" : "0:a:0?", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart", "-shortest", outputPath.absolute);
try {
const result = await execFileAsync(ffmpegBinary, args, { timeout: 20 * 60 * 1000, maxBuffer: 2 * 1024 * 1024 });
const outputInfo = await stat(outputPath.absolute);
const composition = dbGet("SELECT * FROM media_compositions WHERE id = ?", [compositionId]);
const artifact = await registerCompositionArtifact(context, composition, outputPath.relative);
const payload = { dryRun: false, tool: "ffmpeg", version: versionLabel, outputPath: outputPath.relative, outputBytes: outputInfo.size, artifact, stderr: String(result.stderr || "").slice(-2000) };
const finishedAt = new Date().toISOString();
dbRun("UPDATE media_compositions SET status = 'completed', result_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(payload), finishedAt, compositionId]);
addAudit({ context, action: "media.composition.completed", targetType: "media_composition", targetId: compositionId, metadata: payload });
return { composition: { ...compositionPayload(dbGet("SELECT * FROM media_compositions WHERE id = ?", [compositionId])), ...payload } };
} catch (error) {
const message = String(error.stderr || error.message || error).slice(-2000);
dbRun("UPDATE media_compositions SET status = 'failed', error_message = ?, updated_at = ? WHERE id = ?", [message, new Date().toISOString(), compositionId]);
addAudit({ context, action: "media.composition.failed", targetType: "media_composition", targetId: compositionId, result: "error", metadata: { error: message } });
throw httpError(502, "composition_failed", message, { compositionId });
}
}
function now() {
return new Date().toISOString();
}
export function listCompositions(context, limit = 40) {
if (!context.project) return [];
return dbAll("SELECT * FROM media_compositions WHERE organization_id = ? AND workspace_id = ? AND project_id = ? ORDER BY created_at DESC LIMIT ?", [context.organization.id, context.workspace.id, context.project.id, Math.max(1, Math.min(100, Number(limit || 40))) ]).map(compositionPayload);
}