Files
ai-drama-platform/server/production.mjs
T

1547 lines
95 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
import { addAudit, addUsage, hasPermission, httpError, requirePermission, requireProjectWritable } from "./tenant.mjs";
import { inspectMediaForProject } from "./media-qa.mjs";
import { latestArtifactForShot, listProjectArtifacts, syncProjectJobArtifacts } from "./media-artifacts.mjs";
import { dispatchNotificationEvent } from "./notifications.mjs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const projectRoot = resolve(import.meta.dirname, "..");
const REVIEW_LANES = [
["single-frame", "一图一画面"],
["continuity-lock", "角色 / 场景 / 道具连续性"],
["voice-subtitle-asr", "声音 / 字幕 / ASR 对齐"],
["clip-bridge", "片段衔接 / 实际末帧"]
];
const DEFAULT_SERIES_TEMPLATE = {
visualStyle: "原创国产漫画/国漫 2D 动画风格,竖屏 9:16,干净赛璐璐上色,电影感中景,稳定机位。",
continuityRule: "角色、服装、道具、场景、天气、机位、声音全部进入连续性台账;下一段视频优先使用上一段实际末帧。",
showEngine: "每集围绕一个可拍摄的冲突推进,结尾留下下一处行动钩子。"
};
const now = () => new Date().toISOString();
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
function parseJson(value, fallback) {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function requireProject(context) {
if (!context.project) throw httpError(400, "project_required", "生产操作必须绑定项目");
return context.project;
}
function requireAnyPermission(context, permissions, { mutating = false } = {}) {
if (!permissions.some((permission) => hasPermission(context, permission))) {
throw httpError(403, "permission_denied", `缺少生产权限:${permissions.join(" / ")}`, { permissions });
}
if (mutating) requireProjectWritable(context);
}
function projectEpisode(context, episodeId) {
const project = requireProject(context);
const episode = dbGet(
`SELECT e.*, se.series_id, sr.project_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, project.id]
);
if (!episode) throw httpError(404, "episode_not_found", "分集不存在或不属于当前项目", { episodeId });
return episode;
}
function ensureProductionRoot(context) {
const project = requireProject(context);
const timestamp = now();
let series = dbGet("SELECT * FROM series WHERE project_id = ?", [project.id]);
if (!series) {
const seriesId = `series-${project.id}`;
dbRun("INSERT INTO series(id, project_id, title, logline, format, visual_style, continuity_rule, show_engine, created_at, updated_at) VALUES (?, ?, ?, ?, 'vertical-9:16', ?, ?, ?, ?, ?)", [
seriesId,
project.id,
project.name,
`${project.name} 的本地化 AI 短剧生产项目。`,
DEFAULT_SERIES_TEMPLATE.visualStyle,
DEFAULT_SERIES_TEMPLATE.continuityRule,
DEFAULT_SERIES_TEMPLATE.showEngine,
timestamp,
timestamp
]);
series = dbGet("SELECT * FROM series WHERE id = ?", [seriesId]);
}
let season = dbGet("SELECT * FROM seasons WHERE series_id = ? ORDER BY season_number LIMIT 1", [series.id]);
if (!season) {
const seasonId = `season-${project.id}-1`;
dbRun("INSERT INTO seasons(id, series_id, season_number, title, created_at, updated_at) VALUES (?, ?, 1, '第一季', ?, ?)", [seasonId, series.id, timestamp, timestamp]);
season = dbGet("SELECT * FROM seasons WHERE id = ?", [seasonId]);
}
let episode = dbGet("SELECT * FROM episodes WHERE season_id = ? ORDER BY episode_number LIMIT 1", [season.id]);
if (!episode) {
const episodeId = `episode-${project.id}-01`;
dbRun("INSERT INTO episodes(id, season_id, episode_number, title, status, target_duration_sec, hook, cliffhanger, created_at, updated_at) VALUES (?, ?, 1, '试播集', 'draft', 0, '', '', ?, ?)", [episodeId, season.id, timestamp, timestamp]);
episode = dbGet("SELECT * FROM episodes WHERE id = ?", [episodeId]);
}
const shotCount = dbGet("SELECT COUNT(*) AS count FROM shots WHERE episode_id = ?", [episode.id]);
if (Number(shotCount?.count || 0) === 0) {
const shotId = `shot-${project.id}-001`;
const payload = {
id: shotId,
title: "新镜头 01",
durationSec: 6,
characterIds: [],
locationId: "",
propIds: [],
camera: "稳定中景,保持单一连续画面",
action: "待编剧填写镜头动作",
firstFrame: "episode-start",
lastFrame: "pending-actual-last-frame",
transitionFromPrevious: "episode-start",
prompt: "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY.",
negativePrompt: "no split screen, no comic panel, no collage, no contact sheet",
videoPrompt: "待填写视频动作和实际末帧要求",
seed: null
};
dbRun("INSERT INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, 1, ?, 'draft', ?, ?, ?, ?, ?)", [shotId, episode.id, payload.title, payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, timestamp]);
dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', ?, ?)", [`${shotId}-v1`, shotId, JSON.stringify(payload), context.user.id, timestamp]);
dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v1`, shotId]);
}
return { series, season, episode };
}
function episodeSummary(row) {
if (!row) return null;
return {
id: row.id,
seasonId: row.season_id,
episodeNumber: Number(row.episode_number),
title: row.title,
status: row.status,
targetDurationSec: Number(row.target_duration_sec || 0),
hook: row.hook || "",
cliffhanger: row.cliffhanger || "",
shotCount: Number(row.shot_count || 0),
scriptVersionCount: Number(row.script_version_count || 0),
updatedAt: row.updated_at
};
}
export function productionCatalog(context) {
const root = ensureProductionRoot(context);
const seasons = dbAll(
`SELECT se.*, COUNT(DISTINCT e.id) AS episode_count
FROM seasons se
LEFT JOIN episodes e ON e.season_id = se.id
WHERE se.series_id = ?
GROUP BY se.id
ORDER BY se.season_number`,
[root.series.id]
).map((season) => ({
id: season.id,
seriesId: season.series_id,
seasonNumber: Number(season.season_number),
title: season.title,
episodeCount: Number(season.episode_count || 0),
episodes: dbAll(
`SELECT e.*,
(SELECT COUNT(*) FROM shots s WHERE s.episode_id = e.id) AS shot_count,
(SELECT COUNT(*) FROM script_documents d WHERE d.episode_id = e.id) AS script_version_count
FROM episodes e
WHERE e.season_id = ?
ORDER BY e.episode_number`,
[season.id]
).map(episodeSummary)
}));
return {
series: root.series,
activeSeasonId: root.season.id,
activeEpisodeId: root.episode.id,
seasons
};
}
function insertStarterShot(context, episodeId, shotId, timestamp) {
const payload = {
id: shotId,
title: "新镜头 01",
durationSec: 6,
characterIds: [],
locationId: "",
propIds: [],
camera: "稳定中景,保持单一连续画面",
action: "待编剧填写镜头动作",
firstFrame: "episode-start",
lastFrame: "pending-actual-last-frame",
transitionFromPrevious: "episode-start",
prompt: "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY.",
negativePrompt: "no split screen, no comic panel, no collage, no contact sheet",
videoPrompt: "待填写视频动作和实际末帧要求",
seed: null
};
dbRun("INSERT INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, 1, ?, 'draft', ?, ?, ?, ?, ?)", [shotId, episodeId, payload.title, payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, timestamp]);
dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', ?, ?)", [`${shotId}-v1`, shotId, JSON.stringify(payload), context.user.id, timestamp]);
dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v1`, shotId]);
}
export function createSeason(context, body = {}) {
requirePermission(context, "script:edit");
const root = ensureProductionRoot(context);
const title = String(body.title || "").trim();
if (title.length < 1 || title.length > 120) throw httpError(400, "season_title_invalid", "季名称不能为空且不能超过 120 个字符");
const latest = dbGet("SELECT MAX(season_number) AS season_number FROM seasons WHERE series_id = ?", [root.series.id]);
const seasonNumber = Number(body.seasonNumber || Number(latest?.season_number || 0) + 1);
if (!Number.isInteger(seasonNumber) || seasonNumber < 1 || seasonNumber > 999) throw httpError(400, "season_number_invalid", "季编号必须是 1 到 999 的整数");
if (dbGet("SELECT id FROM seasons WHERE series_id = ? AND season_number = ?", [root.series.id, seasonNumber])) throw httpError(409, "season_exists", "该季编号已经存在");
const id = body.id || `season-${context.project.id}-${seasonNumber}-${Date.now().toString(36)}`;
const timestamp = now();
dbRun("INSERT INTO seasons(id, series_id, season_number, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", [id, root.series.id, seasonNumber, title, timestamp, timestamp]);
addAudit({ context, action: "season.created", targetType: "season", targetId: id, metadata: { seasonNumber, title } });
return { season: dbGet("SELECT * FROM seasons WHERE id = ?", [id]), catalog: productionCatalog(context) };
}
export function createEpisode(context, body = {}) {
requirePermission(context, "script:edit");
const root = ensureProductionRoot(context);
const seasonId = String(body.seasonId || root.season.id);
const season = dbGet("SELECT * FROM seasons WHERE id = ? AND series_id = ?", [seasonId, root.series.id]);
if (!season) throw httpError(404, "season_not_found", "目标季不存在或不属于当前项目", { seasonId });
const title = String(body.title || "").trim();
if (title.length < 1 || title.length > 160) throw httpError(400, "episode_title_invalid", "集标题不能为空且不能超过 160 个字符");
const latest = dbGet("SELECT MAX(episode_number) AS episode_number FROM episodes WHERE season_id = ?", [seasonId]);
const episodeNumber = Number(body.episodeNumber || Number(latest?.episode_number || 0) + 1);
if (!Number.isInteger(episodeNumber) || episodeNumber < 1 || episodeNumber > 9999) throw httpError(400, "episode_number_invalid", "集编号必须是 1 到 9999 的整数");
if (dbGet("SELECT id FROM episodes WHERE season_id = ? AND episode_number = ?", [seasonId, episodeNumber])) throw httpError(409, "episode_exists", "该集编号已经存在");
const targetDurationSec = Number(body.targetDurationSec || 0);
if (!Number.isFinite(targetDurationSec) || targetDurationSec < 0 || targetDurationSec > 3600) throw httpError(400, "target_duration_invalid", "目标时长必须在 0 到 3600 秒之间");
const id = body.id || `episode-${context.project.id}-${season.season_number}-${episodeNumber}-${Date.now().toString(36)}`;
const timestamp = now();
const shotId = `${id}-shot-001`;
withTransaction(() => {
dbRun("INSERT INTO episodes(id, season_id, episode_number, title, status, target_duration_sec, hook, cliffhanger, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, seasonId, episodeNumber, title, String(body.status || "draft"), targetDurationSec, String(body.hook || ""), String(body.cliffhanger || ""), timestamp, timestamp]);
insertStarterShot(context, id, shotId, timestamp);
});
addAudit({ context, action: "episode.created", targetType: "episode", targetId: id, metadata: { seasonId, episodeNumber, title, starterShotId: shotId } });
return { episode: episodeSummary(dbGet("SELECT e.*, (SELECT COUNT(*) FROM shots s WHERE s.episode_id = e.id) AS shot_count, (SELECT COUNT(*) FROM script_documents d WHERE d.episode_id = e.id) AS script_version_count FROM episodes e WHERE e.id = ?", [id])), catalog: productionCatalog(context), graph: productionGraph(context, { episodeId: id }) };
}
export function updateEpisode(context, episodeId, body = {}) {
requirePermission(context, "script:edit");
const episode = projectEpisode(context, episodeId);
const title = String(body.title ?? episode.title).trim();
if (title.length < 1 || title.length > 160) throw httpError(400, "episode_title_invalid", "集标题不能为空且不能超过 160 个字符");
const targetDurationSec = Number(body.targetDurationSec ?? episode.target_duration_sec ?? 0);
if (!Number.isFinite(targetDurationSec) || targetDurationSec < 0 || targetDurationSec > 3600) throw httpError(400, "target_duration_invalid", "目标时长必须在 0 到 3600 秒之间");
const status = String(body.status ?? episode.status).trim();
if (!["draft", "production", "review", "approved", "archived"].includes(status)) throw httpError(400, "episode_status_invalid", "分集状态无效");
const timestamp = now();
dbRun("UPDATE episodes SET title = ?, status = ?, target_duration_sec = ?, hook = ?, cliffhanger = ?, updated_at = ? WHERE id = ?", [title, status, targetDurationSec, String(body.hook ?? episode.hook ?? ""), String(body.cliffhanger ?? episode.cliffhanger ?? ""), timestamp, episodeId]);
addAudit({ context, action: "episode.updated", targetType: "episode", targetId: episodeId, metadata: { fields: Object.keys(body) } });
return { episode: episodeSummary(dbGet("SELECT e.*, (SELECT COUNT(*) FROM shots s WHERE s.episode_id = e.id) AS shot_count, (SELECT COUNT(*) FROM script_documents d WHERE d.episode_id = e.id) AS script_version_count FROM episodes e WHERE e.id = ?", [episodeId])), catalog: productionCatalog(context), graph: productionGraph(context, { episodeId }) };
}
function shotRows(context, episodeId = null) {
const project = requireProject(context);
const params = [project.id];
const episodeClause = episodeId ? " AND e.id = ?" : "";
if (episodeId) params.push(episodeId);
return dbAll(
`SELECT s.*, e.episode_number, e.title AS episode_title
FROM shots s
JOIN episodes e ON e.id = s.episode_id
JOIN seasons se ON se.id = e.season_id
JOIN series sr ON sr.id = se.series_id
WHERE sr.project_id = ?${episodeClause}
ORDER BY e.episode_number, s.shot_number`,
params
);
}
function shotPayload(row) {
const latest = (row.current_version_id && dbGet("SELECT * FROM shot_versions WHERE id = ? AND shot_id = ?", [row.current_version_id, row.id]))
|| dbGet("SELECT * FROM shot_versions WHERE shot_id = ? ORDER BY version_number DESC LIMIT 1", [row.id]);
const payload = latest ? parseJson(latest.payload_json, {}) : parseJson(row.continuity_json, {});
const voiceLines = dbAll("SELECT * FROM voice_lines WHERE shot_id = ? ORDER BY line_number", [row.id]).map((line) => ({
id: line.id,
characterId: line.character_key,
text: line.text,
emotion: line.emotion,
targetDurationSec: line.target_duration_sec,
audioFile: line.audio_path,
mouthPlan: line.mouth_plan,
voiceId: line.voice_id,
status: line.status
}));
return {
...payload,
id: row.id,
title: row.title,
status: row.status,
episodeId: row.episode_id,
episodeNumber: row.episode_number,
shotNumber: row.shot_number,
firstFrame: row.first_frame_path || payload.firstFrame || "episode-start",
lastFrame: row.last_frame_path || payload.lastFrame || "pending-actual-last-frame",
continuity: parseJson(row.continuity_json, {}),
versionNumber: Number(latest?.version_number || 1),
versionId: latest?.id || "",
voiceLines
};
}
function scriptDocuments(context, episodeId = null) {
const project = requireProject(context);
const clause = episodeId ? " AND episode_id = ?" : "";
const params = episodeId ? [project.id, episodeId] : [project.id];
return dbAll(`SELECT * FROM script_documents WHERE project_id = ?${clause} ORDER BY version_number DESC`, params).map((row) => ({
...row,
analysis: parseJson(row.analysis_json, {})
}));
}
function reviewRows(context) {
const project = requireProject(context);
return dbAll(
`SELECT r.*, s.title AS shot_title, s.shot_number, u.display_name AS decision_by_name
FROM reviews r
LEFT JOIN shots s ON s.id = r.shot_id
LEFT JOIN users u ON u.id = r.decision_by
WHERE r.project_id = ?
ORDER BY COALESCE(s.shot_number, 999), r.lane`,
[project.id]
).map((row) => ({
...row,
evidence: parseJson(row.evidence_json, {}),
comments: dbAll("SELECT c.*, u.display_name AS author_name FROM review_comments c LEFT JOIN users u ON u.id = c.author_user_id WHERE c.review_id = ? ORDER BY c.created_at", [row.id])
}));
}
function ensureReviews(context) {
const project = requireProject(context);
const shots = shotRows(context);
const timestamp = now();
for (const shot of shots) {
for (const [lane, label] of REVIEW_LANES) {
const id = `review-${shot.id}-${lane}`;
dbRun("INSERT OR IGNORE INTO reviews(id, organization_id, workspace_id, project_id, shot_id, lane, status, evidence_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)", [id, context.organization.id, context.workspace.id, project.id, shot.id, lane, JSON.stringify({ label, source: "production-gate" }), timestamp, timestamp]);
}
}
return reviewRows(context);
}
function projectAssetLocks(context) {
const project = requireProject(context);
const rows = dbAll(
`SELECT a.*, av.version_number, av.storage_path, av.file_name, av.mime_type, av.file_size,
av.content_sha256, av.rights_status, av.metadata_json
FROM assets a
LEFT JOIN asset_versions av ON av.id = a.current_version_id
WHERE a.project_id = ?
ORDER BY CASE a.kind WHEN 'character' THEN 1 WHEN 'location' THEN 2 WHEN 'prop' THEN 3 ELSE 4 END, a.name`,
[project.id]
);
return rows.map((row) => {
const metadata = parseJson(row.metadata_json, {});
const base = {
id: row.id,
name: row.name,
kind: row.kind,
status: row.lock_status,
lockStatus: row.lock_status,
version: Number(row.version_number || 1),
storagePath: row.storage_path || "",
rightsStatus: row.rights_status || "needs-evidence",
contentSha256: row.content_sha256 || "",
metadata,
detail: metadata.detail || metadata.visualLock || metadata.description || "",
lock: metadata.lock || metadata.continuityLock || "",
cameraLock: metadata.cameraLock || metadata.camera || "",
bindings: dbAll("SELECT shot_id, usage_role FROM asset_bindings WHERE asset_id = ? ORDER BY shot_id", [row.id])
};
if (row.kind === "character") {
return {
...base,
visualLock: metadata.visualLock || metadata.detail || "",
costumeState: metadata.costumeState || metadata.lock || "",
voiceLock: {
status: metadata.voiceStatus || row.rights_status || row.lock_status,
voiceId: metadata.voiceId || "",
tone: metadata.tone || "",
ttsModel: metadata.ttsModel || "",
asrModel: metadata.asrModel || "",
referencePolicy: metadata.referencePolicy || metadata.lock || ""
}
};
}
if (row.kind === "location") return { ...base, visualLock: metadata.visualLock || metadata.detail || "" };
if (row.kind === "prop") return { ...base, visualLock: metadata.visualLock || metadata.detail || "" };
return base;
});
}
function deliveries(context) {
const project = requireProject(context);
return dbAll("SELECT d.*, u.display_name AS approved_by_name FROM deliveries d LEFT JOIN users u ON u.id = d.approved_by WHERE d.project_id = ? ORDER BY d.created_at DESC", [project.id]).map((row) => ({
...row,
manifest: row.manifest_path,
releaseCount: Number(dbGet("SELECT COUNT(*) AS count FROM delivery_releases WHERE delivery_id = ?", [row.id])?.count || 0),
publishedReleaseCount: Number(dbGet("SELECT COUNT(*) AS count FROM delivery_releases WHERE delivery_id = ? AND status = 'published'", [row.id])?.count || 0)
}));
}
function safeStoragePath(value) {
const relative = String(value || "").trim();
if (!relative || relative.startsWith("/") || relative.includes("..") || !relative.startsWith("storage/")) return null;
const storageRoot = resolve(projectRoot, "storage");
const absolute = resolve(projectRoot, relative);
if (!absolute.startsWith(`${storageRoot}/`)) return null;
return { relative, absolute };
}
function safePathSegment(value, fallback = "item") {
const normalized = String(value || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
return normalized || fallback;
}
function isPrivateHostname(hostname) {
const host = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
if (["localhost", "::1"].includes(host) || host.endsWith(".local") || host.endsWith(".internal")) return true;
const octets = host.split(".").map((part) => Number(part));
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
const [first, second] = octets;
return first === 10 || first === 127 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168) || (first === 169 && second === 254);
}
function normalizeChannelEndpoint(kind, endpoint) {
const value = String(endpoint || "").trim();
if (kind === "local-file") {
const path = safeStoragePath(value || "storage/releases");
if (!path) throw httpError(400, "delivery_channel_endpoint_invalid", "本地文件渠道必须写入 storage/ 下的安全相对目录");
return path.relative;
}
let parsed;
try {
parsed = new URL(value);
} catch {
throw httpError(400, "delivery_channel_endpoint_invalid", "本地 Webhook 渠道必须填写有效的 HTTP(S) 地址");
}
if (!["http:", "https:"].includes(parsed.protocol) || !isPrivateHostname(parsed.hostname)) {
throw httpError(400, "delivery_channel_endpoint_public", "为遵守本地化约束,Webhook 只能指向 localhost、.local、.internal 或私网地址");
}
return parsed.toString();
}
function normalizeAuthEnv(value) {
const authEnv = String(value || "").trim();
if (authEnv && !/^[A-Z][A-Z0-9_]{0,99}$/.test(authEnv)) {
throw httpError(400, "delivery_channel_auth_env_invalid", "认证环境变量名只能使用大写字母、数字和下划线");
}
return authEnv;
}
function channelPayload(row) {
if (!row) return null;
return {
...row,
enabled: Boolean(row.enabled),
requireApproval: Boolean(row.require_approval),
config: parseJson(row.config_json, {})
};
}
function ensureDefaultDeliveryChannel(context) {
const existing = dbGet(
"SELECT * FROM delivery_channels WHERE organization_id = ? AND workspace_id = ? AND project_id IS NULL ORDER BY created_at LIMIT 1",
[context.organization.id, context.workspace.id]
);
if (existing) return existing;
const timestamp = now();
const id = `channel-${safePathSegment(context.organization.id)}-${safePathSegment(context.workspace.id)}-local`;
dbRun(
`INSERT OR IGNORE INTO delivery_channels(
id, organization_id, workspace_id, project_id, name, kind, enabled, endpoint, auth_env,
require_approval, config_json, created_by, created_at, updated_at
) VALUES (?, ?, ?, NULL, '本地文件(默认)', 'local-file', 1, 'storage/releases', '', 1, ?, ?, ?, ?)`,
[id, context.organization.id, context.workspace.id, JSON.stringify({ root: "storage/releases", localOnly: true }), context.user.id, timestamp, timestamp]
);
return dbGet("SELECT * FROM delivery_channels WHERE id = ?", [id]);
}
function channelForContext(context, channelId) {
const projectId = context.project?.id || null;
const channel = dbGet(
`SELECT * FROM delivery_channels
WHERE id = ? AND organization_id = ? AND workspace_id = ?
AND (project_id IS NULL OR project_id = ?)`,
[channelId, context.organization.id, context.workspace.id, projectId]
);
if (!channel) throw httpError(404, "delivery_channel_not_found", "发布渠道不存在或不属于当前工作区", { channelId });
return channel;
}
export function listDeliveryChannels(context) {
requireAnyPermission(context, ["delivery:view", "delivery:approve"]);
ensureDefaultDeliveryChannel(context);
const projectId = context.project?.id || null;
return {
channels: dbAll(
`SELECT dc.*, u.display_name AS created_by_name
FROM delivery_channels dc
LEFT JOIN users u ON u.id = dc.created_by
WHERE dc.organization_id = ? AND dc.workspace_id = ?
AND (dc.project_id IS NULL OR dc.project_id = ?)
ORDER BY dc.enabled DESC, dc.created_at`,
[context.organization.id, context.workspace.id, projectId]
).map(channelPayload)
};
}
export function createDeliveryChannel(context, body = {}) {
requirePermission(context, "delivery:approve");
const projectId = body.projectId ? String(body.projectId).trim() : null;
if (projectId && (!context.project || context.project.id !== projectId)) {
throw httpError(403, "delivery_channel_project_scope", "渠道项目范围必须是当前项目或留空作为工作区渠道");
}
const name = String(body.name || "").trim();
if (!name || name.length > 80) throw httpError(400, "delivery_channel_name_invalid", "渠道名称不能为空且不能超过 80 个字符");
const kind = String(body.kind || "local-file").trim();
if (!["local-file", "local-webhook"].includes(kind)) throw httpError(400, "delivery_channel_kind_invalid", "只允许 local-file 或 local-webhook 渠道");
const endpoint = normalizeChannelEndpoint(kind, body.endpoint);
const authEnv = normalizeAuthEnv(body.authEnv);
if (kind === "local-file" && authEnv) throw httpError(400, "delivery_channel_auth_env_invalid", "本地文件渠道不需要认证环境变量");
const config = body.config && typeof body.config === "object" && !Array.isArray(body.config) ? body.config : {};
const id = makeId("delivery-channel");
const timestamp = now();
dbRun(
`INSERT INTO delivery_channels(
id, organization_id, workspace_id, project_id, name, kind, enabled, endpoint, auth_env,
require_approval, config_json, created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, context.organization.id, context.workspace.id, projectId, name, kind, body.enabled === false ? 0 : 1, endpoint, authEnv, body.requireApproval === false ? 0 : 1, JSON.stringify(config), context.user.id, timestamp, timestamp]
);
addAudit({ context, action: "delivery.channel.created", targetType: "delivery_channel", targetId: id, metadata: { name, kind, projectId, endpoint: kind === "local-webhook" ? endpoint : "local-file" } });
return { channel: channelPayload(dbGet("SELECT * FROM delivery_channels WHERE id = ?", [id])), ...listDeliveryChannels(context) };
}
export function updateDeliveryChannel(context, channelId, body = {}) {
requirePermission(context, "delivery:approve");
const channel = channelForContext(context, channelId);
const nextName = body.name === undefined ? channel.name : String(body.name || "").trim();
if (!nextName || nextName.length > 80) throw httpError(400, "delivery_channel_name_invalid", "渠道名称不能为空且不能超过 80 个字符");
const kind = body.kind === undefined ? channel.kind : String(body.kind || "").trim();
if (kind !== channel.kind) throw httpError(409, "delivery_channel_kind_immutable", "渠道类型创建后不可修改,请新建渠道");
const endpoint = body.endpoint === undefined ? channel.endpoint : normalizeChannelEndpoint(kind, body.endpoint);
const authEnv = body.authEnv === undefined ? channel.auth_env : normalizeAuthEnv(body.authEnv);
if (kind === "local-file" && authEnv) throw httpError(400, "delivery_channel_auth_env_invalid", "本地文件渠道不需要认证环境变量");
const config = body.config === undefined ? parseJson(channel.config_json, {}) : (body.config && typeof body.config === "object" && !Array.isArray(body.config) ? body.config : {});
const timestamp = now();
dbRun(
`UPDATE delivery_channels
SET name = ?, enabled = ?, endpoint = ?, auth_env = ?, require_approval = ?, config_json = ?, updated_at = ?
WHERE id = ?`,
[nextName, body.enabled === undefined ? Number(channel.enabled) : (body.enabled ? 1 : 0), endpoint, authEnv, body.requireApproval === undefined ? Number(channel.require_approval) : (body.requireApproval ? 1 : 0), JSON.stringify(config), timestamp, channelId]
);
addAudit({ context, action: "delivery.channel.updated", targetType: "delivery_channel", targetId: channelId, metadata: { name: nextName, enabled: body.enabled === undefined ? Boolean(channel.enabled) : Boolean(body.enabled) } });
return { channel: channelPayload(dbGet("SELECT * FROM delivery_channels WHERE id = ?", [channelId])), ...listDeliveryChannels(context) };
}
function parseScript(content) {
const clean = String(content || "").replace(/\r/g, "").trim();
const wordCount = clean.replace(/\s/g, "").length;
const headingMatches = [...clean.matchAll(/第\s*(\d+)\s*集[^\n]*/g)];
const chapterBlocks = headingMatches.length
? headingMatches.map((match, index) => {
const start = match.index + match[0].length;
const end = headingMatches[index + 1]?.index || clean.length;
const block = clean.slice(start, end).trim();
return { id: `chapter-${index + 1}`, title: match[0].trim(), words: block.replace(/\s/g, "").length, scenes: Math.max(1, (block.match(/(?:内|外)[::]/g) || []).length), status: "已拆解" };
})
: clean.split(/\n\s*\n/).filter(Boolean).slice(0, 12).map((block, index) => ({ id: `chapter-${index + 1}`, title: `场次 ${String(index + 1).padStart(2, "0")}`, words: block.replace(/\s/g, "").length, scenes: 1, status: "已拆解" }));
const names = [...clean.matchAll(/([\u4e00-\u9fa5]{2,4})[::]/g)].map((match) => match[1]).filter((name, index, list) => list.indexOf(name) === index).slice(0, 12);
const locationKeywords = ["地铁口", "玻璃连廊", "雨棚", "教室", "客厅", "街道", "医院", "仓库", "山路", "门口"];
const propKeywords = ["手机", "雨伞", "雨披", "路锥", "警戒线", "钥匙", "刀", "书包", "项链", "文件"];
const locations = locationKeywords.filter((item) => clean.includes(item)).map((name) => ({ type: "场景", name, confidence: 0.88, target: "location-locks" }));
const props = propKeywords.filter((item) => clean.includes(item)).map((name) => ({ type: "道具", name, confidence: 0.82, target: "prop-locks" }));
const extracted = [
...names.map((name) => ({ type: "角色", name, confidence: 0.94, target: "character-locks" })),
...locations,
...props
];
const lines = clean.split("\n").map((line) => line.trim()).filter(Boolean);
const sceneHeaders = lines.map((line, index) => {
const match = line.match(/^(?:场景\s*)?(?:\d+[.、]\s*)?(内|外)[::]\s*(.+)$/);
return match ? { index, title: `${match[1]} · ${match[2]}` } : null;
}).filter(Boolean);
const sceneDrafts = (sceneHeaders.length ? sceneHeaders : [{ index: 0, title: "试播集 · 开场" }]).map((header, index) => {
const end = sceneHeaders[index + 1]?.index || lines.length;
const blockLines = lines.slice(header.index + (sceneHeaders.length ? 1 : 0), end);
const dialogueLines = blockLines.map((line) => {
const match = line.match(/^([\u4e00-\u9fa5]{2,4})[::]\s*(.+)$/);
return match ? { characterId: match[1], text: match[2], emotion: "自然、克制", mouthPlan: "侧脸/反应镜头", targetDurationSec: Math.max(1.5, Math.min(8, Math.round(match[2].length * 0.22 * 10) / 10)) } : null;
}).filter(Boolean);
const actionLines = blockLines.filter((line) => !/^[\u4e00-\u9fa5]{2,4}[::]/.test(line));
return {
id: `scene-${index + 1}`,
sceneNumber: index + 1,
title: header.title,
action: actionLines.join(" ") || "人物在当前场景内完成一个连续、可拍摄的动作。",
camera: index === 0 ? "稳定中景,保留环境信息" : "沿用上一镜头轴线的连续中景",
dialogue: dialogueLines,
sourceLines: blockLines.length
};
});
return {
wordCount,
chapterCount: chapterBlocks.length,
chapters: chapterBlocks.length ? chapterBlocks : [{ id: "chapter-1", title: "未命名章节", words: wordCount, scenes: 1, status: "已拆解" }],
extracted,
sceneDrafts,
parser: "local-rule-v1"
};
}
export function productionGraph(context, options = {}) {
const root = ensureProductionRoot(context);
const project = requireProject(context);
const activeEpisode = options.episodeId ? projectEpisode(context, options.episodeId) : root.episode;
const shots = shotRows(context, activeEpisode.id).map(shotPayload);
const documents = scriptDocuments(context, activeEpisode.id);
const reviews = ensureReviews(context).filter((review) => !review.shot_id || shots.some((shot) => shot.id === review.shot_id));
const locks = projectAssetLocks(context);
const characters = locks.filter((asset) => asset.kind === "character");
const locations = locks.filter((asset) => asset.kind === "location");
const props = locks.filter((asset) => asset.kind === "prop");
return {
series: root.series,
season: root.season,
episode: activeEpisode,
activeEpisodeId: activeEpisode.id,
catalog: { ...productionCatalog(context), activeSeasonId: activeEpisode.season_id, activeEpisodeId: activeEpisode.id },
documents,
shots,
characters,
locations,
props,
assets: locks,
reviews,
deliveries: deliveries(context),
constraints: {
singleFrameOnly: true,
requireActualLastFrame: true,
localOnlyDefault: true,
blockedImageTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图"]
}
};
}
export function updateBible(context, body = {}) {
requirePermission(context, "script:edit");
const project = requireProject(context);
const root = ensureProductionRoot(context);
const episode = projectEpisode(context, body.episodeId || root.episode.id);
const timestamp = now();
const seriesValues = {
title: body.title ?? body.seriesTitle ?? root.series.title,
logline: body.logline ?? root.series.logline,
format: body.format ?? root.series.format,
visualStyle: body.visualStyle ?? body.visual_style ?? root.series.visual_style,
continuityRule: body.continuityRule ?? body.continuity_rule ?? root.series.continuity_rule,
showEngine: body.showEngine ?? body.show_engine ?? root.series.show_engine
};
const episodeValues = {
title: body.episodeTitle ?? episode.title,
status: body.episodeStatus ?? episode.status,
targetDurationSec: body.targetDurationSec ?? episode.target_duration_sec,
hook: body.hook ?? episode.hook,
cliffhanger: body.cliffhanger ?? episode.cliffhanger
};
const targetDuration = Number(episodeValues.targetDurationSec);
if (!Number.isFinite(targetDuration) || targetDuration < 0 || targetDuration > 3600) {
throw httpError(400, "target_duration_invalid", "目标时长必须在 0 到 3600 秒之间");
}
withTransaction(() => {
dbRun("UPDATE series SET title = ?, logline = ?, format = ?, visual_style = ?, continuity_rule = ?, show_engine = ?, updated_at = ? WHERE id = ? AND project_id = ?", [
String(seriesValues.title).trim(),
String(seriesValues.logline).trim(),
String(seriesValues.format).trim(),
String(seriesValues.visualStyle).trim(),
String(seriesValues.continuityRule).trim(),
String(seriesValues.showEngine).trim(),
timestamp,
root.series.id,
project.id
]);
dbRun("UPDATE episodes SET title = ?, status = ?, target_duration_sec = ?, hook = ?, cliffhanger = ?, updated_at = ? WHERE id = ?", [
String(episodeValues.title).trim(),
String(episodeValues.status).trim(),
targetDuration,
String(episodeValues.hook).trim(),
String(episodeValues.cliffhanger).trim(),
timestamp,
episode.id
]);
});
addAudit({ context, action: "series_bible.updated", targetType: "series", targetId: root.series.id, metadata: { episodeId: episode.id, fields: Object.keys(body) } });
return {
bible: {
series: dbGet("SELECT * FROM series WHERE id = ?", [root.series.id]),
season: dbGet("SELECT * FROM seasons WHERE id = ?", [root.season.id]),
episode: dbGet("SELECT * FROM episodes WHERE id = ?", [episode.id])
},
graph: productionGraph(context, { episodeId: episode.id })
};
}
export function importScript(context, body) {
requirePermission(context, "script:edit");
const project = requireProject(context);
const content = String(body.content || "").trim();
if (content.length < 10) throw httpError(400, "script_content_required", "剧本内容至少需要 10 个字符");
const root = ensureProductionRoot(context);
const latest = dbGet("SELECT MAX(version_number) AS version_number FROM script_documents WHERE project_id = ?", [project.id]);
const versionNumber = Number(latest?.version_number || 0) + 1;
const analysis = parseScript(content);
const id = makeId("script");
const timestamp = now();
dbRun("INSERT INTO script_documents(id, organization_id, workspace_id, project_id, episode_id, version_number, title, source_type, content, status, analysis_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'analyzed', ?, ?, ?, ?)", [id, context.organization.id, context.workspace.id, project.id, body.episodeId || root.episode.id, versionNumber, String(body.title || analysis.chapters[0]?.title || "未命名剧本"), String(body.sourceType || "原创短剧剧本"), content, JSON.stringify(analysis), context.user.id, timestamp, timestamp]);
dbRun("UPDATE episodes SET title = COALESCE(NULLIF(?, ''), title), updated_at = ? WHERE id = ?", [String(body.episodeTitle || "").trim(), timestamp, body.episodeId || root.episode.id]);
addAudit({ context, action: "script.imported", targetType: "script_document", targetId: id, metadata: { versionNumber, wordCount: analysis.wordCount, chapterCount: analysis.chapterCount } });
addUsage({ context, kind: "script-analysis", units: 1, unitName: "document", metadata: { scriptId: id, parser: analysis.parser } });
return { document: { ...dbGet("SELECT * FROM script_documents WHERE id = ?", [id]), analysis }, graph: productionGraph(context, { episodeId: body.episodeId || root.episode.id }) };
}
export function materializeScript(context, documentId, body = {}) {
requirePermission(context, "script:edit");
const project = requireProject(context);
const document = dbGet("SELECT * FROM script_documents WHERE id = ? AND project_id = ?", [documentId, project.id]);
if (!document) throw httpError(404, "script_not_found", "剧本版本不存在或不属于当前项目", { documentId });
const analysis = parseJson(document.analysis_json, {});
const drafts = Array.isArray(analysis.sceneDrafts) && analysis.sceneDrafts.length ? analysis.sceneDrafts : [{ title: document.title, action: document.content, camera: "稳定中景,保持单一连续画面", dialogue: [] }];
const episodeId = document.episode_id || ensureProductionRoot(context).episode.id;
projectEpisode(context, episodeId);
const existing = dbGet("SELECT MAX(shot_number) AS shot_number FROM shots WHERE episode_id = ?", [episodeId]);
let nextShotNumber = Number(existing?.shot_number || 0);
const created = [];
const timestamp = now();
withTransaction(() => {
for (const draft of drafts) {
nextShotNumber += 1;
const shotId = `shot-${project.id}-${String(nextShotNumber).padStart(3, "0")}-${Date.now().toString(36)}-${nextShotNumber}`;
const payload = {
id: shotId,
title: String(draft.title || `场景 ${String(nextShotNumber).padStart(2, "0")}`),
durationSec: Number(body.durationSec || 6),
characterIds: [],
locationId: "",
propIds: [],
camera: String(draft.camera || "稳定中景,保持单一连续画面"),
action: String(draft.action || "待补充镜头动作"),
firstFrame: nextShotNumber === 1 ? "episode-start" : "AUTO_PREVIOUS_ACTUAL_LAST_FRAME",
lastFrame: "pending-actual-last-frame",
transitionFromPrevious: nextShotNumber === 1 ? "episode-start" : "actual-last-frame",
prompt: "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY.",
negativePrompt: "no split screen, no comic panel, no collage, no contact sheet, no storyboard",
videoPrompt: "保持角色、服装、道具、天气和机位连续;结尾保留可作为下一段首帧的实际末帧。",
seed: null
};
dbRun("INSERT INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)", [shotId, episodeId, nextShotNumber, payload.title, payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, timestamp]);
dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', ?, ?)", [`${shotId}-v1`, shotId, JSON.stringify(payload), context.user.id, timestamp]);
dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v1`, shotId]);
for (let index = 0; index < (draft.dialogue || []).length; index += 1) insertVoiceLine(context, shotId, index + 1, draft.dialogue[index], timestamp);
created.push(shotId);
}
});
addAudit({ context, action: "script.materialized", targetType: "script_document", targetId: documentId, metadata: { shotsCreated: created.length, shotIds: created } });
return { created, graph: productionGraph(context, { episodeId }) };
}
export function createShot(context, body) {
requireAnyPermission(context, ["script:edit", "prompt:edit"], { mutating: true });
const project = requireProject(context);
const root = ensureProductionRoot(context);
const episodeId = body.episodeId || root.episode.id;
projectEpisode(context, episodeId);
const next = dbGet("SELECT MAX(shot_number) AS shot_number FROM shots WHERE episode_id = ?", [episodeId]);
const shotNumber = Number(next?.shot_number || 0) + 1;
const id = body.id || `shot-${project.id}-${String(shotNumber).padStart(3, "0")}-${Date.now().toString(36)}`;
const timestamp = now();
const payload = {
id,
title: String(body.title || `新镜头 ${String(shotNumber).padStart(2, "0")}`),
durationSec: Number(body.durationSec || 6),
characterIds: Array.isArray(body.characterIds) ? body.characterIds : [],
locationId: String(body.locationId || ""),
propIds: Array.isArray(body.propIds) ? body.propIds : [],
camera: String(body.camera || "稳定中景,保持单一连续画面"),
action: String(body.action || "待填写镜头动作"),
firstFrame: String(body.firstFrame || (shotNumber === 1 ? "episode-start" : "AUTO_PREVIOUS_ACTUAL_LAST_FRAME")),
lastFrame: String(body.lastFrame || "pending-actual-last-frame"),
transitionFromPrevious: String(body.transitionFromPrevious || (shotNumber === 1 ? "episode-start" : "actual-last-frame")),
prompt: String(body.prompt || "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY."),
negativePrompt: String(body.negativePrompt || "no split screen, no comic panel, no collage, no contact sheet"),
videoPrompt: String(body.videoPrompt || "待填写视频动作和实际末帧要求"),
seed: body.seed === undefined || body.seed === "" ? null : Number(body.seed)
};
withTransaction(() => {
dbRun("INSERT INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)", [id, episodeId, shotNumber, payload.title, payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, timestamp]);
dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', ?, ?)", [`${id}-v1`, id, JSON.stringify(payload), context.user.id, timestamp]);
dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${id}-v1`, id]);
for (let index = 0; index < (body.voiceLines || []).length; index += 1) insertVoiceLine(context, id, index + 1, body.voiceLines[index], timestamp);
});
addAudit({ context, action: "shot.created", targetType: "shot", targetId: id, metadata: { shotNumber, episodeId } });
return { shot: shotPayload(dbGet("SELECT * FROM shots WHERE id = ?", [id])), graph: productionGraph(context, { episodeId }) };
}
function ensureShot(context, shotId) {
const project = requireProject(context);
const row = dbGet(
`SELECT s.* FROM shots s
JOIN episodes e ON e.id = s.episode_id
JOIN seasons se ON se.id = e.season_id
JOIN series sr ON sr.id = se.series_id
WHERE s.id = ? AND sr.project_id = ?`,
[shotId, project.id]
);
if (!row) throw httpError(404, "shot_not_found", "镜头不存在或不属于当前项目", { shotId });
return row;
}
function insertVoiceLine(context, shotId, lineNumber, line, timestamp = now()) {
const item = line || {};
const id = item.id || `${shotId}-line-${lineNumber}`;
dbRun("INSERT INTO voice_lines(id, shot_id, line_number, character_key, text, emotion, mouth_plan, target_duration_sec, voice_id, audio_path, status, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(shot_id, line_number) DO UPDATE SET character_key = excluded.character_key, text = excluded.text, emotion = excluded.emotion, mouth_plan = excluded.mouth_plan, target_duration_sec = excluded.target_duration_sec, voice_id = excluded.voice_id, audio_path = excluded.audio_path, status = excluded.status, updated_at = excluded.updated_at", [id, shotId, lineNumber, String(item.characterId || item.character_key || ""), String(item.text || ""), String(item.emotion || ""), String(item.mouthPlan || item.mouth_plan || "侧脸/反应镜头"), Number(item.targetDurationSec || item.target_duration_sec || 2), String(item.voiceId || item.voice_id || ""), String(item.audioFile || item.audio_path || ""), String(item.status || "draft"), context.user.id, timestamp, timestamp]);
}
export function updateShot(context, shotId, body) {
requireAnyPermission(context, ["script:edit", "prompt:edit"], { mutating: true });
const current = ensureShot(context, shotId);
const existing = shotPayload(current);
const timestamp = now();
const payload = {
...existing,
...body,
id: shotId,
characterIds: Array.isArray(body.characterIds) ? body.characterIds : existing.characterIds || [],
propIds: Array.isArray(body.propIds) ? body.propIds : existing.propIds || [],
durationSec: Number(body.durationSec ?? existing.durationSec ?? 6)
};
withTransaction(() => {
const nextVersion = Number(existing.versionNumber || 0) + 1;
dbRun("UPDATE shots SET title = ?, status = ?, first_frame_path = ?, last_frame_path = ?, continuity_json = ?, updated_at = ? WHERE id = ?", [payload.title, String(body.status || current.status || "draft"), payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, shotId]);
dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [`${shotId}-v${nextVersion}`, shotId, nextVersion, JSON.stringify(payload), String(body.status || current.status || "draft"), context.user.id, timestamp]);
dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v${nextVersion}`, shotId]);
if (Array.isArray(body.voiceLines)) {
dbRun("DELETE FROM voice_lines WHERE shot_id = ?", [shotId]);
for (let index = 0; index < body.voiceLines.length; index += 1) insertVoiceLine(context, shotId, index + 1, body.voiceLines[index], timestamp);
}
});
addAudit({ context, action: "shot.updated", targetType: "shot", targetId: shotId, metadata: { version: Number(existing.versionNumber || 0) + 1 } });
return { shot: shotPayload(dbGet("SELECT * FROM shots WHERE id = ?", [shotId])), graph: productionGraph(context, { episodeId: current.episode_id }) };
}
export function savePromptVersion(context, shotId, body) {
requirePermission(context, "prompt:edit");
const current = ensureShot(context, shotId);
const existing = shotPayload(current);
const timestamp = now();
const nextVersion = Number(existing.versionNumber || 0) + 1;
const payload = {
...existing,
prompt: String(body.imagePrompt ?? body.prompt ?? existing.prompt ?? ""),
negativePrompt: String(body.negativePrompt ?? existing.negativePrompt ?? ""),
videoPrompt: String(body.videoPrompt ?? existing.videoPrompt ?? ""),
seed: body.seed === undefined || body.seed === "" ? existing.seed : Number(body.seed)
};
dbRun("UPDATE shots SET continuity_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(payload), timestamp, shotId]);
dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, ?, ?, 'draft', ?, ?)", [`${shotId}-v${nextVersion}`, shotId, nextVersion, JSON.stringify(payload), context.user.id, timestamp]);
dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v${nextVersion}`, shotId]);
addAudit({ context, action: "shot.prompt.version.created", targetType: "shot_version", targetId: `${shotId}-v${nextVersion}`, metadata: { shotId, version: nextVersion } });
return { shot: shotPayload(dbGet("SELECT * FROM shots WHERE id = ?", [shotId])), graph: productionGraph(context, { episodeId: current.episode_id }) };
}
export function listShotVersions(context, shotId) {
requireAnyPermission(context, ["script:read", "script:edit", "prompt:edit"]);
const shot = ensureShot(context, shotId);
return {
shotId,
currentVersionId: shot.current_version_id || "",
versions: dbAll(
`SELECT sv.*, u.display_name AS created_by_name
FROM shot_versions sv
LEFT JOIN users u ON u.id = sv.created_by
WHERE sv.shot_id = ?
ORDER BY sv.version_number DESC`,
[shotId]
).map((version) => ({
...version,
payload: parseJson(version.payload_json, {}),
isCurrent: version.id === shot.current_version_id
}))
};
}
export function restoreShotVersion(context, shotId, versionId) {
requireAnyPermission(context, ["script:edit", "prompt:edit"], { mutating: true });
const current = ensureShot(context, shotId);
const version = dbGet("SELECT * FROM shot_versions WHERE id = ? AND shot_id = ?", [versionId, shotId]);
if (!version) throw httpError(404, "shot_version_not_found", "镜头版本不存在或不属于当前镜头", { shotId, versionId });
if (version.id === current.current_version_id) return { shot: shotPayload(current), versions: listShotVersions(context, shotId), graph: productionGraph(context, { episodeId: current.episode_id }) };
const payload = parseJson(version.payload_json, {});
const timestamp = now();
withTransaction(() => {
dbRun(
`UPDATE shots
SET title = ?, status = ?, first_frame_path = ?, last_frame_path = ?, current_version_id = ?, continuity_json = ?, updated_at = ?
WHERE id = ?`,
[
String(payload.title || current.title),
String(version.status || current.status || "draft"),
String(payload.firstFrame || current.first_frame_path || ""),
String(payload.lastFrame || current.last_frame_path || ""),
version.id,
JSON.stringify(payload),
timestamp,
shotId
]
);
if (Array.isArray(payload.voiceLines)) {
dbRun("DELETE FROM voice_lines WHERE shot_id = ?", [shotId]);
for (let index = 0; index < payload.voiceLines.length; index += 1) insertVoiceLine(context, shotId, index + 1, payload.voiceLines[index], timestamp);
}
});
addAudit({ context, action: "shot.version.restored", targetType: "shot_version", targetId: version.id, metadata: { shotId, version: version.version_number, previousVersionId: current.current_version_id || null } });
const refreshed = dbGet("SELECT * FROM shots WHERE id = ?", [shotId]);
return { shot: shotPayload(refreshed), versions: listShotVersions(context, shotId), graph: productionGraph(context, { episodeId: current.episode_id }) };
}
export function qaReviews(context) {
requirePermission(context, "qa:review");
ensureProductionRoot(context);
return { reviews: ensureReviews(context), graph: productionGraph(context) };
}
export function runAutomatedQa(context) {
requirePermission(context, "qa:review");
requireProjectWritable(context);
const project = requireProject(context);
ensureReviews(context);
const shots = shotRows(context).map(shotPayload);
const timestamp = now();
const blockedTerms = ["storyboard", "comic panel", "collage", "contact sheet", "split screen", "多格", "拼图", "分屏", "故事板拼图"];
const results = [];
for (const shot of shots) {
const bindings = dbAll(
`SELECT a.kind, a.lock_status, av.rights_status
FROM asset_bindings ab
JOIN assets a ON a.id = ab.asset_id
LEFT JOIN asset_versions av ON av.id = a.current_version_id
WHERE ab.shot_id = ?`,
[shot.id]
);
// The negative prompt is the evidence of the block rule, not a request to generate that layout.
const promptText = [shot.prompt, shot.action, shot.camera].join(" ").toLowerCase();
const blocked = blockedTerms.filter((term) => promptText.includes(term));
const missingLocks = ["character", "location", "prop"].filter((kind) => !bindings.some((binding) => binding.kind === kind && binding.lock_status === "locked" && binding.rights_status === "approved"));
const hasVoiceIssue = shot.voiceLines.some((line) => !line.voiceId || !line.audioFile);
const needsActualLastFrame = shot.shotNumber > 1 && shot.transitionFromPrevious !== "episode-start";
const bridgeIssue = needsActualLastFrame && (!shot.firstFrame || /pending|auto_previous/i.test(shot.firstFrame));
const checks = [
{ lane: "single-frame", ok: blocked.length === 0, blockers: blocked.length ? [`禁用词:${blocked.join("、")}`] : [] },
{ lane: "continuity-lock", ok: missingLocks.length === 0, blockers: missingLocks.length ? [`未锁定或未确认:${missingLocks.join("、")}`] : [] },
{ lane: "voice-subtitle-asr", ok: !hasVoiceIssue, blockers: hasVoiceIssue ? ["对白缺少固定 voiceId 或音频产物"] : [] },
{ lane: "clip-bridge", ok: !bridgeIssue, blockers: bridgeIssue ? ["连续片段缺少上一段实际末帧首帧"] : [] }
];
for (const check of checks) {
const reviewId = `review-${shot.id}-${check.lane}`;
const current = dbGet("SELECT * FROM reviews WHERE id = ?", [reviewId]);
const status = check.ok ? "approved" : "changes_requested";
const evidence = { source: "automated-contract-qa", checkedAt: timestamp, blockers: check.blockers, shotId: shot.id };
if (!current || current.status !== "approved" || parseJson(current.evidence_json, {}).source === "automated-contract-qa") {
dbRun("UPDATE reviews SET status = ?, score = ?, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE id = ?", [status, check.ok ? 100 : 40, context.user.id, timestamp, JSON.stringify(evidence), timestamp, reviewId]);
}
results.push({ shotId: shot.id, lane: check.lane, status, blockers: check.blockers });
}
}
addAudit({ context, action: "qa.automated.run", targetType: "project", targetId: project.id, metadata: { checks: results.length, passed: results.filter((item) => item.status === "approved").length } });
return { results, reviews: ensureReviews(context), graph: productionGraph(context) };
}
export async function runMediaQa(context) {
requirePermission(context, "qa:review");
requireProjectWritable(context);
const project = requireProject(context);
ensureReviews(context);
await syncProjectJobArtifacts(context);
const shots = shotRows(context).map(shotPayload);
const report = await inspectMediaForProject(context, shots);
const timestamp = now();
for (const shotReport of report.reports) {
for (const gate of shotReport.gates) {
const reviewId = `review-${shotReport.shotId}-${gate.lane}`;
const current = dbGet("SELECT * FROM reviews WHERE id = ?", [reviewId]);
const currentEvidence = parseJson(current?.evidence_json, {});
const isManualDecision = current?.status === "approved" && currentEvidence.source === "manual-review";
if (!isManualDecision) {
dbRun(
`UPDATE reviews
SET status = ?, score = ?, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ?
WHERE id = ?`,
[gate.status, gate.status === "approved" ? 100 : gate.status === "changes_requested" ? 40 : null, context.user.id, gate.status === "pending" ? null : timestamp, JSON.stringify({ source: "media-inspection-v1", checkedAt: report.checkedAt, blockers: gate.blockers, evidence: gate.evidence, media: shotReport.media }), timestamp, reviewId]
);
}
}
}
addAudit({ context, action: "qa.media_inspection.run", targetType: "project", targetId: project.id, metadata: report.totals });
return { report, reviews: ensureReviews(context), graph: productionGraph(context) };
}
function ensureReview(context, reviewId) {
const project = requireProject(context);
const review = dbGet("SELECT * FROM reviews WHERE id = ? AND project_id = ?", [reviewId, project.id]);
if (!review) throw httpError(404, "review_not_found", "质检项不存在或不属于当前项目", { reviewId });
return review;
}
export function decideReview(context, reviewId, body) {
requirePermission(context, "qa:review");
requireProjectWritable(context);
const review = ensureReview(context, reviewId);
const status = String(body.status || "").trim();
if (!["approved", "rejected", "changes_requested", "pending"].includes(status)) throw httpError(400, "review_status_invalid", "质检状态无效");
const timestamp = now();
const evidence = body.evidence && typeof body.evidence === "object" ? body.evidence : parseJson(review.evidence_json, {});
dbRun("UPDATE reviews SET status = ?, score = ?, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE id = ?", [status, body.score === undefined ? null : Number(body.score), context.user.id, status === "pending" ? null : timestamp, JSON.stringify(evidence), timestamp, reviewId]);
addAudit({ context, action: `qa.review.${status}`, targetType: "review", targetId: reviewId, result: status === "approved" ? "pass" : status, metadata: { lane: review.lane, score: body.score ?? null, evidence } });
const eventKey = status === "approved" ? "review.approved" : status === "changes_requested" ? "review.changes_requested" : status === "rejected" ? "review.rejected" : "review.updated";
void dispatchNotificationEvent({ context, eventKey, payload: { reviewId, lane: review.lane, status, shotId: review.shot_id, targetId: reviewId } });
return { review: dbGet("SELECT * FROM reviews WHERE id = ?", [reviewId]), reviews: ensureReviews(context) };
}
export function addReviewComment(context, reviewId, body) {
requirePermission(context, "qa:review");
requireProjectWritable(context);
ensureReview(context, reviewId);
const bodyText = String(body.body || "").trim();
if (!bodyText) throw httpError(400, "comment_required", "质检评论不能为空");
const id = makeId("comment");
dbRun("INSERT INTO review_comments(id, review_id, author_user_id, body, created_at) VALUES (?, ?, ?, ?, ?)", [id, reviewId, context.user.id, bodyText, now()]);
addAudit({ context, action: "qa.comment.created", targetType: "review_comment", targetId: id, metadata: { reviewId } });
void dispatchNotificationEvent({ context, eventKey: "review.comment", payload: { reviewId, targetId: reviewId, body: bodyText } });
return { comments: dbAll("SELECT c.*, u.display_name AS author_name FROM review_comments c LEFT JOIN users u ON u.id = c.author_user_id WHERE c.review_id = ? ORDER BY c.created_at", [reviewId]) };
}
export function listDeliveries(context) {
requireAnyPermission(context, ["delivery:view", "delivery:approve"]);
return { deliveries: deliveries(context) };
}
export function createDelivery(context, body) {
requirePermission(context, "delivery:approve");
const project = requireProject(context);
const latest = dbGet("SELECT COUNT(*) AS count FROM deliveries WHERE project_id = ?", [project.id]);
const version = String(body.version || `v${Number(latest?.count || 0) + 1}.0`);
const id = makeId("delivery");
const timestamp = now();
const manifestPath = String(body.manifestPath || `storage/deliveries/${project.id}/${version}/delivery-manifest.json`);
if (!manifestPath.startsWith("storage/") || manifestPath.includes("..") || manifestPath.startsWith("/")) throw httpError(400, "manifest_path_invalid", "交付 manifest 必须写入 storage/ 下的安全相对路径");
dbRun("INSERT INTO deliveries(id, organization_id, workspace_id, project_id, version, manifest_path, channel, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)", [id, context.organization.id, context.workspace.id, project.id, version, manifestPath, String(body.channel || "internal"), timestamp, timestamp]);
addAudit({ context, action: "delivery.created", targetType: "delivery", targetId: id, metadata: { version, manifestPath } });
void dispatchNotificationEvent({ context, eventKey: "delivery.created", payload: { deliveryId: id, version, targetId: id } });
return { delivery: dbGet("SELECT * FROM deliveries WHERE id = ?", [id]), deliveries: deliveries(context) };
}
function deliveryForContext(context, deliveryId) {
const project = requireProject(context);
const delivery = dbGet(
`SELECT * FROM deliveries
WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`,
[deliveryId, context.organization.id, context.workspace.id, project.id]
);
if (!delivery) throw httpError(404, "delivery_not_found", "交付版本不存在或不属于当前项目", { deliveryId });
return delivery;
}
function releasePayload(row) {
if (!row) return null;
return {
...row,
channel: {
id: row.channel_id,
name: row.channel_name,
kind: row.channel_kind,
enabled: Boolean(row.channel_enabled),
endpoint: row.channel_endpoint || "",
requireApproval: Boolean(row.channel_require_approval)
},
preflight: parseJson(row.preflight_json, {}),
result: parseJson(row.result_json, {})
};
}
function releaseForContext(context, releaseId) {
const project = requireProject(context);
const release = dbGet(
`SELECT dr.*, dc.name AS channel_name, dc.kind AS channel_kind, dc.enabled AS channel_enabled,
dc.endpoint AS channel_endpoint, dc.require_approval AS channel_require_approval,
d.version AS delivery_version, d.status AS delivery_status,
requested.display_name AS requested_by_name, reviewed.display_name AS reviewed_by_name,
published.display_name AS published_by_name
FROM delivery_releases dr
JOIN delivery_channels dc ON dc.id = dr.channel_id
JOIN deliveries d ON d.id = dr.delivery_id
LEFT JOIN users requested ON requested.id = dr.requested_by
LEFT JOIN users reviewed ON reviewed.id = dr.reviewed_by
LEFT JOIN users published ON published.id = dr.published_by
WHERE dr.id = ? AND dr.organization_id = ? AND dr.workspace_id = ? AND dr.project_id = ?`,
[releaseId, context.organization.id, context.workspace.id, project.id]
);
if (!release) throw httpError(404, "delivery_release_not_found", "发布记录不存在或不属于当前项目", { releaseId });
return release;
}
function releasePreflight(context, delivery, channel) {
const blockers = [];
const project = requireProject(context);
if (!channel.enabled) blockers.push({ type: "channel", status: "disabled", reason: "发布渠道已停用" });
if (delivery.status !== "approved") blockers.push({ type: "delivery", status: delivery.status, reason: "交付版本必须先通过交付审批" });
const activeBatch = delivery.active_batch_id
? dbGet(
`SELECT * FROM delivery_batches
WHERE id = ? AND delivery_id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`,
[delivery.active_batch_id, delivery.id, context.organization.id, context.workspace.id, project.id]
)
: null;
if (!activeBatch) {
blockers.push({ type: "delivery_batch", status: "missing", reason: "交付版本没有当前生效批次" });
} else {
if (activeBatch.status !== "active") blockers.push({ type: "delivery_batch", status: activeBatch.status, reason: "当前交付批次不是 active" });
const batchResult = parseJson(activeBatch.result_json, {});
if (Array.isArray(batchResult.blockers) && batchResult.blockers.length) blockers.push({ type: "delivery_batch", status: "blocked", reason: "批次创建时已登记媒体阻断项", items: batchResult.blockers });
const manifest = safeStoragePath(activeBatch.manifest_path);
if (!manifest || !existsSync(manifest.absolute)) blockers.push({ type: "manifest", status: "missing", reason: "当前批次 Manifest 文件不存在" });
const items = dbAll(
`SELECT dbi.*, s.title AS shot_title
FROM delivery_batch_items dbi
LEFT JOIN shots s ON s.id = dbi.shot_id
WHERE dbi.batch_id = ? ORDER BY dbi.sequence_number`,
[activeBatch.id]
);
if (!items.length) blockers.push({ type: "delivery_batch_items", status: "missing", reason: "当前批次没有镜头条目" });
for (const item of items) {
const source = safeStoragePath(item.source_path);
const actualLastFrame = safeStoragePath(item.actual_last_frame_path);
if (!source || !existsSync(source.absolute)) blockers.push({ type: "media", shotId: item.shot_id, status: "missing", reason: "视频源文件不存在" });
if (!item.source_sha256) blockers.push({ type: "media", shotId: item.shot_id, status: "unverified", reason: "视频源缺少 SHA-256" });
if (!actualLastFrame || !existsSync(actualLastFrame.absolute)) blockers.push({ type: "actual-last-frame", shotId: item.shot_id, status: "missing", reason: "实际末帧证据不存在" });
}
return {
ok: blockers.length === 0,
checkedAt: now(),
projectId: project.id,
deliveryId: delivery.id,
channelId: channel.id,
batchId: activeBatch.id,
manifestPath: activeBatch.manifest_path,
itemCount: items.length,
blockers
};
}
return {
ok: false,
checkedAt: now(),
projectId: project.id,
deliveryId: delivery.id,
channelId: channel.id,
batchId: null,
manifestPath: "",
itemCount: 0,
blockers
};
}
export function listDeliveryReleases(context, deliveryId) {
requireAnyPermission(context, ["delivery:view", "delivery:approve"]);
deliveryForContext(context, deliveryId);
return {
deliveryId,
releases: dbAll(
`SELECT dr.*, dc.name AS channel_name, dc.kind AS channel_kind, dc.enabled AS channel_enabled,
dc.endpoint AS channel_endpoint, dc.require_approval AS channel_require_approval,
d.version AS delivery_version, d.status AS delivery_status,
requested.display_name AS requested_by_name, reviewed.display_name AS reviewed_by_name,
published.display_name AS published_by_name
FROM delivery_releases dr
JOIN delivery_channels dc ON dc.id = dr.channel_id
JOIN deliveries d ON d.id = dr.delivery_id
LEFT JOIN users requested ON requested.id = dr.requested_by
LEFT JOIN users reviewed ON reviewed.id = dr.reviewed_by
LEFT JOIN users published ON published.id = dr.published_by
WHERE dr.delivery_id = ? AND dr.organization_id = ? AND dr.workspace_id = ? AND dr.project_id = ?
ORDER BY dr.created_at DESC`,
[deliveryId, context.organization.id, context.workspace.id, context.project.id]
).map(releasePayload)
};
}
export function createDeliveryRelease(context, deliveryId, body = {}) {
requirePermission(context, "delivery:approve");
const delivery = deliveryForContext(context, deliveryId);
const requestedChannelId = String(body.channelId || "").trim();
const channel = requestedChannelId ? channelForContext(context, requestedChannelId) : ensureDefaultDeliveryChannel(context);
const idempotencyKey = String(body.idempotencyKey || "").trim().slice(0, 160);
if (idempotencyKey) {
const existing = dbGet(
`SELECT dr.*, dc.name AS channel_name, dc.kind AS channel_kind, dc.enabled AS channel_enabled,
dc.endpoint AS channel_endpoint, dc.require_approval AS channel_require_approval,
d.version AS delivery_version, d.status AS delivery_status,
requested.display_name AS requested_by_name, reviewed.display_name AS reviewed_by_name,
published.display_name AS published_by_name
FROM delivery_releases dr
JOIN delivery_channels dc ON dc.id = dr.channel_id
JOIN deliveries d ON d.id = dr.delivery_id
LEFT JOIN users requested ON requested.id = dr.requested_by
LEFT JOIN users reviewed ON reviewed.id = dr.reviewed_by
LEFT JOIN users published ON published.id = dr.published_by
WHERE dr.organization_id = ? AND dr.project_id = ? AND dr.idempotency_key = ?`,
[context.organization.id, context.project.id, idempotencyKey]
);
if (existing) return { release: releasePayload(existing), idempotent: true, ...listDeliveryReleases(context, deliveryId) };
}
const submit = body.submit !== false;
const preflight = submit ? releasePreflight(context, delivery, channel) : { ok: false, checkedAt: now(), blockers: [{ type: "not_submitted", status: "draft", reason: "发布申请尚未提交" }] };
if (submit && !preflight.ok) throw httpError(409, "release_blocked", "当前交付版本不满足发布申请条件", { blockers: preflight.blockers, preflight });
const id = makeId("delivery-release");
const timestamp = now();
const status = submit ? "submitted" : "draft";
dbRun(
`INSERT INTO delivery_releases(
id, organization_id, workspace_id, project_id, delivery_id, channel_id, status, idempotency_key,
requested_by, requested_at, preflight_json, result_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)`,
[id, context.organization.id, context.workspace.id, context.project.id, delivery.id, channel.id, status, idempotencyKey, submit ? context.user.id : null, submit ? timestamp : null, JSON.stringify(preflight), timestamp, timestamp]
);
addAudit({ context, action: submit ? "delivery.release.submitted" : "delivery.release.created", targetType: "delivery_release", targetId: id, metadata: { deliveryId, channelId: channel.id, status, idempotencyKey: Boolean(idempotencyKey) } });
void dispatchNotificationEvent({ context, eventKey: submit ? "delivery.release.submitted" : "delivery.release.created", payload: { releaseId: id, deliveryId, targetId: id, status } });
return { release: releasePayload(releaseForContext(context, id)), ...listDeliveryReleases(context, deliveryId) };
}
export function decideDeliveryRelease(context, releaseId, body = {}) {
requirePermission(context, "delivery:approve");
const release = releaseForContext(context, releaseId);
const status = String(body.status || "").trim();
const note = String(body.note || "").trim().slice(0, 4000);
const delivery = deliveryForContext(context, release.delivery_id);
const channel = channelForContext(context, release.channel_id);
let preflight = parseJson(release.preflight_json, {});
if (status === "draft") {
if (release.status !== "rejected") throw httpError(409, "release_transition_invalid", "只有已驳回的发布申请可以退回草稿");
const timestamp = now();
dbRun("UPDATE delivery_releases SET status = 'draft', decision_note = ?, reviewed_by = NULL, reviewed_at = NULL, updated_at = ? WHERE id = ?", [note, timestamp, releaseId]);
} else if (status === "submitted") {
if (!["draft", "rejected"].includes(release.status)) throw httpError(409, "release_transition_invalid", "只有草稿或已驳回的发布申请可以重新提交");
preflight = releasePreflight(context, delivery, channel);
if (!preflight.ok) throw httpError(409, "release_blocked", "发布申请仍未满足发布条件", { blockers: preflight.blockers, preflight });
const timestamp = now();
dbRun("UPDATE delivery_releases SET status = 'submitted', requested_by = ?, requested_at = ?, decision_note = ?, preflight_json = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, note, JSON.stringify(preflight), timestamp, releaseId]);
} else if (status === "approved") {
if (release.status !== "submitted") throw httpError(409, "release_transition_invalid", "只有已提交的发布申请可以批准");
preflight = releasePreflight(context, delivery, channel);
if (!preflight.ok) throw httpError(409, "release_blocked", "发布申请审批被质量门阻断", { blockers: preflight.blockers, preflight });
const timestamp = now();
dbRun("UPDATE delivery_releases SET status = 'approved', reviewed_by = ?, reviewed_at = ?, decision_note = ?, preflight_json = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, note, JSON.stringify(preflight), timestamp, releaseId]);
} else if (status === "rejected") {
if (release.status !== "submitted") throw httpError(409, "release_transition_invalid", "只有已提交的发布申请可以驳回");
const timestamp = now();
dbRun("UPDATE delivery_releases SET status = 'rejected', reviewed_by = ?, reviewed_at = ?, decision_note = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, note || "审批人要求修改后重新提交", timestamp, releaseId]);
} else {
throw httpError(400, "release_status_invalid", "发布申请状态只能是 draft、submitted、approved 或 rejected");
}
addAudit({ context, action: `delivery.release.${status}`, targetType: "delivery_release", targetId: releaseId, result: status === "approved" ? "pass" : status, metadata: { deliveryId: release.delivery_id, channelId: release.channel_id, note } });
void dispatchNotificationEvent({ context, eventKey: `delivery.release.${status}`, payload: { releaseId, deliveryId: release.delivery_id, targetId: releaseId, status } });
return { release: releasePayload(releaseForContext(context, releaseId)), ...listDeliveryReleases(context, release.delivery_id) };
}
export async function publishDeliveryRelease(context, releaseId) {
requirePermission(context, "delivery:approve");
const release = releaseForContext(context, releaseId);
if (release.status === "published") return { release: releasePayload(release), idempotent: true, ...listDeliveryReleases(context, release.delivery_id) };
if (!["approved", "failed"].includes(release.status)) throw httpError(409, "release_transition_invalid", "只有已批准或上次发布失败的申请可以发布");
const delivery = deliveryForContext(context, release.delivery_id);
const channel = channelForContext(context, release.channel_id);
const preflight = releasePreflight(context, delivery, channel);
if (!preflight.ok) throw httpError(409, "release_blocked", "发布前复核未通过", { blockers: preflight.blockers, preflight });
const outputDirectory = `storage/releases/${safePathSegment(context.project.id)}/${safePathSegment(delivery.version)}/${safePathSegment(release.id)}`;
const releaseOutputPath = `${outputDirectory}/release.json`;
const manifestOutputPath = `${outputDirectory}/delivery-manifest.json`;
const timestamp = now();
const releaseDocument = {
schema: "ai-drama-platform.delivery-release.v1",
publishedAt: timestamp,
release: { id: release.id, status: "published", requestedBy: release.requested_by, reviewedBy: release.reviewed_by },
delivery: { id: delivery.id, version: delivery.version, status: delivery.status, manifestPath: delivery.manifest_path },
channel: { id: channel.id, name: channel.name, kind: channel.kind, endpoint: channel.kind === "local-file" ? channel.endpoint : "private-webhook" },
batch: { id: preflight.batchId, manifestPath: preflight.manifestPath, itemCount: preflight.itemCount },
preflight
};
let result = { kind: channel.kind, outputPath: releaseOutputPath, manifestPath: manifestOutputPath, localOnly: true };
try {
const outputDirectoryTarget = safeStoragePath(`${outputDirectory}/release.json`);
const manifestTarget = safeStoragePath(`${outputDirectory}/delivery-manifest.json`);
const sourceManifest = safeStoragePath(preflight.manifestPath);
if (!outputDirectoryTarget || !manifestTarget || !sourceManifest) throw httpError(500, "release_output_path_invalid", "发布输出路径不满足本地存储安全约束");
mkdirSync(resolve(projectRoot, outputDirectory), { recursive: true });
if (!existsSync(sourceManifest.absolute)) throw httpError(409, "release_manifest_missing", "发布时找不到当前批次 Manifest");
writeFileSync(manifestTarget.absolute, readFileSync(sourceManifest.absolute));
writeFileSync(outputDirectoryTarget.absolute, `${JSON.stringify(releaseDocument, null, 2)}\n`, "utf8");
if (channel.kind === "local-webhook") {
const authValue = channel.auth_env ? process.env[channel.auth_env] : "";
if (channel.auth_env && !authValue) throw httpError(409, "delivery_channel_auth_missing", `本地 Webhook 所需环境变量未设置:${channel.auth_env}`);
const headers = { "content-type": "application/json", accept: "application/json" };
if (authValue) headers.authorization = `Bearer ${authValue}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch(channel.endpoint, { method: "POST", headers, body: JSON.stringify(releaseDocument), signal: controller.signal });
const responseText = await response.text();
if (!response.ok) throw httpError(502, "delivery_webhook_failed", `本地 Webhook 返回 HTTP ${response.status}`, { status: response.status, body: responseText.slice(0, 1000) });
result = { ...result, webhookStatus: response.status, webhookBody: responseText.slice(0, 1000) };
} finally {
clearTimeout(timeout);
}
}
dbRun("UPDATE delivery_releases SET status = 'published', published_by = ?, published_at = ?, output_path = ?, preflight_json = ?, result_json = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, releaseOutputPath, JSON.stringify(preflight), JSON.stringify(result), timestamp, releaseId]);
addUsage({ context, kind: "delivery_release_published", units: 1, unitName: "release", metadata: { releaseId, deliveryId: delivery.id, channelId: channel.id, channelKind: channel.kind } });
addAudit({ context, action: "delivery.release.published", targetType: "delivery_release", targetId: releaseId, metadata: { deliveryId: delivery.id, channelId: channel.id, outputPath: releaseOutputPath, channelKind: channel.kind } });
void dispatchNotificationEvent({ context, eventKey: "delivery.release.published", payload: { releaseId, deliveryId: delivery.id, targetId: releaseId, outputPath: releaseOutputPath } });
} catch (error) {
const failure = { kind: channel.kind, error: error.message, code: error.code || "delivery_release_publish_failed" };
dbRun("UPDATE delivery_releases SET status = 'failed', result_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(failure), now(), releaseId]);
addAudit({ context, action: "delivery.release.publish_failed", targetType: "delivery_release", targetId: releaseId, result: "error", metadata: { deliveryId: delivery.id, channelId: channel.id, error: error.message, code: error.code || "delivery_release_publish_failed" } });
if (error.status) throw error;
throw httpError(502, "delivery_release_publish_failed", `发布执行失败:${error.message}`);
}
return { release: releasePayload(releaseForContext(context, releaseId)), ...listDeliveryReleases(context, delivery.id) };
}
function deliveryBatchPayload(row) {
return {
...row,
source: parseJson(row.source_json, {}),
result: parseJson(row.result_json, {}),
items: dbAll(
`SELECT dbi.*, s.title AS shot_title
FROM delivery_batch_items dbi
LEFT JOIN shots s ON s.id = dbi.shot_id
WHERE dbi.batch_id = ?
ORDER BY dbi.sequence_number`,
[row.id]
).map((item) => ({ ...item, metadata: parseJson(item.metadata_json, {}) }))
};
}
export function listDeliveryBatches(context, deliveryId) {
requireAnyPermission(context, ["delivery:view", "delivery:approve"]);
deliveryForContext(context, deliveryId);
return {
deliveryId,
batches: dbAll(
`SELECT db.*, u.display_name AS created_by_name
FROM delivery_batches db
LEFT JOIN users u ON u.id = db.created_by
WHERE db.delivery_id = ?
ORDER BY db.batch_number DESC`,
[deliveryId]
).map(deliveryBatchPayload)
};
}
export function createDeliveryBatch(context, deliveryId, body = {}) {
requirePermission(context, "delivery:approve");
const delivery = deliveryForContext(context, deliveryId);
const compositionId = body.compositionId ? String(body.compositionId) : "";
const composition = compositionId
? dbGet("SELECT * FROM media_compositions WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [compositionId, context.organization.id, context.workspace.id, context.project.id])
: null;
if (compositionId && !composition) throw httpError(404, "composition_not_found", "合成记录不存在或不属于当前项目", { compositionId });
const latest = dbGet("SELECT MAX(batch_number) AS batch_number FROM delivery_batches WHERE delivery_id = ?", [deliveryId]);
const batchNumber = Number(latest?.batch_number || 0) + 1;
const id = makeId("delivery-batch");
const timestamp = now();
const shots = shotRows(context).map(shotPayload);
const shotArtifacts = shots.map((shot) => ({ shot, artifact: latestArtifactForShot(context, shot.id, "video") }));
const artifactBlockers = shotArtifacts
.filter(({ artifact }) => !artifact || artifact.status !== "inspected" || !artifact.sha256 || !artifact.path)
.map(({ shot, artifact }) => ({ shotId: shot.id, reason: artifact ? `视频文件${artifact.status === "missing" ? "不存在" : "未通过文件检查"}` : "没有已登记的视频媒体证据" }));
const source = {
schema: "ai-drama-platform.delivery-batch-source.v2",
capturedAt: timestamp,
deliveryId,
deliveryVersion: delivery.version,
compositionId: composition?.id || null,
blockers: artifactBlockers,
shots: shotArtifacts.map(({ shot, artifact }) => ({
shotId: shot.id,
versionId: shot.versionId || null,
versionNumber: shot.versionNumber || 1,
firstFrame: shot.firstFrame || "",
lastFrame: artifact?.last_frame_path || shot.lastFrame || "",
transitionFromPrevious: shot.transitionFromPrevious || "",
artifactId: artifact?.id || null,
sourcePath: artifact?.path || "",
sourceSha256: artifact?.sha256 || "",
mediaStatus: artifact?.status || "missing"
}))
};
const manifestPath = String(body.manifestPath || `storage/deliveries/${context.project.id}/${delivery.version}/batches/batch-${batchNumber}/manifest.json`);
if (!manifestPath.startsWith("storage/") || manifestPath.includes("..") || manifestPath.startsWith("/")) throw httpError(400, "manifest_path_invalid", "交付 manifest 必须写入 storage/ 下的安全相对路径");
withTransaction(() => {
dbRun(
`INSERT INTO delivery_batches(
id, organization_id, workspace_id, project_id, delivery_id, batch_number, label, status,
manifest_path, composition_id, source_json, result_json, created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'prepared', ?, ?, ?, ?, ?, ?, ?)`,
[id, context.organization.id, context.workspace.id, context.project.id, deliveryId, batchNumber, String(body.label || `批次 ${batchNumber}`), manifestPath, composition?.id || null, JSON.stringify(source), JSON.stringify({ blockers: artifactBlockers, artifactCount: shotArtifacts.filter(({ artifact }) => artifact).length }), context.user.id, timestamp, timestamp]
);
for (let index = 0; index < shotArtifacts.length; index += 1) {
const { shot, artifact } = shotArtifacts[index];
dbRun(
`INSERT INTO delivery_batch_items(
id, batch_id, shot_id, sequence_number, source_path, actual_last_frame_path, source_sha256, metadata_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[makeId("delivery-item"), id, shot.id, index + 1, String(artifact?.path || ""), String(artifact?.last_frame_path || shot.lastFrame || ""), String(artifact?.sha256 || ""), JSON.stringify({ versionId: shot.versionId || null, versionNumber: shot.versionNumber || 1, artifactId: artifact?.id || null, artifactStatus: artifact?.status || "missing", durationSec: artifact?.durationSec || 0, width: artifact?.width || 0, height: artifact?.height || 0 }), timestamp]
);
}
});
const manifest = {
schema: "ai-drama-platform.delivery-manifest.v2",
generatedAt: timestamp,
delivery: { id: delivery.id, version: delivery.version, channel: delivery.channel },
batch: { id, number: batchNumber, label: String(body.label || `批次 ${batchNumber}`), compositionId: composition?.id || null },
blockers: artifactBlockers,
items: source.shots
};
mkdirSync(resolve(projectRoot, manifestPath, ".."), { recursive: true });
writeFileSync(resolve(projectRoot, manifestPath), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
dbRun("UPDATE delivery_batches SET result_json = ? WHERE id = ?", [JSON.stringify({ blockers: artifactBlockers, artifactCount: shotArtifacts.filter(({ artifact }) => artifact).length, manifestWritten: true }), id]);
addAudit({ context, action: "delivery.batch.created", targetType: "delivery_batch", targetId: id, metadata: { deliveryId, batchNumber, shotCount: shots.length, manifestPath, artifactBlockers: artifactBlockers.length } });
return { batch: deliveryBatchPayload(dbGet("SELECT * FROM delivery_batches WHERE id = ?", [id])), batches: listDeliveryBatches(context, deliveryId).batches };
}
export function activateDeliveryBatch(context, batchId) {
requirePermission(context, "delivery:approve");
const project = requireProject(context);
const batch = dbGet("SELECT * FROM delivery_batches WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [batchId, context.organization.id, context.workspace.id, project.id]);
if (!batch) throw httpError(404, "delivery_batch_not_found", "交付批次不存在或不属于当前项目", { batchId });
if (batch.status === "rolled_back") throw httpError(409, "delivery_batch_rolled_back", "已回滚批次不能重新激活");
const timestamp = now();
withTransaction(() => {
dbRun("UPDATE delivery_batches SET status = 'superseded', updated_at = ? WHERE delivery_id = ? AND status = 'active'", [timestamp, batch.delivery_id]);
dbRun("UPDATE delivery_batches SET status = 'active', updated_at = ? WHERE id = ?", [timestamp, batchId]);
dbRun("UPDATE deliveries SET active_batch_id = ?, updated_at = ? WHERE id = ?", [batchId, timestamp, batch.delivery_id]);
});
addAudit({ context, action: "delivery.batch.activated", targetType: "delivery_batch", targetId: batchId, metadata: { deliveryId: batch.delivery_id, batchNumber: batch.batch_number } });
return { batch: deliveryBatchPayload(dbGet("SELECT * FROM delivery_batches WHERE id = ?", [batchId])), batches: listDeliveryBatches(context, batch.delivery_id).batches };
}
export function rollbackDeliveryBatch(context, batchId) {
requirePermission(context, "delivery:approve");
const project = requireProject(context);
const batch = dbGet("SELECT * FROM delivery_batches WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [batchId, context.organization.id, context.workspace.id, project.id]);
if (!batch) throw httpError(404, "delivery_batch_not_found", "交付批次不存在或不属于当前项目", { batchId });
if (batch.status !== "active") throw httpError(409, "delivery_batch_not_active", "只有当前生效批次可以回滚", { status: batch.status });
const previous = dbGet("SELECT * FROM delivery_batches WHERE delivery_id = ? AND batch_number < ? AND status IN ('superseded', 'prepared', 'active') ORDER BY batch_number DESC LIMIT 1", [batch.delivery_id, batch.batch_number]);
if (!previous) throw httpError(409, "delivery_batch_no_rollback_target", "当前批次没有可恢复的历史批次");
const timestamp = now();
withTransaction(() => {
dbRun("UPDATE delivery_batches SET status = 'rolled_back', rollback_of_batch_id = ?, updated_at = ? WHERE id = ?", [previous.id, timestamp, batch.id]);
dbRun("UPDATE delivery_batches SET status = 'active', updated_at = ? WHERE id = ?", [timestamp, previous.id]);
dbRun("UPDATE deliveries SET active_batch_id = ?, updated_at = ? WHERE id = ?", [previous.id, timestamp, batch.delivery_id]);
});
addAudit({ context, action: "delivery.batch.rolled_back", targetType: "delivery_batch", targetId: batchId, metadata: { deliveryId: batch.delivery_id, fromBatch: batch.batch_number, toBatch: previous.batch_number, restoredBatchId: previous.id } });
return { restoredBatch: deliveryBatchPayload(dbGet("SELECT * FROM delivery_batches WHERE id = ?", [previous.id])), batches: listDeliveryBatches(context, batch.delivery_id).batches };
}
export function approveDelivery(context, deliveryId, body = {}) {
requirePermission(context, "delivery:approve");
const project = requireProject(context);
const delivery = dbGet("SELECT * FROM deliveries WHERE id = ? AND project_id = ?", [deliveryId, project.id]);
if (!delivery) throw httpError(404, "delivery_not_found", "交付版本不存在或不属于当前项目", { deliveryId });
const reviews = ensureReviews(context);
const reviewBlockers = reviews.filter((review) => review.status !== "approved").map((review) => ({ id: review.id, type: "review", lane: review.lane, status: review.status, shotId: review.shot_id }));
const jobBlockers = dbAll("SELECT id, kind, status, shot_id FROM generation_jobs WHERE project_id = ? AND status NOT IN ('completed', 'cancelled') ORDER BY created_at", [project.id]).map((job) => ({ id: job.id, type: "job", kind: job.kind, status: job.status, shotId: job.shot_id }));
const activeBatch = delivery.active_batch_id ? dbGet("SELECT * FROM delivery_batches WHERE id = ? AND delivery_id = ?", [delivery.active_batch_id, delivery.id]) : null;
const batchBlockers = !activeBatch
? [{ id: delivery.id, type: "delivery_batch", status: "missing", reason: "交付版本尚未激活一个交付批次" }]
: dbAll("SELECT id, shot_id, source_path, actual_last_frame_path, source_sha256, metadata_json FROM delivery_batch_items WHERE batch_id = ? ORDER BY sequence_number", [activeBatch.id])
.flatMap((item) => {
const metadata = parseJson(item.metadata_json, {});
const missing = [];
if (!item.source_path || !item.source_sha256 || metadata.artifactStatus !== "inspected") missing.push("视频文件证据");
if (!item.actual_last_frame_path) missing.push("实际末帧");
return missing.length ? [{ id: item.id, type: "delivery_batch_item", shotId: item.shot_id, status: "blocked", reason: missing.join("、") }] : [];
});
const blockers = [...reviewBlockers, ...jobBlockers, ...batchBlockers];
if (blockers.length && !body.force) throw httpError(409, "delivery_blocked", "仍有质检项未通过,不能批准交付", { blockers });
const timestamp = now();
dbRun("UPDATE deliveries SET status = 'approved', approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, timestamp, deliveryId]);
addAudit({ context, action: "delivery.approved", targetType: "delivery", targetId: deliveryId, metadata: { forced: Boolean(body.force), blockers: blockers.length } });
void dispatchNotificationEvent({ context, eventKey: "delivery.approved", payload: { deliveryId, version: delivery.version, targetId: deliveryId, forced: Boolean(body.force) } });
return { delivery: dbGet("SELECT * FROM deliveries WHERE id = ?", [deliveryId]), deliveries: deliveries(context), blockers };
}
function scopedJob(context, jobId) {
const project = requireProject(context);
const job = dbGet("SELECT * FROM generation_jobs WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [jobId, context.organization.id, context.workspace.id, project.id]);
if (!job) throw httpError(404, "job_not_found", "任务不存在或不属于当前项目", { jobId });
return job;
}
export function updateJob(context, jobId, action, body = {}) {
const job = scopedJob(context, jobId);
if (action === "priority") {
requireAnyPermission(context, ["job:prioritize", "queue:manage"]);
requireProjectWritable(context);
const priority = Number(body.priority);
if (!Number.isFinite(priority) || priority < 1 || priority > 100) throw httpError(400, "priority_invalid", "优先级必须在 1 到 100 之间");
dbRun("UPDATE generation_jobs SET priority = ?, updated_at = ? WHERE id = ?", [priority, now(), jobId]);
addAudit({ context, action: "generation_job.priority.updated", targetType: "generation_job", targetId: jobId, metadata: { previous: job.priority, priority } });
} else if (action === "retry") {
requireAnyPermission(context, ["job:prioritize", "queue:manage"]);
requireProjectWritable(context);
if (!["failed", "cancelled"].includes(job.status)) throw httpError(409, "job_not_retryable", "只有失败或已取消任务可以重试", { status: job.status });
const timestamp = now();
const attempt = Number(dbGet("SELECT MAX(attempt_number) AS attempt_number FROM job_attempts WHERE job_id = ?", [jobId])?.attempt_number || 0) + 1;
dbRun("UPDATE generation_jobs SET status = 'queued', qa_status = 'wait', updated_at = ? WHERE id = ?", [timestamp, jobId]);
dbRun("INSERT INTO job_attempts(id, job_id, attempt_number, runner_id, status, created_at) VALUES (?, ?, ?, 'local-runner', 'queued', ?)", [makeId("attempt"), jobId, attempt, timestamp]);
addAudit({ context, action: "generation_job.retried", targetType: "generation_job", targetId: jobId, metadata: { attempt } });
} else if (action === "cancel") {
requireAnyPermission(context, ["job:prioritize", "queue:manage"]);
requireProjectWritable(context);
if (!["queued", "running"].includes(job.status)) throw httpError(409, "job_not_cancellable", "当前任务状态不能取消", { status: job.status });
dbRun("UPDATE generation_jobs SET status = 'cancelled', updated_at = ? WHERE id = ?", [now(), jobId]);
addAudit({ context, action: "generation_job.cancelled", targetType: "generation_job", targetId: jobId });
} else {
throw httpError(400, "job_action_invalid", "不支持的任务动作", { action });
}
return { job: dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId]) };
}
export function exportAudit(context, limit = 500) {
requirePermission(context, "audit:view");
return dbAll(
`SELECT a.*, u.display_name AS actor_name
FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id
WHERE a.organization_id = ? AND (a.workspace_id IS NULL OR a.workspace_id = ?) AND (a.project_id IS NULL OR a.project_id = ?)
ORDER BY a.created_at DESC LIMIT ?`,
[context.organization.id, context.workspace.id, context.project?.id || "", limit]
).map((item) => ({ ...item, metadata: parseJson(item.metadata_json, {}) }));
}