feat(pipeline): 资产页实体提取闸门 — 蒙版+3按钮替掉自动生成(不掏口袋)
进资产趴原会自动 genPersonWithTri/genBaseAsset 出图、直接扣用户费,"被掏口袋"感。 改为用户点了才提取/生成: - 删掉进资产趴的自动生成 effect(及 autoGenRef)。 - STAGE 2 盖半透明蒙版 + 三按钮:① 只提取关键词(不出图) / ② 提取+生成角色和场景 (立绘+场景图) / ③ 提取+全套(立绘+三视图+场景图);running 态转 loading,完成自动揭开。 - runExtract 调 /extract-entities 提实体落库,②③ 再按返回实体循环 genBaseAsset/genPersonWithTri。 - 提取后露「重新提取角色/场景」按钮(改了脚本可重提)。 - 闸门据 metadata.script_entities 是否落库自动显隐;已有资产的老项目不打扰。 - api.extractEntities + 类型 script_entities;蒙版 CSS 守 design token(--heat/--r-md/--accent-crimson)。 角色卡仍只露可改的 visual_prompt(质量模板后端 build_person_frontal_prompt 包,用户看不到)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
827fa7fa54
commit
c6d1d2f6fe
@@ -291,6 +291,17 @@ export const api = {
|
||||
body: JSON.stringify({ script_version_id })
|
||||
});
|
||||
},
|
||||
// 独立实体提取:读已定稿脚本 → AI 识别角色/场景 + 每镜 refs,落库覆盖 metadata。商品不提(参考图层用真实主图)。
|
||||
extractEntities(projectId: string) {
|
||||
return request<{
|
||||
entities: Array<{ id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number }>;
|
||||
segments: Array<{ index: number; entity_refs: string[] }>;
|
||||
cast: string[];
|
||||
cast_prompts: Record<string, string>;
|
||||
scenes: string[];
|
||||
scene_prompts: Record<string, string>;
|
||||
}>(`/api/projects/${projectId}/extract-entities/`, { method: "POST" });
|
||||
},
|
||||
updateScriptSegment(projectId: string, payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) {
|
||||
return request<ScriptVersion>(`/api/projects/${projectId}/update-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
|
||||
@@ -758,3 +758,59 @@
|
||||
.actorlib-studio-detail .actor-thumb { aspect-ratio: 3/4; border-radius: var(--r-md); overflow: hidden; position: relative; border: 1px solid var(--border-faint); }
|
||||
.actor-card .actor-thumb.active { border-color: var(--heat); }
|
||||
@media (max-width: 720px) { .actorlib-studio-grid { grid-template-columns: 1fr; } .actorlib-studio-right { border-left: none; border-top: 1px solid var(--border-faint); padding-left: 0; padding-top: 16px; } }
|
||||
|
||||
/* ── 实体提取闸门(进资产趴蒙版 + 三按钮):不自动花钱,用户点了才提取/生成 ── */
|
||||
.stage[data-stage-pane="2"] { position: relative; }
|
||||
.extract-gate {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(255, 255, 255, .72);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.extract-gate-panel {
|
||||
width: min(440px, 100%);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint), var(--shadow-floating);
|
||||
border-radius: var(--r-md);
|
||||
padding: 28px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.eg-title { font-size: 16px; font-weight: 600; color: var(--accent-black); }
|
||||
.eg-sub { font-size: 11px; color: var(--black-alpha-48); margin-top: 6px; }
|
||||
.extract-gate-btns { display: flex; flex-direction: column; gap: 10px; margin-top: 22px; }
|
||||
.eg-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
text-align: left;
|
||||
padding: 13px 15px;
|
||||
border: none;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--background-base);
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||
cursor: pointer;
|
||||
transition: box-shadow .15s ease, background .15s ease;
|
||||
}
|
||||
.eg-btn:hover { box-shadow: inset 0 0 0 1px var(--heat); background: var(--heat-4); }
|
||||
.eg-btn-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.eg-btn-main { font-size: 13.5px; font-weight: 500; color: var(--accent-black); }
|
||||
.eg-btn-sub { font-size: 11.5px; color: var(--black-alpha-48); }
|
||||
.eg-tag { font-size: 10.5px; padding: 2px 8px; border-radius: 999px; font-family: var(--font-mono); white-space: nowrap; line-height: 1.5; }
|
||||
.eg-tag-free { color: var(--black-alpha-56); box-shadow: inset 0 0 0 1px var(--border-faint); }
|
||||
.eg-tag-pay { color: var(--heat); box-shadow: inset 0 0 0 1px var(--heat-40); background: var(--heat-8); }
|
||||
.eg-btn-full { box-shadow: inset 0 0 0 1px var(--heat-40); }
|
||||
.eg-btn-full:hover { box-shadow: inset 0 0 0 1px var(--heat); background: var(--heat-8); }
|
||||
.eg-err { margin-top: 14px; font-size: 12px; color: var(--accent-crimson); }
|
||||
.extract-gate-loading { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 16px 8px; }
|
||||
.eg-spinner { width: 30px; height: 30px; border-radius: 999px; border: 2.5px solid var(--border-faint); border-top-color: var(--heat); animation: eg-spin .8s linear infinite; }
|
||||
@keyframes eg-spin { to { transform: rotate(360deg); } }
|
||||
.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%; }
|
||||
|
||||
@@ -660,6 +660,41 @@ export function PipelinePage(props: {
|
||||
if (newAsset) await genTriview(newAsset, `${busyKey}:tri`);
|
||||
return g;
|
||||
}
|
||||
// ── 流程步骤4 · 实体提取闸门(进资产趴入口):不自动花钱,用户点按钮才提取/生成 ──
|
||||
// idle=露三按钮 / running=露 loading;提取后 metadata.script_entities 落库、或已有资产 → 闸门由渲染层自动隐藏
|
||||
const [extractState, setExtractState] = useState<"idle" | "running">("idle");
|
||||
const [extractMsg, setExtractMsg] = useState("");
|
||||
const [extractErr, setExtractErr] = useState("");
|
||||
// only=只提取关键词(不出图) / gen=提取+生成角色场景(立绘+场景图) / full=提取+全套(立绘+三视图+场景图)
|
||||
async function runExtract(mode: "only" | "gen" | "full") {
|
||||
if (extractState === "running") return;
|
||||
setExtractErr("");
|
||||
setExtractState("running");
|
||||
setExtractMsg("正在从剧本认出角色 / 场景…");
|
||||
try {
|
||||
const res = await api.extractEntities(project.id);
|
||||
await onRefreshProject(); // 拉回 metadata(cast/scenes/script_entities)+ 回填的每镜 refs
|
||||
if (mode !== "only") {
|
||||
setExtractMsg("已认出角色 / 场景,正在生成参考图…");
|
||||
for (const e of res.entities || []) {
|
||||
const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`;
|
||||
if (e.type === "character") {
|
||||
if (mode === "full") await genPersonWithTri(e.visual_prompt, e.name, bk); // 立绘 + 三视图
|
||||
else await genBaseAsset("person", e.visual_prompt, e.name, bk); // 立绘
|
||||
} else {
|
||||
await genBaseAsset("scene", e.visual_prompt, e.name, bk); // 场景图
|
||||
}
|
||||
}
|
||||
await onRefreshProject();
|
||||
}
|
||||
setExtractState("idle"); // 闸门据 metadata.script_entities 已落库 → 渲染层自动隐藏
|
||||
setExtractMsg("");
|
||||
} catch (err) {
|
||||
setExtractState("idle");
|
||||
setExtractMsg("");
|
||||
setExtractErr(err instanceof Error ? err.message : "提取失败,请重试");
|
||||
}
|
||||
}
|
||||
// 资产图片下载(经同源代理取 blob,避开 TOS 跨域 + 强制下载而非新开标签)
|
||||
async function downloadAssetImage(assetId: string, name: string) {
|
||||
try {
|
||||
@@ -725,8 +760,6 @@ export function PipelinePage(props: {
|
||||
void genTriview(portraitAsset, `addet-tri:${ent.key}`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [adDetail, adPortraitId]);
|
||||
// 进入基础资产自动生成的「已触发」哨兵(实际副作用在 productName/viewStage 定义后挂载)
|
||||
const autoGenRef = useRef(false);
|
||||
|
||||
// ── Stage 3:默认展示已采用(is_adopted)版本;点历史缩略图可切换查看任一版本 ──
|
||||
const storyboards = project.storyboard_versions ?? [];
|
||||
@@ -2040,36 +2073,9 @@ export function PipelinePage(props: {
|
||||
const productName = productRecord?.title || "透真补水面膜";
|
||||
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
|
||||
|
||||
// 流程步骤4 · 进入基础资产自动生成脚本里提取出、但还没生成的人物/场景占位卡(每项目仅一次)
|
||||
useEffect(() => {
|
||||
if (activeDot !== 2 && viewStage !== 2) return;
|
||||
if (autoGenRef.current) return;
|
||||
const flagKey = `airshelf:autogen:${project.id}`;
|
||||
try { if (localStorage.getItem(flagKey)) { autoGenRef.current = true; return; } } catch { /* ignore */ }
|
||||
const pending: Array<{ kind: "person" | "scene"; tag: string; prompt: string }> = [];
|
||||
(["person", "scene"] as const).forEach((kind) => {
|
||||
const tags = kind === "person" ? (project.metadata?.cast ?? []) : (project.metadata?.scenes ?? []);
|
||||
const generated = new Set(groupsByKind(kind).map((g) => (g.metadata?.label || "").trim()).filter(Boolean));
|
||||
const promptMap = (kind === "person" ? project.metadata?.cast_prompts : project.metadata?.scene_prompts) || {};
|
||||
tags.filter((t) => !generated.has(t)).forEach((tag) => {
|
||||
const fallback = kind === "person"
|
||||
? `${tag},真人模特出镜,自然光,${productName} 上身展示,9:16 竖屏`
|
||||
: `${tag},使用场景,氛围统一,干净构图,9:16 竖屏`;
|
||||
pending.push({ kind, tag, prompt: promptMap[tag]?.trim() || fallback });
|
||||
});
|
||||
});
|
||||
if (!pending.length) return;
|
||||
autoGenRef.current = true;
|
||||
try { localStorage.setItem(flagKey, "1"); } catch { /* ignore */ }
|
||||
void (async () => {
|
||||
for (const item of pending) {
|
||||
const bk = `seed:${item.kind}:${item.tag}`;
|
||||
if (item.kind === "person") await genPersonWithTri(item.prompt, item.tag, bk);
|
||||
else await genBaseAsset(item.kind, item.prompt, item.tag, bk);
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeDot, viewStage, project.id]);
|
||||
// 进资产趴【不再自动生成基础资产】——原先这里会自动 genPersonWithTri/genBaseAsset 出图、直接扣用户费,
|
||||
// 用户有"被掏口袋"感。改为:进资产趴盖半透明蒙版 + 三按钮(只提取 / 提取+生成角色场景 / 提取+全套),
|
||||
// 由用户点了才提取/生成(见下方 STAGE 2 的 extract gate 与 runExtract)。
|
||||
|
||||
// 流程步骤4 · 在基础资产趴轮询后端在途出图任务,重建「生成中」占位卡 loading(刷新后内存态丢失也能恢复);
|
||||
// 在途数减少=有任务出图完成 → 刷新项目把占位卡换成真卡。离开该趴即停。
|
||||
@@ -2489,8 +2495,46 @@ export function PipelinePage(props: {
|
||||
|| assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板
|
||||
const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`;
|
||||
const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); };
|
||||
// 实体提取闸门:进资产趴还没提取(无 script_entities)且没生成任何基础资产 → 盖蒙版 + 三按钮,不自动花钱
|
||||
const seList = project.metadata?.script_entities;
|
||||
const extracted = Array.isArray(seList) && seList.length > 0;
|
||||
const hasAnyAsset = KIND_ORDER.some((k) => groupsByKind(k).length > 0);
|
||||
const gateVisible = extractState === "running" || (!extracted && !hasAnyAsset);
|
||||
return (
|
||||
<section className="stage active" data-stage-pane="2">
|
||||
{gateVisible && (
|
||||
<div className="extract-gate">
|
||||
<div className="extract-gate-panel">
|
||||
{extractState === "running" ? (
|
||||
<div className="extract-gate-loading">
|
||||
<span className="eg-spinner" aria-hidden="true"></span>
|
||||
<div className="eg-load-msg">{extractMsg || "处理中…"}</div>
|
||||
<div className="mono eg-load-sub">// 蒙版盖住别动,完成自动揭开</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="eg-title">先从剧本认出角色 / 场景</div>
|
||||
<div className="mono eg-sub">// 生成图会扣费,交给你点,不自动花钱</div>
|
||||
<div className="extract-gate-btns">
|
||||
<button type="button" className="eg-btn" onClick={() => void runExtract("only")}>
|
||||
<span className="eg-btn-head"><span className="eg-btn-main">只提取关键词</span><span className="eg-tag eg-tag-free">不出图</span></span>
|
||||
<span className="eg-btn-sub">AI 认出角色 / 场景,出提示词,先不生成</span>
|
||||
</button>
|
||||
<button type="button" className="eg-btn" onClick={() => void runExtract("gen")}>
|
||||
<span className="eg-btn-head"><span className="eg-btn-main">提取 + 生成角色和场景</span><span className="eg-tag eg-tag-pay">会扣费</span></span>
|
||||
<span className="eg-btn-sub">提完顺手生成角色立绘 + 场景图</span>
|
||||
</button>
|
||||
<button type="button" className="eg-btn eg-btn-full" onClick={() => void runExtract("full")}>
|
||||
<span className="eg-btn-head"><span className="eg-btn-main">提取 + 全套资产</span><span className="eg-tag eg-tag-pay">会扣费 · 最全</span></span>
|
||||
<span className="eg-btn-sub">角色全身照 + 三视图 + 场景图,一步到位</span>
|
||||
</button>
|
||||
</div>
|
||||
{extractErr && <div className="eg-err">{extractErr}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="stage-assets">
|
||||
<div className="asset-side">
|
||||
{KIND_ORDER.map((kind) => {
|
||||
@@ -2513,6 +2557,11 @@ export function PipelinePage(props: {
|
||||
<strong className="mono">// 人物 +¥0.20/张</strong>
|
||||
<strong className="mono">// 场景 +¥0.15/张</strong>
|
||||
<span style={{ color: "var(--black-alpha-48)" }}>商品图无成本(直接复用商品库)</span>
|
||||
{extracted && (
|
||||
<button type="button" className="btn btn-ghost btn-sm eg-reextract" disabled={extractState === "running"} data-stop onClick={() => void runExtract("only")}>
|
||||
{extractState === "running" ? "提取中…" : "重新提取角色 / 场景"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -239,6 +239,8 @@ export type Project = {
|
||||
// 流程步骤3/4 · 脚本提取出的每个人物/场景的建议生图提示词(基础资产据此 seed 卡片)
|
||||
cast_prompts?: Record<string, string>;
|
||||
scene_prompts?: Record<string, string>;
|
||||
// 提取步产出:角色/场景实体全量(含 id/ref_index);下游故事板/视频按每镜 entity_refs 取参考图
|
||||
script_entities?: Array<{ id: string; type: string; name: string; visual_prompt?: string; ref_index?: number }>;
|
||||
} & Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
Reference in New Issue
Block a user