火山 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>
112 lines
3.8 KiB
Python
112 lines
3.8 KiB
Python
"""
|
||
计费工具模块 — 分辨率映射 + token/费用计算
|
||
|
||
Token 预估公式(火山官方):(宽 × 高 × 帧率 × 时长) / 1024
|
||
单价:元 / 百万 tokens
|
||
"""
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
|
||
# 分辨率 → 像素映射(来自 Seedance 2.0 API 文档)
|
||
RESOLUTION_MAP = {
|
||
# 720p
|
||
('720p', '16:9'): (1280, 720),
|
||
('720p', '9:16'): (720, 1280),
|
||
('720p', '4:3'): (1112, 834),
|
||
('720p', '1:1'): (960, 960),
|
||
('720p', '3:4'): (834, 1112),
|
||
('720p', '21:9'): (1470, 630),
|
||
# 480p
|
||
('480p', '16:9'): (864, 496),
|
||
('480p', '9:16'): (496, 864),
|
||
('480p', '4:3'): (752, 560),
|
||
('480p', '1:1'): (640, 640),
|
||
('480p', '3:4'): (560, 752),
|
||
('480p', '21:9'): (992, 432),
|
||
# 1080p (来自火山 API 文档,Seedance 2.0 & 2.0 fast 列)
|
||
('1080p', '16:9'): (1920, 1080),
|
||
('1080p', '9:16'): (1080, 1920),
|
||
('1080p', '4:3'): (1664, 1248),
|
||
('1080p', '1:1'): (1440, 1440),
|
||
('1080p', '3:4'): (1248, 1664),
|
||
('1080p', '21:9'): (2206, 946),
|
||
}
|
||
|
||
# 默认帧率
|
||
DEFAULT_FPS = 24
|
||
|
||
|
||
def get_resolution(aspect_ratio: str, tier: str) -> tuple:
|
||
"""根据宽高比和分辨率档位返回 (width, height) 像素值。
|
||
|
||
tier 必填,不设默认值 — 避免调用者遗漏时静默降级为 720p(违反计费准确性原则)。
|
||
若 (tier, aspect_ratio) 组合不在 RESOLUTION_MAP(如 adaptive),raise KeyError,
|
||
让上游感知并 fail loud。上游(serializer/前端)负责保证合法组合。
|
||
"""
|
||
key = (tier, aspect_ratio)
|
||
if key not in RESOLUTION_MAP:
|
||
raise KeyError(
|
||
f'不支持的分辨率组合: tier={tier!r}, aspect_ratio={aspect_ratio!r}. '
|
||
f'仅支持 480p/720p/1080p × 16:9/9:16/4:3/1:1/3:4/21:9'
|
||
)
|
||
return RESOLUTION_MAP[key]
|
||
|
||
|
||
def estimate_tokens(
|
||
width: int,
|
||
height: int,
|
||
duration: int,
|
||
fps: int = DEFAULT_FPS,
|
||
input_video_duration: float = 0,
|
||
) -> int:
|
||
"""预估视频生成消耗的 tokens。
|
||
|
||
火山官方公式:`(输入视频时长 + 输出视频时长) × 宽 × 高 × 帧率 / 1024`
|
||
|
||
⚠️ 这是预估值,仅用于前端展示和额度冻结。
|
||
真实费用以火山 API 返回的 usage.total_tokens 为准(`_settle_payment` 中按实际值结算)。
|
||
最低 token 用量限制是火山计费端的逻辑,我方不在预估端维护该表(避免与官方脱钩)。
|
||
|
||
Args:
|
||
width: 输出视频宽度(像素)
|
||
height: 输出视频高度(像素)
|
||
duration: 输出视频时长(秒)
|
||
fps: 帧率,默认 24
|
||
input_video_duration: 输入参考视频的总时长(秒),默认 0
|
||
|
||
Returns:
|
||
token 估算值(整数)
|
||
"""
|
||
total_duration = duration + (input_video_duration or 0)
|
||
return round(width * height * fps * total_duration / 1024)
|
||
|
||
|
||
def calculate_cost(tokens: int, base_price, markup_percentage) -> Decimal:
|
||
"""计算用户费用(加价后)。
|
||
|
||
Args:
|
||
tokens: 消耗的 tokens 数
|
||
base_price: 成本价(元/百万tokens)
|
||
markup_percentage: 加价百分比,如 20 表示 20%
|
||
Returns:
|
||
Decimal: 加价后费用,保留 2 位小数
|
||
"""
|
||
base_price = Decimal(str(base_price))
|
||
markup = Decimal(str(markup_percentage))
|
||
team_price = base_price * (1 + markup / 100)
|
||
cost = Decimal(str(tokens)) * team_price / Decimal('1000000')
|
||
return cost.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||
|
||
|
||
def calculate_base_cost(tokens: int, base_price) -> Decimal:
|
||
"""计算平台成本(不加价)。
|
||
|
||
Args:
|
||
tokens: 消耗的 tokens 数
|
||
base_price: 成本价(元/百万tokens)
|
||
Returns:
|
||
Decimal: 成本费用,保留 2 位小数
|
||
"""
|
||
base_price = Decimal(str(base_price))
|
||
cost = Decimal(str(tokens)) * base_price / Decimal('1000000')
|
||
return cost.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|