diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index 6600764..3cadfe1 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -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); } diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 73e9fa1..437db79 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -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(); + 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())} />
- @@ -2935,7 +2965,7 @@ export function PipelinePage(props: {
{pct}% - + + {url @@ -3431,7 +3461,7 @@ export function PipelinePage(props: {
{vdVer?.asset_url && 下载} -
@@ -3440,6 +3470,29 @@ export function PipelinePage(props: { ); })()} + {/* ── 流程步骤4/5 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */} + {refGate && ( +
setRefGate(null)}> +
e.stopPropagation()}> +
参考图还没齐,先补一下?
+
+ 下面这些角色 / 场景还没生成参考图。现在直接生成,出来的画面会跟你设定的对不上(像纯文生图、白花钱): +
+
+ {refGate.missing.map((m) => ( + + {m.type === "character" ? "角色" : "场景"} + {m.name} + + ))} +
+
+ + +
+
+
+ )} {/* ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ── */} {adDetail && (() => { const entities = buildEntities(adDetail.kind);