feat(core): 基础资产 Agent 化 — 商品三视图(image_edit 参考主图)+ 人物立绘/三视图异步生成 + 演员库/详情
- 商品三视图:有真实商品主图时走 image_edit,以主图为参考锁包装一致(品牌字/配色/外形/Logo) - 人物:据「某一版立绘」异步生成配套三视图(image_edit,worker 内跑),立绘/三视图各存版本可切换 - 基础资产出图改异步(run_base_asset_task / generate_triview_task),Web 层不被慢出图占住 - 前端:演员库浏览/新增、人物详情(立绘+三视图+版本切换/下载/查看大图)、按 busyKey 的单卡并发 loading(genBusy,替代全局 genBusyKey,各按钮互不阻塞) - dev:本地连云端 MySQL 复用连接(CONN_MAX_AGE/health check/connect_timeout),仅 development 生效 - 含 projects 测试补充 tsc + py_compile + 26 后端测试通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -576,11 +576,56 @@ def _find_entity_group(project, kind: str, label: str, group_id: str | None):
|
||||
return None
|
||||
|
||||
|
||||
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> BaseAssetGroup:
|
||||
def _product_cover_url(product) -> str:
|
||||
"""商品主图 URL:优先 cover_asset,其次标记为主图的商品图,再次首张商品图。无图返回 ''。"""
|
||||
if product is None:
|
||||
return ""
|
||||
if product.cover_asset_id:
|
||||
url = _asset_preview_url(product.cover_asset)
|
||||
if url:
|
||||
return url
|
||||
image = product.images.filter(is_primary=True).first() or product.images.order_by("sort_order", "created_at").first()
|
||||
if image is not None:
|
||||
return _asset_preview_url(image.asset)
|
||||
return ""
|
||||
|
||||
|
||||
def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str:
|
||||
"""商品三视图 image_edit 提示词(refs 版):参考图1=商品真实主图,锁包装一致性。"""
|
||||
name = (getattr(product, "title", "") or "商品").strip()
|
||||
lines = [
|
||||
f"参考图1是「{name}」的真实商品主图。",
|
||||
"请严格参照该图的包装外形、品牌文字、配色、Logo 与材质,生成同一件商品的三视图:",
|
||||
"从左到右依次为正面、侧面、背面,统一光照,纯白背景,16:9 构图。",
|
||||
"三个视图必须是同一件商品,品牌字样/配色/外形高度一致,不要改动或重新设计包装。",
|
||||
]
|
||||
if base_prompt and base_prompt.strip():
|
||||
lines.append(base_prompt.strip())
|
||||
return " ".join(lines)
|
||||
|
||||
|
||||
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask:
|
||||
"""提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级),
|
||||
慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。
|
||||
|
||||
这样 Web 层(gunicorn)不被 ~30s+ 的出图请求占住 → 健康探针不饿死 → 不再"生成几张就整站 502/卡死"。
|
||||
返回 RESERVED 的 AITask,前端拿 id 轮询 /api/ai/generate-image/?ids=… 取结果;出图后刷新项目即见新组。"""
|
||||
from apps.ai.tasks import generate_base_asset_task
|
||||
|
||||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||||
if model_config is None:
|
||||
raise ValueError("no active image model configured")
|
||||
payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "kind": kind}
|
||||
provider = get_image_provider(model_config)
|
||||
# 商品三视图:有真实商品主图 → 走 image_edit 以主图为参考,锁定包装(品牌字/配色/外形/Logo)一致;
|
||||
# 无主图或当前模型不支持 image_edit → 回落纯文生图(仅凭商品名脑补,不保证还原真实包装)。
|
||||
product_ref_url = _product_cover_url(project.product) if kind == BaseAssetGroup.Kind.PRODUCT else ""
|
||||
use_edit = bool(product_ref_url) and hasattr(provider, "image_edit")
|
||||
gen_prompt = build_product_triview_prompt_refs(project.product, prompt) if use_edit else prompt
|
||||
payload = {
|
||||
"model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt,
|
||||
"kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "",
|
||||
"use_edit": use_edit, "reference_image": product_ref_url,
|
||||
}
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
user=user,
|
||||
@@ -592,10 +637,33 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
|
||||
model_config=model_config,
|
||||
request_payload=payload,
|
||||
)
|
||||
generate_base_asset_task.delay(str(task.id))
|
||||
return task
|
||||
|
||||
|
||||
def run_base_asset_task(*, task_id: str) -> None:
|
||||
"""Celery worker 内执行基础资产的慢出图:调模型 → 成功落库扣费并归组 / 失败退费。
|
||||
幂等:只处理 RESERVED 任务,重复投递不会二次出图、二次扣费。"""
|
||||
task = AITask.objects.select_related("team", "created_by", "project", "model_config").filter(id=task_id).first()
|
||||
if task is None or task.status != AITask.Status.RESERVED:
|
||||
return
|
||||
project = task.project
|
||||
user = task.created_by
|
||||
payload = task.request_payload or {}
|
||||
kind = payload.get("kind")
|
||||
prompt = str(payload.get("prompt") or "")
|
||||
label = str(payload.get("label") or "")
|
||||
group_id = payload.get("group_id") or None
|
||||
use_edit = bool(payload.get("use_edit"))
|
||||
ref_url = str(payload.get("reference_image") or "")
|
||||
model_config = task.model_config
|
||||
provider = get_image_provider(model_config)
|
||||
reservation = task.credit_reservation
|
||||
try:
|
||||
provider = get_image_provider(model_config)
|
||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
|
||||
if use_edit and ref_url:
|
||||
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size="1536x1024")
|
||||
else:
|
||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
|
||||
media = provider.extract_first_media_url(response)
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
@@ -634,19 +702,19 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
|
||||
from apps.assets.review import submit_asset_for_review
|
||||
|
||||
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
|
||||
return group
|
||||
except Exception as exc:
|
||||
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_message = str(exc)
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
release_credit(reservation=reservation, reason=str(exc))
|
||||
raise
|
||||
|
||||
|
||||
def generate_person_triview(*, project, user, portrait_asset) -> "BaseAssetGroup":
|
||||
"""流程步骤4 · 据「某一版立绘资产」生成它配套的三视图(image_edit 以立绘为参考,锁角色一致性)。
|
||||
def generate_person_triview(*, project, user, portrait_asset) -> AITask:
|
||||
"""流程步骤4 · 据「某一版立绘资产」生成它配套的三视图(**异步**:image_edit 慢,交给 worker)。
|
||||
Web 请求只建 RESERVED 任务 + 预留额度后秒回;worker 内跑 image_edit 并把三视图归组(run_triview_task)。
|
||||
三视图与立绘 1:1 绑定:metadata.triview_of=<立绘 asset id>;同一立绘多次=同组追加候选(版本)。"""
|
||||
from apps.ai.tasks import generate_triview_task
|
||||
from apps.ai.model_library import THREE_VIEW_PROMPT
|
||||
|
||||
if portrait_asset is None:
|
||||
@@ -659,11 +727,31 @@ def generate_person_triview(*, project, user, portrait_asset) -> "BaseAssetGroup
|
||||
if not hasattr(provider, "image_edit"):
|
||||
raise ValueError(f"当前图像模型 {model_config.provider.name}:{model_config.name} 不支持参考图三视图(image_edit)")
|
||||
ref_url = _asset_preview_url(portrait_asset)
|
||||
payload = {"model": model_config.name, "prompt": THREE_VIEW_PROMPT, "kind": "person", "triview_of": asset_key}
|
||||
payload = {"model": model_config.name, "prompt": THREE_VIEW_PROMPT, "kind": "person", "triview_of": asset_key, "reference_image": ref_url}
|
||||
task = create_ai_task(project=project, user=user, task_type=AITask.Type.PERSON_IMAGE, model_config=model_config, request_payload=payload)
|
||||
generate_triview_task.delay(str(task.id))
|
||||
return task
|
||||
|
||||
|
||||
def run_triview_task(*, task_id: str) -> None:
|
||||
"""Celery worker 内执行三视图慢出图(image_edit 以立绘为参考):成功落库扣费并归到立绘的三视图组 / 失败退费。
|
||||
幂等:只处理 RESERVED 任务,重复投递不会二次出图、二次扣费。"""
|
||||
from apps.ai.model_library import THREE_VIEW_PROMPT
|
||||
|
||||
task = AITask.objects.select_related("team", "created_by", "project", "model_config").filter(id=task_id).first()
|
||||
if task is None or task.status != AITask.Status.RESERVED:
|
||||
return
|
||||
project = task.project
|
||||
user = task.created_by
|
||||
payload = task.request_payload or {}
|
||||
asset_key = str(payload.get("triview_of") or "")
|
||||
ref_url = str(payload.get("reference_image") or "")
|
||||
prompt = str(payload.get("prompt") or THREE_VIEW_PROMPT)
|
||||
model_config = task.model_config
|
||||
provider = get_image_provider(model_config)
|
||||
reservation = task.credit_reservation
|
||||
try:
|
||||
response = provider.image_edit(model=model_config.name, prompt=THREE_VIEW_PROMPT, images=[ref_url], size="1536x1024")
|
||||
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size="1536x1024")
|
||||
media = provider.extract_first_media_url(response)
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
@@ -681,20 +769,18 @@ def generate_person_triview(*, project, user, portrait_asset) -> "BaseAssetGroup
|
||||
if (g.metadata or {}).get("triview_of") == asset_key), None)
|
||||
if group is None:
|
||||
group = BaseAssetGroup.objects.create(
|
||||
project=project, kind=BaseAssetGroup.Kind.PERSON, task=task, prompt=THREE_VIEW_PROMPT,
|
||||
project=project, kind=BaseAssetGroup.Kind.PERSON, task=task, prompt=prompt,
|
||||
metadata={"label": "·三视图", "triview_of": asset_key},
|
||||
)
|
||||
group.candidate_assets.add(asset)
|
||||
group.adopted_asset = asset
|
||||
group.save(update_fields=["adopted_asset", "updated_at"])
|
||||
return group
|
||||
except Exception as exc:
|
||||
except Exception as exc: # noqa: BLE001 — 失败退费 + 错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_message = str(exc)
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
release_credit(reservation=reservation, reason=str(exc))
|
||||
raise
|
||||
|
||||
|
||||
def _scene_context(project) -> str:
|
||||
|
||||
@@ -20,3 +20,23 @@ def generate_standalone_image_task(self, task_id: str) -> str:
|
||||
run_standalone_image_task(task_id=task_id)
|
||||
return task_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0)
|
||||
def generate_base_asset_task(self, task_id: str) -> str:
|
||||
"""基础资产(商品/人物/场景立绘)的慢出图在 worker 内跑,Web 层不被占住。
|
||||
幂等且失败自退费(见 run_base_asset_task),故 max_retries=0,不向上抛重试。"""
|
||||
from apps.ai.services import run_base_asset_task
|
||||
|
||||
run_base_asset_task(task_id=task_id)
|
||||
return task_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0)
|
||||
def generate_triview_task(self, task_id: str) -> str:
|
||||
"""三视图(image_edit 以立绘为参考,慢)在 worker 内跑,Web 层不被占住。
|
||||
幂等且失败自退费(见 run_triview_task),故 max_retries=0,不向上抛重试。"""
|
||||
from apps.ai.services import run_triview_task
|
||||
|
||||
run_triview_task(task_id=task_id)
|
||||
return task_id
|
||||
|
||||
|
||||
@@ -62,7 +62,9 @@ class GenerateImageView(APIView):
|
||||
|
||||
|
||||
class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
queryset = AITask.objects.select_related("team", "project", "model_config", "model_config__provider").all()
|
||||
# 序列化器不含 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()
|
||||
serializer_class = AITaskSerializer
|
||||
search_fields = ["idempotency_key", "provider_task_id", "project__name"]
|
||||
ordering_fields = ["created_at", "updated_at", "completed_at"]
|
||||
|
||||
@@ -173,7 +173,8 @@ def trend(request):
|
||||
|
||||
# 本月按阶段分布(task.task_type → 4 桶)
|
||||
month_start = today.replace(day=1)
|
||||
month_charges = charges.filter(created_at__date__gte=month_start).select_related("task")
|
||||
# 只用 task.task_type/project,defer 掉 task 的大 blob(单条可达 3MB+ base64 图),别把几十 MB 拉回
|
||||
month_charges = charges.filter(created_at__date__gte=month_start).select_related("task").defer("task__request_payload", "task__response_payload")
|
||||
by_stage = {"script": Decimal("0"), "base": Decimal("0"), "storyboard": Decimal("0"), "video": Decimal("0")}
|
||||
project_amounts: dict[str, Decimal] = {}
|
||||
for row in month_charges:
|
||||
|
||||
@@ -218,11 +218,118 @@ class ProjectApiTests(TestCase):
|
||||
{"kind": "person", "prompt": "26岁都市女性", "label": "女主"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
# 异步:秒回 202 + 任务;EAGER 下 worker 任务就地执行,组已落库,label 进 metadata
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertIn("task", response.data)
|
||||
from apps.projects.models import BaseAssetGroup
|
||||
group = BaseAssetGroup.objects.get(id=response.data["id"])
|
||||
group = BaseAssetGroup.objects.get(project=project, kind=BaseAssetGroup.Kind.PERSON)
|
||||
self.assertEqual(group.metadata.get("label"), "女主")
|
||||
|
||||
@patch("apps.ai.services._store_generated_media")
|
||||
@patch("apps.ai.services.get_image_provider")
|
||||
def test_product_base_asset_uses_cover_image_as_reference(self, get_provider, store_media):
|
||||
"""商品三视图:商品有主图时走 image_edit 以主图为参考(锁包装一致),而非纯文生图。"""
|
||||
from apps.assets.models import AssetFile
|
||||
|
||||
ModelConfig.objects.create(
|
||||
provider=self.provider, name="img-model", display_name="Img",
|
||||
capability=ModelConfig.Capability.IMAGE, endpoint="images/generations", unit_price="1.0000",
|
||||
)
|
||||
# 商品主图资产(带可访问 preview_url)
|
||||
cover = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="主图",
|
||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.UPLOAD, category=Asset.Category.PRODUCT_IMAGE,
|
||||
)
|
||||
AssetFile.objects.create(asset=cover, object_key="k.png", bucket="b", content_type="image/png", preview_url="http://x/cover.png", is_primary=True)
|
||||
self.product.cover_asset = cover
|
||||
self.product.save(update_fields=["cover_asset"])
|
||||
|
||||
out = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="商品三视图",
|
||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=Asset.Category.PRODUCT_IMAGE,
|
||||
)
|
||||
store_media.return_value = out
|
||||
provider = get_provider.return_value
|
||||
provider.image_edit.return_value = {"data": [{"url": "http://x/tri.png"}]}
|
||||
provider.extract_first_media_url.return_value = "http://x/tri.png"
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/projects/{project.id}/generate-base-asset/",
|
||||
{"kind": "product", "prompt": "商品三视图"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 202) # 异步:秒回任务,EAGER 下 worker 就地出图
|
||||
# 走 image_edit 且把商品主图 URL 作为参考图传入,未走纯文生图
|
||||
provider.image_edit.assert_called_once()
|
||||
self.assertEqual(provider.image_edit.call_args.kwargs["images"], ["http://x/cover.png"])
|
||||
provider.image_generation.assert_not_called()
|
||||
|
||||
@patch("apps.ai.services._store_generated_media")
|
||||
@patch("apps.ai.services.get_image_provider")
|
||||
def test_product_base_asset_falls_back_without_cover(self, get_provider, store_media):
|
||||
"""商品无主图时回落纯文生图(image_generation),不报错。"""
|
||||
ModelConfig.objects.create(
|
||||
provider=self.provider, name="img-model", display_name="Img",
|
||||
capability=ModelConfig.Capability.IMAGE, endpoint="images/generations", unit_price="1.0000",
|
||||
)
|
||||
out = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="商品三视图",
|
||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=Asset.Category.PRODUCT_IMAGE,
|
||||
)
|
||||
store_media.return_value = out
|
||||
provider = get_provider.return_value
|
||||
provider.image_generation.return_value = {"data": [{"url": "http://x/tri.png"}]}
|
||||
provider.extract_first_media_url.return_value = "http://x/tri.png"
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/projects/{project.id}/generate-base-asset/",
|
||||
{"kind": "product", "prompt": "商品三视图"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 202) # 异步:秒回任务,EAGER 下 worker 就地出图
|
||||
provider.image_generation.assert_called_once()
|
||||
provider.image_edit.assert_not_called()
|
||||
|
||||
@patch("apps.ai.services._store_generated_media")
|
||||
@patch("apps.ai.services.get_image_provider")
|
||||
def test_triview_async_binds_group_to_portrait(self, get_provider, store_media):
|
||||
"""三视图异步:提交秒回 202;EAGER 下 worker 就地以立绘为参考 image_edit,生成的组 triview_of 绑定该立绘。"""
|
||||
from apps.assets.models import AssetFile
|
||||
from apps.projects.models import BaseAssetGroup
|
||||
|
||||
ModelConfig.objects.create(
|
||||
provider=self.provider, name="img-model", display_name="Img",
|
||||
capability=ModelConfig.Capability.IMAGE, endpoint="images/generations", unit_price="1.0000",
|
||||
)
|
||||
portrait = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="立绘",
|
||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON,
|
||||
)
|
||||
AssetFile.objects.create(asset=portrait, object_key="p.png", bucket="b", content_type="image/png", preview_url="http://x/portrait.png", is_primary=True)
|
||||
tri = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="三视图",
|
||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON,
|
||||
)
|
||||
store_media.return_value = tri
|
||||
provider = get_provider.return_value
|
||||
provider.image_edit.return_value = {"data": [{"url": "http://x/tri.png"}]}
|
||||
provider.extract_first_media_url.return_value = "http://x/tri.png"
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/projects/{project.id}/generate-triview/",
|
||||
{"portrait_asset_id": str(portrait.id)},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 202)
|
||||
provider.image_edit.assert_called_once()
|
||||
self.assertEqual(provider.image_edit.call_args.kwargs["images"], ["http://x/portrait.png"])
|
||||
group = BaseAssetGroup.objects.get(project=project, kind=BaseAssetGroup.Kind.PERSON)
|
||||
self.assertEqual(group.metadata.get("triview_of"), str(portrait.id))
|
||||
self.assertEqual(group.adopted_asset_id, tri.id)
|
||||
|
||||
def test_extract_cast_and_scenes_parsing_is_robust(self):
|
||||
"""纯函数:_coerce_tag_entries 去重/容错;无可用模型时 extract 返回空且不抛。"""
|
||||
from apps.ai.services import _coerce_tag_entries, extract_cast_and_scenes
|
||||
@@ -418,6 +525,7 @@ class WorkerGateTests(TestCase):
|
||||
def test_generation_endpoints_blocked_without_worker(self):
|
||||
cases = [
|
||||
(f"/api/projects/{self.project.id}/generate-base-asset/", {"kind": "person"}),
|
||||
(f"/api/projects/{self.project.id}/generate-triview/", {"portrait_asset_id": "x"}),
|
||||
(f"/api/projects/{self.project.id}/generate-storyboard/", {}),
|
||||
(f"/api/projects/{self.project.id}/submit-video-segment/", {"video_segment_id": "x"}),
|
||||
("/api/ai/generate-image/", {"prompt": "测试"}),
|
||||
|
||||
@@ -228,12 +228,15 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
kind = request.data.get("kind")
|
||||
if kind not in BaseAssetGroup.Kind.values:
|
||||
return Response({"detail": "invalid base asset kind"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
group = generate_base_asset(project=project, user=request.user, kind=kind, prompt=request.data.get("prompt", ""), label=request.data.get("label", ""))
|
||||
try:
|
||||
task = generate_base_asset(project=project, user=request.user, kind=kind, prompt=request.data.get("prompt", ""), label=request.data.get("label", ""))
|
||||
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS)
|
||||
stage.status = ProjectStage.Status.NEEDS_REVIEW
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
promote_base_asset_stage_if_ready(project)
|
||||
return Response(BaseAssetGroupSerializer(group).data, status=status.HTTP_201_CREATED)
|
||||
# 异步:出图在 worker 里跑,秒回 RESERVED 任务,前端轮询 /api/ai/generate-image/?ids=… 取结果后刷新项目
|
||||
return Response({"task": {"id": str(task.id), "status": task.status}}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="poll-reviews")
|
||||
def poll_reviews(self, request, pk=None):
|
||||
@@ -276,15 +279,17 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
@action(detail=True, methods=["post"], url_path="generate-triview")
|
||||
def generate_triview(self, request, pk=None):
|
||||
"""流程步骤4 · 据某一版立绘资产生成它配套的三视图(image_edit 锁角色一致性,三视图绑定该立绘)。"""
|
||||
require_worker() # 异步出图依赖 worker 兜底执行,没 worker 直接拒绝(否则任务永远 RESERVED)
|
||||
project = self.get_object()
|
||||
portrait_asset = Asset.objects.filter(team=project.team, id=request.data.get("portrait_asset_id")).first()
|
||||
if portrait_asset is None:
|
||||
return Response({"detail": "portrait asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
group = generate_person_triview(project=project, user=request.user, portrait_asset=portrait_asset)
|
||||
task = generate_person_triview(project=project, user=request.user, portrait_asset=portrait_asset)
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(BaseAssetGroupSerializer(group).data, status=status.HTTP_201_CREATED)
|
||||
# 异步:image_edit 出图在 worker 里跑,秒回 RESERVED 任务,前端轮询取结果后刷新项目即见三视图
|
||||
return Response({"task": {"id": str(task.id), "status": task.status}}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
# ── Stage 1 · 镜头脚本逐字段编辑 / 增删分镜 ──
|
||||
|
||||
|
||||
Reference in New Issue
Block a user