生图模型可选(火山/gpt-image)+ 模特上身图提示词强化 + 多会话等改动
本轮(生图模型选择 + 火山接入): - 工作室新增「生图模型」选择器(模特上身图/平台套图头部 chip + 图片创作底部 Pill), 默认火山 Seedream,可切 gpt-image-2;选择写入 localStorage,下次进页面读回 - 后端 resolve_image_model 解析所选模型;enqueue_standalone_images 接 image_model - worker 按模型能力分流:有 image_edit(gpt-image)走多图编辑;无(火山)走 image_generation(image=参考图);新增 _ratio_to_volcano_size 让火山按比例出图 → 内衣等敏感品类用火山可绕开 gpt-image 的 sexual 内容审核 模特上身图提示词: - 穿戴/非穿戴分流、按 index 变化动作场景镜头、负面词尾接、多图参考序号自适应 - _product_reference_urls:商品参考真实上传图优先、排除 AI 生成图、可多张 其他(并入此前各会话未提交改动): - 图片创作多会话(ImageConversation + migration 0019)、任务中心按类型过滤 - accounts/projects/assets/team/auth 等零散调整、相关测试 - 测试脚本(tryon_*.py)、测试清单(core/bug/*.xlsx)
This commit is contained in:
@@ -872,3 +872,82 @@ class BaseAssetAdoptDeleteTests(TestCase):
|
||||
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())
|
||||
|
||||
|
||||
class AttachOfficialModelTests(TestCase):
|
||||
"""ZWQ#4/#5 回归:从演员库挑「官方模特」(跨团队)新增/替换角色,旧版因 team 隔离报 asset not found
|
||||
且替换失败、还留下空角色组。修复后:官方模特形象图自动克隆进本团队并真正挂上,不再 404。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.assets.models import Asset, AssetFile, Model
|
||||
from apps.projects.models import BaseAssetGroup
|
||||
|
||||
self.user = User.objects.create_user(username="biz", password="x")
|
||||
self.team = Team.objects.create(name="商家团队", 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="口红")
|
||||
self.proj = Project.objects.create(team=self.team, name="带货视频", product=self.product, created_by=self.user)
|
||||
|
||||
# 官方模特属于「官方团队」(跨团队可见,但 portrait 资产不属本商家团队)——正是触发 bug 的根因。
|
||||
self.official_owner = User.objects.create_user(username="ops", password="x")
|
||||
self.official_team = Team.objects.create(name="官方", owner=self.official_owner)
|
||||
self.portrait = Asset.objects.create(
|
||||
team=self.official_team, created_by=self.official_owner, name="都市白领 林晚",
|
||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON,
|
||||
metadata={"kind": "model"},
|
||||
)
|
||||
AssetFile.objects.create(asset=self.portrait, object_key="m/linwan.png", bucket="b", content_type="image/png", preview_url="http://x/linwan.png", is_primary=True)
|
||||
self.model = Model.objects.create(
|
||||
team=self.official_team, created_by=self.official_owner, name="都市白领 林晚",
|
||||
is_official=True, portrait_asset=self.portrait,
|
||||
)
|
||||
self.BaseAssetGroup = BaseAssetGroup
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
def test_add_actor_from_official_model_clones_and_attaches(self):
|
||||
"""新增角色:无 group,传 kind+label,选官方模特 → 200,克隆进本团队并采用,不再 asset not found。"""
|
||||
from apps.assets.models import Asset
|
||||
|
||||
res = self.client.post(
|
||||
f"/api/projects/{self.proj.id}/attach-base-asset/",
|
||||
{"kind": "person", "label": "林晚", "asset_id": str(self.portrait.id)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200, res.content)
|
||||
group = self.BaseAssetGroup.objects.get(id=res.json()["id"])
|
||||
self.assertIsNotNone(group.adopted_asset_id)
|
||||
clone = Asset.objects.get(id=group.adopted_asset_id)
|
||||
# 克隆体真正落进本商家团队(不是官方团队),并共享同一份 TOS 对象。
|
||||
self.assertEqual(clone.team_id, self.team.id)
|
||||
self.assertNotEqual(clone.id, self.portrait.id)
|
||||
self.assertEqual(clone.files.first().object_key, "m/linwan.png")
|
||||
self.assertEqual(clone.metadata.get("cloned_from_asset"), str(self.portrait.id))
|
||||
|
||||
def test_replace_existing_group_with_official_model(self):
|
||||
"""替换:已有角色组,传 group_id + 官方模特 asset_id → 200,采用克隆体。"""
|
||||
from apps.assets.models import Asset
|
||||
|
||||
group = self.BaseAssetGroup.objects.create(project=self.proj, kind=self.BaseAssetGroup.Kind.PERSON, metadata={"label": "主播"})
|
||||
res = self.client.post(
|
||||
f"/api/projects/{self.proj.id}/attach-base-asset/",
|
||||
{"group_id": str(group.id), "asset_id": str(self.portrait.id)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200, res.content)
|
||||
group.refresh_from_db()
|
||||
clone = Asset.objects.get(id=group.adopted_asset_id)
|
||||
self.assertEqual(clone.team_id, self.team.id)
|
||||
|
||||
def test_unknown_asset_still_404_without_ghost_group(self):
|
||||
"""彻底不存在的 asset_id → 仍 404,且不留下空角色组(不再在 404 前提交建组)。"""
|
||||
import uuid as _uuid
|
||||
|
||||
before = self.BaseAssetGroup.objects.filter(project=self.proj).count()
|
||||
res = self.client.post(
|
||||
f"/api/projects/{self.proj.id}/attach-base-asset/",
|
||||
{"kind": "person", "label": "幽灵", "asset_id": str(_uuid.uuid4())},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(res.status_code, 404)
|
||||
self.assertEqual(self.BaseAssetGroup.objects.filter(project=self.proj).count(), before)
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.db.models import Count, Q
|
||||
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
@@ -105,6 +105,39 @@ def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str,
|
||||
return asset
|
||||
|
||||
|
||||
def _clone_asset_into_team(src: Asset, *, team, user) -> Asset:
|
||||
"""把跨团队可见的「官方模特」形象图克隆一份进目标团队:共享同一份 TOS 对象(只新建 Asset/AssetFile DB 行,
|
||||
不重新上传),使其成为本团队真实资产 —— 可被基础资产组采用、在「我的演员」里列出,不再因 team 隔离报 asset not found。
|
||||
去掉 metadata 里的 kind=model 标记,让克隆体作为普通「我的演员」person 资产,而不是被误判成模特库预设。"""
|
||||
src_meta = {k: v for k, v in (src.metadata or {}).items() if k != "kind"}
|
||||
clone = Asset.objects.create(
|
||||
team=team,
|
||||
created_by=user,
|
||||
name=src.name,
|
||||
asset_type=src.asset_type,
|
||||
source=src.source,
|
||||
category=src.category,
|
||||
description=src.description,
|
||||
metadata={**src_meta, "cloned_from_asset": str(src.id), "from_official_model": True},
|
||||
in_library=src.in_library,
|
||||
)
|
||||
for f in src.files.all():
|
||||
AssetFile.objects.create(
|
||||
asset=clone,
|
||||
object_key=f.object_key,
|
||||
bucket=f.bucket,
|
||||
content_type=f.content_type,
|
||||
size_bytes=f.size_bytes,
|
||||
checksum=f.checksum,
|
||||
width=f.width,
|
||||
height=f.height,
|
||||
duration_ms=f.duration_ms,
|
||||
preview_url=f.preview_url,
|
||||
is_primary=f.is_primary,
|
||||
)
|
||||
return clone
|
||||
|
||||
|
||||
def promote_base_asset_stage_if_ready(project: Project) -> bool:
|
||||
adopted_kind_count = (
|
||||
project.base_asset_groups.filter(adopted_asset__isnull=False).values("kind").distinct().count()
|
||||
@@ -492,8 +525,26 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
@transaction.atomic
|
||||
def attach_base_asset(self, request, pk=None):
|
||||
"""流程步骤4 · 用演员库现有资产替换某基础资产卡:把团队内现有 Asset 挂为该组候选并采用。
|
||||
seed 占位卡还没有 group,此时不传 group_id,改传 kind+label:按 label 命中同实体组,没有则据 label 建组再挂(不出图)。"""
|
||||
seed 占位卡还没有 group,此时不传 group_id,改传 kind+label:按 label 命中同实体组,没有则据 label 建组再挂(不出图)。
|
||||
所选资产可能是「官方模特」形象图(跨团队可见但不属本团队)→ 克隆一份进本团队再挂:避免 team 隔离导致
|
||||
明明选了官方模特却报 asset not found、替换失败。"""
|
||||
project = self.get_object()
|
||||
# 先把要挂的资产定下来(本团队资产,或官方模特形象图克隆),拿不到立即 404 —— 必须在建/找组「之前」,
|
||||
# 否则会留下一个采用资产为空的幽灵角色组(旧版本在 404 return 前就 create 了组,且 @atomic 正常 return 仍会提交)。
|
||||
asset_id = request.data.get("asset_id")
|
||||
asset = Asset.objects.filter(team=project.team, id=asset_id).first()
|
||||
if asset is None and asset_id:
|
||||
# 跨团队可见的官方模特形象图 / 三视图 → 克隆进本团队再挂
|
||||
official = (
|
||||
Asset.objects.filter(id=asset_id, is_deleted=False)
|
||||
.filter(Q(model_as_portrait__is_official=True) | Q(model_as_triview__is_official=True))
|
||||
.first()
|
||||
)
|
||||
if official is not None:
|
||||
asset = _clone_asset_into_team(official, team=project.team, user=request.user)
|
||||
if asset is None:
|
||||
return Response({"detail": "asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
group = None
|
||||
group_id = request.data.get("group_id")
|
||||
if group_id:
|
||||
@@ -517,9 +568,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
group_meta = {"label": label} if label else {}
|
||||
group = BaseAssetGroup.objects.create(project=project, kind=kind, metadata=group_meta)
|
||||
group = BaseAssetGroup.objects.select_for_update().get(id=group.id)
|
||||
asset = Asset.objects.filter(team=project.team, id=request.data.get("asset_id")).first()
|
||||
if asset is None:
|
||||
return Response({"detail": "asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
group.candidate_assets.add(asset)
|
||||
group.adopted_asset_id = asset.id
|
||||
group.save(update_fields=["adopted_asset", "updated_at"])
|
||||
|
||||
Reference in New Issue
Block a user