feat(video): 期3 视频趴 — 人物→角色 + 引用模特库/自动入库 + 分镜合规归类 + 资产库素材
后端: - B 路:视频流程新生成角色立绘+三视图成套 → 自动入模特库(run_triview_task,幂等 additive) - 合规闭合:三视图 person→tri_view、分镜图 scene→storyboard,两者均加 on_commit 送审(此前只有角色立绘送审,三视图/分镜图漏审 = 合规缺口) - 资产库 _tab_q:成片 finals→素材 materials(所有视频排除最终成片;最终成片隐藏不列) - 单测 TriviewAutoEnrollTests:自动入库 + 幂等 + 三视图落审核范围 前端: - 视频项目资产趴「人物」→「角色」(KIND_LABEL,区块名/新增/空态/详情弹窗一处全改;向导人设 persona 不动) - A 路:演员库收编模特库 —— 顶部「模特库」tab 取自模特库实体(含官方跨团队模特),选一个即用其形象图作角色参考 - 资产库 library:人物→角色、成片 tab→素材、分类标签补全(模特上身图/平台套图/自由创作/视频素材/最终成片) - vite.config:端口与后端代理目标可用 VITE_PORT/VITE_API_TARGET 覆盖(默认不变),避免多实例撞车 验收:后端单测全绿(基线既有 7 失败零新增);tsc+build 全绿;无头 0 console error (资产库角色/素材改名、最终成片隐藏;pipeline 资产趴显角色;演员库模特库 tab 含 5 模特) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c8f3f91d38
commit
c6e9c7e77b
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user