diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 0032400..ea9c5d7 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -1024,6 +1024,22 @@ def _storyboard_reference_images(project, segment) -> list[dict]: "scene": BaseAssetGroup.Kind.SCENE, } groups = list(project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset")) + # 下游只取「已采用」的角色/场景组(C/D):metadata.adopt 显性优先,否则按是否在脚本里推导; + # 商品组永远保留(不受采用态影响)。顺带把不在脚本里的(含三视图组 label「·三视图」)排除, + # 避免兜底误抓未采用/三视图组。 + _entity_names = {(e.get("name") or "").strip() for e in entities.values() if (e.get("name") or "").strip()} + + def _group_adopted(g) -> bool: + if g.kind == BaseAssetGroup.Kind.PRODUCT: + return True + a = (g.metadata or {}).get("adopt") + if a == "adopted": + return True + if a == "unadopted": + return False + return (g.metadata or {}).get("label", "").strip() in _entity_names + + groups = [g for g in groups if _group_adopted(g)] out: list[dict] = [] used: set = set() for rid in (segment.entity_refs or []): diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index 8648525..ff202d2 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -532,3 +532,36 @@ class ExportClipsTests(TestCase): def test_export_empty_returns_400(self): res = self.client.get(f"/api/projects/{self.project.id}/export-clips/") self.assertEqual(res.status_code, 400) + + +class BaseAssetAdoptDeleteTests(TestCase): + """显性采用/未采用 + 删除角色组。""" + + def setUp(self): + from apps.projects.models import BaseAssetGroup + + self.user = User.objects.create_user(username="bad", password="x") + self.team = Team.objects.create(name="BAD", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + self.product = Product.objects.create(team=self.team, created_by=self.user, title="P") + self.proj = Project.objects.create(team=self.team, name="项目甲", product=self.product, created_by=self.user) + self.group = BaseAssetGroup.objects.create(project=self.proj, kind=BaseAssetGroup.Kind.PERSON, metadata={"label": "小明"}) + self.client = APIClient() + self.client.force_authenticate(self.user) + + def test_set_adopt_state(self): + res = self.client.post(f"/api/projects/{self.proj.id}/set-base-asset-adopt/", {"group_id": str(self.group.id), "state": "unadopted"}, format="json") + self.assertEqual(res.status_code, 200) + self.group.refresh_from_db() + self.assertEqual(self.group.metadata.get("adopt"), "unadopted") + + def test_set_adopt_invalid_state(self): + res = self.client.post(f"/api/projects/{self.proj.id}/set-base-asset-adopt/", {"group_id": str(self.group.id), "state": "x"}, format="json") + self.assertEqual(res.status_code, 400) + + def test_delete_base_asset(self): + from apps.projects.models import BaseAssetGroup + + res = self.client.post(f"/api/projects/{self.proj.id}/delete-base-asset/", {"group_id": str(self.group.id)}, format="json") + self.assertEqual(res.status_code, 200) + self.assertFalse(BaseAssetGroup.objects.filter(id=self.group.id).exists()) diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 00a1e25..9c772c5 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -362,6 +362,38 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): promote_base_asset_stage_if_ready(project) return Response(BaseAssetGroupSerializer(group).data) + @action(detail=True, methods=["post"], url_path="set-base-asset-adopt") + @transaction.atomic + def set_base_asset_adopt(self, request, pk=None): + """显性「采用 / 未采用」某角色/场景组(metadata.adopt)。下游只取已采用的;脚本里没有的默认未采用, + 但用户强制采用随他。默认态(不写 adopt)由前端按是否在脚本里推导,这里只存用户的覆盖。""" + project = self.get_object() + group = BaseAssetGroup.objects.select_for_update().filter(project=project, id=request.data.get("group_id")).first() + if group is None: + return Response({"detail": "group not found"}, status=status.HTTP_404_NOT_FOUND) + state = request.data.get("state") + if state not in ("adopted", "unadopted"): + return Response({"detail": "state must be adopted/unadopted"}, status=status.HTTP_400_BAD_REQUEST) + meta = dict(group.metadata or {}) + meta["adopt"] = state + group.metadata = meta + group.save(update_fields=["metadata", "updated_at"]) + return Response(BaseAssetGroupSerializer(group).data) + + @action(detail=True, methods=["post"], url_path="delete-base-asset") + @transaction.atomic + def delete_base_asset(self, request, pk=None): + """删除某角色/场景资产组(连同它的三视图配套组一并删)。资产本身留在资产表,只移除项目内的组引用。""" + project = self.get_object() + group = BaseAssetGroup.objects.filter(project=project, id=request.data.get("group_id")).first() + if group is None: + return Response({"detail": "group not found"}, status=status.HTTP_404_NOT_FOUND) + # 连带删该角色立绘配套的三视图组(triview_of 指向本组采用的立绘) + if group.adopted_asset_id: + project.base_asset_groups.filter(metadata__triview_of=str(group.adopted_asset_id)).delete() + group.delete() + return Response({"deleted": True}) + @action(detail=True, methods=["post"], url_path="attach-base-asset") @transaction.atomic def attach_base_asset(self, request, pk=None): diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 24d3c7a..a075454 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -897,6 +897,8 @@ export function App() { }, "故事板已生成") } onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")} + onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")} + onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")} onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已替换为所选演员")} onGenerateTriview={async (portraitAssetId) => { // 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图 diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 8c55b88..d8b822e 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -364,6 +364,14 @@ export const api = { adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) { return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); }, + // 显性采用/未采用某角色/场景组(下游只取已采用) + setBaseAssetAdopt(projectId: string, payload: { group_id: string; state: "adopted" | "unadopted" }) { + return request(`/api/projects/${projectId}/set-base-asset-adopt/`, { method: "POST", body: JSON.stringify(payload) }); + }, + // 删除某角色/场景资产组(连带其三视图组) + deleteBaseAsset(projectId: string, groupId: string) { + return request(`/api/projects/${projectId}/delete-base-asset/`, { method: "POST", body: JSON.stringify({ group_id: groupId }) }); + }, // 流程步骤4 · 用演员库现有资产替换基础资产卡(挂为候选并采用) // seed 占位卡还没 group:不传 group_id,改传 kind+label,后端按 label 命中/新建实体组再挂(不出图) attachBaseAsset(projectId: string, payload: { group_id?: string; asset_id: string; kind?: "product" | "person" | "scene"; label?: string }) { diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index 7f6e83f..3d6853b 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -860,3 +860,19 @@ .pipeline-reextract { position: absolute; top: 14px; right: 28px; z-index: 5; } /* 提取闸门单按钮:醒目主 CTA */ .eg-extract-btn { margin-top: 24px; min-width: 220px; } + +/* C:角色/场景卡 采用/未采用 toggle */ +.adopt-toggle { height: 22px; padding: 0 9px; border-radius: var(--r-pill); font-size: 11.5px; font-weight: 500; border: 1px solid transparent; cursor: pointer; white-space: nowrap; letter-spacing: .02em; } +.adopt-toggle.adopted { color: var(--accent-forest); background: var(--forest-bg); border-color: var(--forest-bd); } +.adopt-toggle.unadopted { color: var(--black-alpha-56); background: var(--black-alpha-4); border-color: var(--border-faint); } +.adopt-toggle:hover { border-color: var(--black-alpha-24); } +/* D:角色/场景卡 hover 删除 + 二次确认 */ +.asset-card-2 { position: relative; } +.asset-card-del { position: absolute; top: 8px; right: 8px; z-index: 3; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; border: none; border-radius: var(--r-sm); background: var(--black-alpha-48); color: var(--accent-white); cursor: pointer; opacity: 0; transition: opacity .15s, background .15s; } +.asset-card-del svg { width: 14px; height: 14px; } +.asset-card-2:hover .asset-card-del { opacity: 1; } +.asset-card-del:hover { background: var(--accent-crimson); } +.asset-card-del-confirm { position: absolute; top: 8px; right: 8px; z-index: 3; display: inline-flex; gap: 6px; } +.asset-card-del-confirm button { height: 24px; padding: 0 8px; border-radius: var(--r-sm); font-size: 11.5px; border: 1px solid var(--border-faint); cursor: pointer; } +.asset-card-del-confirm .del-yes { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); } +.asset-card-del-confirm .del-no { background: var(--surface); color: var(--black-alpha-56); } diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index bdf2f32..d48a0d4 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -469,6 +469,8 @@ export function PipelinePage(props: { onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise; onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string, label?: string) => void | Promise; onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise; + onSetAdoptState: (groupId: string, state: "adopted" | "unadopted") => void | Promise; + onDeleteBaseAsset: (groupId: string) => void | Promise; // 流程步骤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; @@ -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(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>(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: { ) : ( <>
先从剧本认出角色 / 场景
-
// AI 认出角色 / 场景并出提示词,稍后你再逐个生成,不自动花钱
+
// AI 认出角色 / 场景并出提示词,稍后你再逐个生成
{extractErr &&
{extractErr}
} @@ -2780,7 +2782,7 @@ export function PipelinePage(props: {
- +
@@ -2801,6 +2803,17 @@ export function PipelinePage(props: { const rs = kind === "person" && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : ""; return (
+ {/* D:hover 删除(角色/场景卡;商品主卡不给删)·二次确认 */} + {grp.id && (delArmed === grp.id ? ( + + + + + ) : ( + + ))}
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 ( + + ); })()}
setAssetPromptDraft((m) => ({ ...m, [entity.key]: v }))} />
- +
@@ -2848,7 +2865,7 @@ export function PipelinePage(props: {
[ 基础资产同时展示 · 商品图复用商品库 · 失败不扣 ]
- +
@@ -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 && (
- 三视图据立绘自动生成,以保证正/侧/背多角度一致 + 三视图据这版立绘生成,保证正/侧/背多角度一致。点下方按钮生成。
)} diff --git a/core/qa/visual-parity/_adopt-del.mjs b/core/qa/visual-parity/_adopt-del.mjs new file mode 100644 index 0000000..6fe5739 --- /dev/null +++ b/core/qa/visual-parity/_adopt-del.mjs @@ -0,0 +1,18 @@ +import { chromium } from "playwright"; +import path from "node:path"; import fs from "node:fs"; +const OUT=path.resolve("../../../_qa_shots/adopt"); fs.mkdirSync(OUT,{recursive:true}); +const b=await chromium.launch({headless:true}); const r={consoleErrors:[]}; +const p=await (await b.newContext({viewport:{width:1440,height:900}})).newPage(); +p.on("console",m=>{if(m.type()==="error")r.consoleErrors.push(m.text())}); p.on("pageerror",e=>r.consoleErrors.push("PE:"+e.message)); +await p.goto("http://localhost:5200/login"); await p.waitForTimeout(300); +await p.fill("#auth-username","airshelf"); await p.fill("#auth-pwd","Restraint2026"); +await p.click("button.btn-cta"); await p.waitForFunction(()=>!location.pathname.startsWith("/login"),{timeout:12000}); +await p.goto("http://localhost:5200/pipeline/bdbf39d3-1a89-4135-b1b7-26d3331a7021"); await p.waitForTimeout(2200); +await p.locator('a[data-stage="2"]').first().click().catch(()=>{}); await p.waitForTimeout(2200); +r.adoptToggles=await p.locator(".adopt-toggle").count(); +r.toggleTexts=await p.locator(".adopt-toggle").allInnerTexts().catch(()=>[]); +// hover 第一张角色卡看删除按钮 +const card=p.locator(".asset-card-2[data-asset-kind='person']").first(); +if(await card.count()){ await card.hover(); await p.waitForTimeout(500); r.delBtnVisible=await card.locator(".asset-card-del").isVisible().catch(()=>false); } +await p.screenshot({path:path.join(OUT,"adopt-del.png"),fullPage:true}); +await b.close(); console.log(JSON.stringify(r,null,2)); diff --git a/core/qa/visual-parity/_del-check.mjs b/core/qa/visual-parity/_del-check.mjs new file mode 100644 index 0000000..76104b5 --- /dev/null +++ b/core/qa/visual-parity/_del-check.mjs @@ -0,0 +1,21 @@ +import { chromium } from "playwright"; +import path from "node:path"; import fs from "node:fs"; +const OUT=path.resolve("../../../_qa_shots/adopt"); fs.mkdirSync(OUT,{recursive:true}); +const b=await chromium.launch({headless:true}); const r={}; +const p=await (await b.newContext({viewport:{width:1440,height:900}})).newPage(); +await p.goto("http://localhost:5200/login"); await p.waitForTimeout(300); +await p.fill("#auth-username","airshelf"); await p.fill("#auth-pwd","Restraint2026"); +await p.click("button.btn-cta"); await p.waitForFunction(()=>!location.pathname.startsWith("/login"),{timeout:12000}); +await p.goto("http://localhost:5200/pipeline/bdbf39d3-1a89-4135-b1b7-26d3331a7021"); await p.waitForTimeout(2200); +await p.locator('a[data-stage="2"]').first().click().catch(()=>{}); await p.waitForTimeout(2200); +r.delBtnInDom=await p.locator(".asset-card-del").count(); +// hover 含 toggle 的那张真实角色卡 +const realCard=p.locator(".asset-card-2:has(.adopt-toggle)").first(); +r.realCardCount=await realCard.count(); +if(await realCard.count()){ + await realCard.hover(); await p.waitForTimeout(400); + const opacity=await realCard.locator(".asset-card-del").evaluate(el=>getComputedStyle(el).opacity).catch(()=>"n/a"); + r.delOpacityOnHover=opacity; + await realCard.screenshot({path:path.join(OUT,"card-hover.png")}); +} +await b.close(); console.log(JSON.stringify(r,null,2));