From 296888f0bf49499fb3220c30c4daf6bfdfcfdb5d Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Wed, 17 Jun 2026 15:10:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E5=9F=BA=E7=A1=80=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=20Agent=20=E5=8C=96=20=E2=80=94=20=E5=95=86=E5=93=81?= =?UTF-8?q?=E4=B8=89=E8=A7=86=E5=9B=BE(image=5Fedit=20=E5=8F=82=E8=80=83?= =?UTF-8?q?=E4=B8=BB=E5=9B=BE)+=20=E4=BA=BA=E7=89=A9=E7=AB=8B=E7=BB=98/?= =?UTF-8?q?=E4=B8=89=E8=A7=86=E5=9B=BE=E5=BC=82=E6=AD=A5=E7=94=9F=E6=88=90?= =?UTF-8?q?=20+=20=E6=BC=94=E5=91=98=E5=BA=93/=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 商品三视图:有真实商品主图时走 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 --- core/backend/airshelf/settings/development.py | 8 ++ core/backend/apps/ai/services.py | 116 +++++++++++++++--- core/backend/apps/ai/tasks.py | 20 +++ core/backend/apps/ai/views.py | 4 +- core/backend/apps/billing/views.py | 3 +- core/backend/apps/projects/tests.py | 112 ++++++++++++++++- core/backend/apps/projects/views.py | 15 ++- core/frontend/src/App.tsx | 52 +++++++- core/frontend/src/api.ts | 6 +- core/frontend/src/routes/pipeline.tsx | 50 ++++---- core/frontend/src/routes/projects.tsx | 34 +++-- 11 files changed, 361 insertions(+), 59 deletions(-) diff --git a/core/backend/airshelf/settings/development.py b/core/backend/airshelf/settings/development.py index d6ea858..d4909bc 100644 --- a/core/backend/airshelf/settings/development.py +++ b/core/backend/airshelf/settings/development.py @@ -1,5 +1,13 @@ from .base import * # noqa: F403 +from .base import DATABASES DEBUG = True +# 本地连云端 MySQL 时,默认每请求都重开连接(CONN_MAX_AGE=0), +# 而到远程库一次 TCP 握手就 ~1.6s,登录时前端并发 ~12 个接口 → 每个都重握手 = 卡几十秒。 +# 复用连接 + 连接超时,本地体感立刻顺滑(仅 development 生效,不影响线上)。 +if DATABASES.get("default", {}).get("ENGINE", "").endswith("mysql"): + DATABASES["default"]["CONN_MAX_AGE"] = 300 + DATABASES["default"]["CONN_HEALTH_CHECKS"] = True + DATABASES["default"].setdefault("OPTIONS", {})["connect_timeout"] = 5 diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index c8a9e04..d30afdd 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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: diff --git a/core/backend/apps/ai/tasks.py b/core/backend/apps/ai/tasks.py index 2ce5043..c01db60 100644 --- a/core/backend/apps/ai/tasks.py +++ b/core/backend/apps/ai/tasks.py @@ -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 + diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index e73be37..da1693a 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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"] diff --git a/core/backend/apps/billing/views.py b/core/backend/apps/billing/views.py index 4e0afd7..3df35ea 100644 --- a/core/backend/apps/billing/views.py +++ b/core/backend/apps/billing/views.py @@ -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: diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index b614f4c..3c57b89 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -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": "测试"}), diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index c0f1138..8d1e595 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -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 · 镜头脚本逐字段编辑 / 增删分镜 ── diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 3498a02..c2a155f 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -441,6 +441,45 @@ export function App() { return pollImageTasks(mode, ids).catch(() => null); } + // 轮询一批 AITask(基础资产/三视图异步出图)直到终态;返回成功任务产出的 assets 与最后错误。 + // 轮询期间 Web 层是空闲的(只发轻量 status 请求),整站不卡;出图后调用方刷新项目即见新资产。 + async function pollAiTasks(ids: string[]): Promise<{ assets: Asset[]; error: string }> { + const pending = new Set(ids); + const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]); + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const assets: Asset[] = []; + let error = ""; + const deadline = Date.now() + 6 * 60 * 1000; // 兜底:6 分钟还没好就停轮询(图仍会在后台出完,刷新可见) + while (pending.size > 0 && Date.now() < deadline) { + await sleep(2500); + const res = await api.generateImageStatus([...pending]).catch(() => null); + if (!res) continue; + for (const t of res.tasks) { + if (!TERMINAL.has(t.status)) continue; + pending.delete(t.id); + if (t.status === "succeeded") assets.push(...(t.assets || [])); + else if (t.error_message) error = t.error_message; + } + } + return { assets, error }; + } + + // 基础资产/三视图异步出图统一入口:提交(秒回任务)→ 轮询出图 → 刷新项目 → 返回新资产 id 供链式(立绘→三视图)。 + async function submitAndPollAsset(submit: () => Promise<{ task: { id: string; status: string } } | null>, okText: string): Promise { + const submitted = await submit().catch((e) => { setNotice({ type: "error", text: e instanceof Error ? e.message : "提交失败" }); return null; }); + const taskId = submitted?.task?.id; + if (!taskId) return null; // 提交失败(余额不足/无 worker 等),错误已由 submit 抛出处理 + const { assets, error } = await pollAiTasks([taskId]); + await refreshProjectDetail(); + void loadData(); + if (assets.length === 0) { + setNotice({ type: "error", text: error || "生成超时,稍后可在资产里查看" }); + return null; + } + setNotice({ type: "success", text: okText }); + return assets[0].id; + } + async function onAuthed(payload: { token: string; user: User; team: Team; remember?: boolean }) { setToken(payload.token, payload.remember ?? true); setUser(payload.user); @@ -564,6 +603,7 @@ export function App() { navigate("projects")} onCreate={async (payload) => { @@ -694,7 +734,11 @@ export function App() { } onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")} onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")} - onGenerateBaseAsset={(kind, prompt, label) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label }), "基础资产已生成")} + onGenerateBaseAsset={async (kind, prompt, label) => { + // 异步:提交→轮询出图→刷新;返回新资产 id 供「立绘→三视图」链式生成 + const assetId = await submitAndPollAsset(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label }), "基础资产已生成"); + return assetId ? { adopted_asset: assetId } : null; + }} onGenerateStoryboard={(prompt) => action(async () => { // 异步故事板:提交(秒回)后轮询;后端在后台线程逐帧生成,poll 永远秒回,故每轮间隔等待 @@ -711,7 +755,11 @@ export function App() { } onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")} onAttachBaseAsset={(groupId, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已替换为所选演员")} - onGenerateTriview={(portraitAssetId) => action(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成")} + onGenerateTriview={async (portraitAssetId) => { + // 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图 + const assetId = await submitAndPollAsset(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成"); + return assetId ? { id: assetId } : null; + }} onGenerateActor={(prompt) => generateImages({ prompt, mode: "model", count: 1 })} onUploadActor={(file) => { const fd = new FormData(); diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 3850a4f..ea9c958 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -298,8 +298,9 @@ export const api = { generateVoiceover(projectId: string, payload: { items: Array<{ index: number; text: string }>; voice_type?: string; speed_ratio?: number }) { return request<{ voiceover: VoiceoverInfo }>(`/api/projects/${projectId}/generate-voiceover/`, { method: "POST", body: JSON.stringify(payload) }); }, + // 异步:提交后秒回 RESERVED 任务,再用 generateImageStatus 轮询取结果(慢出图在 worker 跑,Web 不被占住) generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string; label?: string }) { - return request(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); + return request<{ task: { id: string; status: string } }>(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); }, adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) { return request(`/api/projects/${projectId}/adopt-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); @@ -309,8 +310,9 @@ export const api = { return request(`/api/projects/${projectId}/attach-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); }, // 流程步骤4 · 据某一版立绘资产生成它配套的三视图(image_edit 锁角色一致性,绑定该立绘 asset) + // 异步:提交后秒回 RESERVED 任务,再用 generateImageStatus 轮询取结果(慢 image_edit 在 worker 跑) generateTriview(projectId: string, payload: { portrait_asset_id: string }) { - return request<{ id: string }>(`/api/projects/${projectId}/generate-triview/`, { method: "POST", body: JSON.stringify(payload) }); + return request<{ task: { id: string; status: string } }>(`/api/projects/${projectId}/generate-triview/`, { method: "POST", body: JSON.stringify(payload) }); }, // 轮询本团队真人资产的火山审核状态(绿盾 active / 红标 failed),刷新基础资产趴徽章 pollReviews(projectId: string) { diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index e1140a4..806899c 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -521,26 +521,29 @@ export function PipelinePage(props: { }); } - // 行38/流程步骤4 · 单卡生成 loading:action() 全局串行,这里只记「当前哪张卡在生成」做局部转圈 - const [genBusyKey, setGenBusyKey] = useState(null); + // 行38/流程步骤4 · 单卡生成 loading:按「busyKey」分别记录,各按钮互不影响(生成三视图不挡新增人物) + const [genBusy, setGenBusy] = useState>(new Set()); + const isBusy = (k: string) => genBusy.has(k); + const addBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.add(k); return n; }); + const delBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.delete(k); return n; }); type GenResult = { id?: string; adopted_asset?: string | null } | null; async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string): Promise { - if (genBusyKey) return null; // 全局一次只跑一张(后端同步出图,串行更省心) - setGenBusyKey(busyKey); + if (genBusy.has(busyKey)) return null; // 同一按钮防连点(不同按钮可并发) + addBusy(busyKey); try { return (await onGenerateBaseAsset(kind, prompt, label)) as GenResult; } finally { - setGenBusyKey(null); + delBusy(busyKey); } } // 据某一版立绘资产生成它配套的三视图(loading 用 busyKey) async function genTriview(portraitAssetId: string, busyKey: string): Promise<{ id: string } | null> { - if (genBusyKey) return null; - setGenBusyKey(busyKey); + if (genBusy.has(busyKey)) return null; + addBusy(busyKey); try { return await onGenerateTriview(portraitAssetId); } finally { - setGenBusyKey(null); + delBusy(busyKey); } } // 流程步骤4 · 生成立绘后链式配套三视图(人物专用):新立绘(返回组的 adopted_asset)→ 据它生成新三视图 @@ -595,9 +598,9 @@ export function PipelinePage(props: { // 流程步骤4 · 人物三视图「据当前查看的那一版立绘自动生成」:该立绘资产还没三视图就自动据它生成一版(每立绘 asset 仅一次) const triAutoRef = useRef>(new Set()); useEffect(() => { - if (!adDetail || adDetail.kind !== "person" || genBusyKey) return; + if (!adDetail || adDetail.kind !== "person") return; const ent = buildEntities("person").find((e) => e.key === adDetail.key); - if (!ent) return; + if (!ent || isBusy(`addet-tri:${ent.key}`)) return; const portraitAsset = adPortraitId || ent.group.adopted_asset; if (!portraitAsset || triGroupForAsset(portraitAsset)) return; if (triAutoRef.current.has(portraitAsset)) return; @@ -1818,7 +1821,7 @@ export function PipelinePage(props: { // 流程步骤4 · 进入基础资产自动生成脚本里提取出、但还没生成的人物/场景占位卡(每项目仅一次) useEffect(() => { if (activeDot !== 2 && viewStage !== 2) return; - if (autoGenRef.current || genBusyKey) return; + if (autoGenRef.current) return; const flagKey = `airshelf:autogen:${project.id}`; try { if (localStorage.getItem(flagKey)) { autoGenRef.current = true; return; } } catch { /* ignore */ } const pending: Array<{ kind: "person" | "scene"; tag: string; prompt: string }> = []; @@ -2211,7 +2214,7 @@ export function PipelinePage(props: { // 行39 · 商品三视图:一个商品组,candidate_assets = 各版本,adopted_asset = 采用版 const productGroup = groupsByKind("product").find((g) => !isTriview(g)) || null; const productVersions = productGroup?.candidate_assets ?? []; - const triGenerating = genBusyKey === "tri:product"; + const triGenerating = isBusy("tri:product"); const adoptedTriAsset = productGroup?.adopted_asset || ""; const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset; const hasTriView = productVersions.length > 0; @@ -2270,7 +2273,7 @@ export function PipelinePage(props: {
{/* 行39 · 点击展开右侧三视图预览面板并生成一版(对齐原版 prod-preview 交互) */} - @@ -2292,7 +2295,7 @@ export function PipelinePage(props: { })()} {previewTriAsset && (
- + +
{/* 脚本提取出但还没生成的人物/场景:seed 卡(可改提示词后生成,进入页面会自动补齐) */} {pendingTags.map((tag) => { const seedKey = `seed:${kind}:${tag}`; const promptValue = assetPromptDraft[seedKey] ?? tagPrompt(tag); - const busy = genBusyKey === seedKey; + const busy = isBusy(seedKey) || isBusy(`${seedKey}:tri`); return (
@@ -2364,7 +2367,7 @@ export function PipelinePage(props: {
setAssetPromptDraft((m) => ({ ...m, [seedKey]: e.currentTarget.textContent || "" }))}>{promptValue}
- +
@@ -2375,7 +2378,8 @@ export function PipelinePage(props: { const grp = entity.group; const mainUrl = groupMainUrl(grp); const promptValue = assetPromptDraft[entity.key] ?? grp.prompt ?? ""; - const busy = genBusyKey === `ent:${kind}:${entity.key}`; + const entBK = `ent:${kind}:${entity.key}`; + const busy = isBusy(entBK) || isBusy(`${entBK}:tri`); const rs = kind === "person" && grp.adopted_asset ? assetReview(grp.adopted_asset) : ""; return (
@@ -2396,7 +2400,7 @@ export function PipelinePage(props: {
setAssetPromptDraft((m) => ({ ...m, [entity.key]: e.currentTarget.textContent || "" }))}>{promptValue}
- +
@@ -3074,8 +3078,8 @@ export function PipelinePage(props: { const viewTriAsset = (adTriId && triVersions.includes(adTriId)) ? adTriId : (triGroup?.adopted_asset || triVersions.at(-1) || ""); const triUrl = candUrl(triGroup, viewTriAsset); const pBK = `addet-portrait:${entity.key}`; - const busyPortrait = genBusyKey === pBK; - const busyTri = genBusyKey === `addet-tri:${entity.key}` || genBusyKey === `${pBK}:tri`; + const busyPortrait = isBusy(pBK); + const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`); async function regenPortrait() { const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`; // 重跑立绘 → 追加新候选并采用,人物链式据新立绘生成它的三视图;场景只重生立绘 @@ -3148,7 +3152,7 @@ export function PipelinePage(props: {
三视图据立绘自动生成,以保证正/侧/背多角度一致 - +
)} {triVersions.length > 0 && ( @@ -3169,7 +3173,7 @@ export function PipelinePage(props: {