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) => (
Seedance 2.0
+Seedance 2.0(480P / 720P)
Seedance 2.0 Fast
+Seedance 2.0(1080P)
+Seedance 2.0 Fast(不支持 1080P)