diff --git a/core/backend/.env b/core/backend/.env index 9bbb70d..5f4bf38 100644 --- a/core/backend/.env +++ b/core/backend/.env @@ -21,6 +21,8 @@ TOS_ACCESS_KEY_ID=AKLTODVhY2U1NzY1MTU3NDA4NThiYzk2ZDMyZDNjYmZhZGY TOS_SECRET_ACCESS_KEY=TWpjNVpqVm1NbVkzTWprNE5ESXlZMkUyT1dNNFlqVmtaRGRoTVdNME5qRQ== VOLCANO_ARK_API_KEY=ark-24d5627e-28e4-4412-8679-46a6e9f26aab-6e951 VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +# 临时:视频(Seedance)借 AirDrama 火山账号 ARK key,与真人素材库审核同账号 → asset:// 可解析。待自有账号开通素材库后删此行。 +VIDEO_ARK_API_KEY=9225161a-a640-47e6-94ed-81f6c5610072 DEFAULT_TRIAL_CREDITS=1000.0000 YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf YUNQI_BASE_URL=https://www.yunqiai.chat/v1 diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index 67e0f47..34e3992 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -213,6 +213,10 @@ PROVIDER_KEYS = { "yunqi": env("YUNQI_API_KEY", ""), "tokenssr": env("TOKENSSR_API_KEY", ""), } +# 临时:视频(Seedance)单独借 AirDrama 火山账号的 ARK key。真人素材库审核(ASSETS_API)建在 AirDrama 账号下, +# Seedance 引用 asset:// 素材必须同账号才解析得到(否则 asset not found)。只覆盖 video,图像/文本仍用自有 key。 +# 待 AirShelf 自有账号开通人像素材库后,清空此项即回落默认 VOLCANO_ARK_API_KEY。 +VIDEO_ARK_API_KEY = env("VIDEO_ARK_API_KEY", "") # 中转站生图/改图端点的 Azure 风格 api-version(yunqi 的 images/edits 必须带,否则 404)。 # DB 的 ModelProvider.metadata.api_version 优先,留空回退到这里。 PROVIDER_API_VERSIONS = { diff --git a/core/backend/apps/ai/providers/volcano.py b/core/backend/apps/ai/providers/volcano.py index cd8b4ae..9f66f31 100644 --- a/core/backend/apps/ai/providers/volcano.py +++ b/core/backend/apps/ai/providers/volcano.py @@ -11,6 +11,22 @@ from django.conf import settings from .base import AIProviderResult +def _raise_volcano_error(response: requests.Response) -> None: + """火山非 2xx:抽出真实报错(error.code / error.message)抛成可读异常, + 供上层落 error_message → 前端如实显示真实报错参数,不再被通用「HTTP 400」吞掉细节。""" + if response.ok: + return + code = message = "" + try: + err = (response.json() or {}).get("error") or {} + code, message = str(err.get("code") or ""), str(err.get("message") or "") + except Exception: + pass + if code or message: + raise RuntimeError(f"火山报错 [{code}] {message}".strip()) + raise RuntimeError(f"火山接口 {response.status_code}: {(response.text or '')[:300]}") + + @dataclass class VolcanoArkProvider: api_key: str | None = None @@ -196,7 +212,7 @@ class VolcanoArkProvider: json=body, timeout=120, ) - response.raise_for_status() + _raise_volcano_error(response) return response.json() def poll_video_task(self, *, endpoint: str, provider_task_id: str) -> dict[str, Any]: @@ -207,7 +223,7 @@ class VolcanoArkProvider: headers={"Authorization": f"Bearer {self.api_key}"}, timeout=60, ) - response.raise_for_status() + _raise_volcano_error(response) return response.json() @staticmethod diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index d72b708..9c5eb84 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -65,6 +65,11 @@ def build_provider(model_config: ModelConfig): provider = model_config.provider base_url, api_key = resolve_provider_credentials(provider) if provider.name in OFFICIAL_DIRECT_PROVIDERS: + # 临时:视频(Seedance)借 AirDrama 账号的 ARK key,使真人素材库 asset:// 引用与 Seedance 同账号、可解析。 + # 仅覆盖 VIDEO,图像/文本仍用自有 key。VIDEO_ARK_API_KEY 留空则不覆盖(回落默认)。待自有账号开通素材库后清掉。 + video_key = (getattr(settings, "VIDEO_ARK_API_KEY", "") or "").strip() + if video_key and model_config.capability == ModelConfig.Capability.VIDEO: + api_key = video_key return VolcanoArkProvider(base_url=base_url, api_key=api_key) # api_version:某些中转站(yunqi)的 images/edits 需 Azure 风格 ?api-version=...。 # DB(ModelProvider.metadata.api_version)优先 → settings(.env)回退,与凭证同款可插拔。 @@ -1186,7 +1191,8 @@ def _product_reference_image(project, groups: list | None = None) -> dict | None if pg is not None: url = _asset_preview_url(pg.adopted_asset) if url: - return {"url": url, "label": "商品", "type": "product"} + return {"url": url, "label": "商品", "type": "product", + "review_status": pg.adopted_asset.review_status, "review_remote_id": pg.adopted_asset.review_remote_id} cover = _product_cover_url(project.product) if cover: return {"url": cover, "label": "商品", "type": "product"} @@ -1242,7 +1248,9 @@ def _storyboard_reference_images(project, segment) -> list[dict]: used.add(match.id) url = _asset_preview_url(match.adopted_asset) if url: - out.append({"url": url, "label": name or _ENTITY_TYPE_CN.get(ent.get("type"), "参考"), "type": ent.get("type")}) + # 带上审核态/素材库 ID:视频路按需换成 asset:// 引用;图像生成路只读 url 忽略它们 + out.append({"url": url, "label": name or _ENTITY_TYPE_CN.get(ent.get("type"), "参考"), "type": ent.get("type"), + "review_status": match.adopted_asset.review_status, "review_remote_id": match.adopted_asset.review_remote_id}) if len(out) >= 3: # 给商品留一个位置(总计最多 4 张) break # 商品参考图永远带上:优先已采用商品三视图组 → 否则真实主图 @@ -1507,10 +1515,19 @@ def _asset_preview_url(asset) -> str: return "" +def _seedance_ref_url(raw_url: str, review_status: str = "", review_remote_id: str = "") -> str: + """Seedance 参考图 URL:已过审(active + 有素材库 ID)的写实人脸资产 → 用 asset:// 素材库引用, + 其余 → 原始直链。火山对写实人脸的原始直链会以 InputImageSensitiveContentDetected 直接 400 拒, + 必须改用同账号素材库引用才认。人物立绘 / 分镜图(含脸)走 asset://;场景 / 商品(无脸、未送审)保持直链。""" + if review_status == "active" and review_remote_id: + return f"asset://{review_remote_id}" + return raw_url + + def _video_reference_images(project, video_segment) -> list[dict]: """视频参考图(带类型,供 @图N):角色/场景/商品 基础资产 + 本镜故事板帧。 顺序:角色 → 场景 → 商品 → 分镜图(与用户钦定 @图1角色@图2场景@图3商品@图4分镜图 一致)。 - 返回 [{url,label,type}];都取不到时兜底商品图。""" + 返回 [{url,label,type}];url 对过审人脸资产为 asset:// 素材库引用。都取不到时兜底商品图。""" out: list[dict] = [] scene = None adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first() @@ -1534,7 +1551,8 @@ def _video_reference_images(project, video_segment) -> list[dict]: if frame is not None: url = _asset_preview_url(frame.asset) if url: - out.append({"url": url, "label": "分镜图", "type": "storyboard"}) + out.append({"url": url, "label": "分镜图", "type": "storyboard", + "review_status": frame.asset.review_status, "review_remote_id": frame.asset.review_remote_id}) if not out: product_group = ( project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PRODUCT, adopted_asset__isnull=False) @@ -1544,6 +1562,9 @@ def _video_reference_images(project, video_segment) -> list[dict]: url = _asset_preview_url(product_group.adopted_asset) if url: out.append({"url": url, "label": "商品", "type": "product"}) + # 写实人脸(已过审)换素材库引用,避免火山「疑似真人」400 拒 → 退文生 → 人物/场景全错 + for r in out: + r["url"] = _seedance_ref_url(r["url"], r.get("review_status", ""), r.get("review_remote_id", "")) return out @@ -1583,29 +1604,17 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V ) try: provider = build_provider(model_config) - try: - response = provider.create_video_task( - model=model_config.name, - endpoint=model_config.endpoint, - prompt=final_prompt, - duration=video_segment.target_duration_seconds, - ratio="9:16", - resolution="720p", - reference_images=reference_images or None, - ) - except Exception: - # 降级:带参考图被拒时退回纯文生视频(文本里已含本镜旁白/画面,衔接不丢) - if not reference_images: - raise - response = provider.create_video_task( - model=model_config.name, - endpoint=model_config.endpoint, - prompt=final_prompt, - duration=video_segment.target_duration_seconds, - ratio="9:16", - resolution="720p", - reference_images=None, - ) + # 不再静默退文生兜底:火山报错(如人脸需走素材库的 InputImageSensitiveContentDetected)直接抛, + # 由下方 except 落 error_message → 前端如实显示真实报错,绝不假装成功出一条没锁脸的视频。 + response = provider.create_video_task( + model=model_config.name, + endpoint=model_config.endpoint, + prompt=final_prompt, + duration=video_segment.target_duration_seconds, + ratio="9:16", + resolution="720p", + reference_images=reference_images or None, + ) task.provider_task_id = str(response.get("id") or response.get("task_id") or "") task.response_payload = response task.status = AITask.Status.SUBMITTED