fix(video): 真人参考图走素材库 asset:// 引用,根治 Seedance 锁脸全错

根因:Seedance 2.0 对写实人脸的原始直链直接 400(InputImageSensitiveContentDetected),
旧代码静默退纯文生视频 → 任务假成功、人物/场景全错(脚本是文本所以对)。

- 视频参考图:已过审(active+素材库 ID)的人物 + 分镜图换成 asset://{review_remote_id},
  场景/商品(无脸、未送审)保持原始直链(_video_reference_images / _seedance_ref_url)
- 临时:VIDEO_ARK_API_KEY 让 Seedance 借 AirDrama 火山账号,与真人素材库同账号 → asset:// 可解析;
  仅覆盖 VIDEO 能力,图像/文本仍用自有 key。待自有账号开通素材库后删此项即回落
- 删掉静默文生兜底:火山报错经 _raise_volcano_error 抽出真实 code+message 抛出,
  落 error_message → 前端如实显示,绝不再假装成功出没锁脸的视频

真实接口验证:同图旧路 400,新路(asset://+AirDrama key)Seedance ACCEPTED 正常出片。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-23 13:16:30 +08:00
co-authored by Claude Opus 4.8
parent dbd1382fb1
commit 6916f9c877
4 changed files with 60 additions and 29 deletions
+2
View File
@@ -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
+4
View File
@@ -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 = {
+18 -2
View File
@@ -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
+36 -27
View File
@@ -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