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 2796085..eb800fb 100644 --- a/backend/utils/airdrama_client.py +++ b/backend/utils/airdrama_client.py @@ -15,6 +15,7 @@ ERROR_MESSAGES = { 'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试', # Output content moderation 'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试', + 'OutputVideoSensitiveContentDetected.PolicyViolation': '生成的视频涉及版权限制内容(如知名IP、名人肖像等),已被系统拦截,请修改提示词后重试', 'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截', # Parameter errors 'InvalidParameter': '请求参数无效,请检查输入内容', @@ -91,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. @@ -101,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. @@ -119,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/docs/API文档/Seedance 2.0 1080P.md b/docs/API文档/Seedance 2.0 1080P.md new file mode 100644 index 0000000..a713313 --- /dev/null +++ b/docs/API文档/Seedance 2.0 1080P.md @@ -0,0 +1,85 @@ +# [请填写客户名称]Seedance 2.0 1080P + +> 本文档仅供方舟保底客户查阅,请勿发给没有签约保底的客户 + +### 功能说明 + +⚠️❗️❗️:目前seedance2.0产出的1080p暂时无法支持产物受信功能,即seedance2.0产出的含有人脸的1080p视频将接受安全审查,如果您需要参考含有人脸的1080p视频,请您将该视频上传至虚拟素材库 + +#### **功能1 输出视频分辨率 支持 1080P** + +* **上线时间**:预计国内外4月16日22:00完成上线 + +* **用户范围**: + + * **“抢先体验计划”:功能上线后 72 小时内,部分用户可抢先体验**,官网文档暂不更新 + + * 72 小时后,面向全部用户开放,官网文档同步公开 + +* **支持模型**:仅限Seedance 2.0(Seedance 2.0 fast 不支持) + +* **使用方式:**在请求参数`resolution`中传入`1080p` + +```c++ +curl https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ARK_API_KEY" \ + -d '{ + "model": "doubao-seedance-2-0-260128", + "content": [ + { + "type": "text", + "text": "全程使用视频1的第一视角构图,全程使用音频1作为背景音乐。第一人称视角果茶宣传广告,seedance牌「苹苹安安」苹果果茶限定款;首帧为图片1,你的手摘下一颗带晨露的阿克苏红苹果,轻脆的苹果碰撞声;2-4 秒:快速切镜,你的手将苹果块投入雪克杯,加入冰块与茶底,用力摇晃,冰块碰撞声与摇晃声卡点轻快鼓点,背景音:「鲜切现摇」;4-6 秒:第一人称成品特写,分层果茶倒入透明杯,你的手轻挤奶盖在顶部铺展,在杯身贴上粉红包标,镜头拉近看奶盖与果茶的分层纹理;6-8 秒:第一人称手持举杯,你将图片2中的果茶举到镜头前(模拟递到观众面前的视角),杯身标签清晰可见,背景音「来一口鲜爽」,尾帧定格为图片2。背景声音统一为女生音色。" + }, + { + "type": "image_url", + "image_url": { + "url": "https://ark-project.tos-cn-beijing.volces.com/doc_image/r2v_tea_pic1.jpg" + }, + "role": "reference_image" + }, + { + "type": "image_url", + "image_url": { + "url": "https://ark-project.tos-cn-beijing.volces.com/doc_image/r2v_tea_pic2.jpg" + }, + "role": "reference_image" + }, + { + "type": "video_url", + "video_url": { + "url": "https://ark-project.tos-cn-beijing.volces.com/doc_video/r2v_tea_video1.mp4" + }, + "role": "reference_video" + }, + { + "type": "audio_url", + "audio_url": { + "url": "https://ark-project.tos-cn-beijing.volces.com/doc_audio/r2v_tea_audio1.mp3" + }, + "role": "reference_audio" + } + ], + "resolution": "1080p", + "generate_audio":true, + "ratio": "16:9", + "duration": 11, + "watermark": false +}' +``` + +#### **功能2 输入视频分辨率 支持 1080P** + +* **功能说明**:对输入视频的总像素限制扩大至 2086876(2206x946),支持传入1080P视频作为参考 + +* **上线时间**:预计国内外4月16日 22:00 完成上线 + +* **用户范围**:全部用户可用,官网文档同步公开 + +* **支持模型**:Seedance 2.0、Seedance 2.0 fast 均支持 + +### 费用说明 + +1080P 和 720P/480P 视频区分定价 + +价格详见:https://www.volcengine.com/docs/82379/1544106?lang=zh#02affcb8 diff --git a/docs/API文档/seedance 2.0 系列教程.md b/docs/API文档/seedance 2.0 系列教程.md new file mode 100644 index 0000000..1b9e418 --- /dev/null +++ b/docs/API文档/seedance 2.0 系列教程.md @@ -0,0 +1,2233 @@ +seedance 2.0 系列模型(包括 seedance 2.0 和 seedance 2.0 fast )支持图像、视频、音频、文本等多种模态内容输入,具备视频生成、视频编辑、视频延长等能力,可高精度还原物品细节、音色、效果、风格、运镜等,保持稳定角色特征,赋予使用者如同导演般的掌控权。本文介绍 seedance 2.0 系列模型的专属能力,帮助您快速实现 [Video Generation API](https://www.volcengine.com/docs/82379/1520758) 调用。 +:::tip +请确保您的账户余额大于等于 200 元([前往充值](https://console.volcengine.com/finance/fund/recharge)),或已[购买资源包](https://console.volcengine.com/common-buy/fast/ark_bd%7C%7Cd682ppeeq1mp7kd5q0e0),否则无法开通 seedance 2.0 及 seedance 2.0 fast 模型。 +::: + +# 新手入门 +本入门教程专为 **API 新手用户** 设计,帮助您一键搭建 Python 开发环境、完成虚拟环境创建和方舟 SDK 安装,并提供直接可运行的 seedance 2.0 示例代码,您只需修改对应的输入素材,即可开始您的视频生成创作。 +**1. 准备工作** +在开始之前,请确保您已经完成以下准备: + +1. **注册账号**:确保您拥有火山引擎账号并已[登录](https://console.volcengine.com/)。 +2. **获取 API Key**:访问 [API Key 管理页面](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey),点击 **创建 API Key**,并复制保存您的 API Key。注意请妥善保管您的 API Key,不要泄露给他人。 +3. [开通模型](https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=model&projectName=default&tab=ComputerVision):请确保您的账户余额大于等于 200 元,否则无法开通 seedance 2.0 及 seedance 2.0 fast 模型。 +4. **下载并解压文件**:点击下载下方附件,将其解压到您的本地目录(如桌面或“下载”文件夹)。 + + +**2.操作步骤** + +```mixin-react +return ( + +); +``` + +**3.运行说明** +运行脚本后,您将看到如下流程: + +1. **API Key 校验**:脚本会自动检测您本地是否配置了`ARK_API_KEY`环境变量。如果没有,会提示您手动输入。 +2. **素材预览**:脚本会自动在您的默认浏览器中弹出一个本地生成的 HTML 页面,直观地展示本次任务的文本提示词、待替换的参考图片以及原始参考视频。 +3. **任务创建与轮询**:脚本向火山方舟服务器发起异步请求。由于视频生成需要一定时间,控制台会每隔 30 秒打印一次任务状态(如 `running`等)。 +4. **获取结果**:任务成功后,控制台会输出一段最终生成的视频 URL。您可以复制该链接到浏览器下载或在线播放。 + +**4.下一步** +在成功跑通本示例后,您可以尝试修改 `python/demo_standard.py`,来打造您专属的视频生成任务: + +1. 修改文本提示词 + +找到代码中的 `user_content` 变量,更改为您想要的画面描述。 + +2. 替换输入素材 (图片、视频、音频) + +您可以将 `reference_image_url`、`reference_video_url` 和 `reference_audio_url` 替换为您自己的素材链接。 +**注意**:请确保 URL 是公网可公开访问的链接(建议存放在 TOS 对象存储服务中,并配置为公共读)。 + +3. 继续学习下文中丰富的使用示例。 + + +# 模型能力 +seedance 2.0 fast 和 seedance 2.0 的模型能力相同。追求最高生成品质,推荐使用 seedance 2.0;更注重成本与生成速度,不要求极限品质,推荐使用 seedance 2.0 fast。 + + +|模型名称 | |[seedance 2.0](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-2-0&projectName=default) |[seedance 2.0 fast](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-2-0-fast&projectName=default) | +|---|---|---|---| +|Model ID | |doubao\-seedance\-2\-0\-260128 |doubao\-seedance\-2\-0\-fast\-260128 | +|文生视频 | | | | +|图生视频\-首帧 | | | | +|图生视频\-首尾帧 | | | | +|多模态参考【New】 |图片参考 | | | +|^^|视频参考 | | | +|^^|组合参考| | |\ +| || | |\ +| |* 图片 + 音频| | |\ +| |* 图片 + 视频| | |\ +| |* 视频 + 音频| | |\ +| |* 图片 + 视频 + 音频 | | | +|编辑视频【New】 | | | | +|延长视频【New】 | | | | +|生成有声视频 | | | | +|联网搜索增强【New】 | | | | +|样片模式 | | | | +|返回视频尾帧 | | | | +|输出视频规格 |输出分辨率 |480p, 720p |480p, 720p | +| |输出宽高比 |21:9, 16:9, 4:3, 1:1, 3:4, 9:16 || +| |输出时长 |4~15 秒 |4~15 秒 | +| |输出视频格式 |mp4 |mp4 | +|离线推理 | | | | +|在线推理限流 |最大 RPM |企业用户:600|企业用户:600|\ +| | |个人用户:180 |个人用户:180 | +| |最大并发数 |企业用户:10|企业用户:10|\ +| | |个人用户:3 |个人用户:3 | +|离线推理限流 |TPD |\- |\- | + + +# 基础使用 + +## 多模态参考 +输入文本、参考图、视频(可带音轨)和音频等内容,来生成一段新视频。可继承参考图片的角色形象、视觉风格、画面构图;参考视频的主体内容、运镜方式、动作表现、整体风格;以及参考音频的音色、音乐旋律、对话内容等核心信息。 +效果预览如下(访问[模型卡片](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-2-0)查看更多示例): + + +|输入:文本 |输入:图片、视频、音频 |输出 | +|---|---|---| +|全程使用**视频1**的第一视角构图,全程使用**音频1**作为背景音乐。第一人称视角果茶宣传广告,seedance牌「苹苹安安」苹果果茶限定款;首帧为**图片1**,你的手摘下一颗带晨露的阿克苏红苹果,轻脆的苹果碰撞声;2\-4 秒:快速切镜,你的手将苹果块投入雪克杯,加入冰块与茶底,用力摇晃,冰块碰撞声与摇晃声卡点轻快鼓点,背景音:「鲜切现摇」;4\-6 秒:第一人称成品特写,分层果茶倒入透明杯,你的手轻挤奶盖在顶部铺展,在杯身贴上粉红包标,镜头拉近看奶盖与果茶的分层纹理;6\-8 秒:第一人称手持举杯,你将**图片2**中的果茶举到镜头前(模拟递到观众面前的视角),杯身标签清晰可见,背景音「来一口鲜爽」,尾帧定格为**图片2**。背景声音统一为女生音色。 |||\ +| || |\ +| | | | + + +```mixin-react +return ( + + contents = new ArrayList<>(); + + // 1. Text prompt + contents.add(Content.builder() + .type("text") + .text(prompt) + .build()); + + // 2. Reference image 1 + contents.add(Content.builder() + .type("image_url") + .imageUrl(CreateContentGenerationTaskRequest.ImageUrl.builder() + .url(refImage1) + .build()) + .role("reference_image") + .build()); + + // 3. Reference image 2 + contents.add(Content.builder() + .type("image_url") + .imageUrl(CreateContentGenerationTaskRequest.ImageUrl.builder() + .url(refImage2) + .build()) + .role("reference_image") + .build()); + + // 4. Reference video + contents.add(Content.builder() + .type("video_url") + .videoUrl(CreateContentGenerationTaskRequest.VideoUrl.builder() + .url(refVideo) + .build()) + .role("reference_video") + .build()); + + // 5. Reference audio + contents.add(Content.builder() + .type("audio_url") + .audioUrl(CreateContentGenerationTaskRequest.AudioUrl.builder() + .url(refAudio) + .build()) + .role("reference_audio") + .build()); + + // Create video generation task + CreateContentGenerationTaskRequest createRequest = CreateContentGenerationTaskRequest.builder() + .generateAudio(generateAudio) + .model(modelId) + .content(contents) + .ratio(videoRatio) + .duration(videoDuration) + .watermark(showWatermark) + .build(); + + CreateContentGenerationTaskResult createResult = service.createContentGenerationTask(createRequest); + System.out.println("Task Created: " + createResult); + + // Get task details and poll status + String taskId = createResult.getId(); + pollTaskStatus(taskId); + \} + + /** + * Poll task status + * @param taskId Task ID + */ + + private static void pollTaskStatus(String taskId) \{ + GetContentGenerationTaskRequest getRequest = GetContentGenerationTaskRequest.builder() + .taskId(taskId) + .build(); + + System.out.println("----- polling task status -----"); + try \{ + while (true) \{ + GetContentGenerationTaskResponse getResponse = service.getContentGenerationTask(getRequest); + String status = getResponse.getStatus(); + + if ("succeeded".equalsIgnoreCase(status)) \{ + System.out.println("----- task succeeded -----"); + System.out.println(getResponse); + break; + \} else if ("failed".equalsIgnoreCase(status)) \{ + System.out.println("----- task failed -----"); + if (getResponse.getError() != null) \{ + System.out.println("Error: " + getResponse.getError().getMessage()); + \} + break; + \} else \{ + System.out.printf("Current status: %s, Retrying in 10 seconds...%n", status); + TimeUnit.SECONDS.sleep(10); + \} + \} + \} catch (InterruptedException ie) \{ + Thread.currentThread().interrupt(); + System.err.println("Polling interrupted"); + \} catch (Exception e) \{ + System.err.println("Error occurred: " + e.getMessage()); + \} finally \{ + service.shutdownExecutor(); + \} + \} +\} +\`\`\` + +`}> +); +``` + +:::tip + +* 您可任意组合以下模态内容,注意不支持“文本+音频”、“纯音频” 输入。 + * 文本 + * 图片:0~9 张 + * 视频:0~3 个 + * 音频:0~3 个 +* **进阶用法**:多模态生视频可通过提示词指定参考图片作为首帧/尾帧,间接实现“首尾帧+多模态参考”效果。若需严格保障首尾帧和指定图片一致,**优先使用图生视频\-首尾帧**(配置 role 为 first_frame/last_frame)。 +* 各个模态信息输入要求参见[多模态输入](/docs/82379/1366799#63a97f09)。 + +::: + +## 编辑视频 +您可以提供待编辑的视频、参考图片或音频,并结合使用提示词,完成多种视频编辑任务,例如:替换视频主体、视频中对象增删改、局部画面重绘/修复等。 +效果预览如下(访问[模型卡片](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-2-0)查看更多示例): + + +|输入:文本 |输入:视频&图片 |输出 | +|---|---|---| +|将**视频1**礼盒中的香水替换成**图像1**中的面霜,运镜不变 |||\ +| || |\ +| | | | + + +```mixin-react +return ( + + contents = new ArrayList<>(); + + // 1. Text prompt + contents.add(Content.builder() + .type("text") + .text(prompt) + .build()); + + // 2. Reference image 1 + contents.add(Content.builder() + .type("image_url") + .imageUrl(CreateContentGenerationTaskRequest.ImageUrl.builder() + .url(refImage1) + .build()) + .role("reference_image") + .build()); + + // 3. Reference video + contents.add(Content.builder() + .type("video_url") + .videoUrl(CreateContentGenerationTaskRequest.VideoUrl.builder() + .url(refVideo) + .build()) + .role("reference_video") + .build()); + + // Create video generation task + CreateContentGenerationTaskRequest createRequest = CreateContentGenerationTaskRequest.builder() + .generateAudio(generateAudio) + .model(modelId) + .content(contents) + .ratio(videoRatio) + .duration(videoDuration) + .watermark(showWatermark) + .build(); + + CreateContentGenerationTaskResult createResult = service.createContentGenerationTask(createRequest); + System.out.println("Task Created: " + createResult); + + // Get task details and poll status + String taskId = createResult.getId(); + pollTaskStatus(taskId); + \} + + /** + * Poll task status + * @param taskId Task ID + */ + + private static void pollTaskStatus(String taskId) \{ + GetContentGenerationTaskRequest getRequest = GetContentGenerationTaskRequest.builder() + .taskId(taskId) + .build(); + + System.out.println("----- polling task status -----"); + try \{ + while (true) \{ + GetContentGenerationTaskResponse getResponse = service.getContentGenerationTask(getRequest); + String status = getResponse.getStatus(); + + if ("succeeded".equalsIgnoreCase(status)) \{ + System.out.println("----- task succeeded -----"); + System.out.println(getResponse); + break; + \} else if ("failed".equalsIgnoreCase(status)) \{ + System.out.println("----- task failed -----"); + if (getResponse.getError() != null) \{ + System.out.println("Error: " + getResponse.getError().getMessage()); + \} + break; + \} else \{ + System.out.printf("Current status: %s, Retrying in 10 seconds...%n", status); + TimeUnit.SECONDS.sleep(10); + \} + \} + \} catch (InterruptedException ie) \{ + Thread.currentThread().interrupt(); + System.err.println("Polling interrupted"); + \} catch (Exception e) \{ + System.err.println("Error occurred: " + e.getMessage()); + \} finally \{ + service.shutdownExecutor(); + \} + \} +\} +\`\`\` + +`}> +); +``` + + +## 延长视频 +在原有视频基础上,向前或者向后延长视频,或多个视频片段(最多 3 个视频片段)串联成一个连贯视频。 +效果预览如下(访问[模型卡片](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-2-0)查看更多示例): + + +|输入:文本 |输入:待延长视频 |输出 | +|---|---|---| +|**视频1**中的拱形窗户打开,进入美术馆室内,接**视频2**,之后镜头进入画内,接**视频3** |||\ +| || |\ +| || |\ +| || |\ +| || |\ +| | | | + + +```mixin-react +return ( + + contents = new ArrayList<>(); + + // 1. Text prompt + contents.add(Content.builder() + .type("text") + .text(prompt) + .build()); + + // 2. Reference video 1 + contents.add(Content.builder() + .type("video_url") + .videoUrl(CreateContentGenerationTaskRequest.VideoUrl.builder() + .url(refVideo1) + .build()) + .role("reference_video") + .build()); + + // 3. Reference video 2 + contents.add(Content.builder() + .type("video_url") + .videoUrl(CreateContentGenerationTaskRequest.VideoUrl.builder() + .url(refVideo2) + .build()) + .role("reference_video") + .build()); + + // 4. Reference video 3 + contents.add(Content.builder() + .type("video_url") + .videoUrl(CreateContentGenerationTaskRequest.VideoUrl.builder() + .url(refVideo3) + .build()) + .role("reference_video") + .build()); + + // Create video generation task + CreateContentGenerationTaskRequest createRequest = CreateContentGenerationTaskRequest.builder() + .generateAudio(generateAudio) + .model(modelId) + .content(contents) + .ratio(videoRatio) + .duration(videoDuration) + .watermark(showWatermark) + .build(); + + CreateContentGenerationTaskResult createResult = service.createContentGenerationTask(createRequest); + System.out.println("Task Created: " + createResult); + + // Get task details and poll status + String taskId = createResult.getId(); + pollTaskStatus(taskId); + \} + + /** + * Poll task status + * @param taskId Task ID + */ + + private static void pollTaskStatus(String taskId) \{ + GetContentGenerationTaskRequest getRequest = GetContentGenerationTaskRequest.builder() + .taskId(taskId) + .build(); + + System.out.println("----- polling task status -----"); + try \{ + while (true) \{ + GetContentGenerationTaskResponse getResponse = service.getContentGenerationTask(getRequest); + String status = getResponse.getStatus(); + + if ("succeeded".equalsIgnoreCase(status)) \{ + System.out.println("----- task succeeded -----"); + System.out.println(getResponse); + break; + \} else if ("failed".equalsIgnoreCase(status)) \{ + System.out.println("----- task failed -----"); + if (getResponse.getError() != null) \{ + System.out.println("Error: " + getResponse.getError().getMessage()); + \} + break; + \} else \{ + System.out.printf("Current status: %s, Retrying in 10 seconds...%n", status); + TimeUnit.SECONDS.sleep(10); + \} + \} + \} catch (InterruptedException ie) \{ + Thread.currentThread().interrupt(); + System.err.println("Polling interrupted"); + \} catch (Exception e) \{ + System.err.println("Error occurred: " + e.getMessage()); + \} finally \{ + service.shutdownExecutor(); + \} + \} +\} +\`\`\` + +`}> +); +``` + +:::tip + +* 向前或向后延长 1 段视频,生成的视频一般只包含原视频的尾部画面。但您也可以通过提示词灵活控制,使其包含原视频内容。 例如:向前延长视频1,[延长内容描述...],**最后接视频1**。 +* 传入 2~3 段视频,补全中间过渡部分,生成的视频会包含原视频内容和新生成的视频内容。 + +::: + +## 使用联网搜索 +> 联网搜索能力仅适用于纯文本输入 + +seedance 2.0 新增支持调用联网搜索工具,通过配置 tools.**type** 参数为 web_search 即可开启联网搜索。 + +* 开启联网搜索后,模型会根据用户的提示词自主判断是否搜索互联网内容(如商品、天气等)。可提升生成视频的时效性,但也会增加一定的时延。 +* 实际搜索次数可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 usage.tool_usage.**web_search** 字段获取,如果为 0 表示未搜索。 + + + +|输入:文本 |输出 | +|---|---| +|微距镜头对准叶片上翠绿的玻璃蛙。焦点逐渐从它光滑的皮肤,转移到它完全透明的腹部,一颗鲜红的心脏正在有力地、规律地收缩扩张。||\ +|:::tip| |\ +|联网搜索玻璃蛙的容貌特征。| |\ +|| |\ +|:::| | + + +```mixin-react +return ( + + contents = new ArrayList<>(); + + // text prompt + contents.add(Content.builder() + .type("text") + .text(prompt) + .build()); + + // Create a video generation task + CreateContentGenerationTaskRequest createRequest = CreateContentGenerationTaskRequest.builder() + .model(modelId) + .content(contents) + .generateAudio(generateAudio) + .ratio(videoRatio) + .duration(videoDuration) + .watermark(showWatermark) + .tools(Collections.singletonList(webSearchTool)) + .build(); + CreateContentGenerationTaskResult createResult = service.createContentGenerationTask(createRequest); + System.out.println(createResult); + // Get the details of the task + String taskId = createResult.getId(); + GetContentGenerationTaskRequest getRequest = GetContentGenerationTaskRequest.builder() + .taskId(taskId) + .build(); + + // Polling query section + System.out.println("----- polling task status -----"); + while (true) \{ + try \{ + GetContentGenerationTaskResponse getResponse = service.getContentGenerationTask(getRequest); + String status = getResponse.getStatus(); + if ("succeeded".equalsIgnoreCase(status)) \{ + System.out.println("----- task succeeded -----"); + System.out.println(getResponse); + break; + \} else if ("failed".equalsIgnoreCase(status)) \{ + System.out.println("----- task failed -----"); + System.out.println("Error: " + getResponse.getStatus()); + break; + \} else \{ + System.out.printf("Current status: %s, Retrying in 10 seconds...", status); + TimeUnit.SECONDS.sleep(10); + \} + \} catch (InterruptedException ie) \{ + Thread.currentThread().interrupt(); + System.err.println("Polling interrupted"); + break; + \} + \} + \} +\} +\`\`\` + +`}> +); +``` + + +## 更多能力 +seedance 2.0 系列模型也支持文生视频、首帧图生视频、首尾帧图生视频、设置视频输出规格等通用基础能力,详情请参见 [视频生成教程-wip](/docs/82379/2298881)。 + +# 便利创作 +seedance 2.0 系列模型不支持直接上传含有真人人脸的参考图/视频。为便利创作者使用肖像,平台推出了以下解决方案。 + + +|方案 |介绍 | +|---|---| +|[使用虚拟人像](/docs/82379/2291680#2bf01416) |平台预置虚拟人像库,为创作者提供免费、合规、丰富多样的肖像素材。适用于需真人风格人脸但无需指定具体人物,追求零合规风险、快速创作的场景。 | +|[使用已授权真人素材](/docs/82379/2291680#f952d0c3) |支持使用已获得授权的真人肖像素材进行视频生成。 | +|[使用模型产物进行二创](/docs/82379/2291680#86c3831f) |本账号下部分模型生成的含人脸原始产物可作为输入素材,再次调用 seedance 2.0 系列模型进行二次创作,不会触发审核拦截。 | + + +## 使用虚拟人像 +对写实风格视频,可通过虚拟人像库预置人像来控制角色样貌。每个素材对应一个独立素材 ID (asset ID), 在 **content.<模态\>_url.url** 字段中传入 `asset://` 即可生成视频。浏览及检索虚拟人像请参见[虚拟人像库](/docs/82379/2223965)。 + + +|输入:文本 |输入:虚拟人像、图片 |输出 | +|---|---|---| +|固定机位,近景镜头,清新自然风格。在室内自然光下,**图片1**中美妆博主面带笑容,向镜头介绍**图片2**中的面霜。博主将手里的面霜展示给镜头,开心地说“挖到本命面霜了!”;接着她一边用手指轻轻蘸取面霜展示那种软糯感,一边说“质地像云朵一样软糯,一抹就吸收”;最后她把面霜涂抹在脸颊上,展示着水润透亮的皮肤,同时自信地说“熬夜急救、补水保湿全搞定”。要求画面中人物居中,完整展示人物的整个脑袋和上半身,始终对焦人脸,人脸始终清晰,纯净无任何字幕。| ||\ +|:::warning|> 虚拟人像| |\ +|Asset ID 仅用来向模型传入素材,提示词中仍需使用"**素材类型+序号**”格式引用素材,序号为请求体中该素材在同类素材中的排序。|| |\ +|正确用法:**图片1**中美妆博主| | |\ +|错误用法:asset\-2026\*\*\*\*是美妆博主|> 产品图像 | |\ +|| | |\ +|:::| | | + + +```mixin-react +return ( + + contents = new ArrayList<>(); + + // 1. Text prompt + contents.add(Content.builder() + .type("text") + .text(prompt) + .build()); + + // 2. Reference image 1 + contents.add(Content.builder() + .type("image_url") + .imageUrl(CreateContentGenerationTaskRequest.ImageUrl.builder() + .url(refImage1) + .build()) + .role("reference_image") + .build()); + + // 3. Reference image 2 + contents.add(Content.builder() + .type("image_url") + .imageUrl(CreateContentGenerationTaskRequest.ImageUrl.builder() + .url(refImage2) + .build()) + .role("reference_image") + .build()); + + // Create video generation task + CreateContentGenerationTaskRequest createRequest = CreateContentGenerationTaskRequest.builder() + .generateAudio(generateAudio) + .model(modelId) + .content(contents) + .ratio(videoRatio) + .duration(videoDuration) + .watermark(showWatermark) + .build(); + + CreateContentGenerationTaskResult createResult = service.createContentGenerationTask(createRequest); + System.out.println("Task Created: " + createResult); + + // Get task details and poll status + String taskId = createResult.getId(); + pollTaskStatus(taskId); + \} + + /** + * Poll task status + * @param taskId Task ID + */ + + private static void pollTaskStatus(String taskId) \{ + GetContentGenerationTaskRequest getRequest = GetContentGenerationTaskRequest.builder() + .taskId(taskId) + .build(); + + System.out.println("----- polling task status -----"); + try \{ + while (true) \{ + GetContentGenerationTaskResponse getResponse = service.getContentGenerationTask(getRequest); + String status = getResponse.getStatus(); + + if ("succeeded".equalsIgnoreCase(status)) \{ + System.out.println("----- task succeeded -----"); + System.out.println(getResponse); + break; + \} else if ("failed".equalsIgnoreCase(status)) \{ + System.out.println("----- task failed -----"); + if (getResponse.getError() != null) \{ + System.out.println("Error: " + getResponse.getError().getMessage()); + \} + break; + \} else \{ + System.out.printf("Current status: %s, Retrying in 10 seconds...%n", status); + TimeUnit.SECONDS.sleep(10); + \} + \} + \} catch (InterruptedException ie) \{ + Thread.currentThread().interrupt(); + System.err.println("Polling interrupted"); + \} catch (Exception e) \{ + System.err.println("Error occurred: " + e.getMessage()); + \} finally \{ + service.shutdownExecutor(); + \} + \} +\} +\`\`\` + +`}> +); +``` + + +## 使用已授权真人素材 +通过真人认证和本人授权后,可将该真人的相关素材(例如该真人的图片、视频、音频)上传至方舟。素材入库成功后,每个素材将获得一个独立素材 ID (asset ID), 在 **content.<模态\>_url.url** 字段中传入 `asset://`即可使用该素材生成视频。真人认证及素材入库流程请参见[录入真人形象素材](/docs/82379/2315856)。 +```Shell +... +"content": [ + { + "type": "text", + "text": "" + }, + { + "type": "image_url", + "image_url": { + "url": "asset://" + }, + "role": "reference_image" + }, + { + "type": "video_url", + "video_url": { + "url": "asset://" + }, + "role": "reference_video" + }, + { + "type": "audio_url", + "audio_url": { + "url": "asset://" + }, + "role": "reference_audio" + } + ] +... +``` + + + +## 使用模型产物进行二创 +seedance 2.0 系列模型不支持直接上传含有真人人脸的参考图/视频。为了便利创作者在含人脸场景的二次创作需求,方舟平台信任以下模型生成的含人脸产物,您可使用**本账号下近30天内由以下模型生成的含人脸原始产物**,作为输入素材,再次调用 seedance 2.0 系列模型进行二次创作。 + +|信任产物范围 |生效时间|有效期|\ +| |> 信任该时间之后|> 从产物生成时间|\ +| |> 生成的产物 |> 开始计算 | +|---|---|---| +|seedance 2.0 及 2.0 fast 生成的含人脸视频 |2026年03月11日起 |30天 | +|seedance 2.0 及 2.0 fast 生成的含人脸视频对应的尾帧图片 |2026年04月16日起 |30天 | +|[Seedream 5.0 lite 文生图](https://www.volcengine.com/docs/82379/1824121?lang=zh#9695d195)得到的含人脸图片 |2026年04月16日起 |30天 | + +:::warning + +* 对于含人脸场景,方舟平台仅信任模型原始产物,二次剪辑或超过有效期后均不可使用。 +* 对于不含人脸场景,模型产物不存在受信问题,支持自由剪辑后进行二次创作。 + + +::: + +|输入:同账号生成的视频 |输出 | +|---|---| +|||\ +|||\ +|> [使用虚拟人像](/docs/82379/2291680#2bf01416)示例生成的视频 |> 输入:将面霜的颜色修改为白色。|\ +| |> ratio 修改为16:9 | + + +```mixin-react +return ( + + contents = new ArrayList<>(); + + // 1. Text prompt + contents.add(Content.builder() + .type("text") + .text(prompt) + .build()); + + // 2. Reference video + contents.add(Content.builder() + .type("video_url") + .videoUrl(CreateContentGenerationTaskRequest.VideoUrl.builder() + .url(refVideo) + .build()) + .role("reference_video") + .build()); + + // Create video generation task + CreateContentGenerationTaskRequest createRequest = CreateContentGenerationTaskRequest.builder() + .generateAudio(generateAudio) + .model(modelId) + .content(contents) + .ratio(videoRatio) + .duration(videoDuration) + .watermark(showWatermark) + .build(); + + CreateContentGenerationTaskResult createResult = service.createContentGenerationTask(createRequest); + System.out.println("Task Created: " + createResult); + + // Get task details and poll status + String taskId = createResult.getId(); + pollTaskStatus(taskId); + \} + + /** + * Poll task status + * @param taskId Task ID + */ + + private static void pollTaskStatus(String taskId) \{ + GetContentGenerationTaskRequest getRequest = GetContentGenerationTaskRequest.builder() + .taskId(taskId) + .build(); + + System.out.println("----- polling task status -----"); + try \{ + while (true) \{ + GetContentGenerationTaskResponse getResponse = service.getContentGenerationTask(getRequest); + String status = getResponse.getStatus(); + + if ("succeeded".equalsIgnoreCase(status)) \{ + System.out.println("----- task succeeded -----"); + System.out.println(getResponse); + break; + \} else if ("failed".equalsIgnoreCase(status)) \{ + System.out.println("----- task failed -----"); + if (getResponse.getError() != null) \{ + System.out.println("Error: " + getResponse.getError().getMessage()); + \} + break; + \} else \{ + System.out.printf("Current status: %s, Retrying in 10 seconds...%n", status); + TimeUnit.SECONDS.sleep(10); + \} + \} + \} catch (InterruptedException ie) \{ + Thread.currentThread().interrupt(); + System.err.println("Polling interrupted"); + \} catch (Exception e) \{ + System.err.println("Error occurred: " + e.getMessage()); + \} finally \{ + service.shutdownExecutor(); + \} + \} +\} +\`\`\` + +`}> +); +``` + + +# 提示词技巧 +提示词中必须使用"**素材类型+序号**”格式引用素材,序号为请求体中该素材在同类素材中的排序。例如 「图片 n」指代`content`数组中第 n 个`type="image_url"`的参考图片(按数组顺序从1开始计数)。**注意不支持使用 Asset ID 指代素材。** +下文介绍多模态参考、编辑视频、延长视频的提示词典型公式,更多详细内容请参见[Seedance 2.0 系列提示词指南](/docs/82379/2222480)。 +:::tip +平台提供 **seedance 2.0 提示词优化技能**,方便您对提示词进行调优。 + +* 配置方式:可将技能文件配置到 Code Agent / AI Agent 中使用。以 OpenClaw 为例,下载该 SKILL.md 文件,复制完整内容至对话输入框中,并发送”请帮我安装这个技能”,等待工具自动完成安装。 +* 使用方式:在 AI 对话框输入 `/sd2-pe + 你的提示词内容`,开始调试提示词。 + + +::: +**多模态参考** + +* 图片参考:参考 / 提取 / 结合 +「图片 n」中的「主体 / 被参考元素描述」,生成「画面描述」,保持「主体 / 被参考元素描述」特征一致。 +* 视频参考:参考「视频 n」的「动作描述 / 运镜描述 / 特效描述」,生成「画面描述」,保持动作细节 / 运镜 / 特效一致。 +* 音频参考: + * 音色参考:「角色」说:“「台词」,音色参考「音频 n」。 + * 音频内容参考:理想出现时机 +「音频 n」。 + +**编辑视频** + +* 增加元素:清晰描述「元素特征」+「出现时机」+「出现位置」 +* 删除元素:点明需要删除的元素,对于保持不变的元素,在提示词中加以强调,表现更佳 +* 修改元素:清晰描述更换元素即可 + +**延长视频** + +* 延长视频:向前/向后延长「视频n」+「需延长的视频描述」 +* 轨道补全:「视频1」+「过渡画面描述」+接「视频2」+「过渡画面描述」+接「视频3」 + + +# 使用限制 +参见[使用限制](/docs/82379/1366799#66cb028f)。 + + diff --git a/docs/API文档/seedance模型价格.md b/docs/API文档/seedance模型价格.md new file mode 100644 index 0000000..4b3dd3c --- /dev/null +++ b/docs/API文档/seedance模型价格.md @@ -0,0 +1,481 @@ +不同模型服务支持的能力及单价各不相同,本文为您介绍各模型的计费公式及单价,方便您进行模型价格查阅和比较。 +:::tip + +* 如需了解计费方式及详细计费逻辑,请参见 [模型服务计费说明](/docs/82379/1544681)。 +* 支持通过 [价格计算器](https://www.volcengine.com/pricing?product=ark_bd&tab=2) **预估** 满足业务需求所需的费用。 +* 本文价格和 [定价详情页](https://www.volcengine.com/pricing?product=ark_bd&tab=1) 仅作为商品规格和价格的参考,具体可购买的商品规格及费用请以实际下单结果为准。 + +::: + +# 大语言模型 + +## 在线推理(常规) + + +|模型名称 |条件|输入|缓存存储|缓存输入|输出|\ +| |千 token |元/百万token |元/百万 token /小时 |元/百万token |元/百万token | +|---|---|---|---|---|---| +|doubao\-seed\-2.0\-pro |输入长度 [0, 32] |3.2 |0.017 |0.64 |16.0 | +|^^|输入长度 (32, 128] |4.8 |0.017 |0.96 |24.0 | +|^^|输入长度 (128, 256] |9.6 |0.017 |1.92 |48.0 | +|doubao\-seed\-2.0\-lite |输入长度 [0, 32] |0.6 |0.017 |0.12 |3.6 | +|^^|输入长度 (32, 128] |0.9 |0.017 |0.18 |5.4 | +|^^|输入长度 (128, 256] |1.8 |0.017 |0.36 |10.8 | +|doubao\-seed\-2.0\-mini |输入长度 [0, 32] |0.2 |0.017 |0.04 |2.0 | +|^^|输入长度 (32, 128] |0.4 |0.017 |0.08 |4.0 | +|^^|输入长度 (128, 256] |0.8 |0.017 |0.16 |8.0 | +|doubao\-seed\-2.0\-code |输入长度 [0, 32] |3.2 |0.017 |0.64 |16.0 | +|^^|输入长度 (32, 128] |4.8 |0.017 |0.96 |24.0 | +|^^|输入长度 (128, 256] |9.6 |0.017 |1.92 |48.0 | +|doubao\-seed\-1.8 |输入长度 [0, 32]|0.80 |0.017 |0.16 |2.00 |\ +| |且输出长度 [0, 0.2] | | | | | +|^^|输入长度 [0, 32]|0.80 |0.017 |0.16 |8.00 |\ +| |且输出长度 (0.2,+∞) | | | | | +|^^|输入长度 (32, 128] |1.20 |0.017 |0.16 |16.00 | +|^^|输入长度 (128, 256] |2.40 |0.017 |0.16 |24.00 | +|doubao\-seed\-character |输入长度 [0, 32] |0.80 |0.017 |0.16 |2.00 | +|^^|输入长度 (32, 128] |1.20 |0.017 |0.16 |6.00 | +|doubao\-seed\-code |输入长度 [0, 32] |1.20 |0.017 |0.24 |8.00 | +|^^|输入长度 (32, 128] |1.40 |0.017 |0.24 |12.00 | +|^^|输入长度 (128, 256] |2.80 |0.017 |0.24 |16.00 | +|doubao\-seed\-1.6 |输入长度 [0, 32]|0.80 |0.017 |0.16 |2.00 |\ +| |且输出长度 [0, 0.2] | | | | | +|^^|输入长度 [0, 32]|0.80 |0.017 |0.16 |8.00 |\ +| |且输出长度 (0.2,+∞) | | | | | +|^^|输入长度 (32, 128] |1.20 |0.017 |0.16 |16.00 | +|^^|输入长度 (128, 256] |2.40 |0.017 |0.16 |24.00 | +|doubao\-seed\-1.6\-lite |输入长度 [0, 32]|0.30 |0.017 |0.06 |0.60 |\ +| |且输出长度 [0, 0.2] | | | | | +|^^|输入长度 [0, 32]|0.30 |0.017 |0.06 |2.40 |\ +| |且输出长度 (0.2,+∞) | | | | | +|^^|输入长度 (32, 128] |0.60 |0.017 |0.06 |4.00 | +|^^|输入长度 (128, 256] |1.20 |0.017 |0.06 |12.00 | +|doubao\-seed\-1.6\-flash |输入长度 [0, 32] |0.15 |0.017 |0.03 |1.50 | +|^^|输入长度 (32, 128] |0.30 |0.017 |0.03 |3.00 | +|^^|输入长度 (128, 256] |0.60 |0.017 |0.03 |6.00 | +|doubao\-seed\-1.6\-vision |输入长度 [0, 32] |0.80 |0.017 |0.16 |8.00 | +|^^|输入长度 (32, 128] |1.20 |0.017 |0.16 |16.00 | +|^^|输入长度 (128, 256] |2.40 |0.017 |0.16 |24.00 | +|doubao\-seed\-translation |\- |1.20 |不支持 |不支持 |3.60 | +|doubao\-1.5\-pro\-32k |\- |0.80 |0.017 |0.16 |2.00 | +|doubao\-1.5\-lite\-32k |\- |0.30 |0.017 |0.06 |0.60 | +|doubao\-1.5\-vision\-pro |\- |3.00 |不支持 |不支持 |9.00 | +|glm\-4.7 |输入长度 [0, 32]|2.0 |0.017 |0.4 |8.0 |\ +| |且输出长度 [0, 0.2] | | | | | +|^^|输入长度 [0, 32]|3.0 |0.017 |0.6 |14.0 |\ +| |且输出长度 (0.2,+∞) | | | | | +|^^|输入长度 (32, 200] |4.0 |0.017 |0.8 |16.0 | +|deepseek\-v3.2 |输入长度 [0, 32] |2.00 |0.017 |0.4 |3.00 | +|^^|输入长度 (32, 128] |4.00 |0.017 |0.4 |6.00 | +|deepseek\-v3.1 |\- |4.00 |0.017 |0.80 |12.00 | +|deepseek\-v3 |\- |2.00 |0.017 |0.40 |8.00 | +|deepseek\-r1 |\- |4.00 |0.017 |0.80 |16.00 | + + +> * 按 token 后付费,计算公式: +> * `在线推理费用 = 输入单价 × 输入token + 缓存输入单价 × 缓存命中token + 缓存存储单价 × 缓存存储token × 时长 + 输出单价 × 输出token` +> * 分段计费:部分模型适用,不同的输入长度(和输出长度),token单价不同: +> * 举例:请求输入 200k tokens,输出 14k tokens,满足 **输入长度 (128, 256]** 条件,模型输入输出 token 按照:输入 2.4 元/百万 token,输出 24 元/百万 token 单价计费。 +> * 常见问题: [如何查看历史调用的输入输出长度的区间分布?](/docs/82379/1359411#fba666f2) + + +## 在线推理(低延迟) + + +|模型名称 |条件|输入|缓存输入|输出|\ +| |千 token |元/百万token |元/百万token |元/百万token | +|---|---|---|---|---| +|doubao\-seed\-2.0\-pro |输入长度 [0, 32] |9.6 |1.92 |48.0 | +|^^|输入长度 (32, 128] |14.4 |2.88 |72.0 | +|^^|输入长度 (128, 256] |28.8 |5.76 |144.0 | +|doubao\-seed\-2.0\-lite |输入长度 [0, 32] |1.2 |0.24 |7.2 | +|^^|输入长度 (32, 128] |1.8 |0.36 |10.8 | +|^^|输入长度 (128, 256] |3.6 |0.72 |21.6 | +|doubao\-seed\-2.0\-mini |输入长度 [0, 32] |0.4 |0.08 |4.0 | +|^^|输入长度 (32, 128] |0.8 |0.16 |8.0 | +|^^|输入长度 (128, 256] |1.6 |0.32 |16.0 | + + +## 在线推理(TPM 保障包) + + +|模型 |计费方式 |输入|输出|\ +| | |元/每10K TPM |元/每1K TPM | +|---|---|---|---| +|doubao\-seed\-1.8 |按购买时长后付费 |1.920 |0.480 | +|^^|包天预付费 |23.040 |5.760 | +|doubao\-seed\-1.6 |按购买时长后付费 |1.920 |0.480 | +|^^|包天预付费 |23.040 |5.760 | +|doubao\-seed\-1.6\-vision |按购买时长后付费 |1.920 |0.480 | +|^^|包天预付费 |23.040 |5.760 | +|doubao\-seed\-1.6\-flash|按购买时长后付费 |0.360 |0.360 |\ +|> 0615版本不支持 | | | | +|^^|包天预付费 |4.320 |4.320 | +|doubao\-1.5\-vision\-pro |按购买时长后付费 |7.200 |2.160 | +|^^|包天预付费 |86.400 |25.920 | +|doubao\-1.5\-pro\-32k|按购买时长后付费 |1.920 |0.480 |\ +|> 包含 character\-250715 版本 | | | | +|^^|包天预付费 |23.040 |5.760 | +|doubao\-1.5\-lite\-32k |按购买时长后付费 |0.72 |0.144 | +|^^|包天预付费 |8.64 |1.728 | +|doubao\-pro\-32k |按购买时长后付费 |1.920 |0.480 | +|^^|包天预付费 |23.040 |5.760 | +|deepseek\-v3.2 |按购买时长后付费 |7.2 |1.08 | +|^^|包天预付费 |86.4 |12.96 | +|deepseek\-v3.1 |按购买时长后付费 |9.60 |2.88 | +|^^|包天预付费 |115.20 |34.56 | +|deepseek\-v3 |按购买时长后付费 |4.80 |1.92 | +|^^|包天预付费 |57.60 |23.04 | +|deepseek\-r1 |按购买时长后付费 |9.60 |3.84 | +|^^|包天预付费 |115.20 |46.08 | + + +> * 相比普通的按token计费模式,TPM保障包具备更高并发,更低的延迟,更强稳定性。支持的模型,以[接入点创建页](https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint/create)可选的付费方式为准。 +> * 支持「按购买时长后付费」和「包天预付费」两种方式叠加购买,可灵活组合。 +> * **doubao\-seed\-1.6 系列及之后模型,deepseek\-v3.2 模型,不同长度请求抵扣 TPM 速度不同**,可通过 TPM 计算器查看相应的抵扣系数,估算实际需购买的**可抵扣TPM**。 + + +## 批量推理 + + +|模型名称 |条件|输入|缓存命中|输出|\ +| |千 token |元/百万token |元/百万token |元/百万token | +|---|---|---|---|---| +|doubao\-seed\-2.0\-pro |输入长度 [0, 32] |1.6 |0.64 |8.0 | +|^^|输入长度 (32, 128] |2.4 |0.96 |12.0 | +|^^|输入长度 (128, 256] |4.8 |1.92 |24.0 | +|doubao\-seed\-2.0\-lite |输入长度 [0, 32] |0.3 |0.12 |1.8 | +|^^|输入长度 (32, 128] |0.45 |0.18 |2.7 | +|^^|输入长度 (128, 256] |0.9 |0.36 |5.4 | +|doubao\-seed\-2.0\-mini |输入长度 [0, 32] |0.1 |0.04 |1.0 | +|^^|输入长度 (32, 128] |0.2 |0.08 |2.0 | +|^^|输入长度 (128, 256] |0.4 |0.16 |4.0 | +|doubao\-seed\-2.0\-code |输入长度 [0, 32] |1.6 |0.64 |8.0 | +|^^|输入长度 (32, 128] |2.4 |0.96 |12.0 | +|^^|输入长度 (128, 256] |4.8 |1.92 |24.0 | +|doubao\-seed\-1.8 |输入长度 [0, 32]|0.40 |0.16 |1.00 |\ +| |且输出长度 [0, 0.2] | | | | +|^^|输入长度 [0, 32]|0.40 |0.16 |4.00 |\ +| |且输出长度 (0.2,+∞) | | | | +|^^|输入长度 (32, 128] |0.60 |0.16 |8.00 | +|^^|输入长度 (128, 256] |1.20 |0.16 |12.00 | +|doubao\-seed\-1.6\-vision |输入长度 [0, 32] |0.40 |0.16 |4.00 | +|^^|输入长度 (32, 128] |0.60 |0.16 |8.00 | +|^^|输入长度 (128, 256] |1.20 |0.16 |12.00 | +|doubao\-seed\-1.6\-lite |输入长度 [0, 32]|0.15 |0.06 |0.30 |\ +| |且输出长度 [0, 0.2] | | | | +|^^|输入长度 [0, 32]|0.15 |0.06 |1.20 |\ +| |且输出长度 (0.2,+∞) | | | | +|^^|输入长度 (32, 128] |0.30 |0.06 |2.00 | +|^^|输入长度 (128, 256] |0.60 |0.06 |6.00 | +|doubao\-seed\-1.6 |输入长度 [0, 32]|0.40 |0.16 |1.00 |\ +| |且输出长度 [0, 0.2] | | | | +|^^|输入长度 [0, 32]|0.40 |0.16 |4.00 |\ +| |且输出长度 (0.2,+∞) | | | | +|^^|输入长度 (32, 128] |0.60 |0.16 |8.00 | +|^^|输入长度 (128, 256] |1.20 |0.16 |12.00 | +|doubao\-seed\-1.6\-flash |输入长度 [0, 32] |0.075 |0.03 |0.75 | +|^^|输入长度 (32, 128] |0.150 |0.03 |1.50 | +|^^|输入长度 (128, 256] |0.300 |0.03 |3.00 | +|doubao\-seed\-translation |\- |0.60 |0.24 |1.80 | +|doubao\-1.5\-pro\-32k |\- |0.40 |0.16 |1.00 | +|doubao\-1.5\-lite\-32k |\- |0.15 |0.06 |0.30 | +|doubao\-pro\-32k |\- |0.80 |0.16 |2.00 | +|deepseek\-v3.2 |输入长度 [0, 32] |1.00 |0.40 |1.50 | +|^^|输入长度 (32, 128] |2.00 |0.40 |3.00 | +|deepseek\-v3.1 |\- |2.00 |0.80 |6.00 | +|deepseek\-v3 |\- |1.00 |0.40 |4.00 | +|deepseek\-r1 |\- |2.00 |0.80 |8.00 | + + +> * 按 token 后付费,计算公式:`批量推理费用 = 输入单价 × 输入token + 缓存命中单价 × 缓存命中token + 输出单价 × 输出token` +> * 部分模型已支持透明前缀缓存能力,无需任何配置,享受命中缓存后的更低单价。 +> * doubao\-seed\-1.6 系列支持分段计费,即根据每次请求的输入及输出长度,采用不同 token 单价。 +> * 举例:当某次请求的输入长度为 200k,输出长度为 14k 时,满足 **输入长度 (128, 256]** 条件,模型产生的所有 token 按照:输入2.4 元/百万 token,输出 24 元/百万 token 单价计费。 +> * 查看往期调用的输入输出长度分布,请查看常见问题 [如何查看历史调用的输入输出长度的区间分布?](/docs/82379/1359411#fba666f2) + + +# 视频生成模型 + +## 按token单价 + + +|模型 |在线推理|离线推理|\ +| |元/百万token |元/百万token | +|---|---|---| +|doubao\-seedance\-2.0|* 输出视频分辨率为 480p,720p|暂不支持 |\ +|> 按输出视频分辨率和输入是否包含视频区分定价 | * 输入不含视频:46.00| |\ +| | * 输入包含视频:28.00| |\ +| |* 输出视频分辨率为 1080p| |\ +| | * 输入不含视频:51.00| |\ +| | * 输入包含视频:31.00 | | +|doubao\-seedance\-2.0\-fast|* 输入不含视频:37.00|暂不支持 |\ +|> 按输入是否包含视频区分定价|* 输入包含视频:22.00 | |\ +|> 不支持输出 1080p 视频 | | | +|doubao\-seedance\-1.5\-pro|* 有声视频:16.00|* 有声视频:8.00|\ +|> 按输出视频是否包含声音区分定价 |* 无声视频:8.00 |* 无声视频:4.00 | +|doubao\-seedance\-1.0\-pro |15.00 |7.50 | +|doubao\-seedance\-1.0\-pro\-fast |4.20 |2.10 | +|doubao\-seedance\-1.0\-lite |10.00 |5.00 | + + +> * 仅对成功生成的视频计费。因审核等原因导致生成失败的,不收取费用。 +> * 视频价格估算公式:`按 token 单价 × token 用量` +> * 正常视频 token 用量估算:`(输入视频时长+输出视频时长) × 输出视频的宽 × 输出视频的高 × 输出视频的帧率/1024`,注意存在输入视频时, Seedance 2.0 和 Seedance 2.0 fast 模型针对不同的视频输出时长存在最低 Token 用量限制,详见下文表格。 +> * Draft 视频(仅480p)token 用量估算:`正常视频 token 用量公式 × 折算系数`,折算系数与模型相关,Seedance 1.5 pro 的 token 折算系数:无声 0.7;有声 0.6,其他模型暂不支持。 +> * 准确 token 用量:以调用 API 后返回信息中的 usage 字段为准。 + + +## 价格示例 +基于 token 用量公式估算的视频单价,方便您直观了解不同规格的视频成本。更多价格示例请参见[火山方舟视频生成模型价格快查表](https://bytedance.larkoffice.com/wiki/FXaYwxzJ5i5Zdik32ipcWzt7nxd?table=tblns3WjGMNbR8sL&view=vewPa39Do4#CategoryScheduledTask)。 + +### doubao\-seedance\-2.0 & 2.0 fast + +> * 视频价格估算公式:`按 token 单价 × token 用量`=`按 token 单价 × (输入视频时长+输出视频时长) × 输出视频的宽 × 输出视频的高 × 输出视频的帧率/1024` +> * 注意:输入包含视频时, Seedance 2.0 和 Seedance 2.0 fast 模型针对不同的视频输出时长存在最低 token 用量限制,如果 token 估算用量 < 最低 token 用量限制,则按最低 token 用量计算视频价格。 +* **输入不含视频** + + + +|分辨率 |宽高比 |输出视频时长(秒) |doubao\-seedance\-2.0|doubao\-seedance\-2.0\-fast|\ +| | | |视频价格(元/个) |视频价格(元/个) | +|---|---|---|---|---| +|480p |16:9 |5 |2.31 |1.86 | +|720p |16:9 |5 |4.97 |4.00 | +|1080p |16:9 |5 |12.39 |不支持 | + + +* **输入包含视频** + + + +|分辨率 |宽高比 |输入视频时长(秒) |输出视频时长(秒) |doubao\-seedance\-2.0|doubao\-seedance\-2.0\-fast|\ +| | | | |视频价格(元/个) |视频价格(元/个) | +|---|---|---|---|---|---| +|480p |16:9 |2~15 |5 |2.53~5.62|1.99~4.42|\ +| | | | |> 最低价对应输入2~4秒|> 最低价对应输入2~4秒|\ +| | | | |> 最高价对应输入15秒 |> 最高价对应输入15秒 | +|720p |16:9 |2~15 |5 |5.44~12.10|4.28~9.50|\ +| | | | |> 最低价对应输入2~4秒|> 最低价对应输入2~4秒|\ +| | | | |> 最高价对应输入15秒 |> 最高价对应输入15秒 | +|1080p |16:9 |2~15 |5 |13.56~30.13|不支持 |\ +| | | | |> 最低价对应输入2~4秒| |\ +| | | | |> 最高价对应输入15秒 | | + +附:输入包含视频时,Seedance 2.0 & 2.0 fast 的最低 token 用量限制。本表以 16:9 宽高比为例展示各分辨率下的最低 token 用量。不同宽高比的最低 token 用量存在少许差异,详情参见 [火山方舟视频生成模型价格快查表](https://bytedance.larkoffice.com/wiki/FXaYwxzJ5i5Zdik32ipcWzt7nxd?table=tblmNCuMjADrXtDf&view=vewPa39Do4#CategoryScheduledTask)。 + + +|输出视频秒数 |最低tokens\-480P |最低tokens\-720P |最低tokens\-1080P | +|---|---|---|---| +|4 |70308 |151200 |340200 | +|5 |90396 |194400 |437400 | +|6 |100440 |216000 |486000 | +|7 |120528 |259200 |583200 | +|8 |140616 |302400 |680400 | +|9 |150660 |324000 |729000 | +|10 |170748 |367200 |826200 | +|11 |190836 |410400 |923400 | +|12 |200880 |432000 |972000 | +|13 |220968 |475200 |1069200 | +|14 |241056 |518400 |1166400 | +|15 |251100 |540000 |1215000 | + + +### doubao\-seedance\-1.5\-pro + + +|分辨率 |宽高比 |时长(秒) |有声视频|Draft 有声|无声视频|Draft无声|\ +| | | |价格|视频价格|价格|视频价格|\ +| | | |(元/个) |(元/个) |(元/个) |(元/个) | +|---|---|---|---|---|---|---| +|480p |16:9 |5 |0.80 |0.48 |0.40 |0.28 | +|720p |16:9 |5 |1.73 |不支持 |0.86 |不支持 | +|1080p |16:9 |5 |3.89 |不支持 |1.94 |不支持 | + + +# 图片生成模型 + + +|模型名称 |单价|\ +| |元/张 | +|---|---| +|doubao\-seedream\-5.0\-lite |0.22 | +|doubao\-seedream\-4.5 |0.25 | +|doubao\-seedream\-4.0 |0.2 | +|doubao\-seedream\-3.0\-t2i |0.259 | + + +> * 按成功输出图片数量计费: +> * 组图场景按实际生成的图片数量计费。 +> * 因审核等原因未成功输出的图片不计费。 + + + +# 向量模型 + + +|模型 |文本输入|图片输入|\ +| |元/百万 token |元/百万 token | +|---|---|---| +|doubao\-embedding\-vision |0.70 |1.80 | + +> 按输入的 tokens 计费: +> 费用 = `文本输入 tokens × 文本输入单价 + 图片输入 tokens × 图片输入单价` +> = `文本输入 tokens × 文本输入单价+ min((width × height)/784,1312 ) × 图片输入单价` + + +# 模型精调 + +## 精调\-按 token 后付费 + + +|基础模型 ID |LoRA精调|全量精调|\ +| |元/百万token |元/百万token | +|---|---|---| +|doubao\-seed\-1.6 |40 |80 | +|doubao\-seed\-1.6\-flash |7 |14 | +|doubao\-1\-5\-pro\-32k\-250115 |50 |100 | +|doubao\-1\-5\-lite\-32k\-250115 |30 |60 | + +> 训练费用 = 总 token 数 x 精调单价 =(用户训练集token数+混入token数+验证集token数)x 迭代轮次 x 精调token单价 +> * 若 token 数小于 1000,将会上取整为 1000 tokens 计算。 + + +## 精调\-按算力付费 + + +|算力规格 |计费方式 |定价|\ +| | |元/小时 | +|---|---|---| +|方舟A型模型单元 |按量后付费 |25 | +|方舟B型模型单元 |按量后付费 |15 | +|方舟C型模型单元 |按量后付费 |10 | +|方舟D型模型单元 |按量后付费 |20 | + +> 训练费用=训练计费时长*使用的模版单价=训练计费时长*模型单元数\*模型单元单价。 + + +## 推理\-在线推理 + + +|精调模型对应的基础模型 |条件(千 token) |输入|输出|\ +| | |元/百万token |元/百万token | +|---|---|---|---| +|doubao\-seed\-1.6 |输入长度 [0, 32] |1.60 |16.00 | +|^^|输入长度 (32, 128] |2.40 |32.00 | +|doubao\-seed\-1.6\-flash |输入长度 [0, 32] |0.30 |3.00 | +|^^|输入长度 (32, 128] |0.60 |6.00 | +|doubao\-1.5\-pro\-32k |\- |2.00 |5.00 | +|doubao\-1.5\-lite\-32k |\- |0.75 |1.50 | +|doubao\-pro\-32k |\- |0.80 |2.00 | + +> 按 token 后付费价格,仅部分 doubao 模型在精调后支持按 token 付费,以[接入点创建页](https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint/create)可选的付费方式为准。 + + +## 推理\-批量推理 + + +|精调模型对应的基础模型 |条件(千 token) |输入|缓存命中|输出|\ +| | |元/百万token |元/百万token |元/百万token | +|---|---|---|---|---| +|doubao\-seed\-1.6 |输入长度 [0, 32] |0.40 |0.16 |4.00 | +|^^|输入长度 (32, 128] |0.60 |0.16 |8.00 | +|^^|输入长度 (128, 256] |1.20 |0.16 |12.00 | +|doubao\-seed\-1.6\-flash |输入长度 [0, 32] |0.075 |0.03 |0.75 | +|^^|输入长度 (32, 128] |0.15 |0.03 |1.50 | +|^^|输入长度 (128, 256] |0.30 |0.03 |3.00 | +|doubao\-1.5\-pro\-32k |\- |0.40 |0.16 |1.00 | +|doubao\-1.5\-lite\-32k |\- |0.15 |0.06 |0.30 | +|doubao\-pro\-32k |\- |0.80 |0.16 |2.00 | + +> 按token后付费,相比在线推理,价格低至50%。 + + +# 模型单元 + + +|机型 |计费方式 |定价|\ +| | |元/个 | +|---|---|---| +|方舟A型模型单元 |按购买时长后付费 |25.00 | +|^^|包月预付费 |16700.00 | +|方舟B型模型单元 |按购买时长后付费 |15.00 | +|^^|包月预付费 |10400.00 | +|方舟C型模型单元 |按购买时长后付费 |10.00 | +|^^|包月预付费 |7100.00 | +|方舟D型模型单元 |按购买时长后付费 |20.00 | +|^^|包月预付费 |12800.00 | + +> 支持「按购买时长后付费」和「包月预付费」两种方式叠加购买,可灵活组合。 +> **提供** [单元计算器](https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint/create) 估算需要的机型数量。更推荐通过实际业务流量压测,计算需要的机型和数量。 + + +# 工具及插件 + +## 联网内容插件 + + +|服务项 |价格|说明 |\ +| |元/千次 | | +|---|---|---| +|联网资源 |4 |实时搜索互联网公开域内容,每月提供2万次免费额度。 | +|头条资源 |6 |实时搜索今日头条图文内容,并提供内容详情信息供展示交互卡片。 | +|抖音资源 |6 |实时搜索抖音百科内容,并提供内容详情信息供展示交互卡片。 | +|墨迹天气 |6 |实时搜索墨迹天气内容资源。 | + + +> * 出账及计费:按量后付费 +> * 用量:每次请求产生的调用次数,可返回结构体的 **source_type** 字段计算得到。 +> * 更多说明请参见 [联网内容插件功能说明](/docs/82379/1338552)。 + + +## 豆包助手 + + +|服务项 |价格|说明 |\ +| |元/次 | | +|---|---|---| +|日常沟通 |0.1 |全能助手,自然交流,多轮对话,高情商人格化聊天。 | +|深度沟通 |0.2 |深度理解,精准解析,先思考再回答,复杂问题尽在掌握。 | +|联网搜索 |0.2 |全网搜索,信源丰富,无需费力找资料,一键搜索实时资讯。 | +|边想边搜 |0.5 |逻辑缜密,深度洞察,遇难题问豆包,想得更深,答得更准。 | + + +> * 出账及计费:按量后付费 +> * 用量:每次请求产生的调用次数,可返回结构体的 **source_type** 字段计算得到。 +> * 更多说明请参见 [联网内容插件功能说明](/docs/82379/1338552)。 + + +## 知识库 + + +|服务项 |价格 | +|---|---| +|计算资源\-知识库【旗舰版】 |0.45 元/CU/小时 | +|离线存储资源\-知识库【旗舰版】 |0.0015 元/GB/小时 | +|标准计算资源\-知识库【标准版】 |0.0416 元/知识库/小时 | +|文本向量模型\-知识库【通用】 |0.0005 元/千token | +|文本向量模型(多功能版)\-知识库【通用】 |0.0005 元/千token | +|文本向量模型(Doubao\-embedding)\-知识库【通用】 |0.0005 元/千token | +|文本向量模型(Doubao\- embedding\-large)\-知识库【通用】 |0.0007 元/千token | +|多模态向量模型(Doubao\-embedding\-vision\-text)\-知识库【通用】 |0.0007 元/千token | +|多模态向量模型(Doubao\-embedding\-vision\-image)\-知识库【通用】 |0.0018 元/千token | +|重排模型\-知识库【通用】 |0.0005 元/千token | + +> 更多说明请参见 [知识库计费](/docs/82379/1263336)。 + + +# Coding Plan 个人版 + + +|套餐类型 |订阅时长 |价格 | +|---|---|---| +|Lite 套餐 |1 个月 |40 元/月 | +|^^|3 个月 |120 元/季 | +|Pro 套餐 |1 个月 |200 元/月 | +|^^|3 个月 |600 元/季 | + +> 套餐信息及特惠活动参见[套餐概览](/docs/82379/1925114)。 + + + diff --git a/docs/API文档/创建视频生成任务API.md b/docs/API文档/创建视频生成任务API.md new file mode 100644 index 0000000..a9c7c3c --- /dev/null +++ b/docs/API文档/创建视频生成任务API.md @@ -0,0 +1,648 @@ +`POST https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks` [ ](https://api.volcengine.com/api-explorer/?action=CreateContentsGenerationsTasks&data=%7B%7D&groupName=%E8%A7%86%E9%A2%91%E7%94%9F%E6%88%90API&query=%7B%7D&serviceCode=ark&version=2024-01-01)[运行](https://api.volcengine.com/api-explorer/?action=CreateContentsGenerationsTasks&data=%7B%7D&groupName=%E8%A7%86%E9%A2%91%E7%94%9F%E6%88%90API&query=%7B%7D&serviceCode=ark&version=2024-01-01) +本文介绍创建视频生成任务 API 的输入输出参数,供您使用接口时查阅字段含义。模型会依据传入的图片及文本信息生成视频,待生成完成后,您可以按条件查询任务并获取生成的视频。 +:::tip +请确保您的账户余额大于等于 200 元([前往充值](https://console.volcengine.com/finance/fund/recharge)),或已[购买资源包](https://console.volcengine.com/common-buy/fast/ark_bd%7C%7Cd682ppeeq1mp7kd5q0e0),否则无法开通 seedance 2.0 及 seedance 2.0 fast 模型。 + +::: +**模型能力==^new^==** + +* **seedance 2.0 & 2.0 fast==^new^==** ** (有声视频/无声视频)** + * **多模态参考生视频==^new^==**:输入++参考图片(0~9)+参考视频(0~3)+ 参考音频(0~3)+ 文本提示词(可选)++ 生成 1 个目标视频。注意不可单独输入音频,应至少包含 1 个参考视频或图片。支持生成全新视频、编辑视频、延长视频,[阅读教程](https://www.volcengine.com/docs/82379/2291680) 获取详细代码示例。 + * **图生视频\-首尾帧**:输入++首帧图片+尾帧图片+文本提示词(可选)++ 生成 1 个目标视频。 + * **图生视频\-首帧**:输入++首帧图片+文本提示词(可选)++ 生成 1 个目标视频。 + * **文生视频**:输入++文本提示词++生成 1 个目标视频。 +* **seedance 1.5 pro (有声视频/无声视频)** + 【图生视频\-首尾帧】【图生视频\-首帧】【文生视频】 +* **seedance 1.0 pro** + 【图生视频\-首尾帧】【图生视频\-首帧】【文生视频】 +* **seedance 1.0 pro fast** + 【图生视频\-首帧】【文生视频】 +* **seedance 1.0 lite** + * **doubao\-seedance\-1\-0\-lite\-t2v:** 文生视频 + * **doubao\-seedance\-1\-0\-lite\-i2v:** + * 参考图生视频:根据您输入的**++参考图片(1\-4张)++ ** +++文本提示词(可选)++ 生成 1 个目标视频。 + * 图生视频\-首尾帧 + * 图生视频\-首帧 + + +Tips:一键展开折叠,快速检索内容 +打开页面右上角开关,**ctrl ** + **f** 可检索页面内所有内容。 + + + +```mixin-react +return ( + +`}> + + [模型列表](https://www.volcengine.com/docs/82379/1330310?lang=zh#2705b333)  [模型计费](https://www.volcengine.com/docs/82379/1544106?redirect=1&lang=zh#02affcb8)  [API Key](https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D) +  [调用教程](https://www.volcengine.com/docs/82379/1366799)  [接口文档](https://www.volcengine.com/docs/82379/1520758)  [常见问题](https://www.volcengine.com/docs/82379/1359411)  [开通模型](https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false) +`}>); +``` + + +--- + + + +## 请求参数 +> 跳转 [响应参数](#y2hhTyHB) + + +### 请求体 + +--- + + +**model** `string` %%require%% +您需要调用的模型的 ID (Model ID),[开通模型服务](https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false),并[查询 Model ID](https://www.volcengine.com/docs/82379/1330310) 。 +您也可通过 Endpoint ID 来调用模型,获得限流、计费类型(前付费/后付费)、运行状态查询、监控、安全等高级能力,可参考[获取 Endpoint ID](https://www.volcengine.com/docs/82379/1099522)。 + +--- + + +**content** `object[]` %%require%% +输入给模型,生成视频的信息,支持文本、图片、音频、视频、样片任务 ID。 +:::warning +seedance 2.0 系列模型不支持直接上传含有真人人脸的参考图/视频。为了便利创作者对肖像的使用,平台推出了以下解决方案,详情参见 [教程](https://www.volcengine.com/docs/82379/2291680?lang=zh#5c67c9a1)。 + +* 支持使用部分模型的含人脸原始产物作为输入素材 +* 支持使用预置虚拟人像作为输入素材 +* 支持使用已授权真人素材作为输入 + +::: +支持以下几种组合: + +* **文本** +* **文本(可选)+ 图片** +* **文本(可选)+ 视频** +* **文本(可选)+ 图片 + 音频** +* **文本(可选)+ 图片 + 视频** +* **文本(可选)+ 视频 + 音频** +* **文本(可选)+ 图片 + 视频 + 音频** +* **样片任务 ID**:样片指使用 seedance 模型成功生成的样片视频,模型可基于样片生成高质量正式视频。 + + +信息类型 + +--- + + +**文本信息** `object` +输入给模型的提示词信息。 + +属性 + +--- + + +content.**type ** `string` %%require%% +输入内容的类型,此处应为 `text`。 + +--- + + +content.**text ** `string` %%require%% +输入给模型的文本提示词,描述期望生成的视频。 +:::tip + +* 提示词语言支持:所有模型均支持中英文提示词;seedance 2.0 及 seedance 2.0 fast 额外支持日语、印尼语、西班牙语、葡萄牙语。 +* 提示词字数建议:中文提示词不超过500字,英文提示词不超过1000词。字数过多易导致信息分散,模型可能忽略细节、仅关注重点,进而造成视频缺失部分元素。 +* 更多使用技巧:提示词的详细使用技巧,请参见 [seedance 提示词指南](https://www.volcengine.com/docs/82379/2222480?lang=zh)。 + + + +::: + +--- + + +**图片信息==^new^==** `object` +输入给模型的图片信息。 + +属性 + +--- + + +content.**type ** `string` %%require%% +输入内容的类型,此处应为 `image_url`。 + +--- + + +content.**image_url ** `object` %%require%% +输入给模型的图片对象。 + +属性 + +--- + + +content.image_url.**url ** `string` %%require%% +图片 URL 、图片 Base64 编码、素材 ID。 + +* 图片 URL:填入图片的公网 URL。 +* Base64 编码:将本地文件转换为 Base64 编码字符串,然后提交给大模型。遵循格式:`data:image/<图片格式>;base64,`,注意 `<图片格式>` 需小写,如 `data:image/png;base64,{base64_image}`。 +* 素材 ID:用于视频生成的预置素材及虚拟人像的 ID,遵循格式:asset://。可从 [素材&虚拟人像库](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128) 获取。 + +:::tip 传入单张图片要求 + +* 格式:jpeg、png、webp、bmp、tiff、gif。其中,seedance 1.5 pro 新增支持 heic 和 heif。 +* 宽高比(宽/高): (0.4, 2.5) +* 宽高长度(px):(300, 6000) +* 大小:单张图片小于 30 MB。请求体大小不超过 64 MB。大文件请勿使用Base64编码。 +* 图片数量: + * 图生视频\-首帧:1 张 + * 图生视频\-首尾帧:2 张 + * seedance 2.0&2.0 fast 多模态参考生视频:1~9 张 + * seedance 1.0 lite 参考图生视频:1~4 张 + +::: + +--- + + +content.**role ** `string` `条件必填` +图片的位置或用途。 +:::warning + +* **图生视频\-首帧**、**图生视频\-首尾帧**、**多模态参考生视频**(包括参考图、视频、音频)为 3 种互斥场景,**不可混用**。 +* **多模态参考生视频**可通过提示词指定参考图片作为首帧/尾帧,间接实现“首尾帧+多模态参考”效果。若需严格保障首尾帧和指定图片一致,**优先使用图生视频\-首尾帧**(配置 role 为 first_frame/last_frame)。 + + +::: +图生视频\-首帧 + +* **支持模型:** 所有图生视频模型 +* **字段role取值:** 需要传入1个 image_url 对象,字段 role 为 first_frame 或不填。 + + +图生视频\-首尾帧 + +* **支持模型:** seedance 2.0 & 2.0 fast,seedance 1.5 pro、seedance 1.0 pro、seedance 1.0 lite i2v +* **字段role取值:** 需要传入2个image_url对象,且字段 role 必填。 + * 首帧图片对应的字段 role 为:first_frame + * 尾帧图片对应的字段 role 为:last_frame + +:::tip +传入的首尾帧图片可相同。首尾帧图片的宽高比不一致时,以首帧图片为主,尾帧图片会自动裁剪适配。 + +::: + +图生视频\-参考图 + +* **支持模型:** seedance 2.0 & 2.0 fast(1~9 张图片),seedance 1.0 lite i2v(1~4 张图片) +* **字段role取值:** 必填,每张参考图对应的字段 role 均为:reference_image + +:::tip +参考图生视频功能的文本提示词,可以用自然语言指定多张图片的组合。但若想有更好的指令遵循效果,**推荐使用“[图1]xxx,[图2]xxx”的方式来指定图片**。 +示例1:戴着眼镜穿着蓝色T恤的男生和柯基小狗,坐在草坪上,3D卡通风格 +示例2:[图1]戴着眼镜穿着蓝色T恤的男生和[图2]的柯基小狗,坐在[图3]的草坪上,3D卡通风格 + +::: + + +--- + + +**视频信息==^new^==** `object` +输入给模型的视频信息。仅 seedance 2.0 & 2.0 fast 支持输入视频。 +方舟平台信任 seedance 2.0 及 2.0 fast 模型生成的含人脸视频,您可使用**本账号下近30天内由上述模型生成的含人脸原始视频**,作为输入素材进行二次创作,详情参见 [教程](https://www.volcengine.com/docs/82379/2291680?lang=zh#86c3831f)。 + +属性 +content.**type ** `string` %%require%% +输入内容的类型,此处应为`video_url`。 + +--- + + +content.**video_url** ** ** `object` %%require%% +输入给模型的视频对象。 + +属性 +content.video_url.**url ** `string` %%require%% +视频URL、素材 ID。 + +* 视频 URL:填入视频的公网 URL。 +* 素材 ID:用于视频生成的预置素材及虚拟人像视频的 ID,遵循格式:asset://。可从[素材&虚拟人像库](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128)获取。 + +:::tip 传入单个视频要求 + +* 视频格式:mp4、mov,支持编码格式见下表。 +* 分辨率:480p,720p,1080p +* 时长:单个视频时长 [2, 15] s,最多传入 3 个参考视频,所有视频总时长不超过 15s。 +* 尺寸: + * 宽高比(宽/高):[0.4, 2.5] + * 宽高长度(px):[300, 6000] + * 总像素数:[640×640=409600, 2206×946=2086876],即宽和高的乘积符合 [409600, 2086876] 的区间要求。 +* 大小:单个视频不超过 50 MB。 +* 帧率 (FPS):[24, 60] + +::: + +--- + + +content.**role ** `string` `条件必填` +视频的位置或用途。当前仅支持 reference_video:参考视频。 + + +--- + + +**音频信息==^new^==** `object` +输入给模型的音频信息。仅 seedance 2.0&2.0 fast 支持输入音频。 +注意不可单独输入音频,应至少包含 1 个参考视频或图片。 + +属性 +content.**type ** `string` %%require%% +输入内容的类型,此处应为`audio_url`。 + +--- + + +content.**audio_url** ** ** `object` %%require%% +输入给模型的音频对象。 + +属性 +content.audio_url.**url ** `string` %%require%% +音频 URL 、音频 Base64 编码、素材 ID。 + +* 音频 URL:填入音频的公网 URL。 +* Base64 编码:将本地文件转换为 Base64 编码字符串,然后提交给大模型。遵循格式:`data:audio/<音频格式>;base64,`,注意 `<音频格式>` 需小写,如 `data:audio/wav;base64,{base64_audio}`。 +* 素材 ID:用于视频生成的虚拟人的音频素材 ID,遵循格式:asset://。可从[素材&虚拟人像库](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128)获取。 + +:::tip 传入单个音频要求 + +* 格式:wav、mp3 +* 时长:单个音频时长 [2, 15] s,最多传入 3 段参考音频,所有音频总时长不超过 15 s。 +* 大小:单个音频不超过 15 MB,请求体大小不超过 64 MB。大文件请勿使用Base64编码。 + + + +::: + +--- + + +content.**role ** `string` `条件必填` +音频的位置或用途。当前仅支持 reference_audio:参考音频。 + + + +--- + + +**样片信息 ** `object` +基于样片任务 ID,生成正式视频。仅 seedance 1.5 pro 支持该功能。[阅读](https://www.volcengine.com/docs/82379/1366799?lang=zh#5acd28c8)[文档](https://www.volcengine.com/docs/82379/1366799?lang=zh#5acd28c8) 获取 draft 功能的使用教程和注意事项。 + +属性 + +--- + + +content.**type ** `string` %%require%% +输入内容的类型,此处应为 `draft_task`。 + +--- + + +content.**draft_task** ** ** `object` %%require%% +输入给模型的样片任务。 + +属性 + +--- + + +content.draft_task.**id ** `string` %%require%% +样片任务 ID。平台将自动复用 Draft 视频使用的用户输入(**model、** content.**text、** content.**image_url、generate_audio、seed、ratio、duration、camera_fixed ** ),生成正式视频。其余参数支持指定,不指定将使用本模型的默认值。 +使用分为两步:Step1: 调用本接口生成 Draft 视频。Step2: 如果确认 Draft 视频符合预期,可基于 Step1 返回的 Draft 视频任务 ID,调用本接口生成最终视频。[阅读文档](https://www.volcengine.com/docs/82379/1366799?lang=zh#5acd28c8) 获取详细教程。 + + + + +--- + + +**callback_url** `string` +填写本次生成任务结果的回调通知地址。当视频生成任务有状态变化时,方舟将向此地址推送 POST 请求。 +回调请求内容结构与[查询任务API](https://www.volcengine.com/docs/82379/1521309)的返回体一致。 +回调返回的 status 包括以下状态: + +* queued:排队中。 +* running:任务运行中。 +* succeeded: 任务成功。(如发送失败,即5秒内没有接收到成功发送的信息,回调三次) +* failed:任务失败。(如发送失败,即5秒内没有接收到成功发送的信息,回调三次) +* expired:任务超时,即任务处于**运行中或排队中**状态超过过期时间。可通过 **execution_expires_after ** 字段设置过期时间。 + + +--- + + +**return_last_frame** `boolean` `默认值 false` + +* true:返回生成视频的尾帧图像。设置为 `true` 后,可通过 [查询视频生成任务接口](https://www.volcengine.com/docs/82379/1521309) 获取视频的尾帧图像。尾帧图像的格式为 png,宽高像素值与生成的视频保持一致,无水印。 + 使用该参数可实现生成多个连续视频:以上一个生成视频的尾帧作为下一个视频任务的首帧,快速生成多个连续视频,调用示例详见 [教程](https://www.volcengine.com/docs/82379/1366799?lang=zh#141cf7fa)。 +* false:不返回生成视频的尾帧图像。 + + +--- + + +**service_tier** `string` `默认值 default` +> 不支持修改已提交任务的服务等级 +> seedance 2.0 & 2.0 fast 不支持离线推理 + +指定处理本次请求的服务等级类型,枚举值: + +* default:在线推理模式,RPM 和并发数配额较低(详见 [模型列表](https://www.volcengine.com/docs/82379/1330310?lang=zh#2705b333)),适合对推理时效性要求较高的场景。 +* flex:离线推理模式,TPD 配额更高(详见 [模型列表](https://www.volcengine.com/docs/82379/1330310?lang=zh#2705b333)),价格为在线推理的 50%, 适合对推理时延要求不高的场景。 + + +--- + + +**execution_expires_after ** `integer` `默认值 172800` +任务超时阈值。指定任务提交后的过期时间(单位:秒),从 **created at** 时间戳开始计算。默认值 172800 秒,即 48 小时。取值范围:[3600,259200]。 +不论使用哪种 **service_tier**,都建议根据业务场景设置合适的超时时间。超过该时间后任务会被自动终止,并标记为`expired`状态。 + +--- + + +**generate_audio ** `boolean` `默认值 true` +> 仅 seedance 2.0 & 2.0 fast、seedance 1.5 pro 支持 + +控制生成的视频是否包含与画面同步的声音。 + +* true:模型输出的视频包含同步音频。模型会基于文本提示词与视觉内容,自动生成与之匹配的人声、音效及背景音乐。建议将对话部分置于双引号内,以优化音频生成效果。例如:男人叫住女人说:“你记住,以后不可以用手指指月亮。” +* false:模型输出的视频为无声视频。 + +:::warning +生成的有声视频均为单声道,和传入的音频声道数无关。 + +::: +--- + + +**draft ** `boolean` `默认值 false` +> 仅 seedance 1.5 pro 支持 + +控制是否开启样片模式。[阅读文档](https://www.volcengine.com/docs/82379/1366799?lang=zh#5acd28c8) 获取使用教程和注意事项。 + +* true:开启样片模式,生成一段预览视频,快速验证场景结构、镜头调度、主体动作与 prompt 意图是否符合预期。消耗 token 数较正常视频更少,使用成本更低。 +* false:关闭样片模式,正常生成一段视频。 + +:::tip +开启样片模式后,将使用 480p 分辨率生成 Draft 视频(使用其他分辨率会报错),不支持返回尾帧功能,不支持离线推理功能。 + +::: +--- + + +**tools==^new^==** ** ** `object[]` +> 仅 seedance 2.0 & 2.0 fast 支持 + +配置模型要调用的工具。 + +属性 +tools.**type ** `string` +指定使用的工具类型。 + +* web_search:联网搜索工具。[阅读教程](https://www.volcengine.com/docs/82379/1366799?lang=zh#c40ed3ef) 获取详细代码示例。 + +:::tip + +* 开启联网搜索后,模型会根据用户的提示词自主判断是否搜索互联网内容(如商品、天气等)。可提升生成视频的时效性,但也会增加一定的时延。 +* 实际搜索次数可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 usage.tool_usage.**web_search** 字段获取,如果为 0 表示未搜索。 + +::: + +--- + + +**safety_identifier==^new^==** `string` +终端用户的唯一标识符,用于协助平台检测您的应用中可能违反火山方舟使用政策的用户。该标识符为英文字符串,需保证对单个用户固定且唯一,长度不超过 64 个字符。推荐传入对用户名、用户 ID 或邮箱进行哈希处理后生成的字符串,避免泄露用户隐私信息。 + +--- + + + +:::warning 部分参数升级说明 + +* **对于 resolution、ratio、duration、frames、seed、camera_fixed、watermark 参数,平台升级了参数传入方式,示例如下。所有模型依然兼容支持旧方式。** +* 不同模型,可能对应支持不同的参数与取值,详见 [输出视频格式](https://www.volcengine.com/docs/82379/1366799?lang=zh#9fe4cce0)。当输入的参数或取值不符合所选的模型时,该参数将被忽略或触发报错: + * 新方式:在 request body 中直接传入参数。此方式为**强校验,** 若参数填写错误,模型会返回错误提示。 + * 旧方式:在文本提示词后追加 \-\-[parameters]。此方式为**弱校验,** 若参数填写错误,该参数将被忽略或触发报错。 + + +::: +**新方式(推荐):在 request body 中直接传入参数** +```JSON +... + // Specify the aspect ratio of the generated video as 16:9, duration as 5 seconds, resolution as 720p, seed as 11, and include a watermark. The camera is not fixed. + "model": "doubao-seedance-1-5-pro-251215", + "content": [ + { + "type": "text", + "text": "小猫对着镜头打哈欠" + } + ], + // All parameters must be written in full; abbreviations are not supported + "resolution": "720p", + "ratio":"16:9", + "duration": 5, + // "frames": 29, Either duration or frames is required + "seed": 11, + "camera_fixed": false, + "watermark": true +... +``` + + + + +**旧方式:在文本提示词后追加 \-\-[parameters]** +```JSON +... + // Specify the aspect ratio of the generated video as 16:9, duration as 5 seconds, resolution as 720p, seed as 11, and include a watermark. The camera is not fixed. + "model": "doubao-seedance-1-5-pro-251215", + "content": [ + { + "type": "text", + "text": "小猫对着镜头打哈欠 --rs 720p --rt 16:9 --dur 5 --seed 11 --cf false --wm true" + // "text": "小猫对着镜头打哈欠 --resolution 720p --ratio 16:9 --duration 5 --seed 11 --camerafixed false --watermark true" + } + ] +... +``` + + + + +--- + + +**resolution ** `string` +> seedance 2.0 & 2.0 fast、seedance 1.5 pro、seedance 1.0 lite 默认值:`720p` +> seedance 1.0 pro & pro\-fast 默认值:`1080p` + +视频分辨率,枚举值: + +* 480p +* 720p +* 1080p:seedance 1.0 lite 参考图场景、seedance 2.0 & 2.0 fast 不支持 + + +--- + + +**ratio ** `string` +> seedance 2.0 & 2.0 fast、seedance 1.5 pro 默认值为 `adaptive` +> seedance 1.0 lite 参考图场景默认值为 `16:9` +> 其他模型:文生视频默认值 `16:9`,图生视频默认值 `adaptive` + +生成视频的宽高比例。不同宽高比对应的宽高像素值见下方表格。 + +* 16:9 +* 4:3 +* 1:1 +* 3:4 +* 9:16 +* 21:9 +* adaptive:根据输入自动选择最合适的宽高比(详见下文说明) + +:::warning **adaptive ** 适配规则 +当配置 **ratio** 为 `adaptive` 时,模型会根据生成场景自动适配宽高比;实际生成的视频宽高比可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 **ratio** 字段获取。 +**支持模型:** + +* seedance 2.0 & 2.0 fast、seedance 1.5 Pro 支持 +* 其他模型仅图生视频场景支持,注意 seedance 1.0 lite 参考图场景不支持。 + +**取值规则:** + +* 文生视频:根据输入的提示词,智能选择最合适的宽高比。 +* 首帧 / 首尾帧生视频:根据上传的首帧图片比例,自动选择最接近的宽高比。 +* 多模态参考生视频:根据用户提示词意图判断,如果是首帧生视频/编辑视频/延长视频,以该图片/视频为准选择最接近的宽高比;否则,以传入的第一个媒体文件为准(优先级:视频>图片)选择最接近的宽高比。 + +::: + + +不同宽高比对应的宽高像素值 +Note:图生视频,选择的宽高比与您上传的图片宽高比不一致时,方舟会对您的图片进行裁剪,裁剪时会居中裁剪,详细规则见 [图片裁剪规则](https://www.volcengine.com/docs/82379/1366799?lang=zh#f76aafc8)。 + +|分辨率 |宽高比|宽高像素值|宽高像素值|\ +| | |seedance 1.0 系列 |seedance 1.5 pro|\ +| | | |seedance 2.0 & 2.0 fast | +|---|---|---|---| +|480p |16:9 |864×480 |864×496 | +|^^|4:3 |736×544 |752×560 | +|^^|1:1 |640×640 |640×640 | +|^^|3:4 |544×736 |560×752 | +|^^|9:16 |480×864 |496×864 | +|^^|21:9 |960×416 |992×432 | +|720p |16:9 |1248×704 |1280×720 | +|^^|4:3 |1120×832 |1112×834 | +|^^|1:1 |960×960 |960×960 | +|^^|3:4 |832×1120 |834×1112 | +|^^|9:16 |704×1248 |720×1280 | +|^^|21:9 |1504×640 |1470×630 | +|1080p |16:9 |1920×1088 |1920×1080 |\ +|> 1.0 lite 参考图场景不支持,seedance 2.0 & 2.0 fast不支持 | | | | +|^^|4:3 |1664×1248 |1664×1248 | +|^^|1:1 |1440×1440 |1440×1440 | +|^^|3:4 |1248×1664 |1248×1664 | +|^^|9:16 |1088×1920 |1080×1920 | +|^^|21:9 |2176×928 |2206×946 | + + + + +--- + + +**duration** `integer` `默认值 5` +> duration 和 frames 二选一即可,frames 的优先级高于 duration。如果您希望生成整数秒的视频,建议指定 duration。 + +生成视频时长,仅支持整数,单位:秒。 + +* seedance 1.0 pro、seedance 1.0 pro fast、seedance 1.0 lite: [2, 12] s。 +* seedance 1.5 pro: [4,12] 或设置为`-1` +* seedance 2.0 & 2.0 fast: [4,15] 或设置为`-1` + +:::warning +seedance 2.0 & 2.0 fast、seedance 1.5 pro 支持两种配置方法 + + * 指定具体时长:支持有效范围内的任一整数。 + * 智能指定:设置为 `-1`,表示由模型在有效范围内自主选择合适的视频长度(整数秒)。实际生成视频的时长可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 **duration** 字段获取。注意视频时长与计费相关,请谨慎设置。 + + +::: +--- + + +**frames** `integer` +> seedance 2.0 & 2.0 fast、seedance 1.5 pro 暂不支持 +> duration 和 frames 二选一即可,frames 的优先级高于 duration。如果您希望生成小数秒的视频,建议指定 frames。 + +生成视频的帧数。通过指定帧数,可以灵活控制生成视频的长度,生成小数秒的视频。 +由于 frames 的取值限制,仅能支持有限小数秒,您需要根据公式推算最接近的帧数。 + +* 计算公式:帧数 = 时长 × 帧率(24)。 +* 取值范围:支持 [29, 289] 区间内所有满足 `25 + 4n` 格式的整数值,其中 n 为正整数。 + +例如:假设需要生成 2.4 秒的视频,帧数=2.4×24=57.6。由于 frames 不支持 57.6,此时您只能选择一个最接近的值。根据 25+4n 计算出最接近的帧数为 57,实际生成的视频为 57/24=2.375 秒。 + +--- + + +**seed** `integer` `默认值 -1` +种子整数,用于控制生成内容的随机性。 +取值范围:[\-1, 2^32\-1]之间的整数。 +:::warning + +* 相同的请求下,模型收到不同的seed值,如:不指定seed值或令seed取值为\-1(会使用随机数替代)、或手动变更seed值,将生成不同的结果。 +* 相同的请求下,模型收到相同的seed值,会生成类似的结果,但不保证完全一致。 + + +::: +--- + + +**camera_fixed** `boolean` `默认值 false` +> 参考图场景不支持,seedance 2.0 & 2.0 fast 暂不支持 + +是否固定摄像头。枚举值: + +* true:固定摄像头。平台会在用户提示词中追加固定摄像头,实际效果不保证。 +* false:不固定摄像头。 + + +--- + + +**watermark** `boolean` `默认值 false` +生成视频是否包含水印。枚举值: + +* false:不含水印。 +* true:含有水印。 + + +--- + + + +## 响应参数 +> 跳转 [请求参数](#RxN8G2nH) + +**id ** `string` +视频生成任务 ID 。仅保存 7 天(从 **created at** 时间戳开始计算),超时后将自动清除。 + +* 设置`"draft": true`,为 Draft 视频任务 ID。 +* 设置 `"draft": false`,为正常视频任务 ID。 + +创建视频生成任务为异步接口,获取 ID 后,需要通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309) 来查询视频生成任务的状态。任务成功后,会输出生成视频的`video_url`。 + + diff --git a/docs/celery-polling-fix-20260404.md b/docs/archive/celery-polling-fix-20260404.md similarity index 100% rename from docs/celery-polling-fix-20260404.md rename to docs/archive/celery-polling-fix-20260404.md diff --git a/docs/design-review.md b/docs/archive/design-review.md similarity index 100% rename from docs/design-review.md rename to docs/archive/design-review.md diff --git a/docs/API文档/old-Seedance 2.0 & 2.0 fast API文档(邀测用户版).md b/docs/archive/old-Seedance 2.0 & 2.0 fast API文档(邀测用户版).md similarity index 100% rename from docs/API文档/old-Seedance 2.0 & 2.0 fast API文档(邀测用户版).md rename to docs/archive/old-Seedance 2.0 & 2.0 fast API文档(邀测用户版).md diff --git a/docs/API文档/old-「保密信息」【申请权限填客户名称】Assets API 参考文档(邀测用户版).md b/docs/archive/old-「保密信息」【申请权限填客户名称】Assets API 参考文档(邀测用户版).md similarity index 100% rename from docs/API文档/old-「保密信息」【申请权限填客户名称】Assets API 参考文档(邀测用户版).md rename to docs/archive/old-「保密信息」【申请权限填客户名称】Assets API 参考文档(邀测用户版).md diff --git a/docs/prd.md b/docs/archive/prd.md similarity index 100% rename from docs/prd.md rename to docs/archive/prd.md diff --git a/test-report.md b/docs/archive/test-report.md similarity index 100% rename from test-report.md rename to docs/archive/test-report.md diff --git a/docs/changelog.md b/docs/changelog.md index be3cfd8..9b9a160 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,71 @@ --- +## 2026-04-17 — v0.18.3: 版权报错友好提示 + 图片删除即梦式连续重命名 + +**状态**: ✅ 已完成 | **验收**: 14 个自动化测试全过(11 单元 + 3 E2E) + +### 变更内容 + +#### 后端 +1. **版权限制错误友好提示** — `OutputVideoSensitiveContentDetected.PolicyViolation`(漫威等知名 IP 触发的版权拦截)加中文错误码映射:"生成的视频涉及版权限制内容(如知名IP、名人肖像等),已被系统拦截,请修改提示词后重试"。精确匹配 API 返回的 code,不影响父级 `OutputVideoSensitiveContentDetected`(敏感内容)的现有提示 + +#### 前端 +2. **图片删除即梦式连续重命名** — 现有逻辑:删图片2 后,图片3 保持原名称,再上传新图会出现"两个图片2"。修复后: + - `inputBar.ts::removeReference` 删除后,同类型(图片/视频/音频)剩余引用按顺序连续重命名(图片1/图片2/图片3 连续无空位) + - 用 `DOMParser` 解析 editorHtml,找到对应 `data-ref-id` 的 @mention span,更新 textContent(提示词栏里的 `@图片3` 自动变 `@图片2`) + - 缩略图区和提示词栏视觉同步刷新 + +#### 测试覆盖 +- **Vitest 单元测试 11 个**(test/unit/removeReferenceRelabeling.test.ts):图片三场景、视频/音频独立编号、空 editorHtml、无 @mention、传入无效 id、部分 @、连续快速删除等边界 +- **Playwright E2E 3 个**(test/e2e/bug2-rename.spec.ts):真实浏览器验证上传 3 张图 → 删中间 → 再上传,编号不冲突 + +#### 文档整理 +3. **AirDrama 根目录归档**:8 个过期 MD 文档移至 `archive/`(_settle_payment 双重结算/v0.15.1 部署/公告HTML/迁移到火山/全平台账务审计/火山耗时分析/图片上传blob/迭代需求_20260320) +4. **video-shuoshan/docs 归档**:6 个过期文档移至 `docs/archive/`(celery 轮询修复/design-review/PRD/test-report/两版旧 API 文档) +5. **新增 1080P Plan**:`AirDrama/1080P分辨率支持开发计划.md`,对照官方 API 文档完成 3 轮审查修正(21:9 像素值错误、_settle_payment 遗漏、VideoDetailModal 重新编辑、regenerate、API 响应 6 处、serializer 命名、GenerationRecord.resolution 字段已存在等),标注 5 项已知计费缺陷 + +### 变更文件 +- `backend/utils/airdrama_client.py` — ERROR_MESSAGES 加 PolicyViolation 映射 +- `web/src/store/inputBar.ts` — removeReference 重写(即梦逻辑 + editorHtml 同步) +- `web/test/unit/removeReferenceRelabeling.test.ts` — 11 个单元测试(新增) +- `web/test/e2e/bug2-rename.spec.ts` — 3 个 E2E 测试(新增) +- `AirDrama/1080P分辨率支持开发计划.md` — 1080P 开发 Plan(新增) +- `AirDrama/版本管理.md` — 添加 v0.18.3 记录 +- `AirDrama/项目总览与待办.md` — 完成项 + 1080P P0 待办 +- 16 个 MD 文档归档到两个 archive 目录 + +### 触发原因 +- 用户反馈:漫威素材生成失败时显示英文错误,不友好 +- 用户反馈:删除中间图片后再上传会出现重复编号(参考即梦交互) +- 火山 2026-04-16 1080P 上线,需提前规划开发 + +--- + +## 2026-04-13 — v0.18.2: 资产页修复 + 重新编辑素材泄漏 + 音频校验 + +**状态**: ✅ 已完成 | **验收**: 待测试 + +### 变更内容 + +#### 前端 +1. **资产页素材库引用不可查看** — Admin/Team 资产页的 `assetVideoToTask` 直接用了 `asset://` 协议 URL 作为 `previewUrl`,浏览器无法加载。改为检测 `asset://` 后使用 `thumb_url`(真实 TOS 缩略图地址),并标记 `isAssetRef`。同步修复 `BackendTask` 和 `AssetVideo` 类型定义补 `thumb_url` 字段 +2. **重新编辑素材泄漏** — `reEdit()` 把素材库引用混入 `references` 数组(注释写已过滤但实际没有),用户删除 @标签后旧素材仍通过 `filesToUpload` 路径发出。修复:`reEdit/regenerate` 加 `.filter(!isAssetRef)`;`PromptInput.extractText` 每次 DOM 变化时实时同步 `assetMentions` store +3. **音频不能作为唯一参考素材** — Seedance API 不支持"纯音频"和"文本+音频"。`canSubmit()` 去掉 `!hasText` 条件,同时检查 `references` 和 `assetMentions` 中的图片/视频;Toolbar 点击禁用按钮弹 toast 提示原因 +4. **素材库引用缩略图烂图** — `pollStatus` 跨项目素材保护 +5. **音频 ♫ 符号溢出** — 改用 CSS `::before` 渲染,不再污染 prompt 文本 + +### 变更文件 +- `web/src/pages/AdminAssetsPage.tsx` — isAssetUrl + thumb_url 处理 +- `web/src/pages/TeamAssetsPage.tsx` — 同上 +- `web/src/types/index.ts` — BackendTask/AssetVideo 补 thumb_url +- `web/src/store/generation.ts` — reEdit/regenerate 过滤 isAssetRef +- `web/src/components/PromptInput.tsx` — extractText 同步 assetMentions +- `web/src/store/inputBar.ts` — canSubmit 音频校验增强 +- `web/src/components/Toolbar.tsx` — 音频受限 toast 提示 + +--- + ## 2026-03-19 — v0.9.7: 登录风控第二期 — IP归属地解析 + 异常检测 + 飞书告警 + 自动封禁 **状态**: ✅ 已完成 | **验收**: ✅ 通过(本地验证,IP138 在线 API 需部署至阿里云后验证) diff --git a/docs/todo/提示词AI优化功能.md b/docs/todo/提示词AI优化功能.md new file mode 100644 index 0000000..8274c4b --- /dev/null +++ b/docs/todo/提示词AI优化功能.md @@ -0,0 +1,65 @@ +# 提示词 AI 优化功能 + +**状态**:待开发 +**创建日期**:2026-04-17 + +## 需求背景 + +用户写提示词时,经常写得过于简单或不符合 Seedance 2.0 的提示词规范(如没用「图片n」引用素材、缺少核心要素、镜头语言模糊等),导致生成效果不理想。 + +引入火山官方的 SKILL.md(Seedance 2.0 Prompt Optimizer)能力,让用户在写完提示词后一键优化。 + +## 功能设计 + +### 用户视角 +1. 用户在提示词输入框输入原始提示词(带 @素材引用) +2. 点击输入框旁的「AI 优化」按钮 +3. 弹出预览弹窗,显示优化后的提示词 +4. 用户点「采纳」→ 替换原提示词;点「取消」→ 保留原文 +5. 消耗一定 token 数(计入团队 token 池) + +### 技术方案 + +**后端** +- 新接口:`POST /api/v1/prompt/optimize` +- 入参:`prompt`(原始提示词,含 `@素材` 标记)、`asset_refs`(素材引用列表:label + type + url) +- 调用豆包模型(推荐 `doubao-seed-2.0` 最新版本,具体 model id 需确认) +- System prompt:基于 SKILL.md 改造成**一次性输出**模式(不做多轮交互) +- 返回:`optimized_prompt`(优化后的文本)+ `token_used`(消耗 token 数) +- 同时扣减团队 token 池 + +**前端** +- `PromptInput` 组件右侧加「AI 优化」按钮(带 ✨ 图标) +- 点击后:loading 状态 → 调用后端接口 → 弹出 `PromptOptimizeModal` 预览弹窗 +- 弹窗显示:原始 vs 优化对比、token 消耗提示、采纳/取消按钮 +- 采纳后:把优化结果写回 editor(保持 @mention 标签正确渲染) + +**SKILL.md 改造要点** +- 去掉 Step 0(主动引导提问)→ 一次输入一次输出 +- 去掉 Step 3 的「多选模板交互」→ 如遇歧义/冲突,在输出里以备注形式标注(如 `【注:检测到 X 冲突,已按 Y 处理】`) +- 保留 Step 2(素材自动映射 `@图N`)、Step 4(结构化输出:优化后提示词 / 优化问题 / 相关原则) + +## 计费设计 +- 提示词优化和视频生成共用**同一个 token 池**(用户已熟悉的计费机制) +- 不单独限额,按实际 token 消耗扣减 +- 前端展示:"本次优化消耗约 X token" + +## 模型选择 +- **首选**:豆包 2.0 系列最强模型(需查火山文档确认最新 model id) +- 备选:`doubao-1-5-pro-32k`(成本更低,任务够用) + +## 待确认事项 +- [ ] 豆包 2.0 系列当前最强模型的具体 model id +- [ ] Token 池扣减逻辑是否需要团队/个人双重配额 +- [ ] 优化失败时(LLM 报错、token 超限)的前端兜底提示 + +## 验收标准 +1. 用户输入粗糙提示词(如「美女跳舞」)→ 优化后符合 SKILL.md 的三段式结构(全局设定 / 时间线脚本 / 质感风格与约束) +2. 带 `@素材` 的提示词 → 优化后正确使用 `@图1/@图2/@视频1` 等标记 +3. 冲突/缺失场景 → 在输出中以备注标明,不擅自填充 +4. Token 消耗正确扣减到团队池 +5. 用户可在弹窗中选择采纳或取消 + +## 参考文件 +- SKILL.md(火山提供的原始技能文件) +- `docs/API文档/seedance 2.0 系列教程.MD` 第 2152 行起的「提示词技巧」部分 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/Sidebar.module.css b/web/src/components/Sidebar.module.css index 5cf0c88..b37a0f9 100644 --- a/web/src/components/Sidebar.module.css +++ b/web/src/components/Sidebar.module.css @@ -62,37 +62,37 @@ padding-bottom: 8px; } -/* Quota display */ +/* Quota display — 今日剩余生成次数(v0.10.0 起次数制) */ .quota { display: flex; flex-direction: column; align-items: center; - gap: 2px; + gap: 3px; cursor: pointer; padding: 8px 4px; border-radius: 8px; transition: background 0.15s; + min-width: 56px; } .quota:hover { background: rgba(255, 255, 255, 0.04); } -.diamondIcon { - flex-shrink: 0; -} - .quotaNumber { - font-size: 14px; + font-size: 18px; font-weight: 600; color: var(--color-text-primary); line-height: 1; + font-variant-numeric: tabular-nums; + letter-spacing: 0.5px; } .quotaLabel { - font-size: 9px; + font-size: 10px; color: var(--color-text-secondary); white-space: nowrap; + letter-spacing: 0.5px; } /* Admin button */ diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 79805b0..2096876 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -12,8 +12,11 @@ export function Sidebar() { const isActive = (path: string) => location.pathname === path; const role = user?.role; + // 今日剩余生成次数(v0.10.0 起计费体系为次数+金额,不再是秒数池) const dailyRemaining = quota - ? (quota.daily_seconds_limit === -1 ? Infinity : Math.max(0, quota.daily_seconds_limit - quota.daily_seconds_used)) + ? (quota.daily_generation_limit === -1 + ? Infinity + : Math.max(0, quota.daily_generation_limit - quota.daily_generation_used)) : 0; return ( @@ -70,15 +73,15 @@ export function Sidebar() { {/* Quota display - not for super admin */} {role !== 'super_admin' && ( - navigate('/profile')}> - - - - + navigate('/profile')} + title="今日剩余生成次数(实际扣费以火山 token 消耗为准)" + > {dailyRemaining === Infinity ? '∞' : dailyRemaining.toLocaleString()} - 剩余额度 + 今日剩余次数 )} diff --git a/web/src/components/Toolbar.tsx b/web/src/components/Toolbar.tsx index 5bda6e9..4af9680 100644 --- a/web/src/components/Toolbar.tsx +++ b/web/src/components/Toolbar.tsx @@ -3,7 +3,9 @@ import { useInputBarStore } from '../store/inputBar'; import { useGenerationStore } from '../store/generation'; import { useAuthStore } from '../store/auth'; import { Dropdown } from './Dropdown'; -import type { CreationMode, AspectRatio, Duration, GenerationType, ModelOption } from '../types'; +import { showToast } from './Toast'; +import { parseAssetMentions } from '../lib/assetMentions'; +import type { CreationMode, AspectRatio, Duration, Resolution, GenerationType, ModelOption } from '../types'; import styles from './Toolbar.module.css'; const VideoIcon = () => ( @@ -70,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: }, @@ -98,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 = { @@ -119,33 +129,77 @@ 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) return; + if (!isSubmittable) { + const s = useInputBarStore.getState(); + if (s.mode === 'universal' && s.references.some((r) => r.type === 'audio') + && !s.references.some((r) => r.type === 'image' || r.type === 'video')) { + showToast('音频不能作为唯一的参考素材,请同时添加图片或视频'); + } + return; + } addTask(); }, [isSubmittable, addTask]); @@ -216,6 +270,19 @@ export function Toolbar() { } /> + {/* Resolution */} + setResolution(v as Resolution)} + minWidth={100} + trigger={ + + {resolution.toUpperCase()} + + } + /> + {/* 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/ProfilePage.tsx b/web/src/pages/ProfilePage.tsx index 260488c..59faab0 100644 --- a/web/src/pages/ProfilePage.tsx +++ b/web/src/pages/ProfilePage.tsx @@ -153,10 +153,10 @@ export function ProfilePage() { {/* Quota warning */} {dailyPercent >= 80 && dailyPercent < 100 && ( - 今日额度已使用 {dailyPercent.toFixed(0)}%,请合理使用 + 今日生成次数已用 {dailyPercent.toFixed(0)}%,请合理使用 )} {dailyPercent >= 100 && ( - 今日额度已用完,请明天再试 + 今日生成次数已用完,请明天再试 )} {/* Consumption Overview */} 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) 不含视频输入单价 (元/百万tokens) @@ -164,7 +166,28 @@ export function SettingsPage() { /> - Seedance 2.0 Fast + Seedance 2.0(1080P) + + + 不含视频输入单价 (元/百万tokens) + setSettings({ ...settings, base_token_price_1080p: Number(e.target.value) })} + /> + + + 含视频输入单价 (元/百万tokens) + setSettings({ ...settings, base_token_price_1080p_video: Number(e.target.value) })} + /> + + + Seedance 2.0 Fast(不支持 1080P) 不含视频输入单价 (元/百万tokens) 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 dc3900d..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 }), @@ -218,9 +243,43 @@ export const useInputBarStore = create((set, get) => ({ }, removeReference: (id) => { const state = get(); - const ref = state.references.find((r) => r.id === id); - if (ref) URL.revokeObjectURL(ref.previewUrl); - set({ references: state.references.filter((r) => r.id !== id) }); + const removedRef = state.references.find((r) => r.id === id); + if (!removedRef) return; + if (removedRef.previewUrl) URL.revokeObjectURL(removedRef.previewUrl); + + // 删除后同类型的剩余引用按顺序重新编号(即梦逻辑:保持 1/2/3 连续) + const remaining = state.references.filter((r) => r.id !== id); + const labelPrefix = removedRef.type === 'image' ? '图片' : removedRef.type === 'video' ? '视频' : '音频'; + const labelUpdates = new Map(); // refId -> newLabel + let idx = 1; + const relabeled = remaining.map((r) => { + if (r.type !== removedRef.type) return r; + const newLabel = `${labelPrefix}${idx++}`; + if (r.label !== newLabel) labelUpdates.set(r.id, newLabel); + return r.label === newLabel ? r : { ...r, label: newLabel }; + }); + + // 同步更新 editorHtml 里对应 refId 的 @mention span 文本 + let newEditorHtml = state.editorHtml; + if (labelUpdates.size > 0 && newEditorHtml) { + const doc = new DOMParser().parseFromString(`${newEditorHtml}`, 'text/html'); + const container = doc.body.firstChild as HTMLElement | null; + if (container) { + container.querySelectorAll('[data-ref-id]').forEach((span) => { + const el = span as HTMLElement; + const refId = el.dataset.refId; + if (refId && labelUpdates.has(refId)) { + const newLabel = labelUpdates.get(refId)!; + // span 结构:[icon/img] + atHidden(@) + textNode(label) + const labelNode = [...el.childNodes].reverse().find((n) => n.nodeType === Node.TEXT_NODE); + if (labelNode) labelNode.textContent = newLabel; + } + }); + newEditorHtml = container.innerHTML; + } + } + + set({ references: relabeled, editorHtml: newEditorHtml }); }, clearReferences: () => { const state = get(); @@ -285,10 +344,19 @@ export const useInputBarStore = create((set, get) => ({ ? state.references.length > 0 : state.firstFrame !== null || state.lastFrame !== null; if (!hasText && !hasFiles) return false; - // Audio cannot be sent alone — must have image or video - if (state.mode === 'universal' && state.references.length > 0) { - const hasImageOrVideo = state.references.some((r) => r.type === 'image' || r.type === 'video'); - if (!hasImageOrVideo && !hasText) return false; + // Audio cannot be the only reference — Seedance API requires image or video alongside + if (state.mode === 'universal') { + const hasAudioRef = state.references.some((r) => r.type === 'audio'); + const hasAudioAsset = (state.assetMentions || []).some((m: Record) => + (m.assetType || '').toLowerCase() === 'audio'); + if (hasAudioRef || hasAudioAsset) { + const hasImageOrVideoRef = state.references.some((r) => r.type === 'image' || r.type === 'video'); + const hasImageOrVideoAsset = (state.assetMentions || []).some((m: Record) => { + const t = (m.assetType || '').toLowerCase(); + return t === 'image' || t === 'video'; + }); + if (!hasImageOrVideoRef && !hasImageOrVideoAsset) return false; + } } // Block submit if any reference is still uploading or failed if (state.references.some((r) => r.uploading || r.uploadError)) return false; @@ -355,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/bug2-rename.spec.ts b/web/test/e2e/bug2-rename.spec.ts new file mode 100644 index 0000000..093ef3f --- /dev/null +++ b/web/test/e2e/bug2-rename.spec.ts @@ -0,0 +1,133 @@ +/** + * Bug 2 E2E 验证:图片删除后即梦式连续重命名 + * 针对本地开发环境(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'; + +const TEST_IMAGES_DIR = 'C:/Users/Air-work/AppData/Local/Temp/bug2test'; +const IMG_RED = `${TEST_IMAGES_DIR}/test_red.png`; +const IMG_GREEN = `${TEST_IMAGES_DIR}/test_green.png`; +const IMG_BLUE = `${TEST_IMAGES_DIR}/test_blue.png`; + +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 errText = await resp.text(); + console.log('LOGIN FAILED:', resp.status(), errText); + } + expect(resp.ok()).toBeTruthy(); + 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('Bug 2: 图片删除后即梦式连续重命名', () => { + test('上传 3 张图 → 删除图片2 → 图片3 变为图片2', async ({ page }) => { + await login(page); + + // 上传 3 张图 + const fileInput = page.locator('input[type="file"]').first(); + await fileInput.setInputFiles([IMG_RED, IMG_GREEN, IMG_BLUE]); + await page.waitForTimeout(2000); // 等图片校验和 ref 添加完成 + + // 展开缩略图堆栈(hover 触发) + const thumbRow = page.locator('[class*="thumbRow"]').first(); + await thumbRow.hover(); + await page.waitForTimeout(500); + + // 验证上传后初始状态:图片1/图片2/图片3 + const labelsInitial = await page.locator('[class*="thumbLabel"]').allTextContents(); + console.log('初始标签:', labelsInitial); + expect(labelsInitial).toEqual(['图片1', '图片2', '图片3']); + + // 点第 2 张图的删除按钮 + const secondThumb = page.locator('[class*="thumbItem"]').nth(1); + await secondThumb.hover(); + await secondThumb.locator('[class*="thumbClose"]').click({ force: true }); + await page.waitForTimeout(500); + + // 验证重命名后:图片1/图片2(原图片3) + await thumbRow.hover(); + await page.waitForTimeout(300); + const labelsAfterDelete = await page.locator('[class*="thumbLabel"]').allTextContents(); + console.log('删除图片2后:', labelsAfterDelete); + expect(labelsAfterDelete).toEqual(['图片1', '图片2']); + expect(labelsAfterDelete.length).toBe(2); + }); + + test('删除图片2 后再上传 1 张 → 新图是图片3(不和现有冲突)', async ({ page }) => { + await login(page); + + // 上传 3 张 + const fileInput = page.locator('input[type="file"]').first(); + await fileInput.setInputFiles([IMG_RED, IMG_GREEN, IMG_BLUE]); + await page.waitForTimeout(2000); + + const thumbRow = page.locator('[class*="thumbRow"]').first(); + await thumbRow.hover(); + await page.waitForTimeout(500); + + // 删除第 2 张 + const secondThumb = page.locator('[class*="thumbItem"]').nth(1); + await secondThumb.hover(); + await secondThumb.locator('[class*="thumbClose"]').click({ force: true }); + await page.waitForTimeout(500); + + // 再上传 1 张 + await fileInput.setInputFiles([IMG_RED]); + await page.waitForTimeout(2000); + + // 验证:原图片1、原图片3(已改名图片2)、新图片3 + await thumbRow.hover(); + await page.waitForTimeout(300); + const finalLabels = await page.locator('[class*="thumbLabel"]').allTextContents(); + console.log('删除后再上传:', finalLabels); + expect(finalLabels).toEqual(['图片1', '图片2', '图片3']); + // 无重复编号 + expect(new Set(finalLabels).size).toBe(finalLabels.length); + }); + + test('删除第 1 张 → 剩余图片全部前移', async ({ page }) => { + await login(page); + + const fileInput = page.locator('input[type="file"]').first(); + await fileInput.setInputFiles([IMG_RED, IMG_GREEN, IMG_BLUE]); + await page.waitForTimeout(2000); + + const thumbRow = page.locator('[class*="thumbRow"]').first(); + await thumbRow.hover(); + await page.waitForTimeout(500); + + // 删除第 1 张 + const firstThumb = page.locator('[class*="thumbItem"]').nth(0); + await firstThumb.hover(); + await firstThumb.locator('[class*="thumbClose"]').click({ force: true }); + await page.waitForTimeout(500); + + await thumbRow.hover(); + await page.waitForTimeout(300); + const labels = await page.locator('[class*="thumbLabel"]').allTextContents(); + console.log('删除图片1后:', labels); + expect(labels).toEqual(['图片1', '图片2']); + }); +}); diff --git a/web/test/e2e/resolution-1080p-test-env.spec.ts b/web/test/e2e/resolution-1080p-test-env.spec.ts new file mode 100644 index 0000000..448f0e9 --- /dev/null +++ b/web/test/e2e/resolution-1080p-test-env.spec.ts @@ -0,0 +1,154 @@ +/** + * 1080P E2E — 针对**测试服**(airflow-studio.test.airlabs.art)使用团管账号 tudou。 + * 这是对 resolution-1080p.spec.ts 的测试服版本,验证 CI/CD 部署后线上真实行为。 + */ +import { test, expect, Page } from '@playwright/test'; + +const BASE_URL = 'https://airflow-studio.test.airlabs.art'; +const API_URL = 'https://airflow-studio-api.test.airlabs.art'; +const USERNAME = 'tudou'; +const PASSWORD = 'seaislee'; + +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(2000); + + // 关闭公告 + const knowBtn = page.getByRole('button', { name: /我知道了|知道了|关闭/ }).first(); + if (await knowBtn.isVisible().catch(() => false)) { + await knowBtn.click(); + await page.waitForTimeout(300); + } +} + +test.describe.serial('[测试服] 1080P 分辨率支持 — tudou 团管账号', () => { + test('Sidebar 显示「今日剩余次数」(无钻石图标)', async ({ page }) => { + await login(page); + // 含"今日剩余次数"文案 + await expect(page.getByText('今日剩余次数')).toBeVisible(); + // 确认钻石 SVG 不存在(旧的 diamond path) + const diamondPath = page.locator('path[d^="M6 3h12l4 8"]'); + expect(await diamondPath.count()).toBe(0); + }); + + test('Toolbar 默认分辨率显示 720P', async ({ page }) => { + await login(page); + const resolutionBtn = page.getByRole('button', { name: '720P', exact: true }).first(); + await expect(resolutionBtn).toBeVisible(); + }); + + test('AirDrama 模式可切换到 1080P', async ({ page }) => { + await login(page); + 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); + await expect(page.getByRole('button', { name: '1080P', exact: true }).first()).toBeVisible(); + }); + + test('1080P 下 Fast 模型在 Dropdown 中置灰', 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" + await expect(page.getByText(/AirDrama Fast.*不支持 1080P/)).toBeVisible(); + }); + + test('Fast 模式下 1080P 在 Dropdown 中置灰', async ({ page }) => { + await login(page); + // 切 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 不支持" + await expect(page.getByText(/1080P.*Fast 不支持/)).toBeVisible(); + }); + + test('ProfilePage 预警文案显示「今日生成次数」而非「额度」', async ({ page }) => { + await login(page); + await page.goto(`${BASE_URL}/profile`); + await page.waitForTimeout(1500); + // Page 应不含"今日额度"这种老文案 + const body = await page.textContent('body'); + // 能找到"今日"相关字样,不是"额度" + if (body && body.includes('今日')) { + // 如果出现"今日",必须是跟"次数"搭配,不是跟"额度" + expect(body).not.toMatch(/今日额度/); + } + }); + + test('提交 Fast+1080P 组合被后端 400 拒绝', async ({ page }) => { + await login(page); + // 直接调 API 测试(绕过前端 UI 约束,验证后端 fail loud) + const loginResp = await page.request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + const { tokens } = await loginResp.json(); + const resp = await page.request.post(`${API_URL}/api/v1/video/generate`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + data: { + prompt: 'E2E 测试 Fast+1080P', + mode: 'universal', + model: 'seedance_2.0_fast', + aspect_ratio: '16:9', + duration: 5, + resolution: '1080p', + references: [], + }, + }); + expect(resp.status()).toBe(400); + const body = await resp.json(); + expect(body.error).toBe('invalid_resolution'); + expect(body.message).toContain('1080P'); + expect(body.message).toContain('Fast'); + }); + + test('提交 adaptive ratio 被后端 400 拒绝', async ({ page }) => { + await login(page); + const loginResp = await page.request.post(`${API_URL}/api/v1/auth/login`, { + data: { username: USERNAME, password: PASSWORD }, + }); + const { tokens } = await loginResp.json(); + const resp = await page.request.post(`${API_URL}/api/v1/video/generate`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + data: { + prompt: 'E2E adaptive', + mode: 'universal', + model: 'seedance_2.0', + aspect_ratio: 'adaptive', + duration: 5, + resolution: '720p', + references: [], + }, + }); + expect(resp.status()).toBe(400); + }); +}); 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/removeReferenceRelabeling.test.ts b/web/test/unit/removeReferenceRelabeling.test.ts new file mode 100644 index 0000000..2681f51 --- /dev/null +++ b/web/test/unit/removeReferenceRelabeling.test.ts @@ -0,0 +1,234 @@ +/** + * Bug 2 fix verification: 删除引用后,同类型剩余引用连续重命名(即梦逻辑) + * 同时同步更新 editorHtml 中 @mention span 的文本。 + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useInputBarStore } from '../../src/store/inputBar'; + +function mockFile(name: string, type = 'image/jpeg'): File { + return new File(['mock'], name, { type }); +} + +function mockRef(id: string, type: 'image' | 'video' | 'audio', label: string) { + return { + id, + file: mockFile(`${id}.${type === 'image' ? 'jpg' : type === 'video' ? 'mp4' : 'mp3'}`), + type, + previewUrl: `blob:${id}`, + label, + }; +} + +function mentionSpan(refId: string, refType: string, label: string): string { + return `@${label}`; +} + +describe('removeReference — 即梦式连续重命名', () => { + beforeEach(() => { + useInputBarStore.getState().reset(); + }); + + describe('图片重命名', () => { + it('删除图片2 后,图片3 重命名为图片2(references + editorHtml 同步)', () => { + const refs = [ + mockRef('ref_1', 'image', '图片1'), + mockRef('ref_2', 'image', '图片2'), + mockRef('ref_3', 'image', '图片3'), + ]; + const editorHtml = + `开场 ${mentionSpan('ref_1', 'image', '图片1')} 和 ${mentionSpan('ref_2', 'image', '图片2')} 在和 ${mentionSpan('ref_3', 'image', '图片3')} 讲话`; + + useInputBarStore.setState({ references: refs, editorHtml }); + useInputBarStore.getState().removeReference('ref_2'); + + const state = useInputBarStore.getState(); + expect(state.references).toHaveLength(2); + expect(state.references[0].id).toBe('ref_1'); + expect(state.references[0].label).toBe('图片1'); + expect(state.references[1].id).toBe('ref_3'); + expect(state.references[1].label).toBe('图片2'); // 原图片3 → 图片2 + + // editorHtml 里 ref_3 的 textNode 应该变成 "图片2" + expect(state.editorHtml).toContain('data-ref-id="ref_3"'); + expect(state.editorHtml).toMatch(/data-ref-id="ref_3"[^>]*>[\s\S]*?图片2<\/span>/); + // ref_1 保持 "图片1" + expect(state.editorHtml).toMatch(/data-ref-id="ref_1"[^>]*>[\s\S]*?图片1<\/span>/); + }); + + it('删除图片1 后,图片2、图片3 重命名为图片1、图片2', () => { + const refs = [ + mockRef('ref_1', 'image', '图片1'), + mockRef('ref_2', 'image', '图片2'), + mockRef('ref_3', 'image', '图片3'), + ]; + const editorHtml = `${mentionSpan('ref_2', 'image', '图片2')} ${mentionSpan('ref_3', 'image', '图片3')}`; + + useInputBarStore.setState({ references: refs, editorHtml }); + useInputBarStore.getState().removeReference('ref_1'); + + const state = useInputBarStore.getState(); + expect(state.references[0].label).toBe('图片1'); // ref_2 + expect(state.references[1].label).toBe('图片2'); // ref_3 + expect(state.editorHtml).toMatch(/data-ref-id="ref_2"[^>]*>[\s\S]*?图片1<\/span>/); + expect(state.editorHtml).toMatch(/data-ref-id="ref_3"[^>]*>[\s\S]*?图片2<\/span>/); + }); + + it('删除最后一张图片(唯一图片)— references 清空,editorHtml 不变', () => { + const refs = [mockRef('ref_1', 'image', '图片1')]; + const editorHtml = `内容 ${mentionSpan('ref_1', 'image', '图片1')} 尾部`; + + useInputBarStore.setState({ references: refs, editorHtml }); + useInputBarStore.getState().removeReference('ref_1'); + + const state = useInputBarStore.getState(); + expect(state.references).toHaveLength(0); + // remaining 为空 → labelUpdates 为空 → 跳过 DOM 操作 → editorHtml 原样保留 + expect(state.editorHtml).toBe(editorHtml); + }); + }); + + describe('视频/音频独立编号', () => { + it('图片和视频混合时,删图片只重命名图片,视频不动', () => { + const refs = [ + mockRef('ref_1', 'image', '图片1'), + mockRef('ref_2', 'image', '图片2'), + mockRef('ref_3', 'video', '视频1'), + ]; + const editorHtml = + `${mentionSpan('ref_1', 'image', '图片1')} ${mentionSpan('ref_2', 'image', '图片2')} ${mentionSpan('ref_3', 'video', '视频1')}`; + + useInputBarStore.setState({ references: refs, editorHtml }); + useInputBarStore.getState().removeReference('ref_1'); + + const state = useInputBarStore.getState(); + expect(state.references).toHaveLength(2); + expect(state.references[0].label).toBe('图片1'); // 原图片2 + expect(state.references[1].label).toBe('视频1'); // 视频不变 + expect(state.editorHtml).toMatch(/data-ref-id="ref_2"[^>]*>[\s\S]*?图片1<\/span>/); + expect(state.editorHtml).toMatch(/data-ref-id="ref_3"[^>]*>[\s\S]*?视频1<\/span>/); + }); + + it('删除视频2,视频3 重命名为视频2', () => { + const refs = [ + mockRef('ref_1', 'video', '视频1'), + mockRef('ref_2', 'video', '视频2'), + mockRef('ref_3', 'video', '视频3'), + ]; + const editorHtml = `${mentionSpan('ref_3', 'video', '视频3')}`; + + useInputBarStore.setState({ references: refs, editorHtml }); + useInputBarStore.getState().removeReference('ref_2'); + + const state = useInputBarStore.getState(); + expect(state.references[0].label).toBe('视频1'); + expect(state.references[1].label).toBe('视频2'); // 原视频3 + expect(state.editorHtml).toMatch(/data-ref-id="ref_3"[^>]*>[\s\S]*?视频2<\/span>/); + }); + + it('删除音频1,音频2 重命名为音频1', () => { + const refs = [ + mockRef('ref_1', 'audio', '音频1'), + mockRef('ref_2', 'audio', '音频2'), + ]; + const editorHtml = `${mentionSpan('ref_1', 'audio', '音频1')} ${mentionSpan('ref_2', 'audio', '音频2')}`; + + useInputBarStore.setState({ references: refs, editorHtml }); + useInputBarStore.getState().removeReference('ref_1'); + + const state = useInputBarStore.getState(); + expect(state.references).toHaveLength(1); + expect(state.references[0].id).toBe('ref_2'); + expect(state.references[0].label).toBe('音频1'); // 原音频2 + expect(state.editorHtml).toMatch(/data-ref-id="ref_2"[^>]*>[\s\S]*?音频1<\/span>/); + }); + }); + + describe('边界场景', () => { + it('editorHtml 为空 — 不报错,只重命名 references', () => { + const refs = [ + mockRef('ref_1', 'image', '图片1'), + mockRef('ref_2', 'image', '图片2'), + ]; + useInputBarStore.setState({ references: refs, editorHtml: '' }); + useInputBarStore.getState().removeReference('ref_1'); + + const state = useInputBarStore.getState(); + expect(state.references).toHaveLength(1); + expect(state.references[0].label).toBe('图片1'); + expect(state.editorHtml).toBe(''); + }); + + it('editorHtml 中没有对应的 @mention span — 只改 references', () => { + const refs = [ + mockRef('ref_1', 'image', '图片1'), + mockRef('ref_2', 'image', '图片2'), + ]; + const editorHtml = '纯文本,没有 mention span'; + + useInputBarStore.setState({ references: refs, editorHtml }); + useInputBarStore.getState().removeReference('ref_1'); + + const state = useInputBarStore.getState(); + expect(state.references).toHaveLength(1); + expect(state.references[0].label).toBe('图片1'); // ref_2 重命名 + // editorHtml 不含对应 span,无法更新,但不报错 + }); + + it('传入不存在的 id — 静默返回,状态不变', () => { + const refs = [mockRef('ref_1', 'image', '图片1')]; + const editorHtml = mentionSpan('ref_1', 'image', '图片1'); + + useInputBarStore.setState({ references: refs, editorHtml }); + useInputBarStore.getState().removeReference('nonexistent_id'); + + const state = useInputBarStore.getState(); + expect(state.references).toHaveLength(1); + expect(state.references[0].label).toBe('图片1'); + expect(state.editorHtml).toBe(editorHtml); + }); + + it('删除的图片没被 @ 到 editor,其他图片仍被重命名', () => { + const refs = [ + mockRef('ref_1', 'image', '图片1'), + mockRef('ref_2', 'image', '图片2'), + mockRef('ref_3', 'image', '图片3'), + ]; + // editorHtml 只 @ 了图片3,没 @图片1/2 + const editorHtml = `${mentionSpan('ref_3', 'image', '图片3')}`; + + useInputBarStore.setState({ references: refs, editorHtml }); + useInputBarStore.getState().removeReference('ref_1'); + + const state = useInputBarStore.getState(); + expect(state.references[0].label).toBe('图片1'); // 原图片2 + expect(state.references[1].label).toBe('图片2'); // 原图片3 + // editor 里只有 ref_3 的 span,应该更新成"图片2" + expect(state.editorHtml).toMatch(/data-ref-id="ref_3"[^>]*>[\s\S]*?图片2<\/span>/); + }); + }); + + describe('连续删除(并发)', () => { + it('连续删除两张图片,剩余图片正确重编号', () => { + const refs = [ + mockRef('ref_1', 'image', '图片1'), + mockRef('ref_2', 'image', '图片2'), + mockRef('ref_3', 'image', '图片3'), + mockRef('ref_4', 'image', '图片4'), + ]; + const editorHtml = `${mentionSpan('ref_1', 'image', '图片1')} ${mentionSpan('ref_2', 'image', '图片2')} ${mentionSpan('ref_3', 'image', '图片3')} ${mentionSpan('ref_4', 'image', '图片4')}`; + + useInputBarStore.setState({ references: refs, editorHtml }); + useInputBarStore.getState().removeReference('ref_2'); + useInputBarStore.getState().removeReference('ref_1'); + + const state = useInputBarStore.getState(); + expect(state.references).toHaveLength(2); + expect(state.references[0].id).toBe('ref_3'); + expect(state.references[0].label).toBe('图片1'); + expect(state.references[1].id).toBe('ref_4'); + expect(state.references[1].label).toBe('图片2'); + expect(state.editorHtml).toMatch(/data-ref-id="ref_3"[^>]*>[\s\S]*?图片1<\/span>/); + expect(state.editorHtml).toMatch(/data-ref-id="ref_4"[^>]*>[\s\S]*?图片2<\/span>/); + }); + }); +}); 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'); + }); +});
Seedance 2.0
Seedance 2.0(480P / 720P)
Seedance 2.0 Fast
Seedance 2.0(1080P)
Seedance 2.0 Fast(不支持 1080P)