本轮(生图模型选择 + 火山接入): - 工作室新增「生图模型」选择器(模特上身图/平台套图头部 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)
206 lines
8.7 KiB
Python
206 lines
8.7 KiB
Python
from django.db import models
|
|
|
|
from apps.common.models import TeamOwnedModel, TimeStampedModel
|
|
|
|
|
|
class ModelProvider(TimeStampedModel):
|
|
class Status(models.TextChoices):
|
|
ACTIVE = "active", "Active"
|
|
DISABLED = "disabled", "Disabled"
|
|
|
|
name = models.CharField(max_length=64, unique=True)
|
|
display_name = models.CharField(max_length=128)
|
|
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
|
|
base_url = models.URLField(blank=True)
|
|
# 站级 API Key(中转站)。可插拔:换站 = 改这一行的 base_url + api_key,零改代码。
|
|
# 留空则 services 层按 provider.name 回退 settings.PROVIDER_KEYS(.env),避免密钥写死/进库。
|
|
api_key = models.CharField(max_length=255, blank=True)
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
def __str__(self) -> str:
|
|
return self.display_name
|
|
|
|
|
|
class ModelConfig(TimeStampedModel):
|
|
class Capability(models.TextChoices):
|
|
TEXT = "text", "Text"
|
|
IMAGE = "image", "Image"
|
|
VIDEO = "video", "Video"
|
|
VISION = "vision", "Vision"
|
|
AUDIO = "audio", "Audio"
|
|
EXPORT = "export", "Export"
|
|
|
|
class Status(models.TextChoices):
|
|
ACTIVE = "active", "Active"
|
|
DISABLED = "disabled", "Disabled"
|
|
|
|
provider = models.ForeignKey(ModelProvider, on_delete=models.CASCADE, related_name="models")
|
|
name = models.CharField(max_length=128)
|
|
display_name = models.CharField(max_length=128)
|
|
capability = models.CharField(max_length=32, choices=Capability.choices)
|
|
endpoint = models.CharField(max_length=255, blank=True)
|
|
unit_price = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
|
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
|
|
rate_limit_per_minute = models.PositiveIntegerField(default=60)
|
|
# 平台超管显式钦定的「该能力默认模型」。get_default_model 优先取它,未设则回落最早 active(零回归)。
|
|
is_default = models.BooleanField(default=False)
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
unique_together = [("provider", "name", "capability")]
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.provider.name}:{self.name}:{self.capability}"
|
|
|
|
|
|
class ImageConversation(TeamOwnedModel):
|
|
"""图片创作工作室的「对话」实体:一条对话 = 一个生图会话线程,把多次生成(AITask)串起来。
|
|
左栏会话列表、切换、重命名、历史都基于本表。删除走软删(is_deleted),不连带删图——
|
|
成图仍在资产库里。mode 区分图片创作 / 模特上身图 / 平台套图三种工作台。"""
|
|
|
|
class Mode(models.TextChoices):
|
|
IMAGE = "image", "图片创作"
|
|
MODEL = "model", "模特上身图"
|
|
COVER = "cover", "平台套图"
|
|
|
|
title = models.CharField(max_length=120, default="默认创作")
|
|
mode = models.CharField(max_length=16, choices=Mode.choices, default=Mode.IMAGE)
|
|
product = models.ForeignKey(
|
|
"products.Product",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="image_conversations",
|
|
)
|
|
is_deleted = models.BooleanField(default=False)
|
|
# 每次在该对话里发起生成都刷新,左栏「最近」按它倒序
|
|
last_active_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
indexes = [
|
|
models.Index(fields=["team", "mode", "-last_active_at"]),
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
return f"conv:{self.mode}:{self.title}"
|
|
|
|
|
|
class AITask(TeamOwnedModel):
|
|
class Type(models.TextChoices):
|
|
SCRIPT_GENERATION = "script_generation", "Script Generation"
|
|
SCRIPT_OPTIMIZATION = "script_optimization", "Script Optimization"
|
|
ENTITY_EXTRACTION = "entity_extraction", "Entity Extraction"
|
|
PRODUCT_IMAGE = "product_image", "Product Image"
|
|
PERSON_IMAGE = "person_image", "Person Image"
|
|
SCENE_IMAGE = "scene_image", "Scene Image"
|
|
STORYBOARD = "storyboard", "Storyboard"
|
|
VIDEO_SEGMENT = "video_segment", "Video Segment"
|
|
VOICEOVER = "voiceover", "Voiceover"
|
|
EXPORT = "export", "Export"
|
|
|
|
class Status(models.TextChoices):
|
|
CREATED = "created", "Created"
|
|
RESERVED = "reserved", "Reserved"
|
|
SUBMITTED = "submitted", "Submitted"
|
|
POLLING = "polling", "Polling"
|
|
POSTPROCESSING = "postprocessing", "Postprocessing"
|
|
SUCCEEDED = "succeeded", "Succeeded"
|
|
FAILED = "failed", "Failed"
|
|
COMPENSATING = "compensating", "Compensating"
|
|
CANCELLED = "cancelled", "Cancelled"
|
|
|
|
project = models.ForeignKey(
|
|
"projects.Project",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name="ai_tasks",
|
|
)
|
|
# 图片创作工作室的对话归属(独立生图任务才挂;流水线内部任务为空)。删对话不删任务。
|
|
conversation = models.ForeignKey(
|
|
ImageConversation,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="tasks",
|
|
)
|
|
task_type = models.CharField(max_length=48, choices=Type.choices)
|
|
status = models.CharField(max_length=32, choices=Status.choices, default=Status.CREATED)
|
|
model_config = models.ForeignKey(ModelConfig, on_delete=models.PROTECT, related_name="tasks")
|
|
idempotency_key = models.CharField(max_length=128, unique=True)
|
|
provider_task_id = models.CharField(max_length=255, blank=True)
|
|
request_payload = models.JSONField(default=dict, blank=True)
|
|
response_payload = models.JSONField(default=dict, blank=True)
|
|
estimated_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
|
actual_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
|
error_code = models.CharField(max_length=64, blank=True)
|
|
error_message = models.TextField(blank=True)
|
|
submitted_at = models.DateTimeField(null=True, blank=True)
|
|
completed_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
indexes = [
|
|
models.Index(fields=["team", "status"]),
|
|
models.Index(fields=["project", "task_type"]),
|
|
models.Index(fields=["provider_task_id"]),
|
|
# 任务历史默认按团队 + 创建时间倒序(AI 工具页 / asset-factory)
|
|
models.Index(fields=["team", "-created_at"]),
|
|
# 按对话拉历史(图片创作工作室切换会话时回填批次)
|
|
models.Index(fields=["conversation", "created_at"]),
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.task_type}:{self.status}:{self.id}"
|
|
|
|
|
|
class QualityWord(TimeStampedModel):
|
|
"""平台单层质量词配置:按生成阶段(stage)+ 槽位(slot)挂若干质量词,
|
|
生成侧拼提示词时优先读取本配置;**无配置则回落各 builder 的写死值**(零回归)。
|
|
全平台共用一套(不挂 team),团队级覆盖以后再说。"""
|
|
|
|
class Stage(models.TextChoices):
|
|
PERSON = "person", "人物立绘"
|
|
MODEL_TRYON = "model_tryon", "模特上身图"
|
|
STORYBOARD = "storyboard", "故事板"
|
|
VIDEO = "video", "视频"
|
|
|
|
stage = models.CharField(max_length=24, choices=Stage.choices)
|
|
slot = models.CharField(max_length=24, default="quality")
|
|
text = models.CharField(max_length=200)
|
|
sort = models.IntegerField(default=0)
|
|
enabled = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["stage", "sort", "created_at"]
|
|
indexes = [models.Index(fields=["stage", "slot"])]
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.stage}/{self.slot}/{self.text}"
|
|
|
|
|
|
class PromptTemplate(TimeStampedModel):
|
|
"""平台可编辑的「生图/视频提示词模板」(视频项目生产线 6 条)。后台超管在 admin 改正文,
|
|
生成侧据 key 读取本表;留空/停用 → 回落各 builder 的写死默认(零回归)。
|
|
正文里 {占位符}(如 {商品}/{描述}/{脚本})运行时替换;未知占位符原样保留,改错不致命。
|
|
ratio:成品比例(空=按写死默认),gpt-image 仅 1:1 / portrait(竖) / landscape(横)三种。"""
|
|
|
|
class Key(models.TextChoices):
|
|
PERSON_PORTRAIT = "person_portrait", "人物立绘"
|
|
PERSON_TRIVIEW = "person_triview", "人物三视图"
|
|
PRODUCT_TRIVIEW = "product_triview", "商品三视图"
|
|
SCENE = "scene", "场景图"
|
|
STORYBOARD_FRAME = "storyboard_frame", "分镜图"
|
|
VIDEO_SEGMENT = "video_segment", "视频"
|
|
|
|
key = models.CharField(max_length=32, choices=Key.choices, unique=True)
|
|
template = models.TextField(blank=True, default="")
|
|
ratio = models.CharField(max_length=12, blank=True, default="") # "" / "1:1" / "portrait" / "landscape"
|
|
enabled = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["key"]
|
|
|
|
def __str__(self) -> str:
|
|
return f"prompt:{self.key}"
|
|
|