video-shuoshan/backend/utils/airdrama_client.py
seaislee1209 39667ff19c feat: v0.19.0 1080P 分辨率支持 — 完整前后端 + 严格计费准确性 + 47 测试
火山 Seedance 2.0 于 2026-04-16 上线 1080P 支持。本次实现前端 UI、
后端校验/计费、数据库迁移,并严格遵守三原则:
1. 禁止兜底/静默降级 — Fast+1080P 组合在 UI/store/serializer/view/计价
   五层防御,任一层穿透都 fail loud,不悄悄按 720P 扣费
2. 钱的计算绝对准确 — 前端预估公式与后端 estimate_tokens 完全一致
   `(输入时长+输出时长) × 宽 × 高 × fps / 1024`;实际扣费按火山返回
   total_tokens × 官方单价;预估端不维护最低 token 修正表
3. 不隐藏 bug — 无 `or '720p'` / `|| '720p'` 兜底;类型严格;异常暴露

## 后端(7 处 + 1 次迁移)

- models.py: QuotaConfig 加 base_token_price_1080p(51)/base_token_price_1080p_video(31);
  GenerationRecord.resolution 加 RESOLUTION_CHOICES 约束 + default='720p'
- migrations/0020: 含 RunPython data migration 回填历史 resolution='' → '720p'
- utils/billing.py:
  * RESOLUTION_MAP 加 1080P 六种宽高比(21:9 是 2206×946,不是 seedance 1.0 值)
  * get_resolution 去掉 tier 默认值,非法组合 raise KeyError 不静默降级
  * estimate_tokens 纯官方公式,加 input_video_duration 参数(公式完整)
- utils/airdrama_client.py: create_task 加 resolution 必填参数(无默认值)
- apps/generation/serializers.py:
  * VideoGenerateSerializer 加 resolution ChoiceField
  * aspect_ratio 改 ChoiceField 显式拒绝 adaptive
  * SystemSettingsSerializer 加 2 个 1080P 单价
- apps/generation/views.py:
  * _get_token_price 加 resolution 必填参数,Fast+1080P raise ValueError
  * _sum_video_duration 累加视频参考时长
  * video_generate_view 读 resolution、400 拒绝 Fast+1080P 组合、
    传给 get_resolution/estimate_tokens/_get_token_price/create_task/
    GenerationRecord.resolution(移除 L450 硬编码 '720p')
  * _settle_payment 按 record.resolution 取单价(1080P 结算按 1080P 价)
  * _serialize_task + 5 处手工序列化加 resolution 字段(无 `or '720p'`)
- apps/accounts/views.py: team 接口返回 token_price_1080p/_video

## 前端(10 处)

- types/index.ts: Resolution 类型;GenerationTask/BackendTask/Team/
  QuotaConfig/AssetVideo 加字段(全部必填,无 optional)
- store/inputBar.ts: resolution state;setModel/setResolution 双向拦截
  Fast+1080P 组合,toast 提示引导,不静默降级
- store/generation.ts: addTask/backendToFrontend/reEdit/regenerate 全链路
  携带 resolution;mapErrorMessage 改 '今日生成次数或团队余额不足'
- components/Toolbar.tsx:
  * 加分辨率选择器 Dropdown(位置:比例和时长之间)
  * modelItems/resolutionItems 双向 disabled(Fast 下 1080P 灰 / 1080P 下 Fast 灰)
  * estimatedTokens 对齐后端公式(含输入视频时长 + assetMentions 视频时长)
  * estimatedCost 按 resolution 选单价(Fast→fast_*、1080p→1080p_*、其他→基础)
  * tooltip 明示"实际费用以火山 API 返回的 token 数为准"
- components/Dropdown.tsx: 加 disabled 属性支持
- components/VideoDetailModal.tsx: 重新编辑恢复 resolution
- components/GenerationCard.tsx: 动态显示 task.resolution.toUpperCase()
- pages/SettingsPage.tsx: 加 2 个 1080P 单价输入框(独立分组)
- pages/AdminAssetsPage.tsx / TeamAssetsPage.tsx: 去 || '720p' 兜底
- lib/api.ts: videoApi.generate 参数 resolution 必填

## 测试(47 个用例)

### 后端(28 个)
- tests/test_1080p_billing.py(23): RESOLUTION_MAP 像素、estimate_tokens
  公式(含/不含输入视频、不做最低 token 修正)、_get_token_price 六种
  组合、Fast+1080P 抛异常、calculate_cost 对齐官方示例 4.97 / 12.39 元
- tests/test_1080p_api.py(5): video_generate_view 拒绝 Fast+1080P (400)
  + 拒绝 adaptive + 拒绝非法 resolution + 默认值兼容 + 合法组合通过

### 前端(19 个)
- test/unit/resolution1080p.test.ts(14): store 状态、双向拦截
  (1080P 下切 Fast 被阻止 model 不变、反向同样)、官方像素契约测试、
  价格示例对齐(720P 4.97 / 1080P 12.39)
- test/e2e/resolution-1080p.spec.ts(5): 真实浏览器验证默认 720P、
  Dropdown 双向置灰、tooltip 明示以火山为准

## 与官方文档对齐

- 参数:resolution (480p/720p/1080p 小写)、ratio、duration、generate_audio
- 像素:来自 docs/API文档/创建视频生成任务API.md Seedance 2.0 & 2.0 fast 列
- 单价:来自 docs/API文档/seedance模型价格.md (46/28/51/31/37/22)
- Fast 不支持 1080P:来自 docs/API文档/Seedance 2.0 1080P.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:06:45 +08:00

199 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Volcano Engine ARK video generation API client."""
import requests
from django.conf import settings
# API error code → user-friendly Chinese message
ERROR_MESSAGES = {
# Input content moderation — 人脸/敏感内容
'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,请使用虚拟人像素材替代真人照片',
'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试',
'InputVideoSensitiveContentDetected.PrivacyInformation': '参考视频中检测到真实人脸,请使用虚拟人像素材替代真人视频',
'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试',
'InputTextSensitiveContentDetected': '提示词包含敏感内容,请修改后重试',
'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试',
# Output content moderation
'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试',
'OutputVideoSensitiveContentDetected.PolicyViolation': '生成的视频涉及版权限制内容如知名IP、名人肖像等已被系统拦截请修改提示词后重试',
'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截',
# Parameter errors
'InvalidParameter': '请求参数无效,请检查输入内容',
'InvalidImage': '图片格式或尺寸不符合要求,请检查后重试',
'InvalidVideo': '视频格式或尺寸不符合要求,请检查后重试',
'InvalidAudio': '音频格式不符合要求,请检查后重试',
'AudioDurationExceeded': '音频总时长超过15秒限制请缩短音频后重试',
'AudioFormatNotSupported': '音频格式不支持,请使用 MP3 或 WAV 格式',
# Rate limit
'RateLimitExceeded': '请求过于频繁,请稍后重试',
'ConcurrencyLimitExceeded': '当前生成任务过多,请稍后重试',
# Account & billing
'InsufficientBalance': '平台账户余额不足,请联系管理员',
# Asset errors
'AssetNotFound': '引用的素材不存在或已被删除,请检查素材库',
# Server errors
'ServerOverloaded': '服务器繁忙,请稍后重试',
'InternalError': '视频生成服务异常,请稍后重试',
'Timeout': '生成超时,请重试',
}
# 关键词匹配API 返回的 message 中包含这些关键词时,映射为对应中文提示
_MESSAGE_KEYWORDS = {
'face': '检测到真实人脸,请使用虚拟人像素材替代真人照片',
'privacy': '检测到真实人脸,请使用虚拟人像素材替代真人照片',
'sensitive': '内容包含敏感信息,请修改后重试',
'not found': '引用的素材不存在或已被删除,请检查素材库',
'not valid': '请求参数无效,请检查输入内容',
'audio duration': '音频总时长超过15秒限制请缩短音频后重试',
'audio': '音频不符合要求支持MP3/WAV单条2-15秒总时长≤15秒',
}
class AirDramaAPIError(Exception):
"""Raised when video generation API returns an error response."""
def __init__(self, code, message, status_code=400):
self.code = code
self.api_message = message
self.status_code = status_code
# 1. 精确匹配 error code
friendly = ERROR_MESSAGES.get(code)
if not friendly:
# 2. 关键词匹配 message 内容
msg_lower = (message or '').lower()
for keyword, hint in _MESSAGE_KEYWORDS.items():
if keyword in msg_lower:
friendly = hint
break
self.user_message = friendly or '生成失败,请重试'
super().__init__(self.user_message)
MODEL_MAP = {
'seedance_2.0': 'doubao-seedance-2-0-260128',
'seedance_2.0_fast': 'doubao-seedance-2-0-fast-260128',
}
# 推理接入点优先:有 EP 用 EP没有降级到模型 ID
def _resolve_model(model):
ep_map = {
'seedance_2.0': settings.ARK_ENDPOINT_SEEDANCE,
'seedance_2.0_fast': settings.ARK_ENDPOINT_SEEDANCE_FAST,
}
ep = ep_map.get(model, '')
if ep:
return ep
return MODEL_MAP.get(model, model)
def _headers():
return {
'Content-Type': 'application/json',
'Authorization': f'Bearer {settings.ARK_API_KEY}',
}
def create_task(prompt, model, content_items, aspect_ratio, duration, resolution,
generate_audio=True, search_mode='off', seed=-1):
"""Create a video generation task.
Args:
prompt: Text prompt for video generation.
model: Model key ('airdrama' or 'airdrama_fast').
content_items: List of media content dicts (image_url, video_url, audio_url).
aspect_ratio: Video aspect ratio ('16:9', '9:16', etc.).
duration: Video duration in seconds.
resolution: Output video resolution ('480p'|'720p'|'1080p'). 必填,不设默认值避免调用者遗漏导致
静默降级1080p 任务若因默认值被意外降为 720p 会产生计费偏差,违反准确性原则)。
注意1080p 仅 Seedance 2.0 支持。
generate_audio: Whether to generate audio with the video.
search_mode: 'smart' to enable internet search, 'off' to disable.
Returns:
dict: API response with task id and status.
"""
url = f'{settings.ARK_BASE_URL}/contents/generations/tasks'
content = []
if prompt:
content.append({'type': 'text', 'text': prompt})
content.extend(content_items)
payload = {
'model': _resolve_model(model),
'content': content,
'generate_audio': generate_audio,
'ratio': aspect_ratio,
'resolution': resolution,
'duration': duration,
'watermark': False,
'seed': seed,
}
if search_mode and search_mode != 'off':
payload['tools'] = [{'type': 'web_search'}]
import logging
logger = logging.getLogger(__name__)
logger.info('AirDrama API payload: %s', {k: v for k, v in payload.items() if k != 'content'})
# 记录 content 中的非文本项,方便排查素材引用问题
media_items = [ci for ci in content if ci.get('type') != 'text']
if media_items:
logger.info('AirDrama content media items (%d): %s', len(media_items), media_items)
resp = requests.post(url, json=payload, headers=_headers(), timeout=60)
if resp.status_code != 200:
# Extract human-readable error from API response
try:
err = resp.json().get('error', {})
code = err.get('code', '')
message = err.get('message', resp.text)
logger.error('AirDrama API error: status=%s code=%s message=%s', resp.status_code, code, message)
except Exception:
code, message = '', resp.text
logger.error('AirDrama API error: status=%s body=%s', resp.status_code, resp.text)
raise AirDramaAPIError(code, message, resp.status_code)
return resp.json()
def query_task(task_id):
"""Query a video generation task by its ARK task ID.
Returns:
dict: Task status, content (video URL on success), usage info.
"""
url = f'{settings.ARK_BASE_URL}/contents/generations/tasks/{task_id}'
resp = requests.get(url, headers=_headers(), timeout=30)
resp.raise_for_status()
return resp.json()
def extract_video_url(task_response):
"""Extract the video URL from a completed task response."""
content = task_response.get('content')
if not content:
return None
# content could be a list of items or a dict
if isinstance(content, list):
for item in content:
if item.get('type') == 'video_url':
return item.get('video_url', {}).get('url')
elif isinstance(content, dict):
if 'video_url' in content:
url = content['video_url']
return url.get('url') if isinstance(url, dict) else url
return None
def map_status(ark_status):
"""Map ARK task status to our DB status."""
mapping = {
'running': 'processing',
'submitted': 'queued',
'queued': 'queued',
'succeeded': 'completed',
'failed': 'failed',
}
return mapping.get(ark_status, 'processing')