From 39667ff19ce03177fb18498a78b189cc6e4f15eb Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 17 Apr 2026 18:23:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.19.0=201080P=20=E5=88=86=E8=BE=A8?= =?UTF-8?q?=E7=8E=87=E6=94=AF=E6=8C=81=20=E2=80=94=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=89=8D=E5=90=8E=E7=AB=AF=20+=20=E4=B8=A5=E6=A0=BC=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9=E5=87=86=E7=A1=AE=E6=80=A7=20+=2047=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 火山 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) --- backend/apps/accounts/views.py | 2 + ...aconfig_base_token_price_1080p_and_more.py | 41 ++++ backend/apps/generation/models.py | 9 +- backend/apps/generation/serializers.py | 7 +- backend/apps/generation/views.py | 64 +++++- backend/tests/test_1080p_api.py | 131 +++++++++++ backend/tests/test_1080p_billing.py | 208 ++++++++++++++++++ backend/utils/airdrama_client.py | 6 +- backend/utils/billing.py | 54 ++++- web/src/components/Dropdown.tsx | 5 +- web/src/components/GenerationCard.tsx | 2 +- web/src/components/Toolbar.tsx | 85 +++++-- web/src/components/VideoDetailModal.tsx | 1 + web/src/lib/api.ts | 1 + web/src/pages/AdminAssetsPage.tsx | 1 + web/src/pages/SettingsPage.tsx | 27 ++- web/src/pages/TeamAssetsPage.tsx | 1 + web/src/store/generation.ts | 8 +- web/src/store/inputBar.ts | 30 ++- web/src/types/index.ts | 8 + web/test/e2e/resolution-1080p.spec.ts | 136 ++++++++++++ web/test/unit/resolution1080p.test.ts | 160 ++++++++++++++ 22 files changed, 950 insertions(+), 37 deletions(-) create mode 100644 backend/apps/generation/migrations/0020_quotaconfig_base_token_price_1080p_and_more.py create mode 100644 backend/tests/test_1080p_api.py create mode 100644 backend/tests/test_1080p_billing.py create mode 100644 web/test/e2e/resolution-1080p.spec.ts create mode 100644 web/test/unit/resolution1080p.test.ts diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index db8cecd..a11d299 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -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 diff --git a/backend/apps/generation/migrations/0020_quotaconfig_base_token_price_1080p_and_more.py b/backend/apps/generation/migrations/0020_quotaconfig_base_token_price_1080p_and_more.py new file mode 100644 index 0000000..7183cd5 --- /dev/null +++ b/backend/apps/generation/migrations/0020_quotaconfig_base_token_price_1080p_and_more.py @@ -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='分辨率'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index ffa19a3..f7f3ee0 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -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: diff --git a/backend/apps/generation/serializers.py b/backend/apps/generation/serializers.py index 0b75884..2775497 100644 --- a/backend/apps/generation/serializers.py +++ b/backend/apps/generation/serializers.py @@ -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) diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 92774d0..5f184e7 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -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(), }) diff --git a/backend/tests/test_1080p_api.py b/backend/tests/test_1080p_api.py new file mode 100644 index 0000000..72e348a --- /dev/null +++ b/backend/tests/test_1080p_api.py @@ -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) diff --git a/backend/tests/test_1080p_billing.py b/backend/tests/test_1080p_billing.py new file mode 100644 index 0000000..229e974 --- /dev/null +++ b/backend/tests/test_1080p_billing.py @@ -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) diff --git a/backend/utils/airdrama_client.py b/backend/utils/airdrama_client.py index a3b548d..eb800fb 100644 --- a/backend/utils/airdrama_client.py +++ b/backend/utils/airdrama_client.py @@ -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, diff --git a/backend/utils/billing.py b/backend/utils/billing.py index ee34db2..c4d650e 100644 --- a/backend/utils/billing.py +++ b/backend/utils/billing.py @@ -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: diff --git a/web/src/components/Dropdown.tsx b/web/src/components/Dropdown.tsx index 9cd116c..4c0a2b5 100644 --- a/web/src/components/Dropdown.tsx +++ b/web/src/components/Dropdown.tsx @@ -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) => (
{ + if (item.disabled) return; onSelect(item.value); setOpen(false); }} diff --git a/web/src/components/GenerationCard.tsx b/web/src/components/GenerationCard.tsx index 3ba65d9..5136943 100644 --- a/web/src/components/GenerationCard.tsx +++ b/web/src/components/GenerationCard.tsx @@ -389,7 +389,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) { 时长{task.duration}s
- 分辨率720p + 分辨率{task.resolution.toUpperCase()}
模型 diff --git a/web/src/components/Toolbar.tsx b/web/src/components/Toolbar.tsx index 2f431a5..4af9680 100644 --- a/web/src/components/Toolbar.tsx +++ b/web/src/components/Toolbar.tsx @@ -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: }, ]; -const modelItems = [ - { label: 'AirDrama', value: 'seedance_2.0' as ModelOption, icon: }, - { label: 'AirDrama Fast', value: 'seedance_2.0_fast' as ModelOption, icon: }, -]; +// NOTE: modelItems 在组件内部按 resolution 动态构建(1080P 下 Fast 置灰) const modeItems = [ { label: '全能参考', value: 'universal' as CreationMode, icon: }, @@ -99,9 +97,20 @@ const durationItems = Array.from({ length: 12 }, (_, i) => { return { label: `${v}s`, value: String(v) }; }); -const RESOLUTION_MAP: Record = { - '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> = { + '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 = { @@ -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: }, + { + label: resolution === '1080p' ? 'AirDrama Fast(不支持 1080P)' : 'AirDrama Fast', + value: 'seedance_2.0_fast', + icon: , + 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 */} + setResolution(v as Resolution)} + minWidth={100} + trigger={ + + } + /> + {/* Duration */} 0 && ( 预估消耗:{estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost} diff --git a/web/src/components/VideoDetailModal.tsx b/web/src/components/VideoDetailModal.tsx index 9780a50..f4e1b22 100644 --- a/web/src/components/VideoDetailModal.tsx +++ b/web/src/components/VideoDetailModal.tsx @@ -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 => ({ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d9d52b7..60e0916 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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; diff --git a/web/src/pages/AdminAssetsPage.tsx b/web/src/pages/AdminAssetsPage.tsx index 0e2a45d..41c7ff6 100644 --- a/web/src/pages/AdminAssetsPage.tsx +++ b/web/src/pages/AdminAssetsPage.tsx @@ -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', diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index a3c13da..18eb9c8 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -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() { />
-

Seedance 2.0

+

Seedance 2.0(480P / 720P)

@@ -164,7 +166,28 @@ export function SettingsPage() { />
-

Seedance 2.0 Fast

+

Seedance 2.0(1080P)

+
+
+ + setSettings({ ...settings, base_token_price_1080p: Number(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, base_token_price_1080p_video: Number(e.target.value) })} + /> +
+
+

Seedance 2.0 Fast(不支持 1080P)

diff --git a/web/src/pages/TeamAssetsPage.tsx b/web/src/pages/TeamAssetsPage.tsx index 9c37006..54e1e0e 100644 --- a/web/src/pages/TeamAssetsPage.tsx +++ b/web/src/pages/TeamAssetsPage.tsx @@ -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', diff --git a/web/src/store/generation.ts b/web/src/store/generation.ts index 4c6d4b4..5a62325 100644 --- a/web/src/store/generation.ts +++ b/web/src/store/generation.ts @@ -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((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((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((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((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((set, get) => ({ model: task.model, aspectRatio: task.aspectRatio, duration: task.duration, + resolution: task.resolution, references: task.mode === 'universal' ? references : [], assetMentions: task.assetMentions || [], }); diff --git a/web/src/store/inputBar.ts b/web/src/store/inputBar.ts index 7eb18ce..9dcfd23 100644 --- a/web/src/store/inputBar.ts +++ b/web/src/store/inputBar.ts @@ -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((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((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((set, get) => ({ prevAspectRatio: '21:9', duration: 15, prevDuration: 15, + resolution: '720p', prompt: '', editorHtml: '', references: [], diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 9b6e6d0..f187af8 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -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[]; @@ -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; } diff --git a/web/test/e2e/resolution-1080p.spec.ts b/web/test/e2e/resolution-1080p.spec.ts new file mode 100644 index 0000000..978ada4 --- /dev/null +++ b/web/test/e2e/resolution-1080p.spec.ts @@ -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 可能未配单价)'); + } + }); +}); diff --git a/web/test/unit/resolution1080p.test.ts b/web/test/unit/resolution1080p.test.ts new file mode 100644 index 0000000..98a02a8 --- /dev/null +++ b/web/test/unit/resolution1080p.test.ts @@ -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'); + }); +});