feat(pipeline): 基础资产页 A三视图手动 + B进故事板拦截 + C采用/未采用 + D删除

- A 三视图改手动:删"打开角色详情自动出三视图"逻辑、角色生成只出立绘(不再链式自动三视图);
  详情弹窗保留「AI 生成三视图」按钮作手动入口(提示文案去掉"自动")
- B 进下一步拦截:「确认资产,进入故事板」按钮挂 guardGen —— 被引用角色/场景缺"已采用"参考图就弹窗拦
- C 采用/未采用:角色/场景卡加 toggle 徽章(已采用绿/未采用灰);默认按"实体名是否在脚本里"派生,
  用户可点击覆盖并持久化(metadata.adopt);后端 set-base-asset-adopt 端点;下游 _storyboard_reference_images
  只取已采用组(商品组永远保留、顺带排除未采用/三视图组的兜底误抓)
- D 删除:角色/场景卡 hover 显删除按钮(二次确认),商品主卡不给删;后端 delete-base-asset(连带三视图组)
- 顺手:提取闸门按钮去掉"不自动花钱"文案

验收:projects/assets/ai 全套(基线既有 7 失败零新增,新增 set-adopt/delete 3 测过);tsc+build 绿;
无头核对(采用 toggle/hover 删除/待审核盾齐活、0 console error)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-22 19:23:02 +08:00
parent fda0385d2f
commit 612ab6864d
9 changed files with 184 additions and 22 deletions
+2
View File
@@ -897,6 +897,8 @@ export function App() {
}, "故事板已生成")
}
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")}
onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")}
onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已替换为所选演员")}
onGenerateTriview={async (portraitAssetId) => {
// 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图
+8
View File
@@ -364,6 +364,14 @@ export const api = {
adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) {
return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
},
// 显性采用/未采用某角色/场景组(下游只取已采用)
setBaseAssetAdopt(projectId: string, payload: { group_id: string; state: "adopted" | "unadopted" }) {
return request(`/api/projects/${projectId}/set-base-asset-adopt/`, { method: "POST", body: JSON.stringify(payload) });
},
// 删除某角色/场景资产组(连带其三视图组)
deleteBaseAsset(projectId: string, groupId: string) {
return request(`/api/projects/${projectId}/delete-base-asset/`, { method: "POST", body: JSON.stringify({ group_id: groupId }) });
},
// 流程步骤4 · 用演员库现有资产替换基础资产卡(挂为候选并采用)
// seed 占位卡还没 group:不传 group_id,改传 kind+label,后端按 label 命中/新建实体组再挂(不出图)
attachBaseAsset(projectId: string, payload: { group_id?: string; asset_id: string; kind?: "product" | "person" | "scene"; label?: string }) {
+16
View File
@@ -860,3 +860,19 @@
.pipeline-reextract { position: absolute; top: 14px; right: 28px; z-index: 5; }
/* 提取闸门单按钮:醒目主 CTA */
.eg-extract-btn { margin-top: 24px; min-width: 220px; }
/* C:角色/场景卡 采用/未采用 toggle */
.adopt-toggle { height: 22px; padding: 0 9px; border-radius: var(--r-pill); font-size: 11.5px; font-weight: 500; border: 1px solid transparent; cursor: pointer; white-space: nowrap; letter-spacing: .02em; }
.adopt-toggle.adopted { color: var(--accent-forest); background: var(--forest-bg); border-color: var(--forest-bd); }
.adopt-toggle.unadopted { color: var(--black-alpha-56); background: var(--black-alpha-4); border-color: var(--border-faint); }
.adopt-toggle:hover { border-color: var(--black-alpha-24); }
/* D:角色/场景卡 hover 删除 + 二次确认 */
.asset-card-2 { position: relative; }
.asset-card-del { position: absolute; top: 8px; right: 8px; z-index: 3; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; border: none; border-radius: var(--r-sm); background: var(--black-alpha-48); color: var(--accent-white); cursor: pointer; opacity: 0; transition: opacity .15s, background .15s; }
.asset-card-del svg { width: 14px; height: 14px; }
.asset-card-2:hover .asset-card-del { opacity: 1; }
.asset-card-del:hover { background: var(--accent-crimson); }
.asset-card-del-confirm { position: absolute; top: 8px; right: 8px; z-index: 3; display: inline-flex; gap: 6px; }
.asset-card-del-confirm button { height: 24px; padding: 0 8px; border-radius: var(--r-sm); font-size: 11.5px; border: 1px solid var(--border-faint); cursor: pointer; }
.asset-card-del-confirm .del-yes { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
.asset-card-del-confirm .del-no { background: var(--surface); color: var(--black-alpha-56); }
+38 -22
View File
@@ -469,6 +469,8 @@ export function PipelinePage(props: {
onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise<unknown>;
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string, label?: string) => void | Promise<unknown>;
onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
onSetAdoptState: (groupId: string, state: "adopted" | "unadopted") => void | Promise<unknown>;
onDeleteBaseAsset: (groupId: string) => void | Promise<unknown>;
// 流程步骤4 · 演员库:用现有资产替换基础资产卡 / AI 生成演员 / 本地上传演员
// target:已生成卡传 {group_id};seed 占位卡还没 group 传 {kind,label}(后端按 label 命中/建组)
onAttachBaseAsset: (target: { group_id?: string; kind?: "product" | "person" | "scene"; label?: string }, assetId: string) => void | Promise<unknown>;
@@ -495,7 +497,7 @@ export function PipelinePage(props: {
const {
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
onGenerateBaseAsset, onAdoptBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard,
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
} = props;
@@ -613,6 +615,18 @@ export function PipelinePage(props: {
const triGroupForAsset = (assetId?: string | null): BaseGroup | null =>
(assetId ? groupsByKind("person").find((g) => triviewOf(g) === assetId) || null : null);
type AssetEntity = { key: string; label: string; name: string; kind: "person" | "scene"; group: BaseGroup; versions: string[] };
// 采用态:metadata.adopt 显性优先;否则按"实体名是否在脚本里"推导(在脚本→已采用,孤儿→未采用)
const scriptEntityNames = useMemo(
() => new Set(((project.metadata?.script_entities as Array<{ name?: string }>) ?? []).map((e) => (e?.name || "").trim()).filter(Boolean)),
[project.metadata?.script_entities]
);
function adoptStateOf(group: BaseGroup, name: string): "adopted" | "unadopted" {
const a = (group?.metadata as { adopt?: string } | undefined)?.adopt;
if (a === "adopted" || a === "unadopted") return a;
return scriptEntityNames.has((name || "").trim()) ? "adopted" : "unadopted";
}
// 删除二次确认:点删除先 arm(显"确认删除"),再点才真删;3s 自动撤销
const [delArmed, setDelArmed] = useState<string | null>(null);
// 一个 kind 下的实体列表:按 label 归并(同名取最新一组为代表),三视图组不计入
function buildEntities(kind: "person" | "scene"): AssetEntity[] {
const portraits = groupsByKind(kind).filter((g) => !isTriview(g));
@@ -778,19 +792,7 @@ export function PipelinePage(props: {
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [adDetail]);
// 流程步骤4 · 人物三视图「据当前查看的那一版立绘自动生成」:该立绘资产还没三视图就自动据它生成一版(每立绘 asset 仅一次)
const triAutoRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!adDetail || adDetail.kind !== "person") return;
const ent = buildEntities("person").find((e) => e.key === adDetail.key);
if (!ent || isBusy(`addet-tri:${ent.key}`)) return;
const portraitAsset = adPortraitId || ent.group.adopted_asset;
if (!portraitAsset || triGroupForAsset(portraitAsset)) return;
if (triAutoRef.current.has(portraitAsset)) return;
triAutoRef.current.add(portraitAsset);
void genTriview(portraitAsset, `addet-tri:${ent.key}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adDetail, adPortraitId]);
// 三视图改为「手动生成」(用户点详情弹窗里的「生成三视图」按钮),不再打开角色详情就自动出图、自动扣费。
// ── Stage 3:默认展示已采用(is_adopted)版本;点历史缩略图可切换查看任一版本 ──
const storyboards = project.storyboard_versions ?? [];
@@ -2589,7 +2591,7 @@ export function PipelinePage(props: {
) : (
<>
<div className="eg-title"> / </div>
<div className="mono eg-sub">// AI 认出角色 / 场景并出提示词,稍后你再逐个生成,不自动花钱</div>
<div className="mono eg-sub">// AI 认出角色 / 场景并出提示词,稍后你再逐个生成</div>
<button type="button" className="btn btn-primary btn-lg eg-extract-btn" onClick={() => void runExtract("only")}> / </button>
{extractErr && <div className="eg-err">{extractErr}</div>}
</>
@@ -2780,7 +2782,7 @@ export function PipelinePage(props: {
<div className="hstack" style={{ marginTop: 10 }}>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => openActorReplaceSeed(kind, tag)}></button>
<span className="spacer"></span>
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); if (kind === "person") void genPersonWithTri(p, tag, seedKey); else void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
</div>
</div>
</div>
@@ -2801,6 +2803,17 @@ export function PipelinePage(props: {
const rs = kind === "person" && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : "";
return (
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
{/* D:hover 删除(角色/场景卡;商品主卡不给删)·二次确认 */}
{grp.id && (delArmed === grp.id ? (
<span className="asset-card-del-confirm" data-stop>
<button type="button" className="del-yes" onClick={(e) => { e.stopPropagation(); setDelArmed(null); void onDeleteBaseAsset(grp.id); }}></button>
<button type="button" className="del-no" onClick={(e) => { e.stopPropagation(); setDelArmed(null); }}></button>
</span>
) : (
<button type="button" className="asset-card-del" data-stop title="删除该角色/场景" onClick={(e) => { e.stopPropagation(); setDelArmed(grp.id); window.setTimeout(() => setDelArmed((v) => (v === grp.id ? null : v)), 3000); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
))}
<div className={`placeholder thumb-2${mainUrl && !busy ? " has-mock-media" : ""}`} style={mainUrl && !busy ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
role="button" tabIndex={0} title="点击查看详情(立绘 / 三视图 / 版本)"
onClick={() => openAssetDetail(kind, entity)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}>
@@ -2820,10 +2833,14 @@ export function PipelinePage(props: {
onSubmit={() => submitAssetReview(grp.adopted_asset!)}
/>
)}
{/* C:采用/未采用 toggle —— 下游只取已采用;脚本里没有的默认未采用,可点采用。需已有真实组 */}
{grp.id && grp.adopted_asset && (() => { const st = adoptStateOf(grp, entity.name); return (
<button className={`adopt-toggle ${st}`} type="button" data-stop title={st === "adopted" ? "已采用 · 点击改为未采用" : "未采用 · 点击采用(下游只取已采用的)"} onClick={(e) => { e.stopPropagation(); void onSetAdoptState(grp.id, st === "adopted" ? "unadopted" : "adopted"); }}>{st === "adopted" ? "已采用" : "未采用"}</button>
); })()}
</div>
<PromptBox key={entity.key} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: v }))} />
<div className="hstack" style={{ marginTop: 10 }}>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; if (kind === "person") void genPersonWithTri(p, entity.name, entBK); else void genBaseAsset(kind, p, entity.name, entBK); }}>{busy ? "生成中…" : "重跑"}</button>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; void genBaseAsset(kind, p, entity.name, entBK); }}>{busy ? "生成中…" : "重跑"}</button>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => openActorReplace(entity)}></button>
</div>
@@ -2848,7 +2865,7 @@ export function PipelinePage(props: {
<div className="info"><span className="mono">[ · · ]</span></div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(1)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> </button>
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(3)}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
<button className="btn btn-primary btn-lg" type="button" onClick={() => guardGen(shots, () => goStage(3))}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
@@ -3565,9 +3582,8 @@ export function PipelinePage(props: {
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`);
async function regenPortrait() {
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
// 重跑立绘 → 追加新候选并采用,人物链式据新立绘生成它的三视图;场景只重生立绘
if (isPerson) await genPersonWithTri(prompt, entity!.name, pBK);
else await genBaseAsset("scene", prompt, entity!.name, pBK);
// 重跑立绘 → 追加新立绘候选并采用;三视图改手动(用「生成三视图」按钮),不再链式自动出
await genBaseAsset(isPerson ? "person" : "scene", prompt, entity!.name, pBK);
setAdPortraitId(null); setAdTriId(null);
}
async function regenTri() {
@@ -3637,7 +3653,7 @@ export function PipelinePage(props: {
{!triUrl && !busyTri && (
<div className="asset-detail-tip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
<span>,//</span>
<span>,//</span>
<button className="ai-gen-btn" type="button" disabled={busyTri || !viewPortraitAsset} onClick={() => void regenTri()}>AI </button>
</div>
)}