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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user