feat: 生图模型可选(火山/gpt-image) + 工作台图「加入资产库」收纳 + 任务中心排序修复
- AI 工作室生图支持显式选模型:resolve_image_model 解析 volcano(Seedream 图生图, 无 image_edit 时走 image_generation 带参考图)/ gpt-image(image_edit 多图编辑), 未选回落系统默认;enqueue_standalone_images 透传 image_model - 资产库收纳:Asset 加 in_library 字段(迁移 0007,既有资产 db_default=True 不动); 工作台生成图默认 in_library=False,只在工作台展示,用户「加入资产库」后才进库列表; assets 视图/序列化器/library 页/types 配套 - 任务中心修复:AITaskViewSet 默认 order_by(-created_at),前端 aiTasks 取 page_size=200。 原因:列表无排序时 MySQL 按 UUID 主键乱序返回,把一批旧失败记录顶到首页, 前端又只取首页 20 条算 全部/已完成/失败 → 误显示「全部失败」(实为 421 成功/少量失败) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,28 @@ def get_default_model(capability: str) -> ModelConfig:
|
||||
return qs.filter(is_default=True).order_by("created_at").first() or qs.order_by("created_at").first()
|
||||
|
||||
|
||||
def resolve_image_model(key: str | None) -> "ModelConfig | None":
|
||||
"""前端「生图模型选择」→ ModelConfig。用户显式选的可以是 disabled 模型(故不按 status 过滤)。
|
||||
· "volcano" → 火山官方 Seedream(取最新一版)
|
||||
· "gpt-image"→ gpt-image-2(优先 active provider 的那个)
|
||||
· "provider:name" 或裸 name → 精确匹配
|
||||
解析不到返回 None,调用方回落 get_default_model。"""
|
||||
if not key:
|
||||
return None
|
||||
qs = ModelConfig.objects.select_related("provider").filter(capability=ModelConfig.Capability.IMAGE)
|
||||
if key == "volcano":
|
||||
# 火山 Seedream:优先版本号最高的(seedream-5 > seedream-4),按 name 倒序
|
||||
vqs = qs.filter(provider__name__in=OFFICIAL_DIRECT_PROVIDERS)
|
||||
return vqs.filter(name__icontains="seedream").order_by("-name").first() or vqs.order_by("-name").first()
|
||||
if key in ("gpt-image", "gpt-image-2"):
|
||||
return (qs.filter(name__icontains="gpt-image", provider__status="active").first()
|
||||
or qs.filter(name__icontains="gpt-image").first())
|
||||
if ":" in key:
|
||||
pname, mname = key.split(":", 1)
|
||||
return qs.filter(provider__name=pname, name=mname).first()
|
||||
return qs.filter(name=key).first()
|
||||
|
||||
|
||||
# 火山官方直连(SeeDream 生图 / Seedance 视频 / 豆包文本)走 ARK SDK;其余 provider 一律
|
||||
# 视为「OpenAI 兼容中转站」走通用适配器。加/换中转站 = DB 加一行 ModelProvider,零改代码。
|
||||
# 注意:DB 里火山 provider 实际命名为 "volcengine"(豆包),必须包含,否则会被错路由到中转站。
|
||||
@@ -1976,7 +1998,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
|
||||
continue
|
||||
|
||||
|
||||
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None) -> list[AITask]:
|
||||
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None) -> list[AITask]:
|
||||
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
||||
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
||||
|
||||
@@ -1986,7 +2008,8 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
||||
from apps.ai.tasks import generate_standalone_image_task
|
||||
|
||||
_reap_stale_standalone_image_tasks(team=team)
|
||||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||||
# 用户在工作室选的生图模型(火山 / gpt-image)优先;未选或解析不到则回落系统默认
|
||||
model_config = resolve_image_model(image_model) or get_default_model(ModelConfig.Capability.IMAGE)
|
||||
if model_config is None:
|
||||
raise ValueError("no active image model configured")
|
||||
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
|
||||
@@ -2060,30 +2083,35 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
# 模特上身图:真实上传图优先、排除 AI 生成图,可多张(多角度更易锁外形/品牌)
|
||||
product_urls = _product_reference_urls(product, limit=3) if product is not None else []
|
||||
|
||||
# 参考图收集与 provider 无关:先把「该用哪些参考图 + 哪条提示词」定下来,再按模型能力选调用方式。
|
||||
edit_images: list[str] = []
|
||||
edit_prompt = ""
|
||||
if mode == "model" and can_edit and product_urls:
|
||||
if mode == "model" and product_urls:
|
||||
# 模特上身图:参考图1~N=商品真实图(多角度),参考图N+1=模特(模特图可缺则让模型自取真人模特)
|
||||
edit_images = product_urls + ([model_url] if model_url else [])
|
||||
edit_prompt = build_model_tryon_prompt_refs(
|
||||
product, has_model=bool(model_url), base_prompt=prompt,
|
||||
index=index, n_product=len(product_urls),
|
||||
)
|
||||
elif mode == "cover" and can_edit and product_url:
|
||||
elif mode == "cover" and product_url:
|
||||
# 平台套图:参考图1=商品真实主图(锁包装一致性),有模特则参考图2=模特(锁人脸/身形)
|
||||
edit_images = [product_url] + ([model_url] if model_url else [])
|
||||
edit_prompt = build_platform_cover_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt)
|
||||
elif bool(payload.get("reference_product")) and can_edit and product_url:
|
||||
elif bool(payload.get("reference_product")) and product_url:
|
||||
edit_images = [product_url]
|
||||
edit_prompt = build_product_triview_prompt_refs(product, "")
|
||||
use_edit = bool(edit_images)
|
||||
try:
|
||||
if use_edit:
|
||||
if use_edit and can_edit:
|
||||
# gpt-image 等支持 image_edit(多图参考编辑接口)
|
||||
if payload.get("reference_product"):
|
||||
size = "1536x1024" # 三视图固定横向
|
||||
else:
|
||||
size = _ratio_to_image_size(str(payload.get("ratio") or "")) # 模特图按选中比例
|
||||
response = provider.image_edit(model=model_config.name, prompt=edit_prompt, images=edit_images, size=size)
|
||||
elif use_edit:
|
||||
# 火山 Seedream 无 image_edit:走 image_generation 带 image=参考图(图生图多参考),size 用 2K
|
||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=edit_prompt, image=edit_images, size="2K")
|
||||
else:
|
||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
|
||||
media = provider.extract_first_media_url(response)
|
||||
@@ -2112,6 +2140,8 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
|
||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task,
|
||||
metadata=asset_meta,
|
||||
# 工作台生成的图默认不进资产库列表,只在工作台展示;用户「加入资产库」后才置 True
|
||||
in_library=False,
|
||||
)
|
||||
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
|
||||
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||||
|
||||
@@ -34,9 +34,10 @@ class GenerateImageView(APIView):
|
||||
model_id = str(request.data.get("model_id") or "").strip() or None
|
||||
model_entity_id = str(request.data.get("model_entity_id") or "").strip() or None
|
||||
ratio = str(request.data.get("ratio") or "").strip() or None
|
||||
image_model = str(request.data.get("image_model") or "").strip() or None
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio)
|
||||
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model)
|
||||
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
@@ -69,7 +70,10 @@ class GenerateImageView(APIView):
|
||||
class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
# 序列化器不含 request_payload/response_payload(单条可达 3MB+ base64 图),defer 掉:
|
||||
# 否则只为序列化 14 个小字段也会把几十 MB blob 从库里拉回(远程库实测 40 条要 30s+)。
|
||||
queryset = AITask.objects.select_related("team", "project", "model_config", "model_config__provider").defer("request_payload", "response_payload").all()
|
||||
# 默认按创建时间倒序:任务中心 = 历史流水,最新的(多为成功)排最前。
|
||||
# 缺省排序时 MySQL 按主键(UUID)乱序返回,会把一批旧失败记录顶到首页,
|
||||
# 前端只取首页 → 误判「全部失败」。order_by 保证稳定且新任务优先。
|
||||
queryset = AITask.objects.select_related("team", "project", "model_config", "model_config__provider").defer("request_payload", "response_payload").order_by("-created_at")
|
||||
serializer_class = AITaskSerializer
|
||||
search_fields = ["idempotency_key", "provider_task_id", "project__name"]
|
||||
ordering_fields = ["created_at", "updated_at", "completed_at"]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.15 on 2026-06-27 02:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("assets", "0006_alter_asset_category_model"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="asset",
|
||||
name="in_library",
|
||||
field=models.BooleanField(db_default=True, default=True),
|
||||
),
|
||||
]
|
||||
@@ -51,6 +51,10 @@ class Asset(TeamOwnedModel):
|
||||
)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
# 是否进「资产库」列表:工作台生成的图(模特上身图/平台套图/自由创作)默认 False(只在工作台展示),
|
||||
# 用户「加入资产库」后才置 True 出现在资产库列表;「取消加入」置回 False 即从列表移出。
|
||||
# 字段默认 True 是为了让历史数据 / 上传 / 项目内资产保持原样可见(只对新生成的工作台图显式置 False)。
|
||||
in_library = models.BooleanField(default=True, db_default=True)
|
||||
# 火山人像素材库审核(仅真人 person 资产):"" = 未送审 / processing 审核中 / active 绿盾通过 / failed 红标(需改提示词重生)
|
||||
review_status = models.CharField(max_length=16, blank=True, db_default="")
|
||||
review_remote_id = models.CharField(max_length=128, blank=True, db_default="")
|
||||
|
||||
@@ -87,6 +87,7 @@ class AssetSerializer(serializers.ModelSerializer):
|
||||
"description",
|
||||
"metadata",
|
||||
"is_deleted",
|
||||
"in_library",
|
||||
"origin_task",
|
||||
"product",
|
||||
"files",
|
||||
@@ -95,7 +96,8 @@ class AssetSerializer(serializers.ModelSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "review_status", "review_error", "created_at", "updated_at"]
|
||||
# in_library 只读:增删资产库走专用 set-library 接口,不允许普通 PATCH 直接改
|
||||
read_only_fields = ["id", "in_library", "review_status", "review_error", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class AssetUploadSerializer(serializers.Serializer):
|
||||
|
||||
@@ -141,6 +141,39 @@ class VideoPacksTests(TestCase):
|
||||
self.assertEqual(len(data[0]["clips"]), 1)
|
||||
|
||||
|
||||
class AssetBatchesTests(TestCase):
|
||||
"""图片趴(模特上身图)按生成批次成组:同 metadata.batch_id 的多张图 = 一批;
|
||||
无 batch_id 的旧图各自成单图批。summary 也按批次计数。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user, self.team = _mk_team("ubt", "TeamBT")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
# 一批 3 张(同 batch_id)
|
||||
for i in range(3):
|
||||
Asset.objects.create(
|
||||
team=self.team, name=f"AI 生成 · 模特上身图 · {i + 1}", asset_type="image",
|
||||
source="ai_generated", category=Asset.Category.MODEL_TRYON, metadata={"batch_id": "B1"},
|
||||
)
|
||||
# 一张旧图(无 batch_id)→ 自己成一批
|
||||
Asset.objects.create(
|
||||
team=self.team, name="AI 生成 · 模特上身图 · 1", asset_type="image",
|
||||
source="ai_generated", category=Asset.Category.MODEL_TRYON,
|
||||
)
|
||||
|
||||
def test_batches_grouped(self):
|
||||
data = self.client.get("/api/assets/batches/?tab=tryon&page_size=10").json()
|
||||
self.assertEqual(data["count"], 2) # 一批 3 张 + 一张单图批
|
||||
by_count = sorted(b["count"] for b in data["results"])
|
||||
self.assertEqual(by_count, [1, 3])
|
||||
big = next(b for b in data["results"] if b["count"] == 3)
|
||||
self.assertEqual(big["name"], "AI 生成 · 模特上身图") # 去掉「· 序号」
|
||||
self.assertEqual(len(big["items"]), 3)
|
||||
|
||||
def test_summary_counts_batches(self):
|
||||
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 2)
|
||||
|
||||
|
||||
class SubmitReviewTests(TestCase):
|
||||
"""手动兜底:灰盾点击 → 提交审核;只对送审类放行,非送审类 400。"""
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
@@ -61,6 +62,27 @@ def _tab_q(tab: str) -> Q:
|
||||
return Q()
|
||||
|
||||
|
||||
# 图片趴三类按「生成批次」成组展示:同一次提交的 N 张图共享 metadata.batch_id。
|
||||
BATCH_TABS = {"tryon", "kits", "creations"}
|
||||
|
||||
# 资产名形如「AI 生成 · 模特上身图 · 4」,去掉尾部的「· 序号」得到批次名「AI 生成 · 模特上身图」。
|
||||
_BATCH_NAME_RE = re.compile(r"\s*·\s*\d+\s*$")
|
||||
|
||||
|
||||
def _batch_key(batch_id, origin_task_id, asset_id) -> str:
|
||||
"""资产归批:优先 metadata.batch_id;旧图无 batch_id 时回落 origin_task,再无则自身成单图批。"""
|
||||
if batch_id:
|
||||
return f"batch:{batch_id}"
|
||||
if origin_task_id:
|
||||
return f"task:{origin_task_id}"
|
||||
return f"asset:{asset_id}"
|
||||
|
||||
|
||||
def _batch_name(name: str) -> str:
|
||||
cleaned = _BATCH_NAME_RE.sub("", name or "").strip()
|
||||
return cleaned or (name or "")
|
||||
|
||||
|
||||
class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1)。
|
||||
# ★ defer 掉 AITask 的两个巨型 JSON 列(request/response payload):列表序列化只需 project.product_id,
|
||||
@@ -82,6 +104,10 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
参数:tab(资产库分桶)/category/asset_type/source/product/q(搜索)/m_<key>(metadata 过滤)/ordering。
|
||||
默认按 -created_at 排序——无 ORDER BY 时分页会重/漏。"""
|
||||
qs = super().get_queryset().filter(is_deleted=False) # 软删资产不出现在资产库
|
||||
# 资产库列表/批次只展示「已加入资产库」的资产(in_library=True);未加入的工作台生成图不出现在这里。
|
||||
# 仅对列表型 action 过滤——retrieve / set-library / submit-review 等仍要能取到未加入的资产。
|
||||
if self.action in ("list", "batches"):
|
||||
qs = qs.filter(in_library=True)
|
||||
p = self.request.query_params
|
||||
if p.get("tab"):
|
||||
qs = qs.filter(_tab_q(p["tab"]))
|
||||
@@ -110,11 +136,63 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def summary(self, request):
|
||||
"""资产库 tab 计数(人物/场景/商品图/成片/我的上传/未分类),供 tab 徽标——不必取全量。"""
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False)
|
||||
# 资产库成品化:对外只数成品三类 + 其他(角色/场景/商品图各回各库,视频成品走 video-packs)
|
||||
tabs = ["tryon", "kits", "creations", "others"]
|
||||
return Response({t: base.filter(_tab_q(t)).count() for t in tabs})
|
||||
"""资产库 tab 计数,供 tab 徽标——不必取全量。
|
||||
图片趴三类(模特上身图/平台套图/自由创作)按「生成批次」计数(与卡片成组展示一致);
|
||||
其他(我的上传 + 兜底)仍按资产张数计。"""
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
|
||||
out = {}
|
||||
for t in BATCH_TABS: # 图片趴:数批次
|
||||
seen = set()
|
||||
rows = base.filter(_tab_q(t)).values("id", "origin_task_id", "metadata")
|
||||
for row in rows:
|
||||
seen.add(_batch_key((row["metadata"] or {}).get("batch_id"), row["origin_task_id"], row["id"]))
|
||||
out[t] = len(seen)
|
||||
out["others"] = base.filter(_tab_q("others")).count()
|
||||
return Response(out)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def batches(self, request):
|
||||
"""图片趴(模特上身图/平台套图/自由创作)按生成批次成组:同一次提交的 N 张图
|
||||
共享 metadata.batch_id = 一批;无 batch_id 的旧图回落 origin_task / 自身成单图批。
|
||||
复用 get_queryset 的 tab/source/q/m_* 过滤 + ordering,按「批」分页返回,每批含封面 + 整批资产。"""
|
||||
from apps.assets.serializers import _asset_preview
|
||||
|
||||
qs = self.get_queryset() # team scope + tab/source/q/meta 过滤 + ordering + defer
|
||||
groups: dict[str, list] = {}
|
||||
order: list[str] = [] # 保留资产 ordering 决定的批次先后(尊重最近/最早排序)
|
||||
for asset in qs:
|
||||
key = _batch_key((asset.metadata or {}).get("batch_id"), asset.origin_task_id, asset.id)
|
||||
bucket = groups.get(key)
|
||||
if bucket is None:
|
||||
groups[key] = bucket = []
|
||||
order.append(key)
|
||||
bucket.append(asset)
|
||||
|
||||
try:
|
||||
page = max(1, int(request.query_params.get("page", 1)))
|
||||
except (TypeError, ValueError):
|
||||
page = 1
|
||||
try:
|
||||
page_size = min(200, max(1, int(request.query_params.get("page_size", 20))))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 20
|
||||
|
||||
total = len(order)
|
||||
start = (page - 1) * page_size
|
||||
ctx = self.get_serializer_context()
|
||||
results = []
|
||||
for key in order[start:start + page_size]:
|
||||
assets = groups[key]
|
||||
first = assets[0]
|
||||
results.append({
|
||||
"batch_id": key,
|
||||
"name": _batch_name(first.name),
|
||||
"count": len(assets),
|
||||
"cover": _asset_preview(first),
|
||||
"created_at": first.created_at,
|
||||
"items": AssetSerializer(assets, many=True, context=ctx).data,
|
||||
})
|
||||
return Response({"count": total, "results": results})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="submit-review")
|
||||
def submit_review(self, request, pk=None):
|
||||
@@ -136,6 +214,17 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
)
|
||||
return Response({"review_status": asset.review_status, "review_error": asset.review_error or ""})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="set-library")
|
||||
def set_library(self, request, pk=None):
|
||||
"""单图「加入资产库 / 取消加入」:置 in_library。加入 → 出现在资产库列表;取消 → 从列表移出。
|
||||
注:资产在生成时已落库并扣费,此口只控制是否进「资产库」展示列表,不增删真实资产、不二次扣费。"""
|
||||
asset = self.get_object() # team-scoped;此 action 不过滤 in_library,故未加入的图也能取到
|
||||
in_lib = bool(request.data.get("in_library", True))
|
||||
if asset.in_library != in_lib:
|
||||
asset.in_library = in_lib
|
||||
asset.save(update_fields=["in_library"])
|
||||
return Response({"id": str(asset.id), "in_library": asset.in_library})
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="video-packs")
|
||||
def video_packs(self, request):
|
||||
"""视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源:
|
||||
@@ -180,7 +269,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def facets(self, request):
|
||||
"""某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键的取值),供下拉「只列真有的」。
|
||||
参数:tab、meta_keys=gender,age,role,...(逗号分隔)。"""
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False)
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
|
||||
if request.query_params.get("tab"):
|
||||
base = base.filter(_tab_q(request.query_params["tab"]))
|
||||
sources = sorted(s for s in base.values_list("source", flat=True).distinct() if s)
|
||||
|
||||
Reference in New Issue
Block a user