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