feat: bootstrap commercial AI drama platform
This commit is contained in:
+1739
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,701 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AudioLines,
|
||||
Bot,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
CheckSquare2,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
Download,
|
||||
FileAudio,
|
||||
FileImage,
|
||||
FileText,
|
||||
Film,
|
||||
HardDrive,
|
||||
FolderOpen,
|
||||
Images,
|
||||
Layers3,
|
||||
LockKeyhole,
|
||||
MessageSquareText,
|
||||
Mic2,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Square,
|
||||
Tags,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import {
|
||||
bindAssetToShot,
|
||||
createAsset,
|
||||
createAssetVersion,
|
||||
fetchAsset,
|
||||
fetchAssetContent,
|
||||
fetchAssets,
|
||||
fetchJobs,
|
||||
queryAssistant,
|
||||
restoreAssetVersion,
|
||||
updateAssetLock,
|
||||
updateAssetRights,
|
||||
uploadAsset,
|
||||
uploadAssetVersion,
|
||||
verifyAsset
|
||||
} from "../lib/api";
|
||||
import { buildVoiceTable, downloadJson } from "../lib/exporters";
|
||||
|
||||
function SuiteHeader({ kicker, title, description, actions }) {
|
||||
return (
|
||||
<div className="suite-header">
|
||||
<div>
|
||||
<span className="card-kicker">{kicker}</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
{actions && <div className="suite-header-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuiteMetric({ icon: Icon, label, value, detail, tone = "neutral" }) {
|
||||
return (
|
||||
<div className={`suite-metric ${tone}`}>
|
||||
<div className="suite-metric-icon"><Icon size={17} /></div>
|
||||
<div><span>{label}</span><strong>{value}</strong><small>{detail}</small></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuiteStatus({ status }) {
|
||||
const config = {
|
||||
locked: ["已锁定", "ok"],
|
||||
ready: ["可用", "ok"],
|
||||
draft: ["草稿", "neutral"],
|
||||
pending: ["待处理", "warn"],
|
||||
running: ["执行中", "warn"],
|
||||
blocked: ["阻塞", "warn"],
|
||||
review: ["待复核", "warn"],
|
||||
archived: ["已归档", "neutral"],
|
||||
"needs-evidence": ["待版权证据", "warn"],
|
||||
completed: ["已完成", "ok"],
|
||||
approved: ["已确认", "ok"],
|
||||
"needs-user-approved-reference": ["待参考音频", "warn"]
|
||||
}[status] || [status || "未设置", "neutral"];
|
||||
return <span className={`suite-status ${config[1]}`}><span />{config[0]}</span>;
|
||||
}
|
||||
|
||||
function AssetVisual({ asset }) {
|
||||
const rawKind = asset.rawKind || asset.kind;
|
||||
const Icon = rawKind === "character" || asset.kind === "角色" ? Images : rawKind === "location" || asset.kind === "场景" ? Film : rawKind === "prop" || asset.kind === "道具" ? Tags : rawKind === "voice" || asset.kind === "声音" ? FileAudio : FileImage;
|
||||
const visualKind = rawKind === "character" || asset.kind === "角色" ? "character" : rawKind === "location" || asset.kind === "场景" ? "location" : rawKind === "prop" || asset.kind === "道具" ? "prop" : rawKind === "voice" || asset.kind === "声音" ? "voice" : "system";
|
||||
return <div className={`asset-visual asset-${visualKind}`}><Icon size={22} /><strong>{asset.initial}</strong></div>;
|
||||
}
|
||||
|
||||
const assetKindLabels = {
|
||||
character: "角色",
|
||||
location: "场景",
|
||||
prop: "道具",
|
||||
style: "风格",
|
||||
lora: "LoRA",
|
||||
voice: "声音",
|
||||
subtitle: "字幕",
|
||||
reference: "参考"
|
||||
};
|
||||
|
||||
const assetKindOptions = [
|
||||
["character", "角色"],
|
||||
["location", "场景"],
|
||||
["prop", "道具"],
|
||||
["voice", "声音"],
|
||||
["style", "风格"],
|
||||
["lora", "LoRA"],
|
||||
["reference", "参考"]
|
||||
];
|
||||
|
||||
const MAX_ASSET_UPLOAD_BYTES = 12 * 1024 * 1024;
|
||||
const ASSET_UPLOAD_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "gif", "svg", "wav", "mp3", "m4a", "mp4", "webm", "json", "txt", "md", "csv", "safetensors", "ckpt", "pt", "bin"]);
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes || 0);
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(value / 1024 ** 3).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function validateAssetFile(file) {
|
||||
if (!file) throw new Error("没有选择文件");
|
||||
if (!file.size) throw new Error("不能上传空文件");
|
||||
if (file.size > MAX_ASSET_UPLOAD_BYTES) throw new Error(`单次本地资产上传不能超过 12MB,当前文件为 ${formatBytes(file.size)}`);
|
||||
const extension = file.name.toLowerCase().split(".").pop();
|
||||
const supported = file.type.startsWith("image/") || file.type.startsWith("audio/") || file.type.startsWith("video/") || file.type.startsWith("text/") || file.type === "application/json" || ASSET_UPLOAD_EXTENSIONS.has(extension);
|
||||
if (!supported) throw new Error(`暂不支持 ${extension ? `.${extension}` : "该类型"} 文件,请导入图片、音频、视频、文本、JSON 或本地模型文件`);
|
||||
return file;
|
||||
}
|
||||
|
||||
function isTextAsset(fileName = "", contentType = "") {
|
||||
return contentType.startsWith("text/") || contentType.includes("json") || /\.(json|txt|md|csv)$/i.test(fileName);
|
||||
}
|
||||
|
||||
function normalizeAsset(row) {
|
||||
const metadata = row.currentVersion?.metadata || {};
|
||||
const rawKind = row.kind || "reference";
|
||||
const currentVersion = row.currentVersion || {};
|
||||
return {
|
||||
...row,
|
||||
rawKind,
|
||||
kind: assetKindLabels[rawKind] || rawKind,
|
||||
status: row.lockStatus || row.lock_status || "draft",
|
||||
currentVersionId: row.currentVersionId || row.current_version_id || row.currentVersion?.id || "",
|
||||
version: row.currentVersion?.version_number ? `v${row.currentVersion.version_number}` : "v1",
|
||||
initial: metadata.initial || row.name?.slice(0, 1) || "资",
|
||||
subtitle: metadata.subtitle || "本地项目资产",
|
||||
usage: metadata.usage || "当前项目",
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
|
||||
detail: metadata.detail || "待补充资产描述",
|
||||
lock: metadata.lock || "待补充连续性备注",
|
||||
rightsStatus: row.currentVersion?.rights_status || "needs-evidence",
|
||||
versions: row.versions || [],
|
||||
bindings: row.bindings || [],
|
||||
fileName: currentVersion.fileName || currentVersion.file_name || "",
|
||||
mimeType: currentVersion.mimeType || currentVersion.mime_type || metadata.mimeType || "application/octet-stream",
|
||||
fileSize: Number(currentVersion.fileSize ?? currentVersion.file_size ?? metadata.size ?? 0),
|
||||
contentSha256: currentVersion.contentSha256 || currentVersion.content_sha256 || ""
|
||||
};
|
||||
}
|
||||
|
||||
function readFileBase64(file) {
|
||||
return file.arrayBuffer().then((buffer) => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
});
|
||||
}
|
||||
|
||||
function inferAssetKind(file) {
|
||||
const name = file.name.toLowerCase();
|
||||
if (name.endsWith(".safetensors") || name.endsWith(".ckpt") || name.endsWith(".pt")) return "lora";
|
||||
if (file.type.startsWith("audio/")) return "voice";
|
||||
if (file.type === "application/json" || name.endsWith(".json")) return "style";
|
||||
return "reference";
|
||||
}
|
||||
|
||||
export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
const [filter, setFilter] = useState("全部");
|
||||
const [query, setQuery] = useState("");
|
||||
const [assets, setAssets] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState("");
|
||||
const [previewText, setPreviewText] = useState("");
|
||||
const [previewError, setPreviewError] = useState("");
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [showBinding, setShowBinding] = useState(false);
|
||||
const [assetForm, setAssetForm] = useState({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
const [uploadForm, setUploadForm] = useState({ name: "", kind: "reference", subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
const [pendingFile, setPendingFile] = useState(null);
|
||||
const [bindingForm, setBindingForm] = useState({ shotId: project.shots[0]?.id || "", usageRole: "continuity" });
|
||||
const inputRef = useRef(null);
|
||||
const versionInputRef = useRef(null);
|
||||
const filtered = assets.filter((asset) => (filter === "全部" || asset.kind === filter) && `${asset.name} ${asset.subtitle} ${asset.tags.join(" ")}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const selected = assets.find((asset) => asset.id === selectedId) || filtered[0] || assets[0];
|
||||
const lockedCount = assets.filter((asset) => asset.status === "locked").length;
|
||||
|
||||
async function loadAssets() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = await fetchAssets(contextOverrides);
|
||||
const next = (payload.assets || []).map(normalizeAsset);
|
||||
setAssets(next);
|
||||
setSelectedId((current) => next.some((item) => item.id === current) ? current : next[0]?.id || "");
|
||||
setLoadError("");
|
||||
} catch (error) {
|
||||
setLoadError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadAssets(); }, [project.series?.id, contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let objectUrl = "";
|
||||
async function loadPreview() {
|
||||
setPreviewUrl("");
|
||||
setPreviewType("");
|
||||
setPreviewText("");
|
||||
setPreviewError("");
|
||||
setPreviewLoading(false);
|
||||
if (!selected?.id || !selected.currentVersion?.storage_path || selected.currentVersion.storage_path.endsWith("metadata.json")) return;
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const payload = await fetchAssetContent(selected.id, contextOverrides);
|
||||
if (cancelled) return;
|
||||
setPreviewType(payload.contentType);
|
||||
if (isTextAsset(selected.fileName, payload.contentType)) {
|
||||
const text = await payload.blob.text();
|
||||
if (!cancelled) setPreviewText(text.slice(0, 24000));
|
||||
} else {
|
||||
objectUrl = URL.createObjectURL(payload.blob);
|
||||
if (!cancelled) setPreviewUrl(objectUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) setPreviewError(error.message);
|
||||
} finally {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
}
|
||||
}
|
||||
loadPreview();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [selected?.id, selected?.currentVersion?.storage_path, contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
|
||||
|
||||
function replaceAsset(nextAsset) {
|
||||
const normalized = normalizeAsset(nextAsset);
|
||||
setAssets((current) => current.some((item) => item.id === normalized.id) ? current.map((item) => item.id === normalized.id ? normalized : item) : [normalized, ...current]);
|
||||
setSelectedId(normalized.id);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function prepareUpload(file) {
|
||||
try {
|
||||
const checked = validateAssetFile(file);
|
||||
const name = checked.name.replace(/\.[^.]+$/, "") || checked.name;
|
||||
setPendingFile(checked);
|
||||
setUploadForm({ name, kind: inferAssetKind(checked), subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
setShowUpload(true);
|
||||
setNotice("");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
event.target.value = "";
|
||||
prepareUpload(file);
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
event.preventDefault();
|
||||
prepareUpload(event.dataTransfer.files?.[0]);
|
||||
}
|
||||
|
||||
async function submitUpload(event) {
|
||||
event.preventDefault();
|
||||
if (!pendingFile) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const asset = await uploadAsset({
|
||||
data: await readFileBase64(pendingFile),
|
||||
fileName: pendingFile.name,
|
||||
name: uploadForm.name.trim() || pendingFile.name,
|
||||
kind: uploadForm.kind,
|
||||
subtitle: uploadForm.subtitle,
|
||||
usage: uploadForm.usage,
|
||||
detail: uploadForm.detail,
|
||||
lock: uploadForm.lock,
|
||||
mimeType: pendingFile.type || "application/octet-stream",
|
||||
rightsStatus: uploadForm.rightsStatus,
|
||||
tags: uploadForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean)
|
||||
}, contextOverrides);
|
||||
replaceAsset(asset.asset);
|
||||
setShowUpload(false);
|
||||
setPendingFile(null);
|
||||
setNotice(`本地资产“${pendingFile.name}”已写入资产库,SHA-256、文件大小和版本台账已登记。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate(event) {
|
||||
event.preventDefault();
|
||||
if (!assetForm.name.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await createAsset({ ...assetForm, name: assetForm.name.trim(), tags: assetForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean) }, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setShowCreate(false);
|
||||
setAssetForm({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
setNotice(`资产“${payload.asset.name}”已创建为 v1 草稿。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLock() {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const nextStatus = selected.status === "locked" ? "review" : "locked";
|
||||
const payload = await updateAssetLock(selected.id, nextStatus, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setNotice(`${selected.name} 已${nextStatus === "locked" ? "锁定" : "转为待复核"},后续镜头会读取最新状态。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function bindSelected(event) {
|
||||
event.preventDefault();
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await bindAssetToShot(selected.id, bindingForm, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setShowBinding(false);
|
||||
setNotice(`${selected.name} 已绑定到 ${bindingForm.shotId},生成任务会继承该资产版本。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createVersion() {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await createAssetVersion(selected.id, { versionNote: "从资产检查器创建的新版本", rightsStatus: selected.rightsStatus }, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setNotice(`${selected.name} 已创建 ${normalizeAsset(payload.asset).version} 版本。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVersionUpload(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !selected) return;
|
||||
event.target.value = "";
|
||||
try {
|
||||
validateAssetFile(file);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await uploadAssetVersion(selected.id, {
|
||||
data: await readFileBase64(file),
|
||||
fileName: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
rightsStatus: selected.rightsStatus,
|
||||
versionNote: "从资产库上传新文件版本"
|
||||
}, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setNotice(`${selected.name} 已上传文件版本 v${normalizeAsset(payload.asset).version},SHA-256 已登记。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySelected() {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await verifyAsset(selected.id, contextOverrides);
|
||||
setNotice(payload.verification.verified ? `${selected.name} 当前版本完整性校验通过。` : `${selected.name} 当前版本完整性校验失败,请检查文件是否被替换。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSelected() {
|
||||
if (!selected?.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await fetchAssetContent(selected.id, contextOverrides);
|
||||
const url = URL.createObjectURL(payload.blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = selected.fileName || `${selected.name}-v${selected.currentVersion?.version_number || 1}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
setNotice(`${selected.name} 当前版本已准备本地下载。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreVersion(version) {
|
||||
if (!selected || selected.currentVersionId === version.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await restoreAssetVersion(selected.id, version.id, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setShowVersions(false);
|
||||
setNotice(`${selected.name} 已恢复到 v${version.version_number},后续镜头将继承该版本。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="suite-page">
|
||||
<SuiteHeader
|
||||
kicker="CREATOR SUITE / ASSET LIBRARY"
|
||||
title="资产库"
|
||||
description={`${project.series.title} · 角色、场景、道具、风格和本地 LoRA 统一管理,所有生产镜头从锁定资产继承。`}
|
||||
actions={<><input ref={inputRef} className="visually-hidden" type="file" accept="image/*,audio/*,video/*,text/*,application/json,.safetensors,.ckpt,.pt,.bin" onChange={handleUpload} /><input ref={versionInputRef} className="visually-hidden" type="file" accept="image/*,audio/*,video/*,text/*,application/json,.safetensors,.ckpt,.pt,.bin" onChange={handleVersionUpload} /><button className="subtle" onClick={() => inputRef.current?.click()} disabled={busy}><Upload size={15} />导入本地资产</button><button className="primary" onClick={() => setShowCreate(true)} disabled={busy}><Plus size={15} />新建资产</button></>}
|
||||
/>
|
||||
{notice && <div className="suite-notice"><CheckCircle2 size={15} />{notice}<button onClick={() => setNotice("")} aria-label="关闭提示"><XCircle size={14} /></button></div>}
|
||||
{loadError && <div className="suite-notice warning"><CircleAlert size={15} />资产库读取失败:{loadError}<button onClick={loadAssets} disabled={loading}><RefreshCw size={14} />重试</button></div>}
|
||||
<div className="suite-metric-grid">
|
||||
<SuiteMetric icon={Images} label="资产总数" value={assets.length} detail="当前项目可复用" tone="ok" />
|
||||
<SuiteMetric icon={LockKeyhole} label="已锁定" value={`${lockedCount}/${assets.length}`} detail="连续性继承源" tone="ok" />
|
||||
<SuiteMetric icon={RefreshCw} label="版本" value={assets.reduce((sum, item) => sum + item.versions.length, 0)} detail="已登记版本" />
|
||||
<SuiteMetric icon={ShieldCheck} label="版权证据" value={`${assets.filter((item) => item.rightsStatus === "approved").length}/${assets.length}`} detail="未确认资产不会进入正式批次" tone={assets.some((item) => item.rightsStatus !== "approved") ? "warn" : "ok"} />
|
||||
</div>
|
||||
<div className="asset-library-layout">
|
||||
<aside className="suite-filter-rail">
|
||||
<span className="suite-filter-title">资产类型</span>
|
||||
{["全部", ...assetKindOptions.map(([, label]) => label)].map((item) => <button key={item} className={filter === item ? "selected" : ""} onClick={() => setFilter(item)}>{item}<span>{item === "全部" ? assets.length : assets.filter((asset) => asset.kind === item).length}</span></button>)}
|
||||
<div className="suite-rail-divider" />
|
||||
<span className="suite-filter-title">锁定状态</span>
|
||||
<div className="suite-rail-note"><LockKeyhole size={14} /><span>角色、服装、道具、天气和镜头继承当前 ledger。</span></div>
|
||||
</aside>
|
||||
<section className="suite-library-main">
|
||||
<div className="suite-toolbar"><div className="suite-search"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索资产、标签或角色" /></div><span>{loading ? "读取中…" : `${filtered.length} 个资产`}</span></div>
|
||||
<div className="asset-intake-strip" onDragOver={(event) => event.preventDefault()} onDrop={handleDrop}>
|
||||
<div className="asset-intake-copy"><HardDrive size={17} /><div><strong>本地资产入口</strong><span>单文件不超过 12MB,导入后自动登记 SHA-256、版本和本地存储路径。</span></div></div>
|
||||
<button className="icon-text-button" onClick={() => inputRef.current?.click()} disabled={busy}><Upload size={14} />选择文件</button>
|
||||
</div>
|
||||
<div className="asset-card-grid">
|
||||
{filtered.map((asset) => <button key={asset.id} className={`asset-card ${selected?.id === asset.id ? "selected" : ""}`} onClick={() => setSelectedId(asset.id)}><AssetVisual asset={asset} /><div className="asset-card-body"><div className="asset-card-title"><strong>{asset.name}</strong><SuiteStatus status={asset.status} /></div><span>{asset.kind} · {asset.subtitle}</span><div className="asset-tag-row">{asset.tags.slice(0, 2).map((tag) => <em key={tag}>{tag}</em>)}</div><small>{asset.version} · {asset.usage}</small></div></button>)}
|
||||
</div>
|
||||
{!filtered.length && <div className="suite-empty"><Search size={18} />{loading ? "正在读取资产库…" : "没有匹配资产"}</div>}
|
||||
</section>
|
||||
{selected && <aside className="asset-inspector"><div className="inspector-heading"><div><span className="card-kicker">ASSET INSPECTOR</span><h3>{selected.name}</h3></div><SuiteStatus status={selected.status} /></div>{previewLoading ? <div className="asset-preview-placeholder"><RefreshCw size={17} />读取本地预览…</div> : previewText ? <pre className="asset-text-preview">{previewText}</pre> : previewUrl && previewType.startsWith("image/") ? <img className="asset-media-preview" src={previewUrl} alt={`${selected.name} 预览`} /> : previewUrl && previewType.startsWith("audio/") ? <audio className="asset-media-preview" controls src={previewUrl} /> : previewUrl && previewType.startsWith("video/") ? <video className="asset-media-preview" controls src={previewUrl} /> : <AssetVisual asset={selected} />}{previewError && <p className="asset-preview-error">文件预览不可用:{previewError}</p>}{selected.fileName && <div className="asset-file-meta"><FileText size={14} /><div><strong>{selected.fileName}</strong><span>{formatBytes(selected.fileSize)} · {selected.mimeType}</span></div></div>}{selected.contentSha256 && <div className="asset-integrity-line"><span>SHA-256</span><code title={selected.contentSha256}>{selected.contentSha256.slice(0, 16)}…</code><button className="icon-text-button" onClick={verifySelected} disabled={busy}><Check size={13} />校验</button></div>}<dl className="suite-definition-list"><dt>类型</dt><dd>{selected.kind}</dd><dt>版本</dt><dd>{selected.version}</dd><dt>文件大小</dt><dd>{selected.fileSize ? formatBytes(selected.fileSize) : "无文件"}</dd><dt>存储路径</dt><dd className="mono">{selected.currentVersion?.storage_path || "仅有元数据"}</dd><dt>使用范围</dt><dd>{selected.usage}</dd><dt>版权/授权</dt><dd>{selected.rightsStatus === "approved" ? "已确认" : "待补证据"}</dd><dt>绑定镜头</dt><dd>{selected.bindings.length ? selected.bindings.map((item) => item.shot_id).join("、") : "尚未绑定"}</dd></dl><div className="inspector-copy"><span>视觉锁</span><p>{selected.detail}</p></div><div className="inspector-copy"><span>连续性备注</span><p>{selected.lock}</p></div><div className="inspector-actions"><button className="subtle" onClick={() => setShowVersions(true)}><RefreshCw size={15} />版本记录</button><button className="subtle" onClick={downloadSelected} disabled={busy || !selected.fileSize}><Download size={15} />下载当前版</button><button className="primary" onClick={() => setShowBinding(true)}><LockKeyhole size={15} />绑定到镜头</button><button className="subtle" onClick={toggleLock} disabled={busy}><ShieldCheck size={15} />{selected.status === "locked" ? "转待复核" : "锁定版本"}</button></div></aside>}
|
||||
</div>
|
||||
{showUpload && pendingFile && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && (setShowUpload(false), setPendingFile(null))}><form className="suite-modal" onSubmit={submitUpload}><div className="suite-modal-head"><div><span className="card-kicker">LOCAL ASSET INTAKE</span><h3>导入资产</h3></div><button type="button" className="icon-only-button" onClick={() => { setShowUpload(false); setPendingFile(null); }} aria-label="关闭"><XCircle size={17} /></button></div><div className="upload-file-summary"><FileText size={19} /><div><strong>{pendingFile.name}</strong><span>{formatBytes(pendingFile.size)} · {pendingFile.type || "application/octet-stream"}</span></div><SuiteStatus status="needs-evidence" /></div><div className="suite-form-grid"><label>资产名称<input value={uploadForm.name} onChange={(event) => setUploadForm((current) => ({ ...current, name: event.target.value }))} required /></label><label>资产类型<select value={uploadForm.kind} onChange={(event) => setUploadForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>副标题<input value={uploadForm.subtitle} onChange={(event) => setUploadForm((current) => ({ ...current, subtitle: event.target.value }))} /></label><label>使用范围<input value={uploadForm.usage} onChange={(event) => setUploadForm((current) => ({ ...current, usage: event.target.value }))} /></label><label>版权状态<select value={uploadForm.rightsStatus} onChange={(event) => setUploadForm((current) => ({ ...current, rightsStatus: event.target.value }))}><option value="needs-evidence">待版权证据</option><option value="submitted">已提交证据</option></select></label><label>标签(逗号分隔)<input value={uploadForm.tags} onChange={(event) => setUploadForm((current) => ({ ...current, tags: event.target.value }))} /></label><label className="full-span">视觉锁<textarea value={uploadForm.detail} onChange={(event) => setUploadForm((current) => ({ ...current, detail: event.target.value }))} placeholder="角色外观、场景构图、服装或风格锚点" /></label><label className="full-span">连续性备注<textarea value={uploadForm.lock} onChange={(event) => setUploadForm((current) => ({ ...current, lock: event.target.value }))} placeholder="后续镜头必须继承的锁定信息" /></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => { setShowUpload(false); setPendingFile(null); }}>取消</button><button type="submit" className="primary" disabled={busy}><Upload size={15} />{busy ? "写入中…" : "写入资产库"}</button></div></form></div>}
|
||||
{showCreate && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowCreate(false)}><form className="suite-modal" onSubmit={submitCreate}><div className="suite-modal-head"><div><span className="card-kicker">NEW ASSET</span><h3>新建资产</h3></div><button type="button" className="icon-only-button" onClick={() => setShowCreate(false)} aria-label="关闭"><XCircle size={17} /></button></div><div className="suite-form-grid"><label>资产名称<input value={assetForm.name} onChange={(event) => setAssetForm((current) => ({ ...current, name: event.target.value }))} autoFocus required /></label><label>资产类型<select value={assetForm.kind} onChange={(event) => setAssetForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>副标题<input value={assetForm.subtitle} onChange={(event) => setAssetForm((current) => ({ ...current, subtitle: event.target.value }))} /></label><label>使用范围<input value={assetForm.usage} onChange={(event) => setAssetForm((current) => ({ ...current, usage: event.target.value }))} /></label><label className="full-span">标签(逗号分隔)<input value={assetForm.tags} onChange={(event) => setAssetForm((current) => ({ ...current, tags: event.target.value }))} placeholder="角色锁, 原创, 待审核" /></label><label className="full-span">视觉锁<textarea value={assetForm.detail} onChange={(event) => setAssetForm((current) => ({ ...current, detail: event.target.value }))} /></label><label className="full-span">连续性备注<textarea value={assetForm.lock} onChange={(event) => setAssetForm((current) => ({ ...current, lock: event.target.value }))} /></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => setShowCreate(false)}>取消</button><button type="submit" className="primary" disabled={busy}><Plus size={15} />创建资产</button></div></form></div>}
|
||||
{showVersions && selected && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowVersions(false)}><section className="suite-modal"><div className="suite-modal-head"><div><span className="card-kicker">VERSION HISTORY</span><h3>{selected.name}</h3></div><button className="icon-only-button" onClick={() => setShowVersions(false)} aria-label="关闭"><XCircle size={17} /></button></div><div className="version-list">{selected.versions.map((version) => <div key={version.id}><div><strong>v{version.version_number}{selected.currentVersionId === version.id ? " · 当前" : ""}</strong><span>{version.metadata?.versionNote || "版本记录"}</span></div><SuiteStatus status={version.rights_status === "approved" ? "approved" : "needs-evidence"} /><small>{version.storage_path}{version.content_sha256 ? ` · SHA ${version.content_sha256.slice(0, 12)}…` : ""}</small>{selected.currentVersionId !== version.id && <button className="icon-text-button" onClick={() => restoreVersion(version)} disabled={busy}><RefreshCw size={13} />恢复此版本</button>}</div>)}</div><div className="suite-modal-actions"><button className="subtle" onClick={() => setShowVersions(false)}>关闭</button><button className="subtle" onClick={() => versionInputRef.current?.click()} disabled={busy}><Upload size={15} />上传新版本</button><button className="primary" onClick={createVersion} disabled={busy}><Plus size={15} />创建空白版本</button></div></section></div>}
|
||||
{showBinding && selected && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowBinding(false)}><form className="suite-modal compact" onSubmit={bindSelected}><div className="suite-modal-head"><div><span className="card-kicker">SHOT BINDING</span><h3>绑定到镜头</h3></div><button type="button" className="icon-only-button" onClick={() => setShowBinding(false)} aria-label="关闭"><XCircle size={17} /></button></div><p className="suite-modal-copy">绑定后,生成任务会把“{selected.name}”的当前版本作为连续性输入。</p><div className="suite-form-grid"><label>镜头<select value={bindingForm.shotId} onChange={(event) => setBindingForm((current) => ({ ...current, shotId: event.target.value }))}>{project.shots.map((shot) => <option key={shot.id} value={shot.id}>{shot.id} · {shot.title}</option>)}</select></label><label>使用角色<select value={bindingForm.usageRole} onChange={(event) => setBindingForm((current) => ({ ...current, usageRole: event.target.value }))}><option value="character">角色</option><option value="location">场景</option><option value="prop">道具</option><option value="style">风格</option><option value="continuity">连续性</option></select></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => setShowBinding(false)}>取消</button><button type="submit" className="primary" disabled={busy}><LockKeyhole size={15} />确认绑定</button></div></form></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VoiceStudioPage({ project, onQueueJob, contextOverrides, canApproveVoice = false }) {
|
||||
const lines = useMemo(() => project.shots.flatMap((shot) => shot.voiceLines.map((line) => ({ ...line, shotId: shot.id, shotTitle: shot.title }))), [project]);
|
||||
const jobs = project.productionJobs || [];
|
||||
const [view, setView] = useState("profiles");
|
||||
const [selectedLineId, setSelectedLineId] = useState(lines[0]?.id || "");
|
||||
const [auditionedLocal, setAuditionedLocal] = useState([]);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [voiceAssets, setVoiceAssets] = useState([]);
|
||||
const [voiceAssetsLoading, setVoiceAssetsLoading] = useState(true);
|
||||
const [rightsNotes, setRightsNotes] = useState({});
|
||||
const selectedLine = lines.find((line) => line.id === selectedLineId) || lines[0];
|
||||
const lockedVoices = voiceAssetsLoading ? project.characters.filter((character) => character.voiceLock?.status === "approved").length : voiceAssets.filter((asset) => asset.currentVersion?.rights_status === "approved").length;
|
||||
const auditioned = useMemo(() => new Set([
|
||||
...auditionedLocal,
|
||||
...jobs.filter((job) => job.kind.includes("TTS") && job.output).flatMap((job) => lines.filter((line) => job.output.includes(line.id)).map((line) => line.id))
|
||||
]), [auditionedLocal, jobs, lines]);
|
||||
const aligned = useMemo(() => new Set(jobs.filter((job) => job.kind.includes("ASR") && job.output).flatMap((job) => lines.filter((line) => job.output.includes(line.id) || job.output.endsWith("/alignment.json") && job.shotId === line.shotId).map((line) => line.id))), [jobs, lines]);
|
||||
const alignmentPercent = lines.length ? Math.round((aligned.size / lines.length) * 100) : 0;
|
||||
|
||||
async function loadVoiceAssets() {
|
||||
setVoiceAssetsLoading(true);
|
||||
try {
|
||||
const payload = await fetchAssets(contextOverrides);
|
||||
const next = (payload.assets || []).filter((asset) => asset.kind === "voice");
|
||||
setVoiceAssets(next);
|
||||
setRightsNotes((current) => Object.fromEntries(next.map((asset) => [asset.id, current[asset.id] || asset.currentVersion?.metadata?.rightsEvidence?.reference || asset.currentVersion?.metadata?.consentRef || ""])));
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setVoiceAssetsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadVoiceAssets(); }, [contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
|
||||
|
||||
async function updateVoiceRights(asset, rightsStatus) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const reference = String(rightsNotes[asset.id] || "").trim();
|
||||
const result = await updateAssetRights(asset.id, { rightsStatus, evidence: { reference, source: "voice-studio-review" } }, contextOverrides);
|
||||
setVoiceAssets((current) => current.map((item) => item.id === asset.id ? result.asset : item));
|
||||
setNotice(`${asset.name} 已更新为“${rightsStatus === "approved" ? "已确认" : rightsStatus === "submitted" ? "已提交证据" : rightsStatus === "rejected" ? "已驳回" : rightsStatus}”。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function audition() {
|
||||
if (!selectedLine) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onQueueJob?.("单句 TTS 试听", selectedLine.shotId, { adapter: "local-tts", output: `voices/auditions/${selectedLine.id}.wav` });
|
||||
setAuditionedLocal((current) => [...new Set([...current, selectedLine.id])]);
|
||||
setNotice(`已将“${selectedLine.id}”作为单句试听写入本地队列。正式批量配音仍需用户确认参考音频。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAlignment() {
|
||||
if (!selectedLine) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onQueueJob?.("ASR 台词校验", selectedLine.shotId, { adapter: "owned-model-platform", output: `qa/${selectedLine.shotId}/${selectedLine.id}-alignment.json` });
|
||||
setNotice(`已将“${selectedLine.id}”的 ASR/字幕对齐任务写入队列。完成后会回填词级时间轴。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="suite-page">
|
||||
<SuiteHeader kicker="CREATOR SUITE / VOICE & SUBTITLE" title="声音与字幕" description="固定角色声线、单句试听、字幕台词和 ASR 对齐集中管理;未确认的参考音频不会进入批量生产。" actions={<button className="subtle" onClick={() => setNotice("当前声音策略:IndexTTS-2.5 / paraformer-zh-long;H3 原生音轨和旧 probe 不可作为正式参考音频。")}><ShieldCheck size={15} />查看声音策略</button>} />
|
||||
{notice && <div className="suite-notice"><CheckCircle2 size={15} />{notice}<button onClick={() => setNotice("")} aria-label="关闭提示"><XCircle size={14} /></button></div>}
|
||||
<div className="suite-metric-grid"><SuiteMetric icon={Mic2} label="角色声线" value={`${project.characters.length}`} detail={`${lockedVoices} 个已确认`} tone={lockedVoices ? "ok" : "warn"} /><SuiteMetric icon={MessageSquareText} label="台词" value={`${lines.length} 句`} detail="中文对白" /><SuiteMetric icon={AudioLines} label="试听记录" value={`${auditioned.size}`} detail="单句,不批量" tone="ok" /><SuiteMetric icon={CheckCircle2} label="ASR 对齐" value={`${alignmentPercent}%`} detail={`${aligned.size}/${lines.length} 句已有任务`} tone={alignmentPercent === 100 ? "ok" : "warn"} /></div>
|
||||
<div className="voice-layout">
|
||||
<section className="studio-card voice-main-card"><div className="suite-tabs">{[["profiles", "角色声线", Mic2], ["lines", "台词表", MessageSquareText], ["alignment", "ASR 对齐", AudioLines]].map(([id, label, Icon]) => <button key={id} className={view === id ? "selected" : ""} onClick={() => setView(id)}><Icon size={15} />{label}</button>)}</div>
|
||||
{view === "profiles" && <div className="voice-profile-grid">{project.characters.map((character) => <article key={character.id} className="voice-profile"><div className="voice-avatar">{character.name.slice(0, 1)}</div><div className="voice-profile-main"><div className="voice-profile-title"><strong>{character.name}</strong><SuiteStatus status={character.voiceLock?.status} /></div><span>{character.voiceLock?.voiceId}</span><p>{character.voiceLock?.tone}</p><div className="voice-meta"><span>{character.voiceLock?.ttsModel}</span><span>{character.voiceLock?.asrModel}</span></div></div><button className="icon-text-button" onClick={() => setNotice(`${character.name}:${character.voiceLock?.referencePolicy}`)}><ChevronRight size={15} />详情</button></article>)}</div>}
|
||||
{view === "lines" && <div className="voice-lines-table"><div className="voice-lines-head"><span>台词</span><span>角色</span><span>镜头</span><span>目标时长</span><span>口型策略</span><span>状态</span></div>{lines.map((line) => <button key={line.id} className={selectedLine?.id === line.id ? "selected" : ""} onClick={() => setSelectedLineId(line.id)}><strong>{line.text}</strong><span>{project.characters.find((character) => character.id === line.characterId)?.name}</span><span>{line.shotId}</span><span>{line.targetDurationSec}s</span><span>{line.mouthPlan}</span><SuiteStatus status={auditioned.has(line.id) ? "ready" : "pending"} /></button>)}</div>}
|
||||
{view === "alignment" && <div className="alignment-list">{lines.map((line) => <div key={line.id}><div><strong>{line.id} · {line.text}</strong><span>{line.shotTitle} · {line.audioFile}</span></div><div className="alignment-track"><span style={{ width: aligned.has(line.id) ? "100%" : auditioned.has(line.id) ? "38%" : "0%" }} /></div><SuiteStatus status={aligned.has(line.id) ? "ready" : auditioned.has(line.id) ? "running" : "pending"} /></div>)}</div>}
|
||||
</section>
|
||||
<aside className="studio-card voice-inspector"><div className="card-heading-row"><div><span className="card-kicker">LINE INSPECTOR</span><h3>单句试听</h3></div><FileAudio size={18} /></div>{selectedLine ? <><div className="selected-line"><span>{selectedLine.shotId} · {selectedLine.id}</span><strong>{selectedLine.text}</strong><small>{selectedLine.emotion} · 目标 {selectedLine.targetDurationSec}s</small></div><div className="voice-lock-box"><ShieldCheck size={16} /><div><strong>正式声音门</strong><p>必须使用用户确认的自然参考音频。正脸长对白不作为默认镜头策略。</p></div></div><div className="voice-action-stack"><button className="primary full-width" onClick={audition} disabled={busy}><Play size={15} />生成这一句试听</button><button className="subtle full-width" onClick={runAlignment} disabled={busy}><AudioLines size={15} />运行 ASR 对齐</button><button className="subtle full-width" onClick={() => { downloadJson(`${project.episode.id}-voice-lines.json`, buildVoiceTable(project)); setNotice("字幕字段 JSON 已准备下载,包含角色、台词、镜头、目标时长和 ASR 字段。"); }}><FolderOpen size={15} />下载字幕字段 JSON</button></div></> : <div className="suite-empty">暂无台词</div>}</aside>
|
||||
</div>
|
||||
<section className="studio-card voice-approval-card"><div className="card-heading-row"><div><span className="card-kicker">VOICE RIGHTS / APPROVAL</span><h3>参考音频授权台账</h3><p className="muted-copy">声音资产必须有可追溯的授权证据;未批准的参考音频只能做单句试听,不能进入批量 TTS。</p></div><ShieldCheck size={18} /></div>{voiceAssetsLoading ? <div className="suite-empty">正在读取声音资产…</div> : voiceAssets.length ? <div className="voice-rights-list">{voiceAssets.map((asset) => { const rightsStatus = asset.currentVersion?.rights_status || "needs-evidence"; const evidence = asset.currentVersion?.metadata?.rightsEvidence?.reference || asset.currentVersion?.metadata?.consentRef || ""; return <div className="voice-rights-row" key={asset.id}><div className="voice-rights-title"><div className="voice-avatar"><FileAudio size={16} /></div><div><strong>{asset.name}</strong><span>{asset.currentVersion?.version_number ? `v${asset.currentVersion.version_number}` : "v1"} · {asset.currentVersion?.storage_path}</span></div></div><SuiteStatus status={rightsStatus} /><input aria-label={`${asset.name} 授权证据引用`} value={rightsNotes[asset.id] ?? evidence} onChange={(event) => setRightsNotes((current) => ({ ...current, [asset.id]: event.target.value }))} placeholder="授权合同、同意书或本地证据路径" /><div className="voice-rights-actions"><button className="subtle" onClick={() => updateVoiceRights(asset, "submitted")} disabled={busy}>提交证据</button>{canApproveVoice && <button className="primary" onClick={() => updateVoiceRights(asset, "approved")} disabled={busy || !String(rightsNotes[asset.id] || evidence).trim()}>批准使用</button>}<button className="danger-button" onClick={() => updateVoiceRights(asset, "rejected")} disabled={busy}>驳回</button></div></div>; })}</div> : <div className="suite-empty">当前项目还没有声音资产,请先导入一条本地参考音频。</div>}</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function jobStatus(project, shotId, kind) {
|
||||
const job = (project.productionJobs || []).find((item) => item.shotId === shotId && item.kind.includes(kind));
|
||||
return job?.status || "pending";
|
||||
}
|
||||
|
||||
export function BatchProductionPage({ project, onQueueJob, contextOverrides }) {
|
||||
const [selectedShots, setSelectedShots] = useState(project.shots.map((shot) => shot.id));
|
||||
const [taskKind, setTaskKind] = useState("单画面关键帧");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [executionLogs, setExecutionLogs] = useState([]);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const allSelected = selectedShots.length === project.shots.length;
|
||||
function toggleShot(id) { setSelectedShots((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]); }
|
||||
async function runBatch() {
|
||||
if (!selectedShots.length) { setNotice("请先选择至少一个镜头。"); return; }
|
||||
setNotice(`正在将 ${selectedShots.length} 个镜头拆成独立任务…`);
|
||||
try {
|
||||
for (const shotId of selectedShots) await onQueueJob?.(taskKind, shotId);
|
||||
setNotice(`已写入 ${selectedShots.length} 个独立任务;每个镜头仍保持一次一个完整画面。`);
|
||||
} catch (error) { setNotice(error.message); }
|
||||
}
|
||||
async function loadExecutionLogs() {
|
||||
setShowLogs(true);
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const result = await fetchJobs({ ...contextOverrides, projectId: contextOverrides?.projectId });
|
||||
setExecutionLogs(result.jobs || []);
|
||||
} catch (error) {
|
||||
setNotice(`执行日志读取失败:${error.message}`);
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="suite-page">
|
||||
<SuiteHeader kicker="CREATOR SUITE / BATCH PRODUCTION" title="批量生产" description="把选中的镜头拆成可追踪的独立 job,逐镜头继承角色锁、场景锁、实际末帧和 local-only 成本策略。" actions={<button className="primary" onClick={runBatch}><WandSparkles size={15} />运行选中任务</button>} />
|
||||
{notice && <div className="suite-notice"><CheckCircle2 size={15} />{notice}<button onClick={() => setNotice("")} aria-label="关闭提示"><XCircle size={14} /></button></div>}
|
||||
<div className="batch-control-bar"><div className="batch-kind"><span>任务类型</span><select value={taskKind} onChange={(event) => setTaskKind(event.target.value)}><option>单画面关键帧</option><option>首尾帧图生视频</option><option>批量跑单画面 QA</option><option>剪辑合成清单</option></select></div><div className="batch-scope"><span>当前范围</span><strong>{selectedShots.length}/{project.shots.length} 个镜头</strong><button className="context-link" onClick={() => setSelectedShots(allSelected ? [] : project.shots.map((shot) => shot.id))}>{allSelected ? <Square size={15} /> : <CheckSquare2 size={15} />}{allSelected ? "清空" : "全选"}</button></div><div className="batch-policy"><ShieldCheck size={15} /><span>local-only · 禁止拼图/多格/多时间点</span></div></div>
|
||||
<div className="batch-layout"><section className="studio-card wide-card"><div className="section-bar"><div><h3>镜头批次</h3><span>每行对应一个可重试、可审计的生成任务</span></div><span className="suite-status ok"><span />{project.shots.length} shots</span></div><div className="batch-table"><div className="batch-table-head"><span>选择</span><span>镜头</span><span>连续性源</span><span>关键帧</span><span>视频</span><span>声音</span><span>QA</span></div>{project.shots.map((shot) => <button key={shot.id} className={selectedShots.includes(shot.id) ? "selected" : ""} onClick={() => toggleShot(shot.id)}><span>{selectedShots.includes(shot.id) ? <CheckSquare2 size={17} /> : <Square size={17} />}</span><div><strong>{shot.id} · {shot.title}</strong><small>{shot.durationSec}s · {shot.camera}</small></div><span className="mono">{shot.firstFrame === "AUTO_PREVIOUS_ACTUAL_LAST_FRAME" ? "actual-last-frame" : "episode-start"}</span><SuiteStatus status={jobStatus(project, shot.id, "关键帧")} /><SuiteStatus status={jobStatus(project, shot.id, "图生视频")} /><SuiteStatus status={jobStatus(project, shot.id, "TTS")} /><SuiteStatus status={jobStatus(project, shot.id, "QA")} /></button>)}</div></section><aside className="studio-card"><div className="card-heading-row"><div><span className="card-kicker">BATCH CONTRACT</span><h3>执行约束</h3></div><CircleAlert size={18} /></div><ul className="batch-contract-list"><li><Check size={14} />每个任务只绑定一个镜头</li><li><Check size={14} />图片输出数量固定为 1</li><li><Check size={14} />shot-02 / shot-03 继承上一段实际末帧</li><li><Check size={14} />云端连接器默认不进入批次</li><li><Check size={14} />失败后保留 job、attempt 和证据</li></ul><button className="subtle full-width" onClick={loadExecutionLogs}><FolderOpen size={15} />查看执行日志</button></aside></div>
|
||||
{showLogs && <section className="studio-card batch-log-card"><div className="card-heading-row"><div><span className="card-kicker">EXECUTION LOG</span><h3>批次执行日志</h3></div><div className="row-actions"><button className="subtle" onClick={loadExecutionLogs} disabled={logsLoading}><RefreshCw size={14} />刷新</button><button className="icon-only-button" onClick={() => setShowLogs(false)} title="关闭执行日志" aria-label="关闭执行日志"><XCircle size={15} /></button></div></div>{logsLoading ? <div className="suite-empty">读取任务日志…</div> : <div className="batch-log-table"><div><span>任务</span><span>镜头</span><span>状态</span><span>尝试</span><span>错误</span></div>{executionLogs.map((job) => <div key={job.id}><strong>{job.kind}</strong><span>{job.shotId || job.shot_id || "全局"}</span><SuiteStatus status={job.status} /><span>{job.attempts || job.attemptLog?.length || 0}</span><span>{job.errorMessage || "-"}</span></div>)}{!executionLogs.length && <div className="suite-empty">当前范围没有任务日志。</div>}</div>}</section>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectAssistantPage({ project, setActiveTab, allowedTabs = [] }) {
|
||||
const [messages, setMessages] = useState([{ role: "assistant", text: `我已载入《${project.series.title}》当前项目数据。可以检查连续性、拆解镜头、生成单画面 prompt 或整理交付清单。` }]);
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const actions = [
|
||||
{ id: "continuity", label: "检查连续性", icon: ShieldCheck, target: "qa" },
|
||||
{ id: "shot", label: "拆成镜头", icon: ClapperboardIcon, target: "director" },
|
||||
{ id: "prompt", label: "整理 prompt pack", icon: WandSparkles, target: "director" },
|
||||
{ id: "delivery", label: "检查交付", icon: FolderOpen, target: "export" }
|
||||
];
|
||||
const visibleActions = actions.filter((action) => !allowedTabs.length || allowedTabs.includes(action.target));
|
||||
function ClapperboardIcon(props) { return <Film {...props} />; }
|
||||
|
||||
async function ask(question, action) {
|
||||
setBusy(true);
|
||||
setMessages((current) => [...current, { role: "user", text: question }]);
|
||||
try {
|
||||
const result = await queryAssistant({ question, actionId: action?.id || "freeform" });
|
||||
setMessages((current) => [...current, { role: "assistant", text: result.answer || "项目助手没有返回可执行结果。" }]);
|
||||
const target = result.suggestedTabs?.find((tab) => !allowedTabs.length || allowedTabs.includes(tab)) || action?.target;
|
||||
if (target && (!allowedTabs.length || allowedTabs.includes(target))) setActiveTab(target);
|
||||
} catch (error) {
|
||||
setMessages((current) => [...current, { role: "assistant", text: `项目助手请求失败:${error.message}` }]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function runAction(action) {
|
||||
ask(action.label, action);
|
||||
}
|
||||
|
||||
function submit(event) {
|
||||
event.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text || busy) return;
|
||||
setInput("");
|
||||
ask(text);
|
||||
}
|
||||
return (
|
||||
<div className="suite-page">
|
||||
<SuiteHeader kicker="CREATOR SUITE / PROJECT ASSISTANT" title="项目 AI 助手" description="助手只读取当前组织、工作区和项目上下文,输出可落到剧本、资产、分镜、QA 或交付模块的生产动作。" actions={<span className="assistant-local-badge"><Bot size={15} />本地规则 + 项目数据</span>} />
|
||||
<div className="assistant-layout"><section className="studio-card assistant-chat"><div className="assistant-chat-head"><div><span className="card-kicker">PROJECT CONTEXT</span><h3>{project.series.title} · {project.episode.title}</h3></div><span className="suite-status ok"><span />已连接</span></div><div className="assistant-message-list">{messages.map((message, index) => <div key={`${message.role}-${index}`} className={`assistant-message ${message.role}`}><span className="assistant-message-avatar">{message.role === "assistant" ? <Bot size={15} /> : "我"}</span><p>{message.text}</p></div>)}{busy && <div className="assistant-message assistant"><span className="assistant-message-avatar"><Bot size={15} /></span><p className="typing-dots">正在读取项目锁…</p></div>}</div><form className="assistant-input" onSubmit={submit}><input value={input} onChange={(event) => setInput(event.target.value)} placeholder="输入要检查的镜头、角色或交付问题" /><button className="primary" type="submit" disabled={busy}><MessageSquareText size={15} />发送</button></form></section><aside className="assistant-action-panel"><div className="card-heading-row"><div><span className="card-kicker">PRODUCTION ACTIONS</span><h3>快捷动作</h3></div><Sparkles size={18} /></div>{visibleActions.map((action) => { const Icon = action.icon; return <button key={action.id} className="assistant-action" disabled={busy} onClick={() => runAction(action)}><span className="assistant-action-icon"><Icon size={16} /></span><span><strong>{action.label}</strong><small>调用项目助手并进入对应生产模块</small></span><ChevronRight size={15} /></button>; })}<div className="assistant-context-card"><strong>当前硬约束</strong><span>一图一画面</span><span>实际末帧连续</span><span>固定声线</span><span>原创 / 本地优先</span></div></aside></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AlertTriangle, CheckCircle2, Download, Film, LockKeyhole, MessageSquare, RefreshCw, Send } from "lucide-react";
|
||||
import { fetchPublicDeliveryPortal, publicDeliveryFileUrl, submitPublicDeliveryFeedback } from "../lib/api";
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function DeliveryPortalPage({ token }) {
|
||||
const [payload, setPayload] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [feedbackName, setFeedbackName] = useState("");
|
||||
const [feedbackEmail, setFeedbackEmail] = useState("");
|
||||
const [feedbackMessage, setFeedbackMessage] = useState("");
|
||||
const [feedbackBusy, setFeedbackBusy] = useState(false);
|
||||
const [feedbackNotice, setFeedbackNotice] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchPublicDeliveryPortal(token);
|
||||
setPayload(result);
|
||||
setFeedbackName(result.review?.reviewerName || result.portal?.recipientName || "");
|
||||
setError(null);
|
||||
} catch (nextError) {
|
||||
setPayload(null);
|
||||
setError(nextError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitFeedback(decision) {
|
||||
if (decision === "changes_requested" && !feedbackMessage.trim()) {
|
||||
setFeedbackNotice("提出修改意见前,请填写具体反馈。");
|
||||
return;
|
||||
}
|
||||
setFeedbackBusy(true);
|
||||
setFeedbackNotice("");
|
||||
try {
|
||||
const result = await submitPublicDeliveryFeedback(token, {
|
||||
decision,
|
||||
reviewerName: feedbackName,
|
||||
reviewerEmail: feedbackEmail,
|
||||
message: feedbackMessage
|
||||
});
|
||||
setPayload(result);
|
||||
setFeedbackMessage("");
|
||||
setFeedbackNotice(decision === "approved" ? "已记录验收结果,感谢确认。" : "修改意见已提交给项目方。");
|
||||
} catch (nextError) {
|
||||
setFeedbackNotice(nextError.message);
|
||||
} finally {
|
||||
setFeedbackBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [token]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="delivery-portal-shell"><div className="delivery-portal-loading"><RefreshCw size={18} />正在读取交付资料…</div></div>;
|
||||
}
|
||||
|
||||
if (error || !payload) {
|
||||
const expired = error?.status === 410;
|
||||
return <div className="delivery-portal-shell"><main className="delivery-portal-error"><div className="delivery-portal-brand"><span><Film size={19} /></span><strong>AI 短剧交付门户</strong></div><div className="delivery-portal-error-icon"><AlertTriangle size={28} /></div><h1>{expired ? "访问链接已过期" : "暂时无法打开交付资料"}</h1><p>{expired ? "请联系项目方重新生成一个有效的交付访问链接。" : "链接可能已撤销、输入有误,或对应版本已经下线。"}</p><button className="primary" type="button" onClick={load}><RefreshCw size={15} />重新检查</button></main></div>;
|
||||
}
|
||||
|
||||
const portal = payload.portal || {};
|
||||
const delivery = payload.delivery || {};
|
||||
const files = payload.files || [];
|
||||
const review = payload.review || { status: "pending", count: 0 };
|
||||
const reviewStatusLabel = review.status === "approved" ? "已验收" : review.status === "changes_requested" ? "需修改" : "待客户确认";
|
||||
return <div className="delivery-portal-shell">
|
||||
<header className="delivery-portal-topbar">
|
||||
<div className="delivery-portal-brand"><span><Film size={19} /></span><strong>AI 短剧交付门户</strong></div>
|
||||
<div className="delivery-portal-secure"><LockKeyhole size={14} />本地发行 · 受控访问</div>
|
||||
</header>
|
||||
<main className="delivery-portal-main">
|
||||
<section className="delivery-portal-intro">
|
||||
<span className="card-kicker">DELIVERY PORTAL</span>
|
||||
<h1>{delivery.projectName || "项目交付资料"}</h1>
|
||||
<p>项目方已发布一份可下载的交付版本,请使用下方文件入口获取本次交付资料。</p>
|
||||
{portal.recipientName && <span className="delivery-portal-recipient">收件人:{portal.recipientName}</span>}
|
||||
</section>
|
||||
<section className="delivery-portal-summary">
|
||||
<div><span>交付版本</span><strong>{delivery.version || "未命名版本"}</strong></div>
|
||||
<div><span>发布时间</span><strong>{formatDate(delivery.publishedAt)}</strong></div>
|
||||
<div><span>链接有效期</span><strong>{formatDate(portal.expiresAt)}</strong></div>
|
||||
<div><span>剩余下载</span><strong>{portal.downloadsRemaining ?? 0} / {portal.maxDownloads ?? 0}</strong></div>
|
||||
</section>
|
||||
<section className="delivery-portal-files">
|
||||
<div className="delivery-portal-section-heading"><div><span className="card-kicker">FILES</span><h2>交付文件</h2></div><span className="delivery-portal-count">{files.length} 个文件</span></div>
|
||||
<div className="delivery-portal-file-list">
|
||||
{files.map((file) => <article className="delivery-portal-file" key={file.kind}><div className="delivery-portal-file-icon"><Download size={17} /></div><div><strong>{file.label}</strong><span>{file.fileName}</span><small>下载后会记录一次交付访问</small></div><a className="primary delivery-portal-download" href={publicDeliveryFileUrl(token, file.kind)}><Download size={14} />下载</a></article>)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="delivery-portal-review">
|
||||
<div className="delivery-portal-section-heading"><div><span className="card-kicker">CLIENT REVIEW</span><h2>客户验收</h2></div><span className={`delivery-review-status ${review.status}`}><MessageSquare size={13} />{reviewStatusLabel}</span></div>
|
||||
{review.message && <div className="delivery-portal-review-current"><strong>{review.reviewerName || "客户联系人"}</strong><span>{formatDate(review.submittedAt)}</span><p>{review.message}</p></div>}
|
||||
<div className="delivery-portal-review-form">
|
||||
<div className="delivery-portal-review-fields"><label>联系人<input value={feedbackName} onChange={(event) => setFeedbackName(event.target.value)} placeholder="姓名 / 部门" /></label><label>邮箱<input type="email" value={feedbackEmail} onChange={(event) => setFeedbackEmail(event.target.value)} placeholder="用于项目方识别" /></label></div>
|
||||
<label>反馈说明<textarea rows="4" value={feedbackMessage} onChange={(event) => setFeedbackMessage(event.target.value)} placeholder="如需修改,请写明具体镜头、时间点或交付问题;确认验收时可留空。" /></label>
|
||||
{feedbackNotice && <div className="delivery-portal-review-notice"><CheckCircle2 size={14} />{feedbackNotice}</div>}
|
||||
<div className="delivery-portal-review-actions"><button className="subtle" type="button" onClick={() => submitFeedback("changes_requested")} disabled={feedbackBusy}><Send size={14} />提交修改意见</button><button className="primary" type="button" onClick={() => submitFeedback("approved")} disabled={feedbackBusy}><CheckCircle2 size={14} />确认验收</button></div>
|
||||
</div>
|
||||
{review.count > 0 && <small className="delivery-portal-review-history">本链接已提交 {review.count} 次反馈,项目方可查看完整记录。</small>}
|
||||
</section>
|
||||
<footer className="delivery-portal-footer"><CheckCircle2 size={15} />发布版本:{delivery.version || "-"} · 已通过项目方交付审批</footer>
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
export function ScenePreview({ shot, active }) {
|
||||
return (
|
||||
<div className={`scene-preview ${active ? "active" : ""}`}>
|
||||
<div className="rain"></div>
|
||||
<div className="canopy"></div>
|
||||
<div className="glass"></div>
|
||||
<div className="street-tree"></div>
|
||||
<div className="cones">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div className="puddle"></div>
|
||||
<div className="character chen">
|
||||
<span></span>
|
||||
</div>
|
||||
<div className="character tang">
|
||||
<span></span>
|
||||
</div>
|
||||
<div className="scene-caption">
|
||||
<strong>{shot.id}</strong>
|
||||
<span>{shot.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+919
@@ -0,0 +1,919 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? "http://127.0.0.1:8787";
|
||||
const STORAGE_KEY = "ai-drama-platform-context";
|
||||
const SESSION_KEY = "ai-drama-platform-session";
|
||||
const DEVICE_KEY = "ai-drama-platform-device-id";
|
||||
|
||||
function readStoredContext() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredSession() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createDeviceId() {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto?.getRandomValues?.(bytes);
|
||||
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("") || `device-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function getClientDeviceId() {
|
||||
let deviceId = localStorage.getItem(DEVICE_KEY);
|
||||
if (!deviceId) {
|
||||
deviceId = createDeviceId();
|
||||
localStorage.setItem(DEVICE_KEY, deviceId);
|
||||
}
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
export function getAuthSession() {
|
||||
return readStoredSession();
|
||||
}
|
||||
|
||||
export function saveAuthSession(session) {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function clearAuthSession() {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
export function saveClientContext(next) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
export function getClientContext() {
|
||||
return readStoredContext();
|
||||
}
|
||||
|
||||
function contextHeaders(overrides = {}) {
|
||||
const stored = readStoredContext();
|
||||
const session = readStoredSession();
|
||||
const context = { ...stored, ...overrides };
|
||||
const headers = { "content-type": "application/json", "x-device-id": getClientDeviceId() };
|
||||
if (session?.token) headers.authorization = `Bearer ${session.token}`;
|
||||
if (context.userId) headers["x-user-id"] = context.userId;
|
||||
if (context.organizationId) headers["x-organization-id"] = context.organizationId;
|
||||
if (context.workspaceId) headers["x-workspace-id"] = context.workspaceId;
|
||||
if (context.projectId) headers["x-project-id"] = context.projectId;
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}, contextOverrides = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...contextHeaders(contextOverrides),
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.detail || payload.error || `API ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function apiBlob(path, options = {}, contextOverrides = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...contextHeaders(contextOverrides),
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const error = new Error(payload.detail || payload.error || `API ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return { blob: await response.blob(), contentType: response.headers.get("content-type") || "application/octet-stream" };
|
||||
}
|
||||
|
||||
export async function fetchPlatformContext(overrides = {}) {
|
||||
const payload = await apiFetch("/api/context", {}, overrides);
|
||||
const context = payload.context;
|
||||
if (context) {
|
||||
saveClientContext({
|
||||
userId: context.currentUser.id,
|
||||
organizationId: context.currentOrganization.id,
|
||||
workspaceId: context.currentWorkspace.id,
|
||||
projectId: context.currentProject?.id || ""
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchWorkItems(overrides = {}) {
|
||||
return apiFetch("/api/work-items", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchTasks({ status = "all", assignedTo = "", limit = 100 } = {}, overrides = {}) {
|
||||
const params = new URLSearchParams({ status, limit: String(limit) });
|
||||
if (assignedTo) params.set("assignedTo", assignedTo);
|
||||
return apiFetch(`/api/tasks?${params.toString()}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createTask(body, overrides = {}) {
|
||||
return apiFetch("/api/tasks", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateTask(taskId, body, overrides = {}) {
|
||||
return apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchTaskDetail(taskId, overrides = {}) {
|
||||
return apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/detail`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchTaskComments(taskId, overrides = {}) {
|
||||
return apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/comments`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createTaskComment(taskId, body, overrides = {}) {
|
||||
return apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/comments`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function addTaskLink(taskId, body, overrides = {}) {
|
||||
return apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/links`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function removeTaskLink(taskId, linkId, overrides = {}) {
|
||||
return apiFetch(`/api/tasks/${encodeURIComponent(taskId)}/links/${encodeURIComponent(linkId)}`, { method: "DELETE" }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchProjectActivity({ limit = 80 } = {}, overrides = {}) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
return apiFetch(`/api/project-activity?${params.toString()}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchNotifications({ limit = 60, unreadOnly = false } = {}, overrides = {}) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (unreadOnly) params.set("unreadOnly", "1");
|
||||
return apiFetch(`/api/notifications?${params.toString()}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function markNotificationRead(notificationId, read = true, overrides = {}) {
|
||||
return apiFetch(`/api/notifications/${encodeURIComponent(notificationId)}`, { method: "PATCH", body: JSON.stringify({ read }) }, overrides);
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(overrides = {}) {
|
||||
return apiFetch("/api/notifications/read-all", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchNotificationPreferences(overrides = {}) {
|
||||
return apiFetch("/api/notification-preferences", {}, overrides);
|
||||
}
|
||||
|
||||
export async function updateNotificationPreference(category, enabled, overrides = {}) {
|
||||
return apiFetch(`/api/notification-preferences/${encodeURIComponent(category)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }, overrides);
|
||||
}
|
||||
|
||||
export async function login(body) {
|
||||
const payload = await apiFetch("/api/auth/login", { method: "POST", body: JSON.stringify(body) });
|
||||
if (payload.session) saveAuthSession(payload.session);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function loginWithMfa(body) {
|
||||
const payload = await apiFetch("/api/auth/login/mfa", { method: "POST", body: JSON.stringify(body) });
|
||||
if (payload.session) saveAuthSession(payload.session);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchSsoProviders() {
|
||||
return apiFetch("/api/auth/sso/providers", { headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
export function ssoStartUrl(providerId, options = {}) {
|
||||
const params = new URLSearchParams({ providerId: String(providerId || "") });
|
||||
const returnTo = options.returnTo || `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
params.set("returnTo", returnTo || "/");
|
||||
for (const key of ["organizationId", "workspaceId", "projectId"]) if (options[key]) params.set(key, options[key]);
|
||||
return `${API_BASE}/api/auth/sso/start?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function redeemSsoTicket(ticket) {
|
||||
const payload = await apiFetch("/api/auth/sso/redeem", { method: "POST", body: JSON.stringify({ ticket }) });
|
||||
if (payload.session) saveAuthSession(payload.session);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function startMfaEnrollment(enrollmentToken) {
|
||||
return apiFetch("/api/auth/mfa/enroll/setup", { method: "POST", body: JSON.stringify({ enrollmentToken }) });
|
||||
}
|
||||
|
||||
export async function completeMfaEnrollment(body) {
|
||||
const payload = await apiFetch("/api/auth/mfa/enroll/enable", { method: "POST", body: JSON.stringify(body) });
|
||||
if (payload.session) saveAuthSession(payload.session);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchMfaStatus(overrides = {}) {
|
||||
return apiFetch("/api/auth/mfa", {}, overrides);
|
||||
}
|
||||
|
||||
export async function startMfaSetup(overrides = {}) {
|
||||
return apiFetch("/api/auth/mfa/setup", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function enableMfa(body, overrides = {}) {
|
||||
return apiFetch("/api/auth/mfa/enable", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function cancelMfaSetup(body, overrides = {}) {
|
||||
return apiFetch("/api/auth/mfa/setup/cancel", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function disableMfa(body, overrides = {}) {
|
||||
return apiFetch("/api/auth/mfa/disable", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function previewInvitation(token) {
|
||||
return apiFetch(`/api/invitations/preview?token=${encodeURIComponent(token)}`);
|
||||
}
|
||||
|
||||
export async function registerInvitedUser(body) {
|
||||
const payload = await apiFetch("/api/auth/register", { method: "POST", body: JSON.stringify(body) });
|
||||
saveAuthSession(payload.session);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchAuthSession() {
|
||||
const payload = await apiFetch("/api/auth/session");
|
||||
const current = readStoredSession();
|
||||
if (payload.session && current?.token) saveAuthSession({ ...current, ...payload.session });
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
try {
|
||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||
} finally {
|
||||
clearAuthSession();
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(body, overrides = {}) {
|
||||
return apiFetch("/api/auth/password", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAuthSessions() {
|
||||
return apiFetch("/api/auth/sessions");
|
||||
}
|
||||
|
||||
export async function revokeAuthSession(sessionId) {
|
||||
return apiFetch(`/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
export async function revokeOtherAuthSessions() {
|
||||
return apiFetch("/api/auth/sessions/revoke-others", { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
export async function fetchAuthDevices() {
|
||||
return apiFetch("/api/auth/devices");
|
||||
}
|
||||
|
||||
export async function trustAuthDevice(deviceId) {
|
||||
return apiFetch(`/api/auth/devices/${encodeURIComponent(deviceId)}/trust`, { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
export async function untrustAuthDevice(deviceId) {
|
||||
return apiFetch(`/api/auth/devices/${encodeURIComponent(deviceId)}/untrust`, { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
export async function fetchSecurityEvents({ limit = 80, eventType = "" } = {}, overrides = {}) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (eventType) params.set("eventType", eventType);
|
||||
return apiFetch(`/api/auth/security-events?${params.toString()}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemSecurityEvents(userId = "", overrides = {}, { limit = 120, eventType = "" } = {}) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (userId) params.set("userId", userId);
|
||||
if (eventType) params.set("eventType", eventType);
|
||||
return apiFetch(`/api/system/security-events?${params.toString()}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function acceptInvitation(invitationId, overrides = {}) {
|
||||
return apiFetch(`/api/invitations/${encodeURIComponent(invitationId)}/accept`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchInvitations(overrides = {}) {
|
||||
return apiFetch("/api/invitations", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchProject(overrides = {}) {
|
||||
return apiFetch("/api/project", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createJob(body, overrides = {}) {
|
||||
return apiFetch("/api/jobs", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAssets(overrides = {}) {
|
||||
return apiFetch("/api/assets", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAsset(assetId, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAssetContent(assetId, overrides = {}) {
|
||||
return apiBlob(`/api/assets/${encodeURIComponent(assetId)}/content`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createAsset(body, overrides = {}) {
|
||||
return apiFetch("/api/assets", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function uploadAsset(body, overrides = {}) {
|
||||
return apiFetch("/api/assets/upload", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function uploadAssetVersion(assetId, body, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/versions/upload`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function verifyAsset(assetId, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/verify`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function createAssetVersion(assetId, body, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/versions`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function restoreAssetVersion(assetId, versionId, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/versions/${encodeURIComponent(versionId)}/restore`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateAssetLock(assetId, lockStatus, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/lock`, { method: "POST", body: JSON.stringify({ lockStatus }) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateAssetRights(assetId, body, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/rights`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function bindAssetToShot(assetId, body, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/bindings`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function queryAssistant(body, overrides = {}) {
|
||||
return apiFetch("/api/assistant/query", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createWorkspace(body, overrides = {}) {
|
||||
return apiFetch("/api/workspaces", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createOrganization(body, overrides = {}) {
|
||||
return apiFetch("/api/organizations", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createProject(body, overrides = {}) {
|
||||
return apiFetch("/api/projects", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function inviteMember(organizationId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invitations`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function resendOrganizationInvitation(organizationId, invitationId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invitations/${encodeURIComponent(invitationId)}/resend`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function revokeOrganizationInvitation(organizationId, invitationId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invitations/${encodeURIComponent(invitationId)}/revoke`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateOrganizationMember(organizationId, userId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateWorkspaceMember(workspaceId, userId, body, overrides = {}) {
|
||||
return apiFetch(`/api/workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateProjectMember(projectId, userId, body, overrides = {}) {
|
||||
return apiFetch(`/api/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function addProjectMember(projectId, body, overrides = {}) {
|
||||
return apiFetch(`/api/projects/${encodeURIComponent(projectId)}/members`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemConfig(overrides = {}) {
|
||||
return apiFetch("/api/system/config", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchIdentityCenter(overrides = {}) {
|
||||
return apiFetch("/api/system/identity", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemUsers(overrides = {}, filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.query) params.set("query", filters.query);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.limit) params.set("limit", String(filters.limit));
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(`/api/system/users${suffix}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemUser(userId, overrides = {}) {
|
||||
return apiFetch(`/api/system/users/${encodeURIComponent(userId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function updateSystemUser(userId, body, overrides = {}) {
|
||||
return apiFetch(`/api/system/users/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createSystemUser(body, overrides = {}) {
|
||||
return apiFetch("/api/system/users", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function resetSystemUserPassword(userId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/system/users/${encodeURIComponent(userId)}/reset-password`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function resetSystemUserMfa(userId, overrides = {}) {
|
||||
return apiFetch(`/api/system/users/${encodeURIComponent(userId)}/reset-mfa`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateSystemUserMemberships(userId, body, overrides = {}) {
|
||||
return apiFetch(`/api/system/users/${encodeURIComponent(userId)}/memberships`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function revokeSystemUserSessions(userId, overrides = {}) {
|
||||
return apiFetch(`/api/system/users/${encodeURIComponent(userId)}/revoke-sessions`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchOrganizationCommercial(organizationId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial`, {}, overrides);
|
||||
}
|
||||
|
||||
function usageQuery(filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
for (const key of ["query", "workspaceId", "projectId", "userId", "kind", "unitName", "costCenter", "status", "from", "to", "page", "pageSize", "format"]) {
|
||||
if (filters[key] !== undefined && filters[key] !== null && String(filters[key]) !== "") params.set(key, String(filters[key]));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export async function fetchOrganizationUsage(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery(filters);
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/usage${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function exportOrganizationUsage(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery(filters);
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/usage/export${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function exportOrganizationUsageCsv(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery({ ...filters, format: "csv" });
|
||||
return apiBlob(`/api/organizations/${encodeURIComponent(organizationId)}/usage/export?${query}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function updateOrganizationBilling(organizationId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/billing`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchOrganizationInvoices(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery(filters);
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invoices${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchOrganizationInvoice(organizationId, invoiceId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invoices/${encodeURIComponent(invoiceId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function generateOrganizationInvoice(organizationId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invoices/generate`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateOrganizationInvoiceStatus(organizationId, invoiceId, status, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invoices/${encodeURIComponent(invoiceId)}/status`, { method: "POST", body: JSON.stringify({ status }) }, overrides);
|
||||
}
|
||||
|
||||
export async function exportOrganizationInvoices(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery(filters);
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/invoices/export${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function exportOrganizationInvoicesCsv(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery({ ...filters, format: "csv" });
|
||||
return apiBlob(`/api/organizations/${encodeURIComponent(organizationId)}/invoices/export?${query}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function updateQuotaAllocation(organizationId, quotaId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/quotas/${encodeURIComponent(quotaId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function exportOrganizationCommercial(organizationId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial/export`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function updateCostCenter(organizationId, costCenterId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/cost-centers/${encodeURIComponent(costCenterId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateIdentityPolicy(body, overrides = {}) {
|
||||
return apiFetch("/api/system/identity/policy", { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createIdentityProvider(body, overrides = {}) {
|
||||
return apiFetch("/api/system/identity/providers", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateIdentityProvider(providerId, body, overrides = {}) {
|
||||
return apiFetch(`/api/system/identity/providers/${encodeURIComponent(providerId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function probeIdentityProvider(providerId, overrides = {}) {
|
||||
return apiFetch(`/api/system/identity/providers/${encodeURIComponent(providerId)}/probe`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function createDirectorySync(body, overrides = {}) {
|
||||
return apiFetch("/api/system/identity/directory-syncs", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateDirectorySync(directorySyncId, body, overrides = {}) {
|
||||
return apiFetch(`/api/system/identity/directory-syncs/${encodeURIComponent(directorySyncId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function rotateDirectorySyncToken(directorySyncId, overrides = {}) {
|
||||
return apiFetch(`/api/system/identity/directory-syncs/${encodeURIComponent(directorySyncId)}/rotate-token`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function saveSystemConfig(settings, overrides = {}) {
|
||||
return apiFetch("/api/system/config", { method: "POST", body: JSON.stringify({ settings }) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateFeatureFlag(key, enabled, overrides = {}) {
|
||||
return apiFetch("/api/system/feature-flags", { method: "POST", body: JSON.stringify({ key, enabled }) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateNotificationChannel(channel, overrides = {}) {
|
||||
return apiFetch("/api/system/notifications", { method: "POST", body: JSON.stringify(channel) }, overrides);
|
||||
}
|
||||
|
||||
export async function testNotification(body = {}, overrides = {}) {
|
||||
return apiFetch("/api/system/notifications/test", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchNotificationDeliveries(overrides = {}) {
|
||||
return apiFetch("/api/system/notifications/deliveries", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createApiClient(body, overrides = {}) {
|
||||
return apiFetch("/api/system/api-clients", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateApiClient(clientId, body, overrides = {}) {
|
||||
return apiFetch(`/api/system/api-clients/${encodeURIComponent(clientId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function rotateApiClientKey(clientId, overrides = {}) {
|
||||
return apiFetch(`/api/system/api-clients/${encodeURIComponent(clientId)}/rotate`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateOrganization(organizationId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchOrganizationRolePolicies(organizationId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/role-policies`, {}, { ...overrides, organizationId });
|
||||
}
|
||||
|
||||
export async function updateOrganizationRolePolicy(organizationId, roleKey, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/role-policies/${encodeURIComponent(roleKey)}`, { method: "PATCH", body: JSON.stringify(body) }, { ...overrides, organizationId });
|
||||
}
|
||||
|
||||
export async function updateWorkspace(workspaceId, body, overrides = {}) {
|
||||
return apiFetch(`/api/workspaces/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateProject(projectId, body, overrides = {}) {
|
||||
return apiFetch(`/api/projects/${encodeURIComponent(projectId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function changeProjectLifecycle(projectId, action, overrides = {}) {
|
||||
return apiFetch(`/api/projects/${encodeURIComponent(projectId)}/lifecycle`, { method: "POST", body: JSON.stringify({ action }) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemHealth(overrides = {}) {
|
||||
return apiFetch("/api/system/health", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemReadiness(overrides = {}) {
|
||||
return apiFetch("/api/system/readiness", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemBackups(overrides = {}) {
|
||||
return apiFetch("/api/system/backups", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createSystemBackup(overrides = {}) {
|
||||
return apiFetch("/api/system/backups", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function operateRunner(serviceKey, action, overrides = {}) {
|
||||
return apiFetch(`/api/system/health/${encodeURIComponent(serviceKey)}/action`, { method: "POST", body: JSON.stringify({ action }) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAdminQueue(overrides = {}) {
|
||||
return apiFetch("/api/admin/queue", {}, overrides);
|
||||
}
|
||||
|
||||
export async function batchQueueAction(body, overrides = {}) {
|
||||
return apiFetch("/api/admin/queue/batch", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchWorkerStatus(overrides = {}) {
|
||||
return apiFetch("/api/system/worker", {}, overrides);
|
||||
}
|
||||
|
||||
export async function dispatchWorker(overrides = {}) {
|
||||
return apiFetch("/api/system/worker/dispatch", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchProductionGraph(overrides = {}) {
|
||||
const query = overrides.episodeId ? `?episodeId=${encodeURIComponent(overrides.episodeId)}` : "";
|
||||
return apiFetch(`/api/production/graph${query}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchProductionCatalog(overrides = {}) {
|
||||
return apiFetch("/api/production/catalog", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createProductionSeason(body, overrides = {}) {
|
||||
return apiFetch("/api/production/seasons", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createProductionEpisode(body, overrides = {}) {
|
||||
return apiFetch("/api/production/episodes", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateProductionEpisode(episodeId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/episodes/${encodeURIComponent(episodeId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function importProductionScript(body, overrides = {}) {
|
||||
return apiFetch("/api/production/script/import", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function materializeProductionScript(body, overrides = {}) {
|
||||
return apiFetch("/api/production/script/materialize", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateProductionBible(body, overrides = {}) {
|
||||
return apiFetch("/api/production/bible", { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createProductionShot(body, overrides = {}) {
|
||||
return apiFetch("/api/production/shots", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateProductionShot(shotId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/shots/${encodeURIComponent(shotId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchProductionShotVersions(shotId, overrides = {}) {
|
||||
return apiFetch(`/api/production/shots/${encodeURIComponent(shotId)}/versions`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function restoreProductionShotVersion(shotId, versionId, overrides = {}) {
|
||||
return apiFetch(`/api/production/shots/${encodeURIComponent(shotId)}/versions/${encodeURIComponent(versionId)}/restore`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function saveProductionPrompt(shotId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/shots/${encodeURIComponent(shotId)}/prompt-versions`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchProductionReviews(overrides = {}) {
|
||||
return apiFetch("/api/production/reviews", {}, overrides);
|
||||
}
|
||||
|
||||
export async function runAutomatedProductionQa(overrides = {}) {
|
||||
return apiFetch("/api/production/qa/run", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function runMediaProductionQa(overrides = {}) {
|
||||
return apiFetch("/api/production/qa/media/run", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchMediaArtifacts(params = {}, overrides = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.shotId) query.set("shotId", params.shotId);
|
||||
if (params.jobId) query.set("jobId", params.jobId);
|
||||
if (params.limit) query.set("limit", String(params.limit));
|
||||
return apiFetch(`/api/production/media-artifacts${query.toString() ? `?${query.toString()}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function decideProductionReview(reviewId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/reviews/${encodeURIComponent(reviewId)}/decision`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function addProductionReviewComment(reviewId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/reviews/${encodeURIComponent(reviewId)}/comments`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveries(overrides = {}) {
|
||||
return apiFetch("/api/production/deliveries", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createDelivery(body, overrides = {}) {
|
||||
return apiFetch("/api/production/deliveries", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryChannels(overrides = {}) {
|
||||
return apiFetch("/api/production/delivery-channels", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createDeliveryChannel(body, overrides = {}) {
|
||||
return apiFetch("/api/production/delivery-channels", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateDeliveryChannel(channelId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/delivery-channels/${encodeURIComponent(channelId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryBatches(deliveryId, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/batches`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createDeliveryBatch(deliveryId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/batches`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function activateDeliveryBatch(batchId, overrides = {}) {
|
||||
return apiFetch(`/api/production/delivery-batches/${encodeURIComponent(batchId)}/activate`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function rollbackDeliveryBatch(batchId, overrides = {}) {
|
||||
return apiFetch(`/api/production/delivery-batches/${encodeURIComponent(batchId)}/rollback`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function approveDelivery(deliveryId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/approve`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryReleases(deliveryId, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/releases`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createDeliveryRelease(deliveryId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/releases`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function decideDeliveryRelease(releaseId, body, overrides = {}) {
|
||||
return apiFetch(`/api/production/releases/${encodeURIComponent(releaseId)}/decision`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function publishDeliveryRelease(releaseId, overrides = {}) {
|
||||
return apiFetch(`/api/production/releases/${encodeURIComponent(releaseId)}/publish`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryAccessLinks(releaseId, overrides = {}) {
|
||||
return apiFetch(`/api/production/releases/${encodeURIComponent(releaseId)}/access-links`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryAccessFeedback(releaseId, overrides = {}) {
|
||||
return apiFetch(`/api/production/releases/${encodeURIComponent(releaseId)}/access-feedback`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createDeliveryAccessLink(releaseId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/production/releases/${encodeURIComponent(releaseId)}/access-links`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function revokeDeliveryAccessLink(linkId, overrides = {}) {
|
||||
return apiFetch(`/api/production/access-links/${encodeURIComponent(linkId)}/revoke`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchPublicDeliveryPortal(token) {
|
||||
const response = await fetch(`${API_BASE}/api/public/delivery/${encodeURIComponent(token)}`, { headers: { accept: "application/json" } });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.detail || payload.error || `API ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function publicDeliveryFileUrl(token, kind) {
|
||||
return `${API_BASE}/api/public/delivery/${encodeURIComponent(token)}/file?kind=${encodeURIComponent(kind)}`;
|
||||
}
|
||||
|
||||
export async function submitPublicDeliveryFeedback(token, body = {}) {
|
||||
const response = await fetch(`${API_BASE}/api/public/delivery/${encodeURIComponent(token)}/feedback`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.detail || payload.error || `API ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function writeExports(overrides = {}) {
|
||||
return apiFetch("/api/exports/write", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function retryJob(jobId, overrides = {}) {
|
||||
return apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/retry`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function cancelJob(jobId, overrides = {}) {
|
||||
return apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/cancel`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateJobPriority(jobId, priority, overrides = {}) {
|
||||
return apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/priority`, { method: "POST", body: JSON.stringify({ priority }) }, overrides);
|
||||
}
|
||||
|
||||
function auditQuery(filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters || {})) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export async function fetchAuditEvents(filters = {}, overrides = {}) {
|
||||
const query = auditQuery(filters);
|
||||
return apiFetch(`/api/audit${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAuditEvent(auditId, overrides = {}) {
|
||||
return apiFetch(`/api/audit/${encodeURIComponent(auditId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function exportAuditEvents(filters = {}, overrides = {}) {
|
||||
const query = auditQuery(filters);
|
||||
return apiFetch(`/api/audit/export${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAuditExport(overrides = {}) {
|
||||
return exportAuditEvents({}, overrides);
|
||||
}
|
||||
|
||||
export async function registerModel(body, overrides = {}) {
|
||||
return apiFetch("/api/platform/models/register", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateModel(modelId, body, overrides = {}) {
|
||||
return apiFetch(`/api/platform/models/${encodeURIComponent(modelId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function probeModel(modelId, overrides = {}) {
|
||||
return apiFetch(`/api/platform/models/${encodeURIComponent(modelId)}/probe`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchJobs(overrides = {}) {
|
||||
return apiFetch("/api/jobs", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchStorageUsage(overrides = {}) {
|
||||
return apiFetch("/api/usage/storage", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchCompositions(overrides = {}) {
|
||||
return apiFetch("/api/production/compositions", {}, overrides);
|
||||
}
|
||||
|
||||
export async function composeProduction(body, overrides = {}) {
|
||||
return apiFetch("/api/production/compose", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchJob(jobId, overrides = {}) {
|
||||
return apiFetch(`/api/jobs/${encodeURIComponent(jobId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function runJob(jobId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export { API_BASE };
|
||||
@@ -0,0 +1,153 @@
|
||||
import { projectQa } from "./qa.js";
|
||||
|
||||
const singleFrameRule =
|
||||
"ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY. One complete continuous scene, one location, one time point, one camera angle, one clear action. Fill the entire portrait canvas with this uninterrupted composition.";
|
||||
|
||||
export function buildShotList(project) {
|
||||
return {
|
||||
schema: "ai-drama-platform.shot-list.v1",
|
||||
seriesId: project.series.id,
|
||||
episodeId: project.episode.id,
|
||||
aspectRatio: project.production.aspectRatio,
|
||||
shots: project.shots.map((shot) => ({
|
||||
id: shot.id,
|
||||
title: shot.title,
|
||||
durationSec: shot.durationSec,
|
||||
camera: shot.camera,
|
||||
action: shot.action,
|
||||
characters: shot.characterIds,
|
||||
location: shot.locationId,
|
||||
props: shot.propIds,
|
||||
firstFrame: shot.firstFrame,
|
||||
lastFrame: shot.lastFrame,
|
||||
transitionFromPrevious: shot.transitionFromPrevious,
|
||||
voiceLines: shot.voiceLines
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPromptPack(project) {
|
||||
return {
|
||||
schema: "ai-drama-platform.prompt-pack.v1",
|
||||
adapter: project.production.defaultAdapter,
|
||||
global: {
|
||||
style: project.series.visualStyle,
|
||||
singleFrameRule,
|
||||
negativePrompt: project.production.globalNegativePrompt
|
||||
},
|
||||
characterLocks: project.characters,
|
||||
locationLocks: project.locations,
|
||||
propLocks: project.props,
|
||||
prompts: project.shots.map((shot) => ({
|
||||
id: shot.id,
|
||||
imagePrompt: `${singleFrameRule} ${project.series.visualStyle} ${shot.prompt}`,
|
||||
negativePrompt: `${project.production.globalNegativePrompt}, ${shot.negativePrompt}`,
|
||||
imageToVideoPrompt: shot.videoPrompt,
|
||||
seed: shot.seed,
|
||||
durationSec: shot.durationSec
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVoiceTable(project) {
|
||||
return {
|
||||
schema: "ai-drama-platform.voice-lines.v1",
|
||||
episodeId: project.episode.id,
|
||||
audioPipeline: {
|
||||
adapter: "newapi-audio-production",
|
||||
tts: {
|
||||
model: "IndexTTS-2.5",
|
||||
endpoint: "/audio/speech",
|
||||
request: "multipart/form-data",
|
||||
voiceLockField: "prompt_speech",
|
||||
referenceStatus: "requires-user-approved-natural-reference",
|
||||
blockedReferenceSources: ["macOS say", "MiniMax H3 native audio", "old probe wav", "temporary machine voice"]
|
||||
},
|
||||
asr: {
|
||||
model: "paraformer-zh-long",
|
||||
endpoint: "/audio/transcriptions",
|
||||
request: "multipart/form-data",
|
||||
responseFormat: "verbose_json"
|
||||
}
|
||||
},
|
||||
characters: project.characters.map((character) => ({
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
voiceLock: character.voiceLock
|
||||
})),
|
||||
lines: project.shots.flatMap((shot) =>
|
||||
shot.voiceLines.map((line) => ({
|
||||
shotId: shot.id,
|
||||
lineId: line.id,
|
||||
characterId: line.characterId,
|
||||
text: line.text,
|
||||
emotion: line.emotion,
|
||||
targetDurationSec: line.targetDurationSec,
|
||||
audioFile: line.audioFile,
|
||||
mouthPlan: line.mouthPlan
|
||||
}))
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEditList(project) {
|
||||
return {
|
||||
schema: "ai-drama-platform.edit-list.v1",
|
||||
episodeId: project.episode.id,
|
||||
frameRate: project.production.fps,
|
||||
clips: project.shots.map((shot, index) => ({
|
||||
order: index + 1,
|
||||
shotId: shot.id,
|
||||
sourceVideo: `shots/${shot.id}/${shot.id}.mp4`,
|
||||
durationSec: shot.durationSec,
|
||||
audioTracks: shot.voiceLines.map((line) => line.audioFile),
|
||||
subtitleSource: `voices/${shot.id}.srt`,
|
||||
transition: shot.transitionFromPrevious === "actual-last-frame" ? "match-last-frame" : "insert-bridge-shot",
|
||||
qaRequiredBeforeEdit: ["single-frame", "continuity-lock", "voice-subtitle-asr", "clip-bridge"]
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export function buildModelRequest(project, shot, adapter) {
|
||||
return {
|
||||
adapterId: adapter.id,
|
||||
endpoint: adapter.routes?.imageToVideo || adapter.baseUrl,
|
||||
payload: {
|
||||
series_bible: project.series,
|
||||
episode: project.episode,
|
||||
shot,
|
||||
locks: {
|
||||
characters: project.characters.filter((item) => shot.characterIds.includes(item.id)),
|
||||
location: project.locations.find((item) => item.id === shot.locationId),
|
||||
props: project.props.filter((item) => shot.propIds.includes(item.id))
|
||||
},
|
||||
output_contract: {
|
||||
image_batch_size: 1,
|
||||
image_rule: singleFrameRule,
|
||||
video_segment_sec: shot.durationSec,
|
||||
require_actual_last_frame_for_next_clip: true,
|
||||
reject_cloud_paid_nodes_by_default: true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAllExports(project) {
|
||||
return {
|
||||
shotList: buildShotList(project),
|
||||
promptPack: buildPromptPack(project),
|
||||
voiceTable: buildVoiceTable(project),
|
||||
editList: buildEditList(project),
|
||||
qaResults: projectQa(project)
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadJson(filename, value) {
|
||||
const blob = new Blob([JSON.stringify(value, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
const blockedSingleFrameTerms = [
|
||||
"storyboard",
|
||||
"comic page",
|
||||
"comic strip",
|
||||
"contact sheet",
|
||||
"collage",
|
||||
"montage",
|
||||
"multi-panel",
|
||||
"split screen",
|
||||
"diptych",
|
||||
"triptych",
|
||||
"filmstrip",
|
||||
"picture-in-picture",
|
||||
"inset frame",
|
||||
"multiple scenes",
|
||||
"multiple locations",
|
||||
"multiple time points",
|
||||
"before-and-after",
|
||||
"sequential action",
|
||||
"alternate angles",
|
||||
"grid layout",
|
||||
"poster layout",
|
||||
"分镜拼图",
|
||||
"四宫格",
|
||||
"九宫格",
|
||||
"多画面",
|
||||
"拼贴图",
|
||||
"漫画页",
|
||||
"左右两个画面",
|
||||
"上下两个画面"
|
||||
];
|
||||
|
||||
export function runShotQa(shot, project) {
|
||||
// A negative prompt should contain the exact layouts we want to reject, so it is not a violation by itself.
|
||||
const promptText = [shot.prompt, shot.action, shot.camera].join(" ").toLowerCase();
|
||||
const missingLocks = [];
|
||||
if (!shot.characterIds?.length) missingLocks.push("角色锁");
|
||||
if (!shot.locationId) missingLocks.push("场景锁");
|
||||
if (!shot.propIds?.length) missingLocks.push("道具锁");
|
||||
if (!shot.firstFrame || !shot.lastFrame) missingLocks.push("首尾帧");
|
||||
|
||||
const blockedPromptTerms = blockedSingleFrameTerms.filter((term) =>
|
||||
promptText.includes(term.toLowerCase())
|
||||
);
|
||||
|
||||
const visibleSpeech = shot.voiceLines.some((line) => line.mouthPlan === "严格口型");
|
||||
const hasVoiceLock = shot.voiceLines.every((line) => {
|
||||
const character = project.characters.find((item) => item.id === line.characterId);
|
||||
return character?.voiceLock?.voiceId;
|
||||
});
|
||||
|
||||
const score =
|
||||
100 -
|
||||
missingLocks.length * 12 -
|
||||
blockedPromptTerms.length * 10 -
|
||||
(visibleSpeech && !hasVoiceLock ? 18 : 0) -
|
||||
(shot.transitionFromPrevious === "hard-cut-risk" ? 10 : 0);
|
||||
|
||||
return {
|
||||
shotId: shot.id,
|
||||
score: Math.max(0, score),
|
||||
gates: [
|
||||
{
|
||||
id: "single-frame",
|
||||
label: "一图一画面",
|
||||
status: blockedPromptTerms.length === 0 ? "pass" : "fail",
|
||||
detail:
|
||||
blockedPromptTerms.length === 0
|
||||
? "提示词包含单画面约束,未发现分屏/拼图/漫画页风险词。"
|
||||
: `发现禁用词:${blockedPromptTerms.join("、")}`
|
||||
},
|
||||
{
|
||||
id: "continuity-lock",
|
||||
label: "角色/场景/道具连续性",
|
||||
status: missingLocks.length === 0 ? "pass" : "warn",
|
||||
detail:
|
||||
missingLocks.length === 0
|
||||
? "角色、场景、道具和首尾帧都已绑定。"
|
||||
: `缺少:${missingLocks.join("、")}`
|
||||
},
|
||||
{
|
||||
id: "voice-subtitle-asr",
|
||||
label: "声音/字幕/ASR 对齐",
|
||||
status: hasVoiceLock ? "pass" : "warn",
|
||||
detail: hasVoiceLock
|
||||
? "对白角色均有固定声线 ID,后续可用 ASR 校对字幕时间。"
|
||||
: "有台词角色尚未配置固定声线,禁止把 H3 随机原生声音当最终声线。"
|
||||
},
|
||||
{
|
||||
id: "clip-bridge",
|
||||
label: "片段衔接",
|
||||
status: shot.transitionFromPrevious === "actual-last-frame" ? "pass" : "warn",
|
||||
detail:
|
||||
shot.transitionFromPrevious === "actual-last-frame"
|
||||
? "使用上一段实际末帧作为下一段首帧。"
|
||||
: "场景跨度较大或未指定实际末帧衔接,需要中间过渡镜头。"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function projectQa(project) {
|
||||
return project.shots.map((shot) => runShotQa(shot, project));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,264 @@
|
||||
export const platformResearchMatrix = [
|
||||
{
|
||||
category: "创作入口",
|
||||
commercialNeed: "从长文本、小说、短剧剧本、图片参考、角色设定进入生产,而不是只手填 prompt。",
|
||||
localBuild: "项目工厂、剧本拆解、章节/场次/镜头自动切分、风格预设、分镜生成。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "资产一致性",
|
||||
commercialNeed: "角色、场景、道具、服装、声线可复用,跨集跨镜头保持一致。",
|
||||
localBuild: "角色定妆、三视图、衣橱、location lock、prop lock、voice lock、continuity ledger。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "模型与工作流中台",
|
||||
commercialNeed: "接多模型、多能力、多队列,支持图像、视频、配音、口型、字幕、合成。",
|
||||
localBuild: "自有模型平台为主适配器,OpenAI-compatible 和 ComfyUI optional runner。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "生成编排",
|
||||
commercialNeed: "批量生成、重试、取消、优先级、依赖关系、队列状态、失败原因。",
|
||||
localBuild: "本地 job contract、runner health、队列状态、批次与依赖声明。",
|
||||
status: "next"
|
||||
},
|
||||
{
|
||||
category: "审片质检",
|
||||
commercialNeed: "内容审核、画面完整性、角色一致性、声音字幕对齐、镜头衔接、人工审核。",
|
||||
localBuild: "QA gate、review lanes、证据记录、阻塞项、人工通过/驳回状态。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "成本与额度",
|
||||
commercialNeed: "商业 SaaS 必须有套餐、额度、成本中心、调用计量、预算预警。",
|
||||
localBuild: "local-only 策略、quota、成本中心、每类任务单位成本占位、预算状态。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "权限与团队",
|
||||
commercialNeed: "多团队、多角色、多项目隔离,支持编剧/美术/配音/审片/管理员。",
|
||||
localBuild: "workspace、members、roles、permission matrix、audit log。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "合规版权",
|
||||
commercialNeed: "原创/IP 风险、肖像权、声音权、训练/参考来源、可商用授权记录。",
|
||||
localBuild: "rights ledger、policy gate、禁止真人仿冒、参考素材来源记录。",
|
||||
status: "mvp"
|
||||
},
|
||||
{
|
||||
category: "交付运营",
|
||||
commercialNeed: "版本、成片、字幕、封面、发布渠道、素材归档、复用为模板。",
|
||||
localBuild: "delivery manifest、edit list、版本状态、导出目录、模板复用。",
|
||||
status: "mvp"
|
||||
}
|
||||
];
|
||||
|
||||
export const platformData = {
|
||||
// Static fallback only. The running API hydrates the same shape from SQLite.
|
||||
organizations: [
|
||||
{ id: "org-studio-lab", name: "星河短剧实验室", slug: "xinghe-studio", role: "组织所有者", workspaceCount: 3 },
|
||||
{ id: "org-northstar", name: "北辰内容厂牌", slug: "northstar-content", role: "组织所有者", workspaceCount: 1 }
|
||||
],
|
||||
users: [
|
||||
{ id: "u-owner", name: "林制片", email: "producer@local.test", status: "active" },
|
||||
{ id: "u-writer", name: "白编剧", email: "writer@local.test", status: "active" },
|
||||
{ id: "u-art", name: "沈美术", email: "art@local.test", status: "active" },
|
||||
{ id: "u-voice", name: "许配音", email: "voice@local.test", status: "active" },
|
||||
{ id: "u-review", name: "顾审片", email: "review@local.test", status: "active" }
|
||||
],
|
||||
organizationMemberships: [
|
||||
{ organizationId: "org-studio-lab", userId: "u-owner", role: "org_owner", status: "active" },
|
||||
{ organizationId: "org-studio-lab", userId: "u-writer", role: "org_member", status: "active" },
|
||||
{ organizationId: "org-studio-lab", userId: "u-art", role: "org_member", status: "active" },
|
||||
{ organizationId: "org-studio-lab", userId: "u-voice", role: "org_member", status: "active" },
|
||||
{ organizationId: "org-studio-lab", userId: "u-review", role: "org_member", status: "active" },
|
||||
{ organizationId: "org-northstar", userId: "u-owner", role: "org_owner", status: "active" }
|
||||
],
|
||||
workspaces: [
|
||||
{ id: "ws-local-aidrama", organizationId: "org-studio-lab", name: "短剧生产中心", slug: "drama-production" },
|
||||
{ id: "ws-pilot", organizationId: "org-studio-lab", name: "素材实验室", slug: "asset-lab" },
|
||||
{ id: "ws-northstar-main", organizationId: "org-northstar", name: "北辰试制部", slug: "pilot-room" }
|
||||
],
|
||||
workspaceMemberships: [
|
||||
{ workspaceId: "ws-local-aidrama", userId: "u-owner", role: "producer" },
|
||||
{ workspaceId: "ws-local-aidrama", userId: "u-writer", role: "writer" },
|
||||
{ workspaceId: "ws-local-aidrama", userId: "u-art", role: "art_director" },
|
||||
{ workspaceId: "ws-local-aidrama", userId: "u-voice", role: "voice_editor" },
|
||||
{ workspaceId: "ws-local-aidrama", userId: "u-review", role: "reviewer" },
|
||||
{ workspaceId: "ws-pilot", userId: "u-owner", role: "producer" },
|
||||
{ workspaceId: "ws-northstar-main", userId: "u-owner", role: "producer" }
|
||||
],
|
||||
projectMemberships: [
|
||||
{ projectId: "thunder-mouth", userId: "u-owner", role: "project_editor" },
|
||||
{ projectId: "template-original-manhua", userId: "u-owner", role: "project_editor" }
|
||||
],
|
||||
workspace: {
|
||||
id: "ws-local-aidrama",
|
||||
name: "本地 AI 短剧工作室",
|
||||
deployment: "single-machine-private",
|
||||
basePath: "/Users/xz/Documents/daima/ai短剧/ai-drama-platform",
|
||||
dataPolicy: "local-first-no-paid-cloud-by-default",
|
||||
productTier: "Private Studio MVP",
|
||||
commercialTarget: "私有部署 AI 漫剧/短剧生产平台"
|
||||
},
|
||||
plan: {
|
||||
name: "Studio Local",
|
||||
seats: 8,
|
||||
storageGb: 512,
|
||||
monthlyClipQuota: 1200,
|
||||
localRunnerOnly: true,
|
||||
cloudConnectorsRequireApproval: true
|
||||
},
|
||||
members: [
|
||||
{ id: "u-owner", name: "Owner", role: "平台管理员", status: "active" },
|
||||
{ id: "u-writer", name: "编剧位", role: "编剧", status: "invited" },
|
||||
{ id: "u-art", name: "美术位", role: "资产美术", status: "invited" },
|
||||
{ id: "u-voice", name: "声音位", role: "配音/字幕", status: "invited" },
|
||||
{ id: "u-review", name: "审片位", role: "审片", status: "invited" }
|
||||
],
|
||||
permissionMatrix: [
|
||||
{ role: "平台管理员", permissions: ["workspace:*", "model:*", "billing:*", "compliance:*", "project:*"] },
|
||||
{ role: "制片", permissions: ["project:create", "job:prioritize", "delivery:approve", "cost:view"] },
|
||||
{ role: "编剧", permissions: ["script:edit", "shot:create", "ledger:update"] },
|
||||
{ role: "资产美术", permissions: ["asset:edit", "prompt:edit", "reference:upload"] },
|
||||
{ role: "配音/字幕", permissions: ["voice:edit", "subtitle:edit", "asr:run"] },
|
||||
{ role: "审片", permissions: ["qa:review", "clip:approve", "clip:reject"] }
|
||||
],
|
||||
modelRegistry: [
|
||||
{
|
||||
id: "owned-i2v",
|
||||
label: "自有图生视频平台",
|
||||
capability: ["image-to-video", "first-last-frame", "vertical-video"],
|
||||
endpoint: "http://127.0.0.1:7860/api/generate/i2v",
|
||||
status: "not-connected",
|
||||
costMode: "local",
|
||||
approvalRequired: false
|
||||
},
|
||||
{
|
||||
id: "owned-image",
|
||||
label: "自有图片/改图平台",
|
||||
capability: ["text-to-image", "image-edit", "single-frame"],
|
||||
endpoint: "http://127.0.0.1:7860/api/generate/image",
|
||||
status: "not-connected",
|
||||
costMode: "local",
|
||||
approvalRequired: false
|
||||
},
|
||||
{
|
||||
id: "local-tts",
|
||||
label: "本地固定声线 TTS",
|
||||
capability: ["tts", "voice-lock", "subtitle-timing"],
|
||||
endpoint: "http://127.0.0.1:7861/api/tts",
|
||||
status: "planned",
|
||||
costMode: "local",
|
||||
approvalRequired: false
|
||||
},
|
||||
{
|
||||
id: "newapi-audio-production",
|
||||
label: "NewAPI 音频中转",
|
||||
capability: ["tts", "voice-lock", "emotion-control", "asr", "subtitle-timing"],
|
||||
endpoint: "https://newapi.ysblack.com/v1",
|
||||
status: "ready",
|
||||
costMode: "mixed",
|
||||
approvalRequired: true
|
||||
},
|
||||
{
|
||||
id: "comfyui-optional",
|
||||
label: "ComfyUI 工作流桥接",
|
||||
capability: ["workflow", "qwen-image", "qwen-edit", "h3-bridge"],
|
||||
endpoint: "http://127.0.0.1:8188",
|
||||
status: "optional",
|
||||
costMode: "mixed",
|
||||
approvalRequired: true
|
||||
}
|
||||
],
|
||||
runnerHealth: [
|
||||
{ id: "script-runner", name: "剧本拆解 Runner", status: "ready", queueDepth: 0, lastHeartbeat: "local" },
|
||||
{ id: "image-runner", name: "单画面 Runner", status: "waiting-model", queueDepth: 3, lastHeartbeat: "not-connected" },
|
||||
{ id: "video-runner", name: "视频片段 Runner", status: "waiting-model", queueDepth: 2, lastHeartbeat: "not-connected" },
|
||||
{ id: "voice-runner", name: "固定配音 Runner", status: "planned", queueDepth: 6, lastHeartbeat: "not-connected" },
|
||||
{ id: "qa-runner", name: "审片质检 Runner", status: "ready", queueDepth: 4, lastHeartbeat: "local" },
|
||||
{ id: "export-runner", name: "合成导出 Runner", status: "ready", queueDepth: 1, lastHeartbeat: "local" }
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
id: "thunder-mouth",
|
||||
workspaceId: "ws-local-aidrama",
|
||||
title: "雷雨口",
|
||||
type: "AI 漫剧",
|
||||
episodes: 1,
|
||||
stage: "production",
|
||||
owner: "u-owner",
|
||||
readiness: 72,
|
||||
risk: "medium",
|
||||
updatedAt: "2026-08-19"
|
||||
},
|
||||
{
|
||||
id: "template-original-manhua",
|
||||
workspaceId: "ws-pilot",
|
||||
title: "原创国漫短剧模板",
|
||||
type: "模板",
|
||||
episodes: 0,
|
||||
stage: "template",
|
||||
owner: "u-owner",
|
||||
readiness: 48,
|
||||
risk: "low",
|
||||
updatedAt: "2026-08-19"
|
||||
}
|
||||
],
|
||||
assetStorage: {
|
||||
root: "/Users/xz/Documents/daima/ai短剧/ai-drama-platform/exports/project-template",
|
||||
buckets: [
|
||||
{ id: "references", label: "参考与定妆", fileCount: 0, policy: "must-have-rights" },
|
||||
{ id: "keyframes", label: "单画面关键帧", fileCount: 3, policy: "single-frame-only" },
|
||||
{ id: "clips", label: "视频片段", fileCount: 0, policy: "qa-before-edit" },
|
||||
{ id: "voices", label: "固定配音", fileCount: 6, policy: "voice-rights-required" },
|
||||
{ id: "deliveries", label: "交付版本", fileCount: 5, policy: "approval-required" }
|
||||
]
|
||||
},
|
||||
reviewLanes: [
|
||||
{ id: "lane-frame", label: "画面完整性", owner: "审片", pending: 3, pass: 2, reject: 0 },
|
||||
{ id: "lane-continuity", label: "角色/场景连续性", owner: "美术", pending: 3, pass: 2, reject: 0 },
|
||||
{ id: "lane-voice", label: "声音字幕", owner: "配音/字幕", pending: 6, pass: 0, reject: 0 },
|
||||
{ id: "lane-rights", label: "版权合规", owner: "平台管理员", pending: 1, pass: 3, reject: 0 }
|
||||
],
|
||||
costCenters: [
|
||||
{ id: "cc-local-gpu", label: "本地 GPU/电力", monthBudget: 500, used: 86, unit: "CNY" },
|
||||
{ id: "cc-storage", label: "本地存储", monthBudget: 200, used: 28, unit: "CNY" },
|
||||
{ id: "cc-cloud", label: "外部云接口", monthBudget: 0, used: 0, unit: "CNY" }
|
||||
],
|
||||
compliancePolicies: [
|
||||
{ id: "single-frame", label: "一图一画面", severity: "blocker", status: "enforced" },
|
||||
{ id: "original-ip", label: "原创/IP 风险", severity: "blocker", status: "enforced" },
|
||||
{ id: "voice-right", label: "声音授权", severity: "blocker", status: "needs-evidence" },
|
||||
{ id: "face-right", label: "真人脸权", severity: "blocker", status: "disabled-by-default" },
|
||||
{ id: "cloud-spend", label: "付费云端节点", severity: "approval", status: "disabled-by-default" }
|
||||
],
|
||||
auditLog: [
|
||||
{ id: "aud-001", actor: "u-owner", action: "project.created", target: "thunder-mouth", result: "ok", time: "2026-08-19 15:00" },
|
||||
{ id: "aud-002", actor: "qa-runner", action: "policy.single-frame.checked", target: "shot-01", result: "pass", time: "2026-08-19 15:10" },
|
||||
{ id: "aud-003", actor: "export-runner", action: "exports.write", target: "project-template", result: "ok", time: "2026-08-19 15:18" },
|
||||
{ id: "aud-004", actor: "platform", action: "cloud.connector.blocked", target: "paid-node", result: "requires-approval", time: "2026-08-19 15:22" }
|
||||
]
|
||||
};
|
||||
|
||||
export function getPlatformSummary() {
|
||||
const queueDepth = platformData.runnerHealth.reduce((sum, runner) => sum + runner.queueDepth, 0);
|
||||
const usedBudget = platformData.costCenters.reduce((sum, item) => sum + item.used, 0);
|
||||
const totalBudget = platformData.costCenters.reduce((sum, item) => sum + item.monthBudget, 0);
|
||||
const blockers = platformData.compliancePolicies.filter((item) => item.status !== "enforced").length;
|
||||
return {
|
||||
workspace: platformData.workspace,
|
||||
activeProjects: platformData.projects.filter((item) => item.stage === "production").length,
|
||||
modelCount: platformData.modelRegistry.length,
|
||||
runnerCount: platformData.runnerHealth.length,
|
||||
queueDepth,
|
||||
budget: {
|
||||
used: usedBudget,
|
||||
total: totalBudget
|
||||
},
|
||||
complianceBlockers: blockers,
|
||||
researchMatrix: platformResearchMatrix
|
||||
};
|
||||
}
|
||||
+6048
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user