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>
This commit is contained in:
parent
624e12ae46
commit
39667ff19c
@ -241,6 +241,8 @@ def me_view(request):
|
||||
'token_price_video': float(config.base_token_price_video) * markup_mult,
|
||||
'token_price_fast': float(config.base_token_price_fast) * markup_mult,
|
||||
'token_price_fast_video': float(config.base_token_price_fast_video) * markup_mult,
|
||||
'token_price_1080p': float(config.base_token_price_1080p) * markup_mult,
|
||||
'token_price_1080p_video': float(config.base_token_price_1080p_video) * markup_mult,
|
||||
'is_active': team.is_active,
|
||||
}
|
||||
data['team_disabled'] = not team.is_active
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
# Generated by Django 4.2.29 on 2026-04-17 18:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def backfill_empty_resolution(apps, schema_editor):
|
||||
"""将历史 resolution='' 的记录回填为 '720p'(choices 约束前的旧数据)。"""
|
||||
GenerationRecord = apps.get_model('generation', 'GenerationRecord')
|
||||
GenerationRecord.objects.filter(resolution='').update(resolution='720p')
|
||||
|
||||
|
||||
def reverse_backfill(apps, schema_editor):
|
||||
"""回滚时不恢复为空字符串(历史数据无法精确识别)。"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generation', '0019_duration_nullable'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='quotaconfig',
|
||||
name='base_token_price_1080p',
|
||||
field=models.DecimalField(decimal_places=2, default=51, max_digits=10, verbose_name='1080P单价-不含视频(元/百万tokens)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='quotaconfig',
|
||||
name='base_token_price_1080p_video',
|
||||
field=models.DecimalField(decimal_places=2, default=31, max_digits=10, verbose_name='1080P单价-含视频(元/百万tokens)'),
|
||||
),
|
||||
# 先回填历史空值,再改 choices 约束,避免 MySQL 严格模式 IntegrityError
|
||||
migrations.RunPython(backfill_empty_resolution, reverse_backfill),
|
||||
migrations.AlterField(
|
||||
model_name='generationrecord',
|
||||
name='resolution',
|
||||
field=models.CharField(choices=[('480p', '480P'), ('720p', '720P'), ('1080p', '1080P')], default='720p', max_length=10, verbose_name='分辨率'),
|
||||
),
|
||||
]
|
||||
@ -19,6 +19,11 @@ class GenerationRecord(models.Model):
|
||||
('completed', '已完成'),
|
||||
('failed', '失败'),
|
||||
]
|
||||
RESOLUTION_CHOICES = [
|
||||
('480p', '480P'),
|
||||
('720p', '720P'),
|
||||
('1080p', '1080P'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@ -39,7 +44,7 @@ class GenerationRecord(models.Model):
|
||||
cost_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='用户费用(元)')
|
||||
base_cost_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='平台成本(元)')
|
||||
frozen_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='冻结金额(元)')
|
||||
resolution = models.CharField(max_length=10, blank=True, default='', verbose_name='分辨率')
|
||||
resolution = models.CharField(max_length=10, choices=RESOLUTION_CHOICES, default='720p', verbose_name='分辨率')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态')
|
||||
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
|
||||
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='视频缩略图URL')
|
||||
@ -97,6 +102,8 @@ class QuotaConfig(models.Model):
|
||||
base_token_price_video = models.DecimalField(max_digits=10, decimal_places=2, default=28, verbose_name='基础token单价-含视频(元/百万tokens)')
|
||||
base_token_price_fast = models.DecimalField(max_digits=10, decimal_places=2, default=37, verbose_name='Fast单价-不含视频(元/百万tokens)')
|
||||
base_token_price_fast_video = models.DecimalField(max_digits=10, decimal_places=2, default=22, verbose_name='Fast单价-含视频(元/百万tokens)')
|
||||
base_token_price_1080p = models.DecimalField(max_digits=10, decimal_places=2, default=51, verbose_name='1080P单价-不含视频(元/百万tokens)')
|
||||
base_token_price_1080p_video = models.DecimalField(max_digits=10, decimal_places=2, default=31, verbose_name='1080P单价-含视频(元/百万tokens)')
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -5,8 +5,11 @@ class VideoGenerateSerializer(serializers.Serializer):
|
||||
prompt = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
mode = serializers.ChoiceField(choices=['universal', 'keyframe'])
|
||||
model = serializers.ChoiceField(choices=['seedance_2.0', 'seedance_2.0_fast'])
|
||||
aspect_ratio = serializers.CharField(max_length=10)
|
||||
# 显式枚举拒绝 adaptive(火山默认值)— 估算/计费需要明确宽高
|
||||
aspect_ratio = serializers.ChoiceField(choices=['16:9', '9:16', '4:3', '1:1', '3:4', '21:9'])
|
||||
duration = serializers.IntegerField()
|
||||
# 1080p 仅 Seedance 2.0 支持,Fast 不支持 — 上层 video_generate_view 会做 model/resolution 组合校验
|
||||
resolution = serializers.ChoiceField(choices=['480p', '720p', '1080p'], required=False, default='720p')
|
||||
references = serializers.ListField(child=serializers.DictField(), required=False, default=list)
|
||||
|
||||
|
||||
@ -40,6 +43,8 @@ class SystemSettingsSerializer(serializers.Serializer):
|
||||
base_token_price_video = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False)
|
||||
base_token_price_fast = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False)
|
||||
base_token_price_fast_video = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False)
|
||||
base_token_price_1080p = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False)
|
||||
base_token_price_1080p_video = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False)
|
||||
announcement = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
announcement_enabled = serializers.BooleanField(required=False, default=False)
|
||||
max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1)
|
||||
|
||||
@ -55,10 +55,38 @@ def _has_video_reference(references):
|
||||
return any(ref.get('type') == 'video' for ref in references)
|
||||
|
||||
|
||||
def _get_token_price(config, model, has_video_ref):
|
||||
"""根据模型和是否有视频参考选择单价。"""
|
||||
def _sum_video_duration(references):
|
||||
"""累加所有视频类型参考素材的 duration(秒),用于 token 估算的输入时长。"""
|
||||
if not references:
|
||||
return 0.0
|
||||
total = 0.0
|
||||
for ref in references:
|
||||
if ref.get('type') == 'video':
|
||||
try:
|
||||
total += float(ref.get('duration') or 0)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def _get_token_price(config, model, has_video_ref, resolution):
|
||||
"""根据模型、是否含视频、分辨率选择单价。
|
||||
|
||||
约束(与官方文档一致):
|
||||
- Seedance 2.0 Fast 不支持 1080p — 此组合在 UI 层已阻止、VideoGenerateSerializer
|
||||
也会在 video_generate_view 中拒绝。若仍进到这里,表示前端约束失效或绕过前端
|
||||
直接调 API,应 fail loud,绝不按 720p 价静默降级(那是欺骗用户)。
|
||||
- 1080p 仅 Seedance 2.0 使用独立单价(51/31)
|
||||
- 480p 和 720p 共享同一单价
|
||||
"""
|
||||
if model == 'seedance_2.0_fast' and resolution == '1080p':
|
||||
raise ValueError(
|
||||
'Seedance 2.0 Fast 不支持 1080p — 前端应阻止此组合,不应进到计价函数'
|
||||
)
|
||||
if model == 'seedance_2.0_fast':
|
||||
return config.base_token_price_fast_video if has_video_ref else config.base_token_price_fast
|
||||
if resolution == '1080p':
|
||||
return config.base_token_price_1080p_video if has_video_ref else config.base_token_price_1080p
|
||||
return config.base_token_price_video if has_video_ref else config.base_token_price
|
||||
|
||||
|
||||
@ -175,15 +203,26 @@ def video_generate_view(request):
|
||||
mode = serializer.validated_data['mode']
|
||||
model = serializer.validated_data['model']
|
||||
aspect_ratio = serializer.validated_data['aspect_ratio']
|
||||
# serializer 已设 default='720p' + choices 约束,validated_data 必有合法值
|
||||
resolution = serializer.validated_data['resolution']
|
||||
search_mode = request.data.get('search_mode', 'off')
|
||||
seed = _safe_int(request.data.get('seed', -1), -1)
|
||||
|
||||
# 1080P 仅 Seedance 2.0 支持,Fast 不支持
|
||||
if resolution == '1080p' and model == 'seedance_2.0_fast':
|
||||
return Response({
|
||||
'error': 'invalid_resolution',
|
||||
'message': '1080P 仅支持 AirDrama 模型,AirDrama Fast 不支持 1080P,请切换模型或选择 720P',
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# ── 预估 token 和费用 ──
|
||||
config = QuotaConfig.objects.get_or_create(pk=1)[0]
|
||||
w, h = get_resolution(aspect_ratio)
|
||||
estimated_tokens = estimate_tokens(w, h, duration)
|
||||
has_video_ref = _has_video_reference(request.data.get('references', []))
|
||||
token_price = _get_token_price(config, model, has_video_ref)
|
||||
references = request.data.get('references', [])
|
||||
w, h = get_resolution(aspect_ratio, resolution)
|
||||
has_video_ref = _has_video_reference(references)
|
||||
input_video_dur = _sum_video_duration(references) if has_video_ref else 0
|
||||
estimated_tokens = estimate_tokens(w, h, duration, input_video_duration=input_video_dur)
|
||||
token_price = _get_token_price(config, model, has_video_ref, resolution)
|
||||
estimated_cost = calculate_cost(estimated_tokens, token_price, team.markup_percentage)
|
||||
|
||||
# ── 所有额度检查在 transaction 内完成,select_for_update 串行化同团队请求 ──
|
||||
@ -447,7 +486,7 @@ def video_generate_view(request):
|
||||
duration=duration,
|
||||
seconds_consumed=duration,
|
||||
frozen_amount=estimated_cost,
|
||||
resolution='720p',
|
||||
resolution=resolution,
|
||||
tokens_consumed=0,
|
||||
cost_amount=0,
|
||||
base_cost_amount=0,
|
||||
@ -471,6 +510,7 @@ def video_generate_view(request):
|
||||
duration=duration,
|
||||
search_mode=search_mode,
|
||||
seed=seed,
|
||||
resolution=resolution,
|
||||
)
|
||||
ark_task_id = ark_response.get('id', '')
|
||||
record.ark_task_id = ark_task_id
|
||||
@ -550,7 +590,9 @@ def _settle_payment(record, total_tokens):
|
||||
return
|
||||
config = QuotaConfig.objects.get_or_create(pk=1)[0]
|
||||
has_video_ref = _has_video_reference(record.reference_urls)
|
||||
token_price = _get_token_price(config, record.model, has_video_ref)
|
||||
# 按任务实际 resolution 取单价(1080P 任务用 1080P 单价结算)
|
||||
# record.resolution 有 model 层 default='720p' + choices 约束 + data migration 回填,永远不为空
|
||||
token_price = _get_token_price(config, record.model, has_video_ref, record.resolution)
|
||||
actual_cost = calculate_cost(total_tokens, token_price, team.markup_percentage)
|
||||
base_cost = calculate_base_cost(total_tokens, token_price)
|
||||
frozen = record.frozen_amount
|
||||
@ -634,6 +676,7 @@ def _serialize_task(record):
|
||||
'mode': record.mode,
|
||||
'model': record.model,
|
||||
'aspect_ratio': record.aspect_ratio,
|
||||
'resolution': record.resolution,
|
||||
'duration': record.duration,
|
||||
'seconds_consumed': record.seconds_consumed,
|
||||
'tokens_consumed': record.tokens_consumed,
|
||||
@ -1705,6 +1748,7 @@ def admin_records_view(request):
|
||||
'mode': r.mode,
|
||||
'model': r.model,
|
||||
'aspect_ratio': r.aspect_ratio,
|
||||
'resolution': r.resolution,
|
||||
'status': r.status,
|
||||
'error_message': r.error_message or '',
|
||||
'raw_error': r.raw_error or '',
|
||||
@ -1768,6 +1812,7 @@ def team_records_view(request):
|
||||
'mode': r.mode,
|
||||
'model': r.model,
|
||||
'aspect_ratio': r.aspect_ratio,
|
||||
'resolution': r.resolution,
|
||||
'status': r.status,
|
||||
'error_message': r.error_message or '',
|
||||
'raw_error': r.raw_error or '',
|
||||
@ -2656,6 +2701,7 @@ def profile_records_view(request):
|
||||
'mode': r.mode,
|
||||
'model': r.model,
|
||||
'aspect_ratio': r.aspect_ratio,
|
||||
'resolution': r.resolution,
|
||||
'status': r.status,
|
||||
'error_message': r.error_message or '',
|
||||
})
|
||||
@ -2788,6 +2834,7 @@ def admin_assets_user_videos(request, user_id):
|
||||
'duration': r.duration,
|
||||
'seconds_consumed': r.seconds_consumed,
|
||||
'aspect_ratio': r.aspect_ratio,
|
||||
'resolution': r.resolution,
|
||||
'reference_urls': r.reference_urls or [],
|
||||
'created_at': r.created_at.isoformat(),
|
||||
})
|
||||
@ -2869,6 +2916,7 @@ def team_assets_member_videos(request, member_id):
|
||||
'duration': r.duration,
|
||||
'seconds_consumed': r.seconds_consumed,
|
||||
'aspect_ratio': r.aspect_ratio,
|
||||
'resolution': r.resolution,
|
||||
'reference_urls': r.reference_urls or [],
|
||||
'created_at': r.created_at.isoformat(),
|
||||
})
|
||||
|
||||
131
backend/tests/test_1080p_api.py
Normal file
131
backend/tests/test_1080p_api.py
Normal file
@ -0,0 +1,131 @@
|
||||
"""
|
||||
1080P API 集成测试 — 验证 video_generate_view 的入口校验。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
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 django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APIClient
|
||||
from apps.accounts.models import Team
|
||||
from apps.generation.models import QuotaConfig
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TestVideoGenerateResolution(TestCase):
|
||||
"""video_generate_view 的分辨率+模型组合校验。"""
|
||||
|
||||
def setUp(self):
|
||||
# 初始化 QuotaConfig
|
||||
QuotaConfig.objects.get_or_create(pk=1)
|
||||
|
||||
# 建测试 team + user
|
||||
self.team = Team.objects.create(
|
||||
name='test-1080p',
|
||||
is_active=True,
|
||||
monthly_spending_limit=1000,
|
||||
markup_percentage=0,
|
||||
balance=1000,
|
||||
frozen_amount=0,
|
||||
)
|
||||
self.user = User.objects.create_user(
|
||||
username='test_1080p_user',
|
||||
email='test1080p@example.com',
|
||||
password='testpass123',
|
||||
team=self.team,
|
||||
spending_limit=-1,
|
||||
daily_generation_limit=-1,
|
||||
monthly_generation_limit=-1,
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_reject_fast_plus_1080p(self):
|
||||
"""原则 1:Fast + 1080P 组合必须 400 拒绝,不能静默降级。"""
|
||||
resp = self.client.post('/api/v1/video/generate', {
|
||||
'prompt': '测试',
|
||||
'mode': 'universal',
|
||||
'model': 'seedance_2.0_fast',
|
||||
'aspect_ratio': '16:9',
|
||||
'duration': 5,
|
||||
'resolution': '1080p',
|
||||
'references': [],
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
body = resp.json()
|
||||
self.assertEqual(body.get('error'), 'invalid_resolution')
|
||||
# 提示信息要明确告知用户原因
|
||||
self.assertIn('1080P', body.get('message', ''))
|
||||
self.assertIn('Fast', body.get('message', ''))
|
||||
|
||||
def test_reject_adaptive_ratio(self):
|
||||
"""原则 1:adaptive 不在 6 选 1 白名单,拒绝。"""
|
||||
resp = self.client.post('/api/v1/video/generate', {
|
||||
'prompt': '测试',
|
||||
'mode': 'universal',
|
||||
'model': 'seedance_2.0',
|
||||
'aspect_ratio': 'adaptive',
|
||||
'duration': 5,
|
||||
'resolution': '720p',
|
||||
'references': [],
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
# serializer 错误:aspect_ratio 不在 choices
|
||||
self.assertIn('aspect_ratio', str(resp.content))
|
||||
|
||||
def test_reject_invalid_resolution(self):
|
||||
"""resolution 不在 480p/720p/1080p 白名单,拒绝。"""
|
||||
resp = self.client.post('/api/v1/video/generate', {
|
||||
'prompt': '测试',
|
||||
'mode': 'universal',
|
||||
'model': 'seedance_2.0',
|
||||
'aspect_ratio': '16:9',
|
||||
'duration': 5,
|
||||
'resolution': '4K',
|
||||
'references': [],
|
||||
}, format='json')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_resolution_default_720p_when_missing(self):
|
||||
"""旧客户端不传 resolution 字段时,serializer default='720p' 生效。"""
|
||||
# 不传 resolution(兼容旧客户端)
|
||||
resp = self.client.post('/api/v1/video/generate', {
|
||||
'prompt': '测试',
|
||||
'mode': 'universal',
|
||||
'model': 'seedance_2.0',
|
||||
'aspect_ratio': '16:9',
|
||||
'duration': 5,
|
||||
'references': [],
|
||||
}, format='json')
|
||||
# serializer 应该接受(default='720p');可能因火山 API 未开通等其他原因失败,
|
||||
# 但不该是 resolution 相关的 400 错误
|
||||
if resp.status_code == 400:
|
||||
body = resp.json()
|
||||
self.assertNotEqual(body.get('error'), 'invalid_resolution')
|
||||
|
||||
def test_accept_valid_1080p_airdrama(self):
|
||||
"""原则:AirDrama + 1080P 组合合法,不被 400 拒绝。"""
|
||||
resp = self.client.post('/api/v1/video/generate', {
|
||||
'prompt': '测试',
|
||||
'mode': 'universal',
|
||||
'model': 'seedance_2.0',
|
||||
'aspect_ratio': '16:9',
|
||||
'duration': 5,
|
||||
'resolution': '1080p',
|
||||
'references': [],
|
||||
}, format='json')
|
||||
# 不应该因为分辨率被 400(可能因余额/API 未开通等其他原因失败)
|
||||
if resp.status_code == 400:
|
||||
body = resp.json()
|
||||
self.assertNotEqual(body.get('error'), 'invalid_resolution')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
208
backend/tests/test_1080p_billing.py
Normal file
208
backend/tests/test_1080p_billing.py
Normal file
@ -0,0 +1,208 @@
|
||||
"""
|
||||
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)
|
||||
@ -92,7 +92,7 @@ def _headers():
|
||||
}
|
||||
|
||||
|
||||
def create_task(prompt, model, content_items, aspect_ratio, duration,
|
||||
def create_task(prompt, model, content_items, aspect_ratio, duration, resolution,
|
||||
generate_audio=True, search_mode='off', seed=-1):
|
||||
"""Create a video generation task.
|
||||
|
||||
@ -102,6 +102,9 @@ def create_task(prompt, model, content_items, aspect_ratio, duration,
|
||||
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.
|
||||
|
||||
@ -120,6 +123,7 @@ def create_task(prompt, model, content_items, aspect_ratio, duration,
|
||||
'content': content,
|
||||
'generate_audio': generate_audio,
|
||||
'ratio': aspect_ratio,
|
||||
'resolution': resolution,
|
||||
'duration': duration,
|
||||
'watermark': False,
|
||||
'seed': seed,
|
||||
|
||||
@ -22,20 +22,62 @@ RESOLUTION_MAP = {
|
||||
('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 = '720p') -> tuple:
|
||||
"""根据宽高比和分辨率档位返回 (width, height) 像素值。"""
|
||||
return RESOLUTION_MAP.get((tier, aspect_ratio), (1280, 720))
|
||||
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) -> int:
|
||||
"""预估视频生成消耗的 tokens。"""
|
||||
return round(width * height * fps * duration / 1024)
|
||||
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:
|
||||
|
||||
@ -5,6 +5,7 @@ interface DropdownItem {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
@ -41,8 +42,10 @@ export function Dropdown({ items, value, onSelect, trigger, minWidth = 150 }: Dr
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.value}
|
||||
className={`${styles.item} ${value === item.value ? styles.selected : ''}`}
|
||||
className={`${styles.item} ${value === item.value ? styles.selected : ''} ${item.disabled ? styles.disabled : ''}`}
|
||||
style={item.disabled ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
onSelect(item.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
|
||||
@ -389,7 +389,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
<span>时长</span><span>{task.duration}s</span>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span>分辨率</span><span>720p</span>
|
||||
<span>分辨率</span><span>{task.resolution.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span>模型</span>
|
||||
|
||||
@ -4,7 +4,8 @@ import { useGenerationStore } from '../store/generation';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { Dropdown } from './Dropdown';
|
||||
import { showToast } from './Toast';
|
||||
import type { CreationMode, AspectRatio, Duration, GenerationType, ModelOption } from '../types';
|
||||
import { parseAssetMentions } from '../lib/assetMentions';
|
||||
import type { CreationMode, AspectRatio, Duration, Resolution, GenerationType, ModelOption } from '../types';
|
||||
import styles from './Toolbar.module.css';
|
||||
|
||||
const VideoIcon = () => (
|
||||
@ -71,10 +72,7 @@ const generationTypeItems = [
|
||||
{ label: '视频生成', value: 'video' as GenerationType, icon: <VideoIcon /> },
|
||||
];
|
||||
|
||||
const modelItems = [
|
||||
{ label: 'AirDrama', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> },
|
||||
{ label: 'AirDrama Fast', value: 'seedance_2.0_fast' as ModelOption, icon: <LightningIcon /> },
|
||||
];
|
||||
// NOTE: modelItems 在组件内部按 resolution 动态构建(1080P 下 Fast 置灰)
|
||||
|
||||
const modeItems = [
|
||||
{ label: '全能参考', value: 'universal' as CreationMode, icon: <StarIcon /> },
|
||||
@ -99,9 +97,20 @@ const durationItems = Array.from({ length: 12 }, (_, i) => {
|
||||
return { label: `${v}s`, value: String(v) };
|
||||
});
|
||||
|
||||
const RESOLUTION_MAP: Record<string, [number, number]> = {
|
||||
'16:9': [1280, 720], '9:16': [720, 1280], '4:3': [1112, 834],
|
||||
'1:1': [960, 960], '3:4': [834, 1112], '21:9': [1470, 630],
|
||||
// 对照 billing.py::RESOLUTION_MAP — 前端预估与后端计费保持一致
|
||||
const RESOLUTION_PIXELS: Record<Resolution, Record<string, [number, number]>> = {
|
||||
'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],
|
||||
},
|
||||
};
|
||||
|
||||
const modeLabels: Record<CreationMode, string> = {
|
||||
@ -120,30 +129,67 @@ export function Toolbar() {
|
||||
const setAspectRatio = useInputBarStore((s) => s.setAspectRatio);
|
||||
const duration = useInputBarStore((s) => s.duration);
|
||||
const setDuration = useInputBarStore((s) => s.setDuration);
|
||||
const resolution = useInputBarStore((s) => s.resolution);
|
||||
const setResolution = useInputBarStore((s) => s.setResolution);
|
||||
const isSubmittable = useInputBarStore((s) => s.canSubmit());
|
||||
const triggerInsertAt = useInputBarStore((s) => s.triggerInsertAt);
|
||||
|
||||
const isKeyframe = mode === 'keyframe';
|
||||
const references = useInputBarStore((s) => s.references);
|
||||
const editorHtml = useInputBarStore((s) => s.editorHtml);
|
||||
const team = useAuthStore((s) => s.team);
|
||||
|
||||
const addTask = useGenerationStore((s) => s.addTask);
|
||||
|
||||
const estimatedTokens = useMemo(() => {
|
||||
const res = RESOLUTION_MAP[aspectRatio] || [1280, 720];
|
||||
return Math.round((res[0] * res[1] * 24 * duration) / 1024);
|
||||
}, [aspectRatio, duration]);
|
||||
// 官方公式:`(输入视频时长 + 输出视频时长) × 宽 × 高 × 24fps / 1024`
|
||||
// 前后端必须一致(和 backend/utils/billing.py::estimate_tokens 对齐)。
|
||||
// 输入视频时长 = 直接上传的视频 references.duration + 素材库 @ 视频的 duration
|
||||
// resolution / aspectRatio 都是严格类型枚举,不做 || 兜底 — bug 直接暴露。
|
||||
const [w, h] = RESOLUTION_PIXELS[resolution][aspectRatio];
|
||||
const refVideoDur = references
|
||||
.filter((r) => r.type === 'video' && typeof r.duration === 'number')
|
||||
.reduce((sum, r) => sum + (r.duration || 0), 0);
|
||||
const mentionVideoDur = parseAssetMentions(editorHtml).durations.video;
|
||||
const totalDuration = duration + refVideoDur + mentionVideoDur;
|
||||
return Math.round((w * h * 24 * totalDuration) / 1024);
|
||||
}, [aspectRatio, duration, resolution, references, editorHtml]);
|
||||
|
||||
// 分辨率 Dropdown:Fast 模式下 1080P 置灰
|
||||
const resolutionItems = useMemo(() => [
|
||||
{ label: '480P', value: '480p' as Resolution },
|
||||
{ label: '720P', value: '720p' as Resolution },
|
||||
{
|
||||
label: model === 'seedance_2.0_fast' ? '1080P(Fast 不支持)' : '1080P',
|
||||
value: '1080p' as Resolution,
|
||||
disabled: model === 'seedance_2.0_fast',
|
||||
},
|
||||
], [model]);
|
||||
|
||||
// 模型 Dropdown:当前 1080P 时,Fast 置灰(1080P 仅 AirDrama 支持)
|
||||
const modelItems = useMemo(() => [
|
||||
{ label: 'AirDrama', value: 'seedance_2.0', icon: <DiamondIcon /> },
|
||||
{
|
||||
label: resolution === '1080p' ? 'AirDrama Fast(不支持 1080P)' : 'AirDrama Fast',
|
||||
value: 'seedance_2.0_fast',
|
||||
icon: <LightningIcon />,
|
||||
disabled: resolution === '1080p',
|
||||
},
|
||||
], [resolution]);
|
||||
|
||||
const estimatedCost = useMemo(() => {
|
||||
const hasVideoRef = references.some((r) => r.type === 'video');
|
||||
let price = team?.token_price || 0;
|
||||
if (model === 'seedance_2.0_fast') {
|
||||
// Fast 不支持 1080p,单价不分分辨率
|
||||
price = hasVideoRef ? (team?.token_price_fast_video || 0) : (team?.token_price_fast || 0);
|
||||
} else if (resolution === '1080p') {
|
||||
price = hasVideoRef ? (team?.token_price_1080p_video || 0) : (team?.token_price_1080p || 0);
|
||||
} else {
|
||||
price = hasVideoRef ? (team?.token_price_video || 0) : (team?.token_price || 0);
|
||||
}
|
||||
return (estimatedTokens * price / 1000000).toFixed(2);
|
||||
}, [estimatedTokens, model, references, team]);
|
||||
}, [estimatedTokens, model, resolution, references, team]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (!isSubmittable) {
|
||||
@ -224,6 +270,19 @@ export function Toolbar() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Resolution */}
|
||||
<Dropdown
|
||||
items={resolutionItems}
|
||||
value={resolution}
|
||||
onSelect={(v) => setResolution(v as Resolution)}
|
||||
minWidth={100}
|
||||
trigger={
|
||||
<button className={styles.btn}>
|
||||
<span className={styles.label}>{resolution.toUpperCase()}</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Duration */}
|
||||
<Dropdown
|
||||
items={durationItems}
|
||||
@ -264,7 +323,7 @@ export function Toolbar() {
|
||||
{isSubmittable && (team?.token_price || 0) > 0 && (
|
||||
<span
|
||||
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', marginRight: 16, lineHeight: 1 }}
|
||||
title={`预估公式: (宽 x 高 x 24fps x 时长) / 1024 = tokens, tokens x 单价 / 1000000 = 费用`}
|
||||
title={`预估公式: (宽 × 高 × 24fps × 时长) / 1024 = tokens, tokens × 单价 / 1000000 = 费用\n⚠️ 仅为预估值,实际费用以火山 API 返回的 token 数为准`}
|
||||
>
|
||||
预估消耗:{estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost}
|
||||
</span>
|
||||
|
||||
@ -220,6 +220,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
if (task.model) store.setModel(task.model as 'seedance_2.0' | 'seedance_2.0_fast');
|
||||
if (task.aspectRatio) store.setAspectRatio(task.aspectRatio as any);
|
||||
if (task.duration) store.setDuration(task.duration);
|
||||
if (task.resolution) store.setResolution(task.resolution);
|
||||
// Load references from task (exclude asset library refs — they restore via @mentions in editorHtml)
|
||||
if (task.references && task.references.length > 0) {
|
||||
const refs = task.references.filter(r => r.previewUrl && !r.isAssetRef).map(r => ({
|
||||
|
||||
@ -146,6 +146,7 @@ export const videoApi = {
|
||||
model: string;
|
||||
aspect_ratio: string;
|
||||
duration: number;
|
||||
resolution: string;
|
||||
references: { url: string; type: string; role: string; label: string; thumb_url?: string; duration?: string }[];
|
||||
search_mode?: string;
|
||||
seed?: number;
|
||||
|
||||
@ -57,6 +57,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask {
|
||||
model: 'seedance_2.0',
|
||||
aspectRatio: (v.aspect_ratio as any) || '16:9',
|
||||
duration: v.duration as any,
|
||||
resolution: v.resolution,
|
||||
references,
|
||||
assetMentions: [],
|
||||
status: 'completed',
|
||||
|
||||
@ -14,6 +14,8 @@ export function SettingsPage() {
|
||||
base_token_price_video: 0,
|
||||
base_token_price_fast: 0,
|
||||
base_token_price_fast_video: 0,
|
||||
base_token_price_1080p: 0,
|
||||
base_token_price_1080p_video: 0,
|
||||
announcement: '',
|
||||
announcement_enabled: false,
|
||||
max_desktop_sessions: 1,
|
||||
@ -143,7 +145,7 @@ export function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className={styles.cardDesc}>Seedance 2.0</p>
|
||||
<p className={styles.cardDesc}>Seedance 2.0(480P / 720P)</p>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>不含视频输入单价 (元/百万tokens)</label>
|
||||
@ -164,7 +166,28 @@ export function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className={styles.cardDesc}>Seedance 2.0 Fast</p>
|
||||
<p className={styles.cardDesc}>Seedance 2.0(1080P)</p>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>不含视频输入单价 (元/百万tokens)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={settings.base_token_price_1080p}
|
||||
onChange={(e) => setSettings({ ...settings, base_token_price_1080p: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>含视频输入单价 (元/百万tokens)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={settings.base_token_price_1080p_video}
|
||||
onChange={(e) => setSettings({ ...settings, base_token_price_1080p_video: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className={styles.cardDesc}>Seedance 2.0 Fast(不支持 1080P)</p>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>不含视频输入单价 (元/百万tokens)</label>
|
||||
|
||||
@ -57,6 +57,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask {
|
||||
model: 'seedance_2.0',
|
||||
aspectRatio: (v.aspect_ratio as any) || '16:9',
|
||||
duration: v.duration as any,
|
||||
resolution: v.resolution,
|
||||
references,
|
||||
assetMentions: [],
|
||||
status: 'completed',
|
||||
|
||||
@ -32,7 +32,7 @@ function mapErrorMessage(raw?: string): string | undefined {
|
||||
|
||||
// Model / generation errors
|
||||
if (s.includes('quota') || s.includes('insufficient'))
|
||||
return '额度不足,请联系管理员';
|
||||
return '今日生成次数或团队余额不足,请联系管理员';
|
||||
|
||||
// If already Chinese, return as-is
|
||||
if (/[\u4e00-\u9fa5]/.test(raw)) return raw;
|
||||
@ -121,6 +121,7 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
|
||||
model: bt.model,
|
||||
aspectRatio: bt.aspect_ratio as GenerationTask['aspectRatio'],
|
||||
duration: bt.duration as GenerationTask['duration'],
|
||||
resolution: bt.resolution,
|
||||
references,
|
||||
assetMentions,
|
||||
status: mapStatus(bt.status),
|
||||
@ -402,6 +403,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
model: input.model,
|
||||
aspectRatio: input.aspectRatio,
|
||||
duration: input.duration,
|
||||
resolution: input.resolution,
|
||||
references: localRefs,
|
||||
assetMentions: placeholderAssetMentions,
|
||||
status: 'generating',
|
||||
@ -521,6 +523,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
model: input.model,
|
||||
aspect_ratio: input.aspectRatio,
|
||||
duration: input.duration,
|
||||
resolution: input.resolution,
|
||||
references: uploadedRefs,
|
||||
search_mode: input.searchMode || 'off',
|
||||
seed: input.seed ?? -1,
|
||||
@ -638,6 +641,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
editorHtml: task.prompt,
|
||||
aspectRatio: task.aspectRatio,
|
||||
duration: task.duration,
|
||||
resolution: task.resolution,
|
||||
references,
|
||||
assetMentions: task.assetMentions || [],
|
||||
// 如果 seed 开关打开且 task 有有效 seed,填入;否则不动
|
||||
@ -652,6 +656,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
editorHtml: task.editorHtml || task.prompt,
|
||||
aspectRatio: task.aspectRatio,
|
||||
duration: task.duration,
|
||||
resolution: task.resolution,
|
||||
assetMentions: [],
|
||||
firstFrame: firstRef ? { id: firstRef.id, type: firstRef.type, previewUrl: firstRef.previewUrl, label: '首帧', tosUrl: firstRef.previewUrl } : null,
|
||||
lastFrame: lastRef ? { id: lastRef.id, type: lastRef.type, previewUrl: lastRef.previewUrl, label: '尾帧', tosUrl: lastRef.previewUrl } : null,
|
||||
@ -688,6 +693,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
model: task.model,
|
||||
aspectRatio: task.aspectRatio,
|
||||
duration: task.duration,
|
||||
resolution: task.resolution,
|
||||
references: task.mode === 'universal' ? references : [],
|
||||
assetMentions: task.assetMentions || [],
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
|
||||
import type { CreationMode, ModelOption, AspectRatio, Duration, Resolution, GenerationType, UploadedFile } from '../types';
|
||||
import { showToast } from '../components/Toast';
|
||||
import { mediaApi } from '../lib/api';
|
||||
import { parseAssetMentions } from '../lib/assetMentions';
|
||||
@ -88,6 +88,10 @@ interface InputBarState {
|
||||
setDuration: (duration: Duration) => void;
|
||||
prevDuration: Duration;
|
||||
|
||||
// Resolution (480p/720p/1080p) — 1080p 仅 Seedance 2.0 支持
|
||||
resolution: Resolution;
|
||||
setResolution: (resolution: Resolution) => void;
|
||||
|
||||
// Prompt
|
||||
prompt: string;
|
||||
setPrompt: (prompt: string) => void;
|
||||
@ -145,7 +149,17 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
||||
setMode: (mode) => set({ mode }),
|
||||
|
||||
model: 'seedance_2.0',
|
||||
setModel: (model) => set({ model }),
|
||||
setModel: (model) => {
|
||||
// Fast + 1080P 为非法组合(官方文档约束)。UI Dropdown 已对 Fast 项置灰,
|
||||
// 此处为 UI 被绕过时的防御性拦截(depth defense),不做静默降级:
|
||||
// 阻止切换 + toast 引导用户手动改分辨率,让用户选择始终被尊重。
|
||||
const state = get();
|
||||
if (model === 'seedance_2.0_fast' && state.resolution === '1080p') {
|
||||
showToast('1080P 仅 AirDrama 模型支持,请先切换分辨率到 720P 或 480P');
|
||||
return;
|
||||
}
|
||||
set({ model });
|
||||
},
|
||||
|
||||
aspectRatio: '21:9',
|
||||
setAspectRatio: (aspectRatio) => set({ aspectRatio, prevAspectRatio: aspectRatio }),
|
||||
@ -162,6 +176,17 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
||||
},
|
||||
prevDuration: 15,
|
||||
|
||||
resolution: '720p' as Resolution,
|
||||
setResolution: (resolution) => {
|
||||
// Fast + 1080P 非法组合(对称 setModel 的拦截)— UI Dropdown 已置灰,此处防御性拦截
|
||||
const state = get();
|
||||
if (resolution === '1080p' && state.model === 'seedance_2.0_fast') {
|
||||
showToast('AirDrama Fast 不支持 1080P,请先切换模型到 AirDrama');
|
||||
return;
|
||||
}
|
||||
set({ resolution });
|
||||
},
|
||||
|
||||
prompt: '',
|
||||
setPrompt: (prompt) => set({ prompt }),
|
||||
|
||||
@ -398,6 +423,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
|
||||
prevAspectRatio: '21:9',
|
||||
duration: 15,
|
||||
prevDuration: 15,
|
||||
resolution: '720p',
|
||||
prompt: '',
|
||||
editorHtml: '',
|
||||
references: [],
|
||||
|
||||
@ -2,6 +2,7 @@ export type CreationMode = 'universal' | 'keyframe';
|
||||
export type ModelOption = 'seedance_2.0' | 'seedance_2.0_fast';
|
||||
export type AspectRatio = '16:9' | '9:16' | '1:1' | '21:9' | '4:3' | '3:4';
|
||||
export type Duration = 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
|
||||
export type Resolution = '480p' | '720p' | '1080p';
|
||||
export type GenerationType = 'video' | 'image';
|
||||
export type UserRole = 'super_admin' | 'team_admin' | 'member';
|
||||
|
||||
@ -44,6 +45,7 @@ export interface GenerationTask {
|
||||
model: ModelOption;
|
||||
aspectRatio: AspectRatio;
|
||||
duration: Duration;
|
||||
resolution: Resolution;
|
||||
references: ReferenceSnapshot[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assetMentions: Record<string, any>[];
|
||||
@ -67,6 +69,7 @@ export interface BackendTask {
|
||||
mode: CreationMode;
|
||||
model: ModelOption;
|
||||
aspect_ratio: string;
|
||||
resolution: Resolution;
|
||||
duration: number;
|
||||
seconds_consumed: number;
|
||||
tokens_consumed: number;
|
||||
@ -113,6 +116,8 @@ export interface TeamInfo {
|
||||
token_price_video: number;
|
||||
token_price_fast: number;
|
||||
token_price_fast_video: number;
|
||||
token_price_1080p: number;
|
||||
token_price_1080p_video: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
@ -222,6 +227,8 @@ export interface SystemSettings {
|
||||
base_token_price_video: number;
|
||||
base_token_price_fast: number;
|
||||
base_token_price_fast_video: number;
|
||||
base_token_price_1080p: number;
|
||||
base_token_price_1080p_video: number;
|
||||
announcement: string;
|
||||
announcement_enabled: boolean;
|
||||
max_desktop_sessions: number;
|
||||
@ -407,6 +414,7 @@ export interface AssetVideo {
|
||||
seconds_consumed: number;
|
||||
cost_amount?: number;
|
||||
aspect_ratio: string;
|
||||
resolution: Resolution;
|
||||
reference_urls?: { url: string; type: string; role: string; label: string; thumb_url?: string }[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
136
web/test/e2e/resolution-1080p.spec.ts
Normal file
136
web/test/e2e/resolution-1080p.spec.ts
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 1080P 分辨率支持 E2E — 真实浏览器验证 UI 双向约束和预估费用。
|
||||
* 针对本地开发环境(localhost:5173 + 127.0.0.1:8000)。
|
||||
*/
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
const BASE_URL = 'http://localhost:5173';
|
||||
const API_URL = 'http://127.0.0.1:8000';
|
||||
const USERNAME = 'admin';
|
||||
const PASSWORD = 'admin123';
|
||||
|
||||
async function login(page: Page) {
|
||||
const resp = await page.request.post(`${API_URL}/api/v1/auth/login`, {
|
||||
data: { username: USERNAME, password: PASSWORD },
|
||||
});
|
||||
if (!resp.ok()) {
|
||||
const err = await resp.text();
|
||||
throw new Error(`Login failed: ${resp.status()} ${err}`);
|
||||
}
|
||||
const body = await resp.json();
|
||||
|
||||
await page.goto(BASE_URL);
|
||||
await page.evaluate(({ access, refresh }) => {
|
||||
localStorage.setItem('access_token', access);
|
||||
localStorage.setItem('refresh_token', refresh);
|
||||
}, { access: body.tokens.access, refresh: body.tokens.refresh });
|
||||
await page.goto(`${BASE_URL}/app`);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 关闭公告弹窗
|
||||
const knowBtn = page.getByRole('button', { name: /我知道了|知道了|关闭/ }).first();
|
||||
if (await knowBtn.isVisible().catch(() => false)) {
|
||||
await knowBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.serial('1080P 分辨率支持', () => {
|
||||
test('默认分辨率显示 720P', async ({ page }) => {
|
||||
await login(page);
|
||||
// 找到 Toolbar 里的分辨率按钮(label 应显示 720P)
|
||||
const resolutionBtn = page.getByRole('button', { name: '720P', exact: true }).first();
|
||||
await expect(resolutionBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('AirDrama 模式下可切换到 1080P', async ({ page }) => {
|
||||
await login(page);
|
||||
// 点分辨率按钮展开 dropdown
|
||||
await page.getByRole('button', { name: '720P', exact: true }).first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// 选 1080P
|
||||
await page.getByText('1080P', { exact: true }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 分辨率按钮应显示 1080P
|
||||
await expect(page.getByRole('button', { name: '1080P', exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('1080P 下 Fast 模型置灰(UI 不可达 Fast+1080P)', async ({ page }) => {
|
||||
await login(page);
|
||||
// 先切到 1080P
|
||||
await page.getByRole('button', { name: '720P', exact: true }).first().click();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByText('1080P', { exact: true }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 打开模型 dropdown
|
||||
await page.getByRole('button', { name: /AirDrama$/, exact: false }).first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Fast 项应包含 "不支持 1080P" 且有 disabled 视觉
|
||||
const fastItem = page.getByText(/AirDrama Fast.*不支持 1080P/);
|
||||
await expect(fastItem).toBeVisible();
|
||||
|
||||
// 点击 Fast 不应切换(Dropdown 的 disabled 阻止了 onSelect)
|
||||
await fastItem.click({ force: true });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 模型应仍是 AirDrama
|
||||
await expect(page.getByRole('button', { name: /AirDrama$/, exact: false }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Fast 模式下 1080P 置灰(UI 不可达 Fast+1080P,反向)', async ({ page }) => {
|
||||
await login(page);
|
||||
// 先确保 resolution 是 720P(reset)
|
||||
await page.reload();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 切到 Fast 模型
|
||||
await page.getByRole('button', { name: /AirDrama$/, exact: false }).first().click();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByText('AirDrama Fast', { exact: true }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 打开分辨率 dropdown
|
||||
await page.getByRole('button', { name: '720P', exact: true }).first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// 1080P 项应带 "Fast 不支持" 标注
|
||||
const disabled1080p = page.getByText(/1080P.*Fast 不支持/);
|
||||
await expect(disabled1080p).toBeVisible();
|
||||
|
||||
// 点击不生效
|
||||
await disabled1080p.click({ force: true });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 分辨率仍为 720P(可能 Dropdown 保持打开或关闭,但按钮不该变)
|
||||
const bodyText = await page.textContent('body');
|
||||
expect(bodyText).toContain('720P');
|
||||
});
|
||||
|
||||
test('预估费用 tooltip 明示「以火山为准」', async ({ page }) => {
|
||||
await login(page);
|
||||
// 需要让按钮栏里的"预估"显示出来(需要有 prompt 或素材)
|
||||
// 输入一个简单 prompt
|
||||
const promptArea = page.locator('[contenteditable]').first();
|
||||
if (await promptArea.isVisible().catch(() => false)) {
|
||||
await promptArea.click();
|
||||
await promptArea.type('测试提示词');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// 找到"预估消耗"文案
|
||||
const estSpan = page.getByText(/预估消耗/).first();
|
||||
if (await estSpan.isVisible().catch(() => false)) {
|
||||
const title = await estSpan.getAttribute('title');
|
||||
expect(title).toBeTruthy();
|
||||
expect(title!).toContain('实际');
|
||||
expect(title!).toContain('火山');
|
||||
} else {
|
||||
// 如果没有预估显示(比如 team 没配单价),跳过
|
||||
console.log('跳过:预估未显示(team 可能未配单价)');
|
||||
}
|
||||
});
|
||||
});
|
||||
160
web/test/unit/resolution1080p.test.ts
Normal file
160
web/test/unit/resolution1080p.test.ts
Normal file
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user