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:
co-authored by
Claude Opus 4.8
parent
c6d1d2f6fe
commit
de29449a76
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user