Merge dev into master — v0.19.0 + v0.18.3 发布到生产
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m43s

v0.19.0 (39667ff): 1080P 分辨率支持 — 完整前后端 + 严格计费准确性 + 47 测试
v0.18.3 (dafdc89): 版权报错友好提示 + 图片删除即梦式连续重命名

详见版本管理.md 和各 commit 说明。测试服已完整验证通过:
- 47 自动化测试 (后端 28 + 前端 14 + 本地 E2E 5)
- 测试服 tudou 账号 E2E 8/8 通过
- 团队内容生成人员手动测试通过
This commit is contained in:
seaislee1209 2026-04-17 20:27:46 +08:00
commit fc61650092
40 changed files with 5128 additions and 62 deletions

View File

@ -241,6 +241,8 @@ def me_view(request):
'token_price_video': float(config.base_token_price_video) * markup_mult, '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': float(config.base_token_price_fast) * markup_mult,
'token_price_fast_video': float(config.base_token_price_fast_video) * 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, 'is_active': team.is_active,
} }
data['team_disabled'] = not team.is_active data['team_disabled'] = not team.is_active

View File

@ -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='分辨率'),
),
]

View File

@ -19,6 +19,11 @@ class GenerationRecord(models.Model):
('completed', '已完成'), ('completed', '已完成'),
('failed', '失败'), ('failed', '失败'),
] ]
RESOLUTION_CHOICES = [
('480p', '480P'),
('720p', '720P'),
('1080p', '1080P'),
]
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, 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='用户费用(元)') 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='平台成本(元)') 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='冻结金额(元)') 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='状态') 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') 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') 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_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 = 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_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) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:

View File

@ -5,8 +5,11 @@ class VideoGenerateSerializer(serializers.Serializer):
prompt = serializers.CharField(required=False, allow_blank=True, default='') prompt = serializers.CharField(required=False, allow_blank=True, default='')
mode = serializers.ChoiceField(choices=['universal', 'keyframe']) mode = serializers.ChoiceField(choices=['universal', 'keyframe'])
model = serializers.ChoiceField(choices=['seedance_2.0', 'seedance_2.0_fast']) 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() 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) 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_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 = 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_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 = serializers.CharField(required=False, allow_blank=True, default='')
announcement_enabled = serializers.BooleanField(required=False, default=False) announcement_enabled = serializers.BooleanField(required=False, default=False)
max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1) max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1)

View File

@ -55,10 +55,38 @@ def _has_video_reference(references):
return any(ref.get('type') == 'video' for ref in 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': if model == 'seedance_2.0_fast':
return config.base_token_price_fast_video if has_video_ref else config.base_token_price_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 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'] mode = serializer.validated_data['mode']
model = serializer.validated_data['model'] model = serializer.validated_data['model']
aspect_ratio = serializer.validated_data['aspect_ratio'] 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') search_mode = request.data.get('search_mode', 'off')
seed = _safe_int(request.data.get('seed', -1), -1) 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 和费用 ── # ── 预估 token 和费用 ──
config = QuotaConfig.objects.get_or_create(pk=1)[0] config = QuotaConfig.objects.get_or_create(pk=1)[0]
w, h = get_resolution(aspect_ratio) references = request.data.get('references', [])
estimated_tokens = estimate_tokens(w, h, duration) w, h = get_resolution(aspect_ratio, resolution)
has_video_ref = _has_video_reference(request.data.get('references', [])) has_video_ref = _has_video_reference(references)
token_price = _get_token_price(config, model, has_video_ref) 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) estimated_cost = calculate_cost(estimated_tokens, token_price, team.markup_percentage)
# ── 所有额度检查在 transaction 内完成select_for_update 串行化同团队请求 ── # ── 所有额度检查在 transaction 内完成select_for_update 串行化同团队请求 ──
@ -447,7 +486,7 @@ def video_generate_view(request):
duration=duration, duration=duration,
seconds_consumed=duration, seconds_consumed=duration,
frozen_amount=estimated_cost, frozen_amount=estimated_cost,
resolution='720p', resolution=resolution,
tokens_consumed=0, tokens_consumed=0,
cost_amount=0, cost_amount=0,
base_cost_amount=0, base_cost_amount=0,
@ -471,6 +510,7 @@ def video_generate_view(request):
duration=duration, duration=duration,
search_mode=search_mode, search_mode=search_mode,
seed=seed, seed=seed,
resolution=resolution,
) )
ark_task_id = ark_response.get('id', '') ark_task_id = ark_response.get('id', '')
record.ark_task_id = ark_task_id record.ark_task_id = ark_task_id
@ -550,7 +590,9 @@ def _settle_payment(record, total_tokens):
return return
config = QuotaConfig.objects.get_or_create(pk=1)[0] config = QuotaConfig.objects.get_or_create(pk=1)[0]
has_video_ref = _has_video_reference(record.reference_urls) 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) actual_cost = calculate_cost(total_tokens, token_price, team.markup_percentage)
base_cost = calculate_base_cost(total_tokens, token_price) base_cost = calculate_base_cost(total_tokens, token_price)
frozen = record.frozen_amount frozen = record.frozen_amount
@ -634,6 +676,7 @@ def _serialize_task(record):
'mode': record.mode, 'mode': record.mode,
'model': record.model, 'model': record.model,
'aspect_ratio': record.aspect_ratio, 'aspect_ratio': record.aspect_ratio,
'resolution': record.resolution,
'duration': record.duration, 'duration': record.duration,
'seconds_consumed': record.seconds_consumed, 'seconds_consumed': record.seconds_consumed,
'tokens_consumed': record.tokens_consumed, 'tokens_consumed': record.tokens_consumed,
@ -1705,6 +1748,7 @@ def admin_records_view(request):
'mode': r.mode, 'mode': r.mode,
'model': r.model, 'model': r.model,
'aspect_ratio': r.aspect_ratio, 'aspect_ratio': r.aspect_ratio,
'resolution': r.resolution,
'status': r.status, 'status': r.status,
'error_message': r.error_message or '', 'error_message': r.error_message or '',
'raw_error': r.raw_error or '', 'raw_error': r.raw_error or '',
@ -1768,6 +1812,7 @@ def team_records_view(request):
'mode': r.mode, 'mode': r.mode,
'model': r.model, 'model': r.model,
'aspect_ratio': r.aspect_ratio, 'aspect_ratio': r.aspect_ratio,
'resolution': r.resolution,
'status': r.status, 'status': r.status,
'error_message': r.error_message or '', 'error_message': r.error_message or '',
'raw_error': r.raw_error or '', 'raw_error': r.raw_error or '',
@ -2656,6 +2701,7 @@ def profile_records_view(request):
'mode': r.mode, 'mode': r.mode,
'model': r.model, 'model': r.model,
'aspect_ratio': r.aspect_ratio, 'aspect_ratio': r.aspect_ratio,
'resolution': r.resolution,
'status': r.status, 'status': r.status,
'error_message': r.error_message or '', 'error_message': r.error_message or '',
}) })
@ -2788,6 +2834,7 @@ def admin_assets_user_videos(request, user_id):
'duration': r.duration, 'duration': r.duration,
'seconds_consumed': r.seconds_consumed, 'seconds_consumed': r.seconds_consumed,
'aspect_ratio': r.aspect_ratio, 'aspect_ratio': r.aspect_ratio,
'resolution': r.resolution,
'reference_urls': r.reference_urls or [], 'reference_urls': r.reference_urls or [],
'created_at': r.created_at.isoformat(), 'created_at': r.created_at.isoformat(),
}) })
@ -2869,6 +2916,7 @@ def team_assets_member_videos(request, member_id):
'duration': r.duration, 'duration': r.duration,
'seconds_consumed': r.seconds_consumed, 'seconds_consumed': r.seconds_consumed,
'aspect_ratio': r.aspect_ratio, 'aspect_ratio': r.aspect_ratio,
'resolution': r.resolution,
'reference_urls': r.reference_urls or [], 'reference_urls': r.reference_urls or [],
'created_at': r.created_at.isoformat(), 'created_at': r.created_at.isoformat(),
}) })

View File

@ -0,0 +1,131 @@
"""
1080P API 集成测试 验证 video_generate_view 的入口校验
"""
import os
import sys
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
django.setup()
import unittest
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from apps.accounts.models import Team
from apps.generation.models import QuotaConfig
User = get_user_model()
class TestVideoGenerateResolution(TestCase):
"""video_generate_view 的分辨率+模型组合校验。"""
def setUp(self):
# 初始化 QuotaConfig
QuotaConfig.objects.get_or_create(pk=1)
# 建测试 team + user
self.team = Team.objects.create(
name='test-1080p',
is_active=True,
monthly_spending_limit=1000,
markup_percentage=0,
balance=1000,
frozen_amount=0,
)
self.user = User.objects.create_user(
username='test_1080p_user',
email='test1080p@example.com',
password='testpass123',
team=self.team,
spending_limit=-1,
daily_generation_limit=-1,
monthly_generation_limit=-1,
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
def test_reject_fast_plus_1080p(self):
"""原则 1Fast + 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):
"""原则 1adaptive 不在 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)

View File

@ -0,0 +1,208 @@
"""
1080P 分辨率支持的计费逻辑测试 严格对齐用户三原则
1. 不兜底/静默降级
2. 钱的计算绝对准确纯官方公式
3. 不隐藏 bug非法组合 fail loud
运行方式
cd backend && source venv/Scripts/activate && python -m pytest tests/test_1080p_billing.py -v
Django test runner
python manage.py test tests.test_1080p_billing
"""
import os
import sys
import django
# Django setup
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
django.setup()
import unittest
from utils.billing import (
RESOLUTION_MAP,
get_resolution,
estimate_tokens,
calculate_cost,
calculate_base_cost,
)
class TestResolutionMap(unittest.TestCase):
"""验证 RESOLUTION_MAP 的 18 个组合像素值与官方文档一致。"""
def test_1080p_pixels(self):
# 来自 docs/API文档/创建视频生成任务API.md Seedance 2.0 & 2.0 fast 列
self.assertEqual(get_resolution('16:9', '1080p'), (1920, 1080))
self.assertEqual(get_resolution('9:16', '1080p'), (1080, 1920))
self.assertEqual(get_resolution('4:3', '1080p'), (1664, 1248))
self.assertEqual(get_resolution('1:1', '1080p'), (1440, 1440))
self.assertEqual(get_resolution('3:4', '1080p'), (1248, 1664))
# 21:9 特别注意:是 2206×946不是 seedance 1.0 的 2176×928
self.assertEqual(get_resolution('21:9', '1080p'), (2206, 946))
def test_720p_pixels(self):
self.assertEqual(get_resolution('16:9', '720p'), (1280, 720))
self.assertEqual(get_resolution('21:9', '720p'), (1470, 630))
def test_480p_pixels(self):
self.assertEqual(get_resolution('16:9', '480p'), (864, 496))
self.assertEqual(get_resolution('21:9', '480p'), (992, 432))
def test_invalid_combo_raises(self):
"""原则 1非法组合必须 fail loud不静默降级。"""
with self.assertRaises(KeyError):
get_resolution('adaptive', '720p') # adaptive 不在 map
with self.assertRaises(KeyError):
get_resolution('16:9', '4K') # 不存在的 tier
with self.assertRaises(KeyError):
get_resolution('unknown', 'unknown')
def test_tier_required(self):
"""tier 参数必填,不允许默认 720p 静默降级。"""
with self.assertRaises(TypeError):
get_resolution('16:9') # type: ignore - 故意漏参数
class TestEstimateTokens(unittest.TestCase):
"""
严格对齐官方公式`(输入视频时长+输出时长) × × × 帧率 / 1024`
预估端不做最低 token 修正那是火山计费侧逻辑
"""
def test_formula_no_input_video(self):
# 720P 16:9 (1280×720), 5s 输出, 24fps
# 1280 × 720 × 24 × 5 / 1024 = 108000
self.assertEqual(estimate_tokens(1280, 720, 5), 108000)
def test_formula_with_input_video(self):
# 720P 16:9, 5s 输出 + 5s 输入 = 10s 总时长
# 1280 × 720 × 24 × 10 / 1024 = 216000
self.assertEqual(estimate_tokens(1280, 720, 5, input_video_duration=5), 216000)
def test_1080p_formula(self):
# 1080P 16:9 (1920×1080), 5s 输出, 无输入视频
# 1920 × 1080 × 24 × 5 / 1024 = 243000
self.assertEqual(estimate_tokens(1920, 1080, 5), 243000)
def test_1080p_with_input_video(self):
# 1080P 16:9, 5s 输出 + 2s 输入 = 7s
# 1920 × 1080 × 24 × 7 / 1024 = 340200
self.assertEqual(estimate_tokens(1920, 1080, 5, input_video_duration=2), 340200)
def test_no_silent_min_token_adjustment(self):
"""原则 2预估端严格按公式不做最低 token 修正。
火山文档说 1080p 5s 输入含视频最低 437400 tokens但那是火山计费侧的事
我们预估就老老实实按公式算 (5s+2s)×1920×1080×24/1024 = 340200不擅自拉高
"""
# 1080p 5s 输出 + 2s 输入 = 7s 总时长
# 公式值 340200官方最低 437400
# 我们应该返回公式值,不主动调到最低值
result = estimate_tokens(1920, 1080, 5, input_video_duration=2)
self.assertEqual(result, 340200, "预估端不应主动修正到火山最低 token")
def test_float_input_duration(self):
"""输入视频时长可能是浮点数(前端 getMediaInfo 读取),要正确累加。"""
# 720P 16:9, 5s 输出 + 3.5s 输入 = 8.5s
# 1280 × 720 × 24 × 8.5 / 1024 = 183600
self.assertEqual(estimate_tokens(1280, 720, 5, input_video_duration=3.5), 183600)
class TestGetTokenPrice(unittest.TestCase):
"""验证单价选择逻辑 — 4 种模型×视频组合 + 1080p 独立单价 + Fast+1080P fail loud。"""
def setUp(self):
# Mock QuotaConfig — 用官方文档默认值
from types import SimpleNamespace
from decimal import Decimal
self.config = SimpleNamespace(
base_token_price=Decimal('46'),
base_token_price_video=Decimal('28'),
base_token_price_fast=Decimal('37'),
base_token_price_fast_video=Decimal('22'),
base_token_price_1080p=Decimal('51'),
base_token_price_1080p_video=Decimal('31'),
)
from apps.generation.views import _get_token_price
self._get_token_price = _get_token_price
def test_seedance_2_0_720p_no_video(self):
"""AirDrama 720P 不含视频 = 46 元/百万 tokens."""
price = self._get_token_price(self.config, 'seedance_2.0', False, '720p')
self.assertEqual(price, 46)
def test_seedance_2_0_720p_with_video(self):
"""AirDrama 720P 含视频 = 28."""
price = self._get_token_price(self.config, 'seedance_2.0', True, '720p')
self.assertEqual(price, 28)
def test_seedance_2_0_480p_same_as_720p(self):
"""480p 和 720p 共享同一单价(官方价格一致)。"""
price_480 = self._get_token_price(self.config, 'seedance_2.0', False, '480p')
price_720 = self._get_token_price(self.config, 'seedance_2.0', False, '720p')
self.assertEqual(price_480, price_720)
def test_seedance_2_0_1080p_no_video(self):
"""AirDrama 1080P 不含视频 = 51独立单价不是 720p 的 46."""
price = self._get_token_price(self.config, 'seedance_2.0', False, '1080p')
self.assertEqual(price, 51)
def test_seedance_2_0_1080p_with_video(self):
"""AirDrama 1080P 含视频 = 31独立单价不是 720p 的 28."""
price = self._get_token_price(self.config, 'seedance_2.0', True, '1080p')
self.assertEqual(price, 31)
def test_fast_720p_no_video(self):
"""Fast 720P 不含视频 = 37."""
price = self._get_token_price(self.config, 'seedance_2.0_fast', False, '720p')
self.assertEqual(price, 37)
def test_fast_720p_with_video(self):
"""Fast 720P 含视频 = 22."""
price = self._get_token_price(self.config, 'seedance_2.0_fast', True, '720p')
self.assertEqual(price, 22)
def test_fast_480p_uses_fast_price(self):
"""Fast 不分 480p/720p都用 fast 单价。"""
price_480 = self._get_token_price(self.config, 'seedance_2.0_fast', False, '480p')
price_720 = self._get_token_price(self.config, 'seedance_2.0_fast', False, '720p')
self.assertEqual(price_480, price_720)
self.assertEqual(price_480, 37)
def test_fast_1080p_raises_value_error(self):
"""原则 1 + 3Fast + 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)

View File

@ -15,6 +15,7 @@ ERROR_MESSAGES = {
'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试', 'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试',
# Output content moderation # Output content moderation
'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试', 'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试',
'OutputVideoSensitiveContentDetected.PolicyViolation': '生成的视频涉及版权限制内容如知名IP、名人肖像等已被系统拦截请修改提示词后重试',
'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截', 'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截',
# Parameter errors # Parameter errors
'InvalidParameter': '请求参数无效,请检查输入内容', '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): generate_audio=True, search_mode='off', seed=-1):
"""Create a video generation task. """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). content_items: List of media content dicts (image_url, video_url, audio_url).
aspect_ratio: Video aspect ratio ('16:9', '9:16', etc.). aspect_ratio: Video aspect ratio ('16:9', '9:16', etc.).
duration: Video duration in seconds. 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. generate_audio: Whether to generate audio with the video.
search_mode: 'smart' to enable internet search, 'off' to disable. 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, 'content': content,
'generate_audio': generate_audio, 'generate_audio': generate_audio,
'ratio': aspect_ratio, 'ratio': aspect_ratio,
'resolution': resolution,
'duration': duration, 'duration': duration,
'watermark': False, 'watermark': False,
'seed': seed, 'seed': seed,

View File

@ -22,20 +22,62 @@ RESOLUTION_MAP = {
('480p', '1:1'): (640, 640), ('480p', '1:1'): (640, 640),
('480p', '3:4'): (560, 752), ('480p', '3:4'): (560, 752),
('480p', '21:9'): (992, 432), ('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 DEFAULT_FPS = 24
def get_resolution(aspect_ratio: str, tier: str = '720p') -> tuple: def get_resolution(aspect_ratio: str, tier: str) -> tuple:
"""根据宽高比和分辨率档位返回 (width, height) 像素值。""" """根据宽高比和分辨率档位返回 (width, height) 像素值。
return RESOLUTION_MAP.get((tier, aspect_ratio), (1280, 720))
tier 必填不设默认值 避免调用者遗漏时静默降级为 720p违反计费准确性原则
(tier, aspect_ratio) 组合不在 RESOLUTION_MAP adaptiveraise 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: def estimate_tokens(
"""预估视频生成消耗的 tokens。""" width: int,
return round(width * height * fps * duration / 1024) 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: def calculate_cost(tokens: int, base_price, markup_percentage) -> Decimal:

View File

@ -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.0Seedance 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**
* **功能说明**:对输入视频的总像素限制扩大至 20868762206x946支持传入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

File diff suppressed because it is too large Load Diff

View File

@ -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) 仅作为商品规格和价格的参考,具体可购买的商品规格及费用请以实际下单结果为准。
:::
<span id="76de5911"></span>
# 大语言模型
<span id="aa1874cf"></span>
## 在线推理(常规)
<span aceTableMode="list" aceTableWidth="3,2,1,1,1,1"></span>
|模型名称 |条件|输入|缓存存储|缓存输入|输出|\
| |千 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)
<span id="d3774bbd"></span>
## 在线推理(低延迟)
<span aceTableMode="list" aceTableWidth="3,2,1,1,1"></span>
|模型名称 |条件|输入|缓存输入|输出|\
| |千 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 |
<span id="952683a2"></span>
## 在线推理TPM 保障包)
<span aceTableMode="list" aceTableWidth="3,2,2,2"></span>
|模型 |计费方式 |输入|输出|\
| | |元/每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**。
<span id="a6471f38"></span>
## 批量推理
<span aceTableMode="list" aceTableWidth="3,2,1,1,2"></span>
|模型名称 |条件|输入|缓存命中|输出|\
| |千 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)
<span id="02affcb8"></span>
# 视频生成模型
<span id="2864f00a"></span>
## 按token单价
<span aceTableMode="list" aceTableWidth="3,3,3"></span>
|模型 |在线推理|离线推理|\
| |元/百万token |元/百万token |
|---|---|---|
|doubao\-seedance\-2.0|* 输出视频分辨率为 480p720p|暂不支持 |\
|> 按输出视频分辨率和输入是否包含视频区分定价 | * 输入不含视频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 视频仅480ptoken 用量估算:`正常视频 token 用量公式 × 折算系数`折算系数与模型相关Seedance 1.5 pro 的 token 折算系数:无声 0.7;有声 0.6,其他模型暂不支持。
> * 准确 token 用量:以调用 API 后返回信息中的 usage 字段为准。
<span id="2653dbb3"></span>
## 价格示例
基于 token 用量公式估算的视频单价,方便您直观了解不同规格的视频成本。更多价格示例请参见[火山方舟视频生成模型价格快查表](https://bytedance.larkoffice.com/wiki/FXaYwxzJ5i5Zdik32ipcWzt7nxd?table=tblns3WjGMNbR8sL&view=vewPa39Do4#CategoryScheduledTask)。
<span id="83af2aad"></span>
### doubao\-seedance\-2.0 & 2.0 fast
> * 视频价格估算公式:`按 token 单价 × token 用量`=`按 token 单价 × (输入视频时长+输出视频时长) × 输出视频的宽 × 输出视频的高 × 输出视频的帧率/1024`
> * 注意:输入包含视频时, Seedance 2.0 和 Seedance 2.0 fast 模型针对不同的视频输出时长存在最低 token 用量限制,如果 token 估算用量 最低 token 用量限制,则按最低 token 用量计算视频价格。
* **输入不含视频**
<span aceTableMode="list" aceTableWidth="2,2,3,4,4"></span>
|分辨率 |宽高比 |输出视频时长(秒) |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 |不支持 |
* **输入包含视频**
<span aceTableMode="list" aceTableWidth="2,2,3,3,4,4"></span>
|分辨率 |宽高比 |输入视频时长(秒) |输出视频时长(秒) |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 |215 |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)。
<span aceTableMode="list" aceTableWidth="3,3,3,3"></span>
|输出视频秒数 |最低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 |
<span id="dd571290"></span>
### doubao\-seedance\-1.5\-pro
<span aceTableMode="list" aceTableWidth="2,2,2,3,3,3,3"></span>
|分辨率 |宽高比 |时长(秒) |有声视频|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 |不支持 |
<span id="457edfd0"></span>
# 图片生成模型
<span aceTableMode="list" aceTableWidth="3,6"></span>
|模型名称 |单价|\
| |元/张 |
|---|---|
|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 |
> * 按成功输出图片数量计费:
> * 组图场景按实际生成的图片数量计费。
> * 因审核等原因未成功输出的图片不计费。
&nbsp;
<span id="e68ea83c"></span>
# 向量模型
<span aceTableMode="list" aceTableWidth="3,3,3"></span>
|模型 |文本输入|图片输入|\
| |元/百万 token |元/百万 token |
|---|---|---|
|doubao\-embedding\-vision |0.70 |1.80 |
> 按输入的 tokens 计费:
> 费用 = `文本输入 tokens × 文本输入单价 + 图片输入 tokens × 图片输入单价`
> = `文本输入 tokens × 文本输入单价+ min((width × height)/7841312 ) × 图片输入单价`
<span id="b3a42676"></span>
# 模型精调
<span id="7e451788"></span>
## 精调\-按 token 后付费
<span aceTableMode="list" aceTableWidth="3,3,3"></span>
|基础模型 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 计算。
<span id="b2811e92"></span>
## 精调\-按算力付费
<span aceTableMode="list" aceTableWidth="3,3,3"></span>
|算力规格 |计费方式 |定价|\
| | |元/小时 |
|---|---|---|
|方舟A型模型单元 |按量后付费 |25 |
|方舟B型模型单元 |按量后付费 |15 |
|方舟C型模型单元 |按量后付费 |10 |
|方舟D型模型单元 |按量后付费 |20 |
> 训练费用=训练计费时长*使用的模版单价=训练计费时长*模型单元数\*模型单元单价。
<span id="c6d128f7"></span>
## 推理\-在线推理
<span aceTableMode="list" aceTableWidth="3,2,2,2"></span>
|精调模型对应的基础模型 |条件(千 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)可选的付费方式为准。
<span id="0c211d41"></span>
## 推理\-批量推理
<span aceTableMode="list" aceTableWidth="3,2,1,1,2"></span>
|精调模型对应的基础模型 |条件(千 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%。
<span id="c26435c9"></span>
# 模型单元
<span aceTableMode="list" aceTableWidth="3,3,3"></span>
|机型 |计费方式 |定价|\
| | |元/个 |
|---|---|---|
|方舟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) 估算需要的机型数量。更推荐通过实际业务流量压测,计算需要的机型和数量。
<span id="3adb5876"></span>
# 工具及插件
<span id="f2e7c4f6"></span>
## 联网内容插件
<span aceTableMode="list" aceTableWidth="3,2,4"></span>
|服务项 |价格|说明 |\
| |元/千次 | |
|---|---|---|
|联网资源 |4 |实时搜索互联网公开域内容每月提供2万次免费额度。 |
|头条资源 |6 |实时搜索今日头条图文内容,并提供内容详情信息供展示交互卡片。 |
|抖音资源 |6 |实时搜索抖音百科内容,并提供内容详情信息供展示交互卡片。 |
|墨迹天气 |6 |实时搜索墨迹天气内容资源。 |
> * 出账及计费:按量后付费
> * 用量:每次请求产生的调用次数,可返回结构体的 **source_type** 字段计算得到。
> * 更多说明请参见 [联网内容插件功能说明](/docs/82379/1338552)。
<span id="abf4f1e8"></span>
## 豆包助手
<span aceTableMode="list" aceTableWidth="3,2,4"></span>
|服务项 |价格|说明 |\
| |元/次 | |
|---|---|---|
|日常沟通 |0.1 |全能助手,自然交流,多轮对话,高情商人格化聊天。 |
|深度沟通 |0.2 |深度理解,精准解析,先思考再回答,复杂问题尽在掌握。 |
|联网搜索 |0.2 |全网搜索,信源丰富,无需费力找资料,一键搜索实时资讯。 |
|边想边搜 |0.5 |逻辑缜密,深度洞察,遇难题问豆包,想得更深,答得更准。 |
> * 出账及计费:按量后付费
> * 用量:每次请求产生的调用次数,可返回结构体的 **source_type** 字段计算得到。
> * 更多说明请参见 [联网内容插件功能说明](/docs/82379/1338552)。
<span id="bce8c602"></span>
## 知识库
<span aceTableMode="list" aceTableWidth="6,3"></span>
|服务项 |价格 |
|---|---|
|计算资源\-知识库【旗舰版】 |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)。
<span id="f47e6c9b"></span>
# Coding Plan 个人版
<span aceTableMode="list" aceTableWidth="3,3,3"></span>
|套餐类型 |订阅时长 |价格 |
|---|---|---|
|Lite 套餐 |1 个月 |40 元/月 |
|^^|3 个月 |120 元/季 |
|Pro 套餐 |1 个月 |200 元/月 |
|^^|3 个月 |600 元/季 |
> 套餐信息及特惠活动参见[套餐概览](/docs/82379/1925114)。

View File

@ -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** 可检索页面内所有内容。
<span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_cae7ddb0e1977b68b353f17897b8574c.png) </span>
```mixin-react
return (<Tabs>
<Tabs.TabPane title="在线调试" key="4rK5FhUg"><RenderMd content={`<APILink link="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" description="API Explorer 您可以通过 API Explorer 在线发起调用,无需关注签名生成过程,快速获取调用结果。"></APILink>
`}></RenderMd></Tabs.TabPane>
<Tabs.TabPane title="鉴权说明" key="iRuPtuk6"><RenderMd content={`本接口仅支持 API Key 鉴权请在 [获取 API Key](https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey) 页面获取长效 API Key
`}></RenderMd></Tabs.TabPane>
<Tabs.TabPane title="快速入口" key="5LZLMN0J"><RenderMd content={` [ ](#)[体验中心](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_2abecd05ca2779567c6d32f0ddc7874d.png =20x) </span>[模型列表](https://www.volcengine.com/docs/82379/1330310?lang=zh#2705b333) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_a5fdd3028d35cc512a10bd71b982b6eb.png =20x) </span>[模型计费](https://www.volcengine.com/docs/82379/1544106?redirect=1&lang=zh#02affcb8) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_afbcf38bdec05c05089d5de5c3fd8fc8.png =20x) </span>[API Key](https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D)
<span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_57d0bca8e0d122ab1191b40101b5df75.png =20x) </span>[调用教程](https://www.volcengine.com/docs/82379/1366799) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_f45b5cd5863d1eed3bc3c81b9af54407.png =20x) </span>[接口文档](https://www.volcengine.com/docs/82379/1520758) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_1609c71a747f84df24be1e6421ce58f0.png =20x) </span>[常见问题](https://www.volcengine.com/docs/82379/1359411) <span>![图片](https://portal.volccdn.com/obj/volcfe/cloud-universal-doc/upload_bef4bc3de3535ee19d0c5d6c37b0ffdd.png =20x) </span>[开通模型](https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&OpenTokenDrawer=false)
`}></RenderMd></Tabs.TabPane></Tabs>);
```
---
<span id="5qndT7DS"></span>
## 请求参数
> 跳转 [响应参数](#y2hhTyHB)
<span id="wsGzv1pD"></span>
### 请求体
---
**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,<Base64编码>`,注意 `<图片格式>` 需小写,如 `data:image/png;base64,{base64_image}`
* 素材 ID用于视频生成的预置素材及虚拟人像的 ID遵循格式asset://<ASSET_ID\>可从 [素材&虚拟人像库](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 fastseedance 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 fast1~9 张图片seedance 1.0 lite i2v1~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://<ASSET_ID\>可从[素材&虚拟人像库](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128)获取
:::tip 传入单个视频要求
* 视频格式mp4、mov支持编码格式见下表。
* 分辨率480p720p1080p
* 时长:单个视频时长 [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,<Base64编码>`,注意 `<音频格式>` 需小写,如 `data:audio/wav;base64,{base64_audio}`
* 素材 ID用于视频生成的虚拟人的音频素材 ID遵循格式asset://<ASSET_ID\>可从[素材&虚拟人像库](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 小时。取值范围:[3600259200]。
不论使用哪种 **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 或邮箱进行哈希处理后生成的字符串,避免泄露用户隐私信息。
---
&nbsp;
:::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
* 1080pseedance 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 参考图场景不支持。
**取值规则:**
* 文生视频:根据输入的提示词,智能选择最合适的宽高比。
* 首帧 / 首尾帧生视频:根据上传的首帧图片比例,自动选择最接近的宽高比。
* 多模态参考生视频:根据用户提示词意图判断,如果是首帧生视频/编辑视频/延长视频,以该图片/视频为准选择最接近的宽高比;否则,以传入的第一个媒体文件为准(优先级:视频>图片)选择最接近的宽高比。
:::
&nbsp;
不同宽高比对应的宽高像素值
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含有水印。
---
<span id="oCS1tULg"></span>
## 响应参数
> 跳转 [请求参数](#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`

View File

@ -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归属地解析 + 异常检测 + 飞书告警 + 自动封禁 ## 2026-03-19 — v0.9.7: 登录风控第二期 — IP归属地解析 + 异常检测 + 飞书告警 + 自动封禁
**状态**: ✅ 已完成 | **验收**: ✅ 通过本地验证IP138 在线 API 需部署至阿里云后验证) **状态**: ✅ 已完成 | **验收**: ✅ 通过本地验证IP138 在线 API 需部署至阿里云后验证)

View File

@ -0,0 +1,65 @@
# 提示词 AI 优化功能
**状态**:待开发
**创建日期**2026-04-17
## 需求背景
用户写提示词时,经常写得过于简单或不符合 Seedance 2.0 的提示词规范如没用「图片n」引用素材、缺少核心要素、镜头语言模糊等导致生成效果不理想。
引入火山官方的 SKILL.mdSeedance 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 行起的「提示词技巧」部分

View File

@ -5,6 +5,7 @@ interface DropdownItem {
label: string; label: string;
value: string; value: string;
icon?: ReactNode; icon?: ReactNode;
disabled?: boolean;
} }
interface DropdownProps { interface DropdownProps {
@ -41,8 +42,10 @@ export function Dropdown({ items, value, onSelect, trigger, minWidth = 150 }: Dr
{items.map((item) => ( {items.map((item) => (
<div <div
key={item.value} key={item.value}
className={`${styles.item} ${value === item.value ? styles.selected : ''}`} className={`${styles.item} ${value === item.value ? styles.selected : ''} ${item.disabled ? styles.disabled : ''}`}
style={item.disabled ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
onClick={() => { onClick={() => {
if (item.disabled) return;
onSelect(item.value); onSelect(item.value);
setOpen(false); setOpen(false);
}} }}

View File

@ -389,7 +389,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
<span></span><span>{task.duration}s</span> <span></span><span>{task.duration}s</span>
</div> </div>
<div className={styles.detailRow}> <div className={styles.detailRow}>
<span></span><span>720p</span> <span></span><span>{task.resolution.toUpperCase()}</span>
</div> </div>
<div className={styles.detailRow}> <div className={styles.detailRow}>
<span></span> <span></span>

View File

@ -62,37 +62,37 @@
padding-bottom: 8px; padding-bottom: 8px;
} }
/* Quota display */ /* Quota display — 今日剩余生成次数v0.10.0 起次数制) */
.quota { .quota {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 2px; gap: 3px;
cursor: pointer; cursor: pointer;
padding: 8px 4px; padding: 8px 4px;
border-radius: 8px; border-radius: 8px;
transition: background 0.15s; transition: background 0.15s;
min-width: 56px;
} }
.quota:hover { .quota:hover {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
} }
.diamondIcon {
flex-shrink: 0;
}
.quotaNumber { .quotaNumber {
font-size: 14px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: var(--color-text-primary); color: var(--color-text-primary);
line-height: 1; line-height: 1;
font-variant-numeric: tabular-nums;
letter-spacing: 0.5px;
} }
.quotaLabel { .quotaLabel {
font-size: 9px; font-size: 10px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.5px;
} }
/* Admin button */ /* Admin button */

View File

@ -12,8 +12,11 @@ export function Sidebar() {
const isActive = (path: string) => location.pathname === path; const isActive = (path: string) => location.pathname === path;
const role = user?.role; const role = user?.role;
// 今日剩余生成次数v0.10.0 起计费体系为次数+金额,不再是秒数池)
const dailyRemaining = quota 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; : 0;
return ( return (
@ -70,15 +73,15 @@ export function Sidebar() {
<div className={styles.bottom}> <div className={styles.bottom}>
{/* Quota display - not for super admin */} {/* Quota display - not for super admin */}
{role !== 'super_admin' && ( {role !== 'super_admin' && (
<div className={styles.quota} onClick={() => navigate('/profile')}> <div
<svg className={styles.diamondIcon} width="16" height="16" viewBox="0 0 24 24" fill="none"> className={styles.quota}
<path d="M6 3h12l4 8-10 12L2 11l4-8z" fill="#6c63ff" opacity="0.85" /> onClick={() => navigate('/profile')}
<path d="M2 11h20M6 3l4 8M18 3l-4 8M12 23l-4-12M12 23l4-12" stroke="#fff" strokeWidth="0.8" opacity="0.4" /> title="今日剩余生成次数(实际扣费以火山 token 消耗为准)"
</svg> >
<span className={styles.quotaNumber}> <span className={styles.quotaNumber}>
{dailyRemaining === Infinity ? '∞' : dailyRemaining.toLocaleString()} {dailyRemaining === Infinity ? '∞' : dailyRemaining.toLocaleString()}
</span> </span>
<span className={styles.quotaLabel}></span> <span className={styles.quotaLabel}></span>
</div> </div>
)} )}

View File

@ -3,7 +3,9 @@ import { useInputBarStore } from '../store/inputBar';
import { useGenerationStore } from '../store/generation'; import { useGenerationStore } from '../store/generation';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { Dropdown } from './Dropdown'; 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'; import styles from './Toolbar.module.css';
const VideoIcon = () => ( const VideoIcon = () => (
@ -70,10 +72,7 @@ const generationTypeItems = [
{ label: '视频生成', value: 'video' as GenerationType, icon: <VideoIcon /> }, { label: '视频生成', value: 'video' as GenerationType, icon: <VideoIcon /> },
]; ];
const modelItems = [ // NOTE: modelItems 在组件内部按 resolution 动态构建1080P 下 Fast 置灰)
{ label: 'AirDrama', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> },
{ label: 'AirDrama Fast', value: 'seedance_2.0_fast' as ModelOption, icon: <LightningIcon /> },
];
const modeItems = [ const modeItems = [
{ label: '全能参考', value: 'universal' as CreationMode, icon: <StarIcon /> }, { label: '全能参考', value: 'universal' as CreationMode, icon: <StarIcon /> },
@ -98,9 +97,20 @@ const durationItems = Array.from({ length: 12 }, (_, i) => {
return { label: `${v}s`, value: String(v) }; return { label: `${v}s`, value: String(v) };
}); });
const RESOLUTION_MAP: Record<string, [number, number]> = { // 对照 billing.py::RESOLUTION_MAP — 前端预估与后端计费保持一致
const RESOLUTION_PIXELS: Record<Resolution, Record<string, [number, number]>> = {
'480p': {
'16:9': [864, 496], '9:16': [496, 864], '4:3': [752, 560],
'1:1': [640, 640], '3:4': [560, 752], '21:9': [992, 432],
},
'720p': {
'16:9': [1280, 720], '9:16': [720, 1280], '4:3': [1112, 834], '16:9': [1280, 720], '9:16': [720, 1280], '4:3': [1112, 834],
'1:1': [960, 960], '3:4': [834, 1112], '21:9': [1470, 630], '1:1': [960, 960], '3:4': [834, 1112], '21:9': [1470, 630],
},
'1080p': {
'16:9': [1920, 1080], '9:16': [1080, 1920], '4:3': [1664, 1248],
'1:1': [1440, 1440], '3:4': [1248, 1664], '21:9': [2206, 946],
},
}; };
const modeLabels: Record<CreationMode, string> = { const modeLabels: Record<CreationMode, string> = {
@ -119,33 +129,77 @@ export function Toolbar() {
const setAspectRatio = useInputBarStore((s) => s.setAspectRatio); const setAspectRatio = useInputBarStore((s) => s.setAspectRatio);
const duration = useInputBarStore((s) => s.duration); const duration = useInputBarStore((s) => s.duration);
const setDuration = useInputBarStore((s) => s.setDuration); 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 isSubmittable = useInputBarStore((s) => s.canSubmit());
const triggerInsertAt = useInputBarStore((s) => s.triggerInsertAt); const triggerInsertAt = useInputBarStore((s) => s.triggerInsertAt);
const isKeyframe = mode === 'keyframe'; const isKeyframe = mode === 'keyframe';
const references = useInputBarStore((s) => s.references); const references = useInputBarStore((s) => s.references);
const editorHtml = useInputBarStore((s) => s.editorHtml);
const team = useAuthStore((s) => s.team); const team = useAuthStore((s) => s.team);
const addTask = useGenerationStore((s) => s.addTask); const addTask = useGenerationStore((s) => s.addTask);
const estimatedTokens = useMemo(() => { const estimatedTokens = useMemo(() => {
const res = RESOLUTION_MAP[aspectRatio] || [1280, 720]; // 官方公式:`(输入视频时长 + 输出视频时长) ××× 24fps / 1024`
return Math.round((res[0] * res[1] * 24 * duration) / 1024); // 前后端必须一致(和 backend/utils/billing.py::estimate_tokens 对齐)。
}, [aspectRatio, duration]); // 输入视频时长 = 直接上传的视频 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]);
// 分辨率 DropdownFast 模式下 1080P 置灰
const resolutionItems = useMemo(() => [
{ label: '480P', value: '480p' as Resolution },
{ label: '720P', value: '720p' as Resolution },
{
label: model === 'seedance_2.0_fast' ? '1080PFast 不支持)' : '1080P',
value: '1080p' as Resolution,
disabled: model === 'seedance_2.0_fast',
},
], [model]);
// 模型 Dropdown当前 1080P 时Fast 置灰1080P 仅 AirDrama 支持)
const modelItems = useMemo(() => [
{ label: 'AirDrama', value: 'seedance_2.0', icon: <DiamondIcon /> },
{
label: resolution === '1080p' ? 'AirDrama Fast不支持 1080P' : 'AirDrama Fast',
value: 'seedance_2.0_fast',
icon: <LightningIcon />,
disabled: resolution === '1080p',
},
], [resolution]);
const estimatedCost = useMemo(() => { const estimatedCost = useMemo(() => {
const hasVideoRef = references.some((r) => r.type === 'video'); const hasVideoRef = references.some((r) => r.type === 'video');
let price = team?.token_price || 0; let price = team?.token_price || 0;
if (model === 'seedance_2.0_fast') { if (model === 'seedance_2.0_fast') {
// Fast 不支持 1080p单价不分分辨率
price = hasVideoRef ? (team?.token_price_fast_video || 0) : (team?.token_price_fast || 0); 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 { } else {
price = hasVideoRef ? (team?.token_price_video || 0) : (team?.token_price || 0); price = hasVideoRef ? (team?.token_price_video || 0) : (team?.token_price || 0);
} }
return (estimatedTokens * price / 1000000).toFixed(2); return (estimatedTokens * price / 1000000).toFixed(2);
}, [estimatedTokens, model, references, team]); }, [estimatedTokens, model, resolution, references, team]);
const handleSend = useCallback(() => { 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(); addTask();
}, [isSubmittable, addTask]); }, [isSubmittable, addTask]);
@ -216,6 +270,19 @@ export function Toolbar() {
} }
/> />
{/* Resolution */}
<Dropdown
items={resolutionItems}
value={resolution}
onSelect={(v) => setResolution(v as Resolution)}
minWidth={100}
trigger={
<button className={styles.btn}>
<span className={styles.label}>{resolution.toUpperCase()}</span>
</button>
}
/>
{/* Duration */} {/* Duration */}
<Dropdown <Dropdown
items={durationItems} items={durationItems}
@ -256,7 +323,7 @@ export function Toolbar() {
{isSubmittable && (team?.token_price || 0) > 0 && ( {isSubmittable && (team?.token_price || 0) > 0 && (
<span <span
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', marginRight: 16, lineHeight: 1 }} style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', marginRight: 16, lineHeight: 1 }}
title={`预估公式: (宽 x 高 x 24fps x 时长) / 1024 = tokens, tokens x 单价 / 1000000 = 费用`} title={`预估公式: (宽 ×× 24fps × 时长) / 1024 = tokens, tokens × 单价 / 1000000 = 费用\n⚠ 仅为预估值,实际费用以火山 API 返回的 token 数为准`}
> >
{estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost} {estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost}
</span> </span>

View File

@ -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.model) store.setModel(task.model as 'seedance_2.0' | 'seedance_2.0_fast');
if (task.aspectRatio) store.setAspectRatio(task.aspectRatio as any); if (task.aspectRatio) store.setAspectRatio(task.aspectRatio as any);
if (task.duration) store.setDuration(task.duration); 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) // Load references from task (exclude asset library refs — they restore via @mentions in editorHtml)
if (task.references && task.references.length > 0) { if (task.references && task.references.length > 0) {
const refs = task.references.filter(r => r.previewUrl && !r.isAssetRef).map(r => ({ const refs = task.references.filter(r => r.previewUrl && !r.isAssetRef).map(r => ({

View File

@ -146,6 +146,7 @@ export const videoApi = {
model: string; model: string;
aspect_ratio: string; aspect_ratio: string;
duration: number; duration: number;
resolution: string;
references: { url: string; type: string; role: string; label: string; thumb_url?: string; duration?: string }[]; references: { url: string; type: string; role: string; label: string; thumb_url?: string; duration?: string }[];
search_mode?: string; search_mode?: string;
seed?: number; seed?: number;

View File

@ -57,6 +57,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask {
model: 'seedance_2.0', model: 'seedance_2.0',
aspectRatio: (v.aspect_ratio as any) || '16:9', aspectRatio: (v.aspect_ratio as any) || '16:9',
duration: v.duration as any, duration: v.duration as any,
resolution: v.resolution,
references, references,
assetMentions: [], assetMentions: [],
status: 'completed', status: 'completed',

View File

@ -153,10 +153,10 @@ export function ProfilePage() {
{/* Quota warning */} {/* Quota warning */}
{dailyPercent >= 80 && dailyPercent < 100 && ( {dailyPercent >= 80 && dailyPercent < 100 && (
<div className={styles.warningBanner}>使 {dailyPercent.toFixed(0)}%使</div> <div className={styles.warningBanner}> {dailyPercent.toFixed(0)}%使</div>
)} )}
{dailyPercent >= 100 && ( {dailyPercent >= 100 && (
<div className={styles.dangerBanner}></div> <div className={styles.dangerBanner}></div>
)} )}
{/* Consumption Overview */} {/* Consumption Overview */}

View File

@ -14,6 +14,8 @@ export function SettingsPage() {
base_token_price_video: 0, base_token_price_video: 0,
base_token_price_fast: 0, base_token_price_fast: 0,
base_token_price_fast_video: 0, base_token_price_fast_video: 0,
base_token_price_1080p: 0,
base_token_price_1080p_video: 0,
announcement: '', announcement: '',
announcement_enabled: false, announcement_enabled: false,
max_desktop_sessions: 1, max_desktop_sessions: 1,
@ -143,7 +145,7 @@ export function SettingsPage() {
/> />
</div> </div>
</div> </div>
<p className={styles.cardDesc}>Seedance 2.0</p> <p className={styles.cardDesc}>Seedance 2.0480P / 720P</p>
<div className={styles.formRow}> <div className={styles.formRow}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label> (/tokens)</label> <label> (/tokens)</label>
@ -164,7 +166,28 @@ export function SettingsPage() {
/> />
</div> </div>
</div> </div>
<p className={styles.cardDesc}>Seedance 2.0 Fast</p> <p className={styles.cardDesc}>Seedance 2.01080P</p>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label> (/tokens)</label>
<input
type="number"
step="0.01"
value={settings.base_token_price_1080p}
onChange={(e) => setSettings({ ...settings, base_token_price_1080p: Number(e.target.value) })}
/>
</div>
<div className={styles.formGroup}>
<label> (/tokens)</label>
<input
type="number"
step="0.01"
value={settings.base_token_price_1080p_video}
onChange={(e) => setSettings({ ...settings, base_token_price_1080p_video: Number(e.target.value) })}
/>
</div>
</div>
<p className={styles.cardDesc}>Seedance 2.0 Fast 1080P</p>
<div className={styles.formRow}> <div className={styles.formRow}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label> (/tokens)</label> <label> (/tokens)</label>

View File

@ -57,6 +57,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask {
model: 'seedance_2.0', model: 'seedance_2.0',
aspectRatio: (v.aspect_ratio as any) || '16:9', aspectRatio: (v.aspect_ratio as any) || '16:9',
duration: v.duration as any, duration: v.duration as any,
resolution: v.resolution,
references, references,
assetMentions: [], assetMentions: [],
status: 'completed', status: 'completed',

View File

@ -32,7 +32,7 @@ function mapErrorMessage(raw?: string): string | undefined {
// Model / generation errors // Model / generation errors
if (s.includes('quota') || s.includes('insufficient')) if (s.includes('quota') || s.includes('insufficient'))
return '不足,请联系管理员'; return '今日生成次数或团队余额不足,请联系管理员';
// If already Chinese, return as-is // If already Chinese, return as-is
if (/[\u4e00-\u9fa5]/.test(raw)) return raw; if (/[\u4e00-\u9fa5]/.test(raw)) return raw;
@ -121,6 +121,7 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
model: bt.model, model: bt.model,
aspectRatio: bt.aspect_ratio as GenerationTask['aspectRatio'], aspectRatio: bt.aspect_ratio as GenerationTask['aspectRatio'],
duration: bt.duration as GenerationTask['duration'], duration: bt.duration as GenerationTask['duration'],
resolution: bt.resolution,
references, references,
assetMentions, assetMentions,
status: mapStatus(bt.status), status: mapStatus(bt.status),
@ -402,6 +403,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
model: input.model, model: input.model,
aspectRatio: input.aspectRatio, aspectRatio: input.aspectRatio,
duration: input.duration, duration: input.duration,
resolution: input.resolution,
references: localRefs, references: localRefs,
assetMentions: placeholderAssetMentions, assetMentions: placeholderAssetMentions,
status: 'generating', status: 'generating',
@ -521,6 +523,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
model: input.model, model: input.model,
aspect_ratio: input.aspectRatio, aspect_ratio: input.aspectRatio,
duration: input.duration, duration: input.duration,
resolution: input.resolution,
references: uploadedRefs, references: uploadedRefs,
search_mode: input.searchMode || 'off', search_mode: input.searchMode || 'off',
seed: input.seed ?? -1, seed: input.seed ?? -1,
@ -638,6 +641,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
editorHtml: task.prompt, editorHtml: task.prompt,
aspectRatio: task.aspectRatio, aspectRatio: task.aspectRatio,
duration: task.duration, duration: task.duration,
resolution: task.resolution,
references, references,
assetMentions: task.assetMentions || [], assetMentions: task.assetMentions || [],
// 如果 seed 开关打开且 task 有有效 seed填入否则不动 // 如果 seed 开关打开且 task 有有效 seed填入否则不动
@ -652,6 +656,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
editorHtml: task.editorHtml || task.prompt, editorHtml: task.editorHtml || task.prompt,
aspectRatio: task.aspectRatio, aspectRatio: task.aspectRatio,
duration: task.duration, duration: task.duration,
resolution: task.resolution,
assetMentions: [], assetMentions: [],
firstFrame: firstRef ? { id: firstRef.id, type: firstRef.type, previewUrl: firstRef.previewUrl, label: '首帧', tosUrl: firstRef.previewUrl } : null, 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, lastFrame: lastRef ? { id: lastRef.id, type: lastRef.type, previewUrl: lastRef.previewUrl, label: '尾帧', tosUrl: lastRef.previewUrl } : null,
@ -688,6 +693,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
model: task.model, model: task.model,
aspectRatio: task.aspectRatio, aspectRatio: task.aspectRatio,
duration: task.duration, duration: task.duration,
resolution: task.resolution,
references: task.mode === 'universal' ? references : [], references: task.mode === 'universal' ? references : [],
assetMentions: task.assetMentions || [], assetMentions: task.assetMentions || [],
}); });

View File

@ -1,5 +1,5 @@
import { create } from 'zustand'; 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 { showToast } from '../components/Toast';
import { mediaApi } from '../lib/api'; import { mediaApi } from '../lib/api';
import { parseAssetMentions } from '../lib/assetMentions'; import { parseAssetMentions } from '../lib/assetMentions';
@ -88,6 +88,10 @@ interface InputBarState {
setDuration: (duration: Duration) => void; setDuration: (duration: Duration) => void;
prevDuration: Duration; prevDuration: Duration;
// Resolution (480p/720p/1080p) — 1080p 仅 Seedance 2.0 支持
resolution: Resolution;
setResolution: (resolution: Resolution) => void;
// Prompt // Prompt
prompt: string; prompt: string;
setPrompt: (prompt: string) => void; setPrompt: (prompt: string) => void;
@ -145,7 +149,17 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
setMode: (mode) => set({ mode }), setMode: (mode) => set({ mode }),
model: 'seedance_2.0', 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', aspectRatio: '21:9',
setAspectRatio: (aspectRatio) => set({ aspectRatio, prevAspectRatio: aspectRatio }), setAspectRatio: (aspectRatio) => set({ aspectRatio, prevAspectRatio: aspectRatio }),
@ -162,6 +176,17 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
}, },
prevDuration: 15, 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: '', prompt: '',
setPrompt: (prompt) => set({ prompt }), setPrompt: (prompt) => set({ prompt }),
@ -218,9 +243,43 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
}, },
removeReference: (id) => { removeReference: (id) => {
const state = get(); const state = get();
const ref = state.references.find((r) => r.id === id); const removedRef = state.references.find((r) => r.id === id);
if (ref) URL.revokeObjectURL(ref.previewUrl); if (!removedRef) return;
set({ references: state.references.filter((r) => r.id !== id) }); 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<string, string>(); // 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(`<div>${newEditorHtml}</div>`, '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: () => { clearReferences: () => {
const state = get(); const state = get();
@ -285,10 +344,19 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
? state.references.length > 0 ? state.references.length > 0
: state.firstFrame !== null || state.lastFrame !== null; : state.firstFrame !== null || state.lastFrame !== null;
if (!hasText && !hasFiles) return false; if (!hasText && !hasFiles) return false;
// Audio cannot be sent alone — must have image or video // Audio cannot be the only reference — Seedance API requires image or video alongside
if (state.mode === 'universal' && state.references.length > 0) { if (state.mode === 'universal') {
const hasImageOrVideo = state.references.some((r) => r.type === 'image' || r.type === 'video'); const hasAudioRef = state.references.some((r) => r.type === 'audio');
if (!hasImageOrVideo && !hasText) return false; const hasAudioAsset = (state.assetMentions || []).some((m: Record<string, string>) =>
(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<string, string>) => {
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 // Block submit if any reference is still uploading or failed
if (state.references.some((r) => r.uploading || r.uploadError)) return false; if (state.references.some((r) => r.uploading || r.uploadError)) return false;
@ -355,6 +423,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
prevAspectRatio: '21:9', prevAspectRatio: '21:9',
duration: 15, duration: 15,
prevDuration: 15, prevDuration: 15,
resolution: '720p',
prompt: '', prompt: '',
editorHtml: '', editorHtml: '',
references: [], references: [],

View File

@ -2,6 +2,7 @@ export type CreationMode = 'universal' | 'keyframe';
export type ModelOption = 'seedance_2.0' | 'seedance_2.0_fast'; 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 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 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 GenerationType = 'video' | 'image';
export type UserRole = 'super_admin' | 'team_admin' | 'member'; export type UserRole = 'super_admin' | 'team_admin' | 'member';
@ -44,6 +45,7 @@ export interface GenerationTask {
model: ModelOption; model: ModelOption;
aspectRatio: AspectRatio; aspectRatio: AspectRatio;
duration: Duration; duration: Duration;
resolution: Resolution;
references: ReferenceSnapshot[]; references: ReferenceSnapshot[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
assetMentions: Record<string, any>[]; assetMentions: Record<string, any>[];
@ -67,6 +69,7 @@ export interface BackendTask {
mode: CreationMode; mode: CreationMode;
model: ModelOption; model: ModelOption;
aspect_ratio: string; aspect_ratio: string;
resolution: Resolution;
duration: number; duration: number;
seconds_consumed: number; seconds_consumed: number;
tokens_consumed: number; tokens_consumed: number;
@ -113,6 +116,8 @@ export interface TeamInfo {
token_price_video: number; token_price_video: number;
token_price_fast: number; token_price_fast: number;
token_price_fast_video: number; token_price_fast_video: number;
token_price_1080p: number;
token_price_1080p_video: number;
is_active: boolean; is_active: boolean;
} }
@ -222,6 +227,8 @@ export interface SystemSettings {
base_token_price_video: number; base_token_price_video: number;
base_token_price_fast: number; base_token_price_fast: number;
base_token_price_fast_video: number; base_token_price_fast_video: number;
base_token_price_1080p: number;
base_token_price_1080p_video: number;
announcement: string; announcement: string;
announcement_enabled: boolean; announcement_enabled: boolean;
max_desktop_sessions: number; max_desktop_sessions: number;
@ -407,6 +414,7 @@ export interface AssetVideo {
seconds_consumed: number; seconds_consumed: number;
cost_amount?: number; cost_amount?: number;
aspect_ratio: string; aspect_ratio: string;
resolution: Resolution;
reference_urls?: { url: string; type: string; role: string; label: string; thumb_url?: string }[]; reference_urls?: { url: string; type: string; role: string; label: string; thumb_url?: string }[];
created_at: string; created_at: string;
} }

View File

@ -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']);
});
});

View File

@ -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);
});
});

View File

@ -0,0 +1,136 @@
/**
* 1080P E2E UI
* localhost:5173 + 127.0.0.1:8000
*/
import { test, expect, Page } from '@playwright/test';
const BASE_URL = 'http://localhost:5173';
const API_URL = 'http://127.0.0.1:8000';
const USERNAME = 'admin';
const PASSWORD = 'admin123';
async function login(page: Page) {
const resp = await page.request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
if (!resp.ok()) {
const err = await resp.text();
throw new Error(`Login failed: ${resp.status()} ${err}`);
}
const body = await resp.json();
await page.goto(BASE_URL);
await page.evaluate(({ access, refresh }) => {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
}, { access: body.tokens.access, refresh: body.tokens.refresh });
await page.goto(`${BASE_URL}/app`);
await page.waitForTimeout(1500);
// 关闭公告弹窗
const knowBtn = page.getByRole('button', { name: /我知道了|知道了|关闭/ }).first();
if (await knowBtn.isVisible().catch(() => false)) {
await knowBtn.click();
await page.waitForTimeout(300);
}
}
test.describe.serial('1080P 分辨率支持', () => {
test('默认分辨率显示 720P', async ({ page }) => {
await login(page);
// 找到 Toolbar 里的分辨率按钮label 应显示 720P
const resolutionBtn = page.getByRole('button', { name: '720P', exact: true }).first();
await expect(resolutionBtn).toBeVisible();
});
test('AirDrama 模式下可切换到 1080P', async ({ page }) => {
await login(page);
// 点分辨率按钮展开 dropdown
await page.getByRole('button', { name: '720P', exact: true }).first().click();
await page.waitForTimeout(200);
// 选 1080P
await page.getByText('1080P', { exact: true }).click();
await page.waitForTimeout(300);
// 分辨率按钮应显示 1080P
await expect(page.getByRole('button', { name: '1080P', exact: true }).first()).toBeVisible();
});
test('1080P 下 Fast 模型置灰UI 不可达 Fast+1080P', async ({ page }) => {
await login(page);
// 先切到 1080P
await page.getByRole('button', { name: '720P', exact: true }).first().click();
await page.waitForTimeout(200);
await page.getByText('1080P', { exact: true }).click();
await page.waitForTimeout(300);
// 打开模型 dropdown
await page.getByRole('button', { name: /AirDrama$/, exact: false }).first().click();
await page.waitForTimeout(200);
// Fast 项应包含 "不支持 1080P" 且有 disabled 视觉
const fastItem = page.getByText(/AirDrama Fast.*不支持 1080P/);
await expect(fastItem).toBeVisible();
// 点击 Fast 不应切换Dropdown 的 disabled 阻止了 onSelect
await fastItem.click({ force: true });
await page.waitForTimeout(300);
// 模型应仍是 AirDrama
await expect(page.getByRole('button', { name: /AirDrama$/, exact: false }).first()).toBeVisible();
});
test('Fast 模式下 1080P 置灰UI 不可达 Fast+1080P反向', async ({ page }) => {
await login(page);
// 先确保 resolution 是 720Preset
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 可能未配单价)');
}
});
});

View File

@ -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 `<span data-ref-id="${refId}" data-ref-type="${refType}" class="mention" contenteditable="false"><span style="font-size:0;width:0;overflow:hidden;display:inline">@</span>${label}</span>`;
}
describe('removeReference — 即梦式连续重命名', () => {
beforeEach(() => {
useInputBarStore.getState().reset();
});
describe('图片重命名', () => {
it('删除图片2 后图片3 重命名为图片2references + 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 = '<span>纯文本,没有 mention span</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>/);
});
});
});

View File

@ -0,0 +1,160 @@
/**
* 1080P
*
*
* 1. / setModel/setResolution Fast+1080P
* 2. estimatedTokens
* 3. bug || '720p'
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useInputBarStore } from '../../src/store/inputBar';
// Mock Toast 避免真实 DOM 调用
vi.mock('../../src/components/Toast', () => ({
showToast: vi.fn(),
}));
describe('1080P — Store 分辨率状态', () => {
beforeEach(() => {
useInputBarStore.getState().reset();
});
it('默认分辨率是 720p', () => {
expect(useInputBarStore.getState().resolution).toBe('720p');
});
it('setResolution 能设为 480p / 720p / 1080p', () => {
const { setResolution } = useInputBarStore.getState();
setResolution('480p');
expect(useInputBarStore.getState().resolution).toBe('480p');
setResolution('1080p');
expect(useInputBarStore.getState().resolution).toBe('1080p');
setResolution('720p');
expect(useInputBarStore.getState().resolution).toBe('720p');
});
it('reset 把分辨率恢复为 720p', () => {
const { setResolution, reset } = useInputBarStore.getState();
setResolution('1080p');
reset();
expect(useInputBarStore.getState().resolution).toBe('720p');
});
});
describe('1080P — 双向拦截(原则 1不静默降级', () => {
beforeEach(() => {
useInputBarStore.getState().reset();
});
it('1080P 下切 Fast 模型应被阻止resolution 不变model 也不变', () => {
const { setResolution, setModel } = useInputBarStore.getState();
setResolution('1080p');
setModel('seedance_2.0_fast');
// 拦截成功model 保持原值resolution 不变(不降级为 720p
const state = useInputBarStore.getState();
expect(state.model).toBe('seedance_2.0');
expect(state.resolution).toBe('1080p');
});
it('Fast 模式下切 1080P 分辨率应被阻止model 不变resolution 不变', () => {
const { setModel, setResolution } = useInputBarStore.getState();
setModel('seedance_2.0_fast');
setResolution('1080p');
const state = useInputBarStore.getState();
expect(state.model).toBe('seedance_2.0_fast');
expect(state.resolution).toBe('720p'); // 仍是默认 720p没被改到 1080p
});
it('AirDrama 下切 1080P 正常生效', () => {
const { setResolution } = useInputBarStore.getState();
setResolution('1080p');
expect(useInputBarStore.getState().resolution).toBe('1080p');
});
it('1080P 下切回 AirDrama 正常生效(同模型不拦截)', () => {
const { setModel, setResolution } = useInputBarStore.getState();
setResolution('1080p');
setModel('seedance_2.0');
expect(useInputBarStore.getState().model).toBe('seedance_2.0');
expect(useInputBarStore.getState().resolution).toBe('1080p');
});
it('Fast 下切 480p/720p 正常生效(不是 1080p 不拦截)', () => {
const { setModel, setResolution } = useInputBarStore.getState();
setModel('seedance_2.0_fast');
setResolution('480p');
expect(useInputBarStore.getState().resolution).toBe('480p');
setResolution('720p');
expect(useInputBarStore.getState().resolution).toBe('720p');
});
});
describe('1080P — 官方像素值(与后端 RESOLUTION_MAP 对齐)', () => {
// 这里硬编码官方文档的像素表,作为前端契约测试
// 如果 Toolbar.tsx 的 RESOLUTION_PIXELS 改动,这些测试应该跟着更新
// 对应 backend/utils/billing.py::RESOLUTION_MAP
const EXPECTED_PIXELS = {
'480p': {
'16:9': [864, 496],
'9:16': [496, 864],
'4:3': [752, 560],
'1:1': [640, 640],
'3:4': [560, 752],
'21:9': [992, 432],
},
'720p': {
'16:9': [1280, 720],
'9:16': [720, 1280],
'4:3': [1112, 834],
'1:1': [960, 960],
'3:4': [834, 1112],
'21:9': [1470, 630],
},
'1080p': {
'16:9': [1920, 1080],
'9:16': [1080, 1920],
'4:3': [1664, 1248],
'1:1': [1440, 1440],
'3:4': [1248, 1664],
'21:9': [2206, 946], // 关键:不是 2176×928seedance 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');
});
});