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:
co-authored by
Claude Opus 4.8
parent
dbd1382fb1
commit
6916f9c877
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user