diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index bc11597..0032400 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -851,7 +851,7 @@ def run_triview_task(*, task_id: str) -> None: charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) asset = _store_generated_media( team=project.team, user=user, project=project, task=task, media=media, - name=f"{project.name}-三视图", category=Asset.Category.PERSON, asset_type=Asset.Type.IMAGE, + name=f"{project.name}-三视图", category=Asset.Category.TRI_VIEW, asset_type=Asset.Type.IMAGE, ) # 复用该立绘的三视图组(triview_of==立绘asset id):追加候选 + 采用最新 group = next((g for g in project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PERSON).order_by("created_at") @@ -864,6 +864,23 @@ def run_triview_task(*, task_id: str) -> None: group.candidate_assets.add(asset) group.adopted_asset = asset group.save(update_fields=["adopted_asset", "updated_at"]) + # B 路(期3):视频流程新生成的「角色」立绘 + 三视图成套 → 自动加入模特库(团队级可复用)。 + # 幂等:同一立绘只建一次 Model;best-effort,不影响主链路。 + from apps.assets.models import Model as ModelEntity + + portrait = Asset.objects.filter(id=asset_key).first() + if portrait is not None and not ModelEntity.objects.filter(portrait_asset=portrait).exists(): + ModelEntity.objects.create( + team=project.team, created_by=user, + name=(portrait.name or f"{project.name}-角色").split("·")[0].strip()[:255] or "角色", + source=ModelEntity.Source.AI, + portrait_asset=portrait, triview_asset=asset, + metadata={"from_project": str(project.id), "auto_enrolled": True}, + ) + # 合规(期3):三视图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort) + from apps.assets.review import submit_asset_for_review + + transaction.on_commit(lambda a=asset: submit_asset_for_review(a)) except Exception as exc: # noqa: BLE001 — 失败退费 + 错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费) task.status = AITask.Status.FAILED task.error_message = str(exc) @@ -1144,7 +1161,7 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None: task=task, media=media, name=f"{project.name}-storyboard-{segment.sort_order + 1}", - category=Asset.Category.SCENE, + category=Asset.Category.STORYBOARD, asset_type=Asset.Type.IMAGE, ) with transaction.atomic(): @@ -1163,6 +1180,10 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None: sort_order=segment.sort_order, prompt=segment.visual_prompt, ) + # 合规(期3):分镜图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort) + from apps.assets.review import submit_asset_for_review + + transaction.on_commit(lambda a=asset: submit_asset_for_review(a)) except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务失败供 poll 上报 task.status = AITask.Status.FAILED task.error_message = str(exc) diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index 3e54d81..f974e01 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -275,6 +275,64 @@ class StandaloneCategoryTests(TestCase): self.assertEqual(a.category, Asset.Category.PERSON) +class TriviewAutoEnrollTests(TestCase): + """B 路(期3):视频流程新生成的角色立绘 + 三视图成套 → 自动入模特库;幂等不重复建。""" + + def setUp(self): + from apps.projects.models import Project + + self.user = User.objects.create_user(username="trio", password="p") + self.team = Team.objects.create(name="TR", owner=self.user) + CreditAccount.objects.create(team=self.team, balance="100.0000") + self.product = Product.objects.create(team=self.team, created_by=self.user, title="P") + self.project = Project.objects.create(team=self.team, name="项目甲", product=self.product, created_by=self.user) + self.portrait = Asset.objects.create( + team=self.team, created_by=self.user, name="项目甲-person", asset_type=Asset.Type.IMAGE, + source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON, + ) + AssetFile.objects.create(asset=self.portrait, object_key="p.png", bucket="b", content_type="image/png", preview_url="http://x/p.png", is_primary=True) + + def _patch_provider(self): + provider = patch("apps.ai.services.get_image_provider").start() + prov = provider.return_value + prov.image_edit.return_value = {"data": [{"url": "http://x/tri.png"}]} + prov.extract_first_media_url.return_value = "http://x/tri.png" + media = patch("apps.ai.services.VolcanoArkProvider.media_to_bytes").start() + media.return_value = (BytesIO(b"img"), "image/png") + store = patch("apps.ai.services.TosStorage").start() + stored = store.return_value.upload_fileobj.return_value + stored.object_key, stored.bucket, stored.content_type, stored.size_bytes = "o.png", "b", "image/png", 3 + self.addCleanup(patch.stopall) + + def _gen_triview(self): + from apps.ai.services import generate_person_triview, run_triview_task + + task = generate_person_triview(project=self.project, user=self.user, portrait_asset=self.portrait) + run_triview_task(task_id=str(task.id)) + + def test_triview_auto_enrolls_model(self): + from apps.assets.models import Model + + self._patch_provider() + self._gen_triview() + m = Model.objects.filter(portrait_asset=self.portrait).first() + self.assertIsNotNone(m) + self.assertIsNotNone(m.triview_asset) # 成套 + self.assertTrue(m.metadata.get("auto_enrolled")) + self.assertEqual(m.source, Model.Source.AI) + # 合规(期3):三视图归 tri_view → 落在送审范围内 + self.assertEqual(m.triview_asset.category, Asset.Category.TRI_VIEW) + self.assertIn(m.triview_asset.category, Asset.REVIEW_CATEGORIES) + + def test_auto_enroll_is_idempotent(self): + from apps.assets.models import Model + + self._patch_provider() + self._gen_triview() + self._gen_triview() # 同一立绘再出一版三视图 + self.assertEqual(Model.objects.filter(portrait_asset=self.portrait).count(), 1) + + class _FakeStreamResp: """模拟 requests 流式响应:支持 with、raise_for_status、可写 encoding、iter_lines。""" status_code = 200 diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 5a1e7dd..5bb80d3 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -52,9 +52,9 @@ def _tab_q(tab: str) -> Q: return Q(category="free_create") if tab == "uploads": return Q(category="upload") - if tab == "finals": # final_video,或「未归类但是视频」 - return Q(category="final_video") | (~Q(category__in=_KNOWN_CATS) & Q(asset_type="video")) - if tab == "unclassified": # 未归类且非视频 + if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列) + return ~Q(category="final_video") & Q(asset_type="video") + if tab == "unclassified": # 未归类且非视频(也不含最终成片) return ~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video") return Q() @@ -110,7 +110,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): def summary(self, request): """资产库 tab 计数(人物/场景/商品图/成片/我的上传/未分类),供 tab 徽标——不必取全量。""" base = Asset.objects.filter(team=self.get_team()) - tabs = ["people", "scenes", "products", "tryon", "kits", "creations", "finals", "uploads", "unclassified"] + tabs = ["people", "scenes", "products", "tryon", "kits", "creations", "materials", "uploads", "unclassified"] return Response({t: base.filter(_tab_q(t)).count() for t in tabs}) @action(detail=False, methods=["get"]) diff --git a/core/frontend/src/components/actor-library.tsx b/core/frontend/src/components/actor-library.tsx index 1fe94df..d16e682 100644 --- a/core/frontend/src/components/actor-library.tsx +++ b/core/frontend/src/components/actor-library.tsx @@ -2,15 +2,29 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { CSSProperties } from "react"; import { api } from "../api"; -import type { Asset } from "../types"; +import type { Asset, ModelEntity } from "../types"; import { useBodyScrollLock, MediaLightbox } from "./overlays"; -// 流程步骤4 · 演员库:平台预设演员 + 我的演员(本地上传/AI 生成)。 -// 两种用法:browse(纯浏览 / 添加演员)与 replace(从演员库选一个回填到某基础资产卡)。 +// 流程步骤4 · 演员库(期3 收编):角色可「引用模特库」—— 顶部「模特库」tab 取自模特库实体(含官方跨团队模特), +// 选一个即用其形象图作该角色的参考/回填;「我的演员」= 本项目未入库的 person 资产。 +// 两种用法:browse(纯浏览 / 添加演员)与 replace(从库里选一个回填到某基础资产卡)。 const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties); const previewOf = (a: Asset): string => a.files?.find((f) => f.is_primary)?.preview_url || a.files?.[0]?.preview_url || ""; -// 平台预设 = 模特库生成的(metadata.kind==="model")或系统来源;其余 person 资产归「我的演员」 -const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model" || a.source === "system" || a.source === "ai_generated"; +// 模特库实体 → Asset 形(id=形象图资产 id,选中即作角色参考图);标 kind=model 归「模特库」tab +const modelToAsset = (m: ModelEntity): Asset => ({ + id: m.portrait_asset as string, + name: m.name, + asset_type: "image", + source: "ai_generated", + category: "person", + description: "", + metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official }, + files: m.portrait ? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }] : [], + created_at: m.created_at, + updated_at: m.updated_at +}); +// 模特库 = 模特库实体(metadata.kind==="model");其余 person 资产归「我的演员」 +const isPreset = (a: Asset): boolean => (a.metadata?.kind as string) === "model"; export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPick, onGenerate, onUpload, onGenerateTriview, onRename, onRefresh }: { open: boolean; @@ -57,8 +71,14 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic // 否则平台预设/我的演员全空。打开时拉一页(团队 person 资产),保存后 reload 让新人物即时入列。 const [fetched, setFetched] = useState([]); const reload = useCallback(async () => { - const res = await api.assetsPage({ category: "person", pageSize: 200, ordering: "-created_at" }).catch(() => null); - setFetched(res?.results ?? []); + // 期3:同时拉「项目 person 资产」+「模特库实体」。模特放后面 → 按 id 去重时模特胜出, + // 同一张形象图(既是 person 资产又被模特库引用)归「模特库」tab,可作角色参考。 + const [persons, models] = await Promise.all([ + api.assetsPage({ category: "person", pageSize: 200, ordering: "-created_at" }).catch(() => null), + api.listModels({ pageSize: 200 }).catch(() => null) + ]); + const mapped = (models?.results ?? []).filter((m) => m.portrait_asset).map(modelToAsset); + setFetched([...(persons?.results ?? []), ...mapped]); }, []); useEffect(() => { if (open) void reload(); }, [open, reload]); @@ -228,7 +248,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
- +
@@ -266,7 +286,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic ) : (
- // {tab === "preset" ? "暂无平台预设演员" : "还没有自己的演员 · 点右上「添加演员」"} + // {tab === "preset" ? "模特库暂无模特 · 去「模特库」页添加" : "还没有自己的演员 · 点右上「添加演员」"}
)}
diff --git a/core/frontend/src/routes/library.tsx b/core/frontend/src/routes/library.tsx index fcf6532..905ec5c 100644 --- a/core/frontend/src/routes/library.tsx +++ b/core/frontend/src/routes/library.tsx @@ -13,7 +13,7 @@ const LIB_PAGE_SIZE = 10; // asset.source / asset.asset_type / asset.category → 中文标签(筛选下拉 + 卡片 meta 用,不给用户看裸枚举) const SOURCE_LABELS: Record = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" }; const KIND_LABELS: Record = { image: "图片", video: "视频", audio: "音频", subtitle: "字幕", document: "文档" }; -const CATEGORY_LABELS: Record = { person: "人物", scene: "场景", product_image: "商品图", video_clip: "视频片段", final_video: "成片", upload: "上传", uncategorized: "未分类" }; +const CATEGORY_LABELS: Record = { person: "角色", scene: "场景", product_image: "商品图", model_tryon: "模特上身图", platform_kit: "平台套图", free_create: "自由创作", video_clip: "视频素材", final_video: "最终成片", upload: "上传", uncategorized: "未分类" }; // 上传文件 → 资产类型:按 MIME 推断,字幕按后缀,兜底文档(后端直接存这个值,不能一律写死 image) function inferAssetType(file: File): string { @@ -35,12 +35,13 @@ const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint: ]; // 图片趴三类(期2):tryon=模特上身图 / kits=平台套图 / creations=自由创作 -type LibTab = "people" | "scenes" | "products" | "tryon" | "kits" | "creations" | "finals" | "uploads" | "unclassified"; +// 期3:人物→角色;成片 tab→素材(视频素材,排除最终成片;最终成片隐藏不列) +type LibTab = "people" | "scenes" | "products" | "tryon" | "kits" | "creations" | "materials" | "uploads" | "unclassified"; const LIB_TABS: Array<{ key: LibTab; label: string }> = [ - { key: "people", label: "人物" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" }, + { key: "people", label: "角色" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" }, { key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "creations", label: "自由创作" }, - { key: "finals", label: "成片" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" } + { key: "materials", label: "素材" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" } ]; // 对齐 api-bridge:工具栏 chip 按 tab 显隐 @@ -50,8 +51,8 @@ const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [ { key: "role", label: "角色标签", tabs: ["people"] }, { key: "sceneType", label: "场景类型", tabs: ["scenes"] }, { key: "product", label: "关联商品", tabs: ["products", "tryon", "kits"] }, - { key: "project", label: "关联项目", tabs: ["finals"] }, - { key: "duration", label: "时长", tabs: ["finals"] }, + { key: "project", label: "关联项目", tabs: ["materials"] }, + { key: "duration", label: "时长", tabs: ["materials"] }, { key: "kind", label: "资产类型", tabs: ["uploads"] }, { key: "source", label: "来源", tabs: ["people", "scenes", "products", "tryon", "kits", "creations", "uploads"] } ]; @@ -427,7 +428,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD // ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)── const [items, setItems] = useState([]); const [total, setTotal] = useState(0); - const [counts, setCounts] = useState>({ people: 0, scenes: 0, products: 0, tryon: 0, kits: 0, creations: 0, finals: 0, uploads: 0, unclassified: 0 }); + const [counts, setCounts] = useState>({ people: 0, scenes: 0, products: 0, tryon: 0, kits: 0, creations: 0, materials: 0, uploads: 0, unclassified: 0 }); const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record }>({ sources: [], kinds: [], metadata: {} }); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); @@ -522,7 +523,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD

资产库

-
// 跨项目复用 · {counts.people} 人 · {counts.scenes} 景 · {counts.products} 商 · {counts.finals}
+
// 跨项目复用 · {counts.people} 角色 · {counts.scenes} 景 · {counts.products} 商 · {counts.materials} 素材