feat(pipeline): 兜底弹窗拦截 — 生成故事板/视频前查参考图齐不齐,缺了拦住

原兜底是静默退化文生图/文生视频 → 出来的跟用户建的角色/商品无关,白花钱、易纠纷。
因参考图是自动关联(没给手选),改为生成前拦截:

- missingRefsFor:对待生成镜的 entity_refs,查每个角色/场景是否有「按名字采用」的基础资产
  (商品永远有主图、不算缺);没提取过实体则不拦(交给提取闸门)。
- guardGen 包住 4 个生成触发点:故事板整张 / 全部视频 / 单段视频 ×2;单段只查对应镜。
- 缺了弹窗:列出缺的角色/场景 + 「去补齐参考图」(跳资产趴)/「仍要生成」(随他便,带警告)。
- 弹窗 CSS 守 design token(深色遮罩 + --r-md + --heat/--accent-black)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 17:57:52 +08:00
co-authored by Claude Opus 4.8
parent c6d1d2f6fe
commit de29449a76
2 changed files with 90 additions and 4 deletions
+33
View File
@@ -814,3 +814,36 @@
.eg-load-msg { font-size: 14px; font-weight: 500; color: var(--accent-black); }
.eg-load-sub { font-size: 11px; color: var(--black-alpha-48); }
.eg-reextract { margin-top: 14px; width: 100%; }
/* ── 兜底弹窗拦截:生成故事板/视频前,参考图不齐就拦住,让用户去补 / 仍要生成 ── */
.ref-gate-mask {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(21, 20, 15, .42);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.ref-gate-modal {
width: min(460px, 100%);
background: var(--surface-raised);
box-shadow: var(--shadow-floating);
border-radius: var(--r-md);
padding: 24px;
}
.rg-title { font-size: 16px; font-weight: 600; color: var(--accent-black); }
.rg-body { font-size: 13px; line-height: 1.6; color: var(--black-alpha-64); margin-top: 10px; }
.rg-body strong { color: var(--heat); font-weight: 600; }
.rg-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.rg-chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--accent-black); padding: 5px 11px 5px 5px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--border-faint); background: var(--background-base); }
.rg-kind { font-size: 10px; font-family: var(--font-mono); padding: 2px 6px; border-radius: 999px; }
.rg-kind-person { color: var(--heat); background: var(--heat-8); }
.rg-kind-scene { color: var(--black-alpha-56); background: var(--black-alpha-5); }
.rg-actions { display: flex; gap: 10px; margin-top: 22px; align-items: stretch; }
.rg-actions .btn-primary { flex: 1; }
.rg-force { display: inline-flex; align-items: baseline; gap: 5px; }
.rg-warn { font-size: 10px; font-family: var(--font-mono); color: var(--black-alpha-48); }
+57 -4
View File
@@ -695,6 +695,36 @@ export function PipelinePage(props: {
setExtractErr(err instanceof Error ? err.message : "提取失败,请重试");
}
}
// ── 流程步骤4/5 · 兜底弹窗拦截:生成故事板/视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
// 缺了就弹窗(去补齐 / 仍要生成),不静默退化文生图、不让用户花冤枉钱。商品永远有主图、不算缺。
const [refGate, setRefGate] = useState<{ missing: Array<{ name: string; type: string }>; proceed: () => void } | null>(null);
useBodyScrollLock(Boolean(refGate));
function missingRefsFor(segs: Array<{ entity_refs?: string[] }>): Array<{ name: string; type: string }> {
const ents = project.metadata?.script_entities;
if (!Array.isArray(ents) || !ents.length) return []; // 没提取过实体就不拦(交给提取闸门)
const byId = new Map(ents.map((e) => [e.id, e]));
const adopted = {
person: new Set(buildEntities("person").filter((e) => e.group.adopted_asset).map((e) => (e.name || "").trim())),
scene: new Set(buildEntities("scene").filter((e) => e.group.adopted_asset).map((e) => (e.name || "").trim())),
};
const miss = new Map<string, { name: string; type: string }>();
for (const seg of segs) {
for (const rid of seg.entity_refs || []) {
const ent = byId.get(rid);
if (!ent || ent.type === "product") continue; // 商品永远有主图,不算缺
const kind = ent.type === "character" ? "person" : "scene";
const name = (ent.name || "").trim();
if (name && !adopted[kind].has(name)) miss.set(name, { name, type: ent.type });
}
}
return [...miss.values()];
}
// 拦截包装:参考图齐 → 直接生成;不齐 → 弹窗让用户去补 / 仍要生成(随他便)
function guardGen(segs: Array<{ entity_refs?: string[] }>, action: () => void) {
const missing = missingRefsFor(segs);
if (missing.length) setRefGate({ missing, proceed: action });
else action();
}
// 资产图片下载(经同源代理取 blob,避开 TOS 跨域 + 强制下载而非新开标签)
async function downloadAssetImage(assetId: string, name: string) {
try {
@@ -2853,7 +2883,7 @@ export function PipelinePage(props: {
onChange={(v) => setStoryboardPrompt(v.trim())}
/>
<div className="sb-stage-actions">
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)}>
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => guardGen(shots, () => onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT))}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
{adoptedStoryboard ? "整张重跑" : "生成故事板"}
</button>
@@ -2935,7 +2965,7 @@ export function PipelinePage(props: {
</div>
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => onSubmitAllVideos(videoPrompt)}>{anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => guardGen(shots, () => onSubmitAllVideos(videoPrompt))}>{anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => triggerVideoUpload((segments.find((s) => s.status !== "succeeded") || segments[0]).id)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
@@ -2967,7 +2997,7 @@ export function PipelinePage(props: {
</div>
<div className="video-meta">{seg.target_duration_seconds}s · {timeline?.resolution || "1080×1920"} · ~¥0.45{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
<div className="video-actions">
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || busy} onClick={() => onSubmitVideo(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`)}>{busy ? "生成中…" : "重跑"}</button>
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || busy} onClick={() => guardGen(shots.filter((s) => s.sort_order === seg.sort_order), () => onSubmitVideo(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`))}>{busy ? "生成中…" : "重跑"}</button>
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading} onClick={() => triggerVideoUpload(seg.id)}></button>
<span className="spacer"></span>
{url
@@ -3431,7 +3461,7 @@ export function PipelinePage(props: {
<div className="vd-modal-actions">
{vdVer?.asset_url && <a className="btn btn-ghost" href={vdVer.asset_url} target="_blank" rel="noreferrer"></a>}
<button className="btn btn-ghost" type="button" onClick={() => setVdSegId(null)}></button>
<button className="btn btn-primary" type="button" disabled={loading || busy} onClick={() => onSubmitVideo(vdSeg.id, rerunPrompt)}>
<button className="btn btn-primary" type="button" disabled={loading || busy} onClick={() => guardGen(shots.filter((s) => s.sort_order === vdSeg.sort_order), () => onSubmitVideo(vdSeg.id, rerunPrompt))}>
{busy ? "生成中…" : "↻ 重跑本场"}
</button>
</div>
@@ -3440,6 +3470,29 @@ export function PipelinePage(props: {
</div>
);
})()}
{/* ── 流程步骤4/5 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */}
{refGate && (
<div className="ref-gate-mask" onClick={() => setRefGate(null)}>
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="rg-title">,?</div>
<div className="rg-body">
/ <strong></strong>,():
</div>
<div className="rg-list">
{refGate.missing.map((m) => (
<span className="rg-chip" key={`${m.type}:${m.name}`}>
<span className={`rg-kind rg-kind-${m.type === "character" ? "person" : "scene"}`}>{m.type === "character" ? "角色" : "场景"}</span>
{m.name}
</span>
))}
</div>
<div className="rg-actions">
<button type="button" className="btn btn-primary" onClick={() => { setRefGate(null); goStage(2); }}></button>
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}> <span className="rg-warn"></span></button>
</div>
</div>
</div>
)}
{/* ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ── */}
{adDetail && (() => {
const entities = buildEntities(adDetail.kind);