火山 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>
209 lines
8.9 KiB
Python
209 lines
8.9 KiB
Python
"""
|
||
1080P 分辨率支持的计费逻辑测试 — 严格对齐用户三原则:
|
||
1. 不兜底/静默降级
|
||
2. 钱的计算绝对准确(纯官方公式)
|
||
3. 不隐藏 bug(非法组合 fail loud)
|
||
|
||
运行方式:
|
||
cd backend && source venv/Scripts/activate && python -m pytest tests/test_1080p_billing.py -v
|
||
或 Django test runner:
|
||
python manage.py test tests.test_1080p_billing
|
||
"""
|
||
import os
|
||
import sys
|
||
import django
|
||
|
||
# Django setup
|
||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||
django.setup()
|
||
|
||
import unittest
|
||
from utils.billing import (
|
||
RESOLUTION_MAP,
|
||
get_resolution,
|
||
estimate_tokens,
|
||
calculate_cost,
|
||
calculate_base_cost,
|
||
)
|
||
|
||
|
||
class TestResolutionMap(unittest.TestCase):
|
||
"""验证 RESOLUTION_MAP 的 18 个组合像素值与官方文档一致。"""
|
||
|
||
def test_1080p_pixels(self):
|
||
# 来自 docs/API文档/创建视频生成任务API.md Seedance 2.0 & 2.0 fast 列
|
||
self.assertEqual(get_resolution('16:9', '1080p'), (1920, 1080))
|
||
self.assertEqual(get_resolution('9:16', '1080p'), (1080, 1920))
|
||
self.assertEqual(get_resolution('4:3', '1080p'), (1664, 1248))
|
||
self.assertEqual(get_resolution('1:1', '1080p'), (1440, 1440))
|
||
self.assertEqual(get_resolution('3:4', '1080p'), (1248, 1664))
|
||
# 21:9 特别注意:是 2206×946,不是 seedance 1.0 的 2176×928
|
||
self.assertEqual(get_resolution('21:9', '1080p'), (2206, 946))
|
||
|
||
def test_720p_pixels(self):
|
||
self.assertEqual(get_resolution('16:9', '720p'), (1280, 720))
|
||
self.assertEqual(get_resolution('21:9', '720p'), (1470, 630))
|
||
|
||
def test_480p_pixels(self):
|
||
self.assertEqual(get_resolution('16:9', '480p'), (864, 496))
|
||
self.assertEqual(get_resolution('21:9', '480p'), (992, 432))
|
||
|
||
def test_invalid_combo_raises(self):
|
||
"""原则 1:非法组合必须 fail loud,不静默降级。"""
|
||
with self.assertRaises(KeyError):
|
||
get_resolution('adaptive', '720p') # adaptive 不在 map
|
||
with self.assertRaises(KeyError):
|
||
get_resolution('16:9', '4K') # 不存在的 tier
|
||
with self.assertRaises(KeyError):
|
||
get_resolution('unknown', 'unknown')
|
||
|
||
def test_tier_required(self):
|
||
"""tier 参数必填,不允许默认 720p 静默降级。"""
|
||
with self.assertRaises(TypeError):
|
||
get_resolution('16:9') # type: ignore - 故意漏参数
|
||
|
||
|
||
class TestEstimateTokens(unittest.TestCase):
|
||
"""
|
||
严格对齐官方公式:`(输入视频时长+输出时长) × 宽 × 高 × 帧率 / 1024`
|
||
预估端不做最低 token 修正(那是火山计费侧逻辑)。
|
||
"""
|
||
|
||
def test_formula_no_input_video(self):
|
||
# 720P 16:9 (1280×720), 5s 输出, 24fps
|
||
# 1280 × 720 × 24 × 5 / 1024 = 108000
|
||
self.assertEqual(estimate_tokens(1280, 720, 5), 108000)
|
||
|
||
def test_formula_with_input_video(self):
|
||
# 720P 16:9, 5s 输出 + 5s 输入 = 10s 总时长
|
||
# 1280 × 720 × 24 × 10 / 1024 = 216000
|
||
self.assertEqual(estimate_tokens(1280, 720, 5, input_video_duration=5), 216000)
|
||
|
||
def test_1080p_formula(self):
|
||
# 1080P 16:9 (1920×1080), 5s 输出, 无输入视频
|
||
# 1920 × 1080 × 24 × 5 / 1024 = 243000
|
||
self.assertEqual(estimate_tokens(1920, 1080, 5), 243000)
|
||
|
||
def test_1080p_with_input_video(self):
|
||
# 1080P 16:9, 5s 输出 + 2s 输入 = 7s
|
||
# 1920 × 1080 × 24 × 7 / 1024 = 340200
|
||
self.assertEqual(estimate_tokens(1920, 1080, 5, input_video_duration=2), 340200)
|
||
|
||
def test_no_silent_min_token_adjustment(self):
|
||
"""原则 2:预估端严格按公式,不做最低 token 修正。
|
||
火山文档说 1080p 5s 输入含视频最低 437400 tokens,但那是火山计费侧的事,
|
||
我们预估就老老实实按公式算 (5s+2s)×1920×1080×24/1024 = 340200,不擅自拉高。
|
||
"""
|
||
# 1080p 5s 输出 + 2s 输入 = 7s 总时长
|
||
# 公式值 340200,官方最低 437400
|
||
# 我们应该返回公式值,不主动调到最低值
|
||
result = estimate_tokens(1920, 1080, 5, input_video_duration=2)
|
||
self.assertEqual(result, 340200, "预估端不应主动修正到火山最低 token")
|
||
|
||
def test_float_input_duration(self):
|
||
"""输入视频时长可能是浮点数(前端 getMediaInfo 读取),要正确累加。"""
|
||
# 720P 16:9, 5s 输出 + 3.5s 输入 = 8.5s
|
||
# 1280 × 720 × 24 × 8.5 / 1024 = 183600
|
||
self.assertEqual(estimate_tokens(1280, 720, 5, input_video_duration=3.5), 183600)
|
||
|
||
|
||
class TestGetTokenPrice(unittest.TestCase):
|
||
"""验证单价选择逻辑 — 4 种模型×视频组合 + 1080p 独立单价 + Fast+1080P fail loud。"""
|
||
|
||
def setUp(self):
|
||
# Mock QuotaConfig — 用官方文档默认值
|
||
from types import SimpleNamespace
|
||
from decimal import Decimal
|
||
self.config = SimpleNamespace(
|
||
base_token_price=Decimal('46'),
|
||
base_token_price_video=Decimal('28'),
|
||
base_token_price_fast=Decimal('37'),
|
||
base_token_price_fast_video=Decimal('22'),
|
||
base_token_price_1080p=Decimal('51'),
|
||
base_token_price_1080p_video=Decimal('31'),
|
||
)
|
||
from apps.generation.views import _get_token_price
|
||
self._get_token_price = _get_token_price
|
||
|
||
def test_seedance_2_0_720p_no_video(self):
|
||
"""AirDrama 720P 不含视频 = 46 元/百万 tokens."""
|
||
price = self._get_token_price(self.config, 'seedance_2.0', False, '720p')
|
||
self.assertEqual(price, 46)
|
||
|
||
def test_seedance_2_0_720p_with_video(self):
|
||
"""AirDrama 720P 含视频 = 28."""
|
||
price = self._get_token_price(self.config, 'seedance_2.0', True, '720p')
|
||
self.assertEqual(price, 28)
|
||
|
||
def test_seedance_2_0_480p_same_as_720p(self):
|
||
"""480p 和 720p 共享同一单价(官方价格一致)。"""
|
||
price_480 = self._get_token_price(self.config, 'seedance_2.0', False, '480p')
|
||
price_720 = self._get_token_price(self.config, 'seedance_2.0', False, '720p')
|
||
self.assertEqual(price_480, price_720)
|
||
|
||
def test_seedance_2_0_1080p_no_video(self):
|
||
"""AirDrama 1080P 不含视频 = 51(独立单价,不是 720p 的 46)."""
|
||
price = self._get_token_price(self.config, 'seedance_2.0', False, '1080p')
|
||
self.assertEqual(price, 51)
|
||
|
||
def test_seedance_2_0_1080p_with_video(self):
|
||
"""AirDrama 1080P 含视频 = 31(独立单价,不是 720p 的 28)."""
|
||
price = self._get_token_price(self.config, 'seedance_2.0', True, '1080p')
|
||
self.assertEqual(price, 31)
|
||
|
||
def test_fast_720p_no_video(self):
|
||
"""Fast 720P 不含视频 = 37."""
|
||
price = self._get_token_price(self.config, 'seedance_2.0_fast', False, '720p')
|
||
self.assertEqual(price, 37)
|
||
|
||
def test_fast_720p_with_video(self):
|
||
"""Fast 720P 含视频 = 22."""
|
||
price = self._get_token_price(self.config, 'seedance_2.0_fast', True, '720p')
|
||
self.assertEqual(price, 22)
|
||
|
||
def test_fast_480p_uses_fast_price(self):
|
||
"""Fast 不分 480p/720p,都用 fast 单价。"""
|
||
price_480 = self._get_token_price(self.config, 'seedance_2.0_fast', False, '480p')
|
||
price_720 = self._get_token_price(self.config, 'seedance_2.0_fast', False, '720p')
|
||
self.assertEqual(price_480, price_720)
|
||
self.assertEqual(price_480, 37)
|
||
|
||
def test_fast_1080p_raises_value_error(self):
|
||
"""原则 1 + 3:Fast + 1080P 必须 fail loud,不能静默按 720p 价(欺骗用户)。"""
|
||
with self.assertRaises(ValueError) as ctx:
|
||
self._get_token_price(self.config, 'seedance_2.0_fast', False, '1080p')
|
||
self.assertIn('1080p', str(ctx.exception).lower())
|
||
|
||
with self.assertRaises(ValueError):
|
||
self._get_token_price(self.config, 'seedance_2.0_fast', True, '1080p')
|
||
|
||
|
||
class TestCalculateCost(unittest.TestCase):
|
||
"""验证扣费金额计算 = tokens × 单价 × (1 + 加价%),精确到分."""
|
||
|
||
def test_720p_cost_matches_official_example(self):
|
||
"""官方示例:720P 5s 16:9 = 4.97 元(无加价)."""
|
||
# 720p 5s 公式值 108000 tokens
|
||
tokens = estimate_tokens(1280, 720, 5)
|
||
# 46 元/百万 × 108000 / 1000000 = 4.968 ≈ 4.97
|
||
cost = calculate_cost(tokens, 46, 0)
|
||
self.assertEqual(str(cost), '4.97')
|
||
|
||
def test_1080p_no_video_cost(self):
|
||
"""1080P 5s 16:9 不含视频 = 1920×1080×24×5/1024 × 51 / 1000000 = 12.393 ≈ 12.39 元."""
|
||
tokens = estimate_tokens(1920, 1080, 5)
|
||
cost = calculate_cost(tokens, 51, 0)
|
||
self.assertEqual(str(cost), '12.39')
|
||
|
||
def test_markup_applied(self):
|
||
"""团队加价 20% 的情况。"""
|
||
tokens = estimate_tokens(1280, 720, 5) # 108000
|
||
cost = calculate_cost(tokens, 46, 20)
|
||
# 4.968 × 1.2 = 5.9616 → 5.96
|
||
self.assertEqual(str(cost), '5.96')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main(verbosity=2)
|