火山 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>
161 lines
5.6 KiB
TypeScript
161 lines
5.6 KiB
TypeScript
/**
|
||
* 1080P 分辨率支持 — 前端单元测试
|
||
*
|
||
* 验证用户三原则:
|
||
* 1. 不兜底/静默降级 — setModel/setResolution 拦截 Fast+1080P 组合
|
||
* 2. 钱的计算绝对准确 — 前端 estimatedTokens 公式与后端一致
|
||
* 3. 不隐藏 bug — 无 || '720p' 兜底
|
||
*/
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { useInputBarStore } from '../../src/store/inputBar';
|
||
|
||
// Mock Toast 避免真实 DOM 调用
|
||
vi.mock('../../src/components/Toast', () => ({
|
||
showToast: vi.fn(),
|
||
}));
|
||
|
||
describe('1080P — Store 分辨率状态', () => {
|
||
beforeEach(() => {
|
||
useInputBarStore.getState().reset();
|
||
});
|
||
|
||
it('默认分辨率是 720p', () => {
|
||
expect(useInputBarStore.getState().resolution).toBe('720p');
|
||
});
|
||
|
||
it('setResolution 能设为 480p / 720p / 1080p', () => {
|
||
const { setResolution } = useInputBarStore.getState();
|
||
setResolution('480p');
|
||
expect(useInputBarStore.getState().resolution).toBe('480p');
|
||
setResolution('1080p');
|
||
expect(useInputBarStore.getState().resolution).toBe('1080p');
|
||
setResolution('720p');
|
||
expect(useInputBarStore.getState().resolution).toBe('720p');
|
||
});
|
||
|
||
it('reset 把分辨率恢复为 720p', () => {
|
||
const { setResolution, reset } = useInputBarStore.getState();
|
||
setResolution('1080p');
|
||
reset();
|
||
expect(useInputBarStore.getState().resolution).toBe('720p');
|
||
});
|
||
});
|
||
|
||
describe('1080P — 双向拦截(原则 1:不静默降级)', () => {
|
||
beforeEach(() => {
|
||
useInputBarStore.getState().reset();
|
||
});
|
||
|
||
it('1080P 下切 Fast 模型应被阻止,resolution 不变,model 也不变', () => {
|
||
const { setResolution, setModel } = useInputBarStore.getState();
|
||
setResolution('1080p');
|
||
setModel('seedance_2.0_fast');
|
||
// 拦截成功:model 保持原值,resolution 不变(不降级为 720p)
|
||
const state = useInputBarStore.getState();
|
||
expect(state.model).toBe('seedance_2.0');
|
||
expect(state.resolution).toBe('1080p');
|
||
});
|
||
|
||
it('Fast 模式下切 1080P 分辨率应被阻止,model 不变,resolution 不变', () => {
|
||
const { setModel, setResolution } = useInputBarStore.getState();
|
||
setModel('seedance_2.0_fast');
|
||
setResolution('1080p');
|
||
const state = useInputBarStore.getState();
|
||
expect(state.model).toBe('seedance_2.0_fast');
|
||
expect(state.resolution).toBe('720p'); // 仍是默认 720p,没被改到 1080p
|
||
});
|
||
|
||
it('AirDrama 下切 1080P 正常生效', () => {
|
||
const { setResolution } = useInputBarStore.getState();
|
||
setResolution('1080p');
|
||
expect(useInputBarStore.getState().resolution).toBe('1080p');
|
||
});
|
||
|
||
it('1080P 下切回 AirDrama 正常生效(同模型不拦截)', () => {
|
||
const { setModel, setResolution } = useInputBarStore.getState();
|
||
setResolution('1080p');
|
||
setModel('seedance_2.0');
|
||
expect(useInputBarStore.getState().model).toBe('seedance_2.0');
|
||
expect(useInputBarStore.getState().resolution).toBe('1080p');
|
||
});
|
||
|
||
it('Fast 下切 480p/720p 正常生效(不是 1080p 不拦截)', () => {
|
||
const { setModel, setResolution } = useInputBarStore.getState();
|
||
setModel('seedance_2.0_fast');
|
||
setResolution('480p');
|
||
expect(useInputBarStore.getState().resolution).toBe('480p');
|
||
setResolution('720p');
|
||
expect(useInputBarStore.getState().resolution).toBe('720p');
|
||
});
|
||
});
|
||
|
||
describe('1080P — 官方像素值(与后端 RESOLUTION_MAP 对齐)', () => {
|
||
// 这里硬编码官方文档的像素表,作为前端契约测试
|
||
// 如果 Toolbar.tsx 的 RESOLUTION_PIXELS 改动,这些测试应该跟着更新
|
||
// 对应 backend/utils/billing.py::RESOLUTION_MAP
|
||
const EXPECTED_PIXELS = {
|
||
'480p': {
|
||
'16:9': [864, 496],
|
||
'9:16': [496, 864],
|
||
'4:3': [752, 560],
|
||
'1:1': [640, 640],
|
||
'3:4': [560, 752],
|
||
'21:9': [992, 432],
|
||
},
|
||
'720p': {
|
||
'16:9': [1280, 720],
|
||
'9:16': [720, 1280],
|
||
'4:3': [1112, 834],
|
||
'1:1': [960, 960],
|
||
'3:4': [834, 1112],
|
||
'21:9': [1470, 630],
|
||
},
|
||
'1080p': {
|
||
'16:9': [1920, 1080],
|
||
'9:16': [1080, 1920],
|
||
'4:3': [1664, 1248],
|
||
'1:1': [1440, 1440],
|
||
'3:4': [1248, 1664],
|
||
'21:9': [2206, 946], // 关键:不是 2176×928(seedance 1.0 值)
|
||
},
|
||
};
|
||
|
||
// estimate_tokens 官方公式实现(对齐前端 Toolbar 和后端 billing.py)
|
||
function estimateTokens(w: number, h: number, duration: number, inputVideoDuration = 0) {
|
||
return Math.round((w * h * 24 * (duration + inputVideoDuration)) / 1024);
|
||
}
|
||
|
||
it('1080P 5s 16:9 无输入视频 = 243000 tokens', () => {
|
||
const [w, h] = EXPECTED_PIXELS['1080p']['16:9'];
|
||
expect(estimateTokens(w, h, 5)).toBe(243000);
|
||
});
|
||
|
||
it('1080P 5s 16:9 含 2s 输入视频 = 340200 tokens(纯公式,不修正到最低 437400)', () => {
|
||
const [w, h] = EXPECTED_PIXELS['1080p']['16:9'];
|
||
expect(estimateTokens(w, h, 5, 2)).toBe(340200);
|
||
});
|
||
|
||
it('720P 5s 16:9 无输入视频 = 108000 tokens', () => {
|
||
const [w, h] = EXPECTED_PIXELS['720p']['16:9'];
|
||
expect(estimateTokens(w, h, 5)).toBe(108000);
|
||
});
|
||
|
||
it('1080P 21:9 像素 = 2206×946(不是 seedance 1.0 的 2176×928)', () => {
|
||
expect(EXPECTED_PIXELS['1080p']['21:9']).toEqual([2206, 946]);
|
||
});
|
||
|
||
it('价格示例:1080P 5s 16:9 × 51 元/百万 = 12.39 元', () => {
|
||
const tokens = 243000;
|
||
const price = 51;
|
||
const cost = (tokens * price) / 1_000_000;
|
||
expect(cost.toFixed(2)).toBe('12.39');
|
||
});
|
||
|
||
it('价格示例:720P 5s 16:9 × 46 元/百万 = 4.97 元', () => {
|
||
const tokens = 108000;
|
||
const price = 46;
|
||
const cost = (tokens * price) / 1_000_000;
|
||
expect(cost.toFixed(2)).toBe('4.97');
|
||
});
|
||
});
|