火山 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>
199 lines
8.2 KiB
Python
199 lines
8.2 KiB
Python
"""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')
|