火山 Seedance 2.0 于 2026-04-16 上线 1080P 支持。本次实现前端 UI、
后端校验/计费、数据库迁移,并严格遵守三原则:
1. 禁止兜底/静默降级 — Fast+1080P 组合在 UI/store/serializer/view/计价
五层防御,任一层穿透都 fail loud,不悄悄按 720P 扣费
2. 钱的计算绝对准确 — 前端预估公式与后端 estimate_tokens 完全一致
`(输入时长+输出时长) × 宽 × 高 × fps / 1024`;实际扣费按火山返回
total_tokens × 官方单价;预估端不维护最低 token 修正表
3. 不隐藏 bug — 无 `or '720p'` / `|| '720p'` 兜底;类型严格;异常暴露
## 后端(7 处 + 1 次迁移)
- models.py: QuotaConfig 加 base_token_price_1080p(51)/base_token_price_1080p_video(31);
GenerationRecord.resolution 加 RESOLUTION_CHOICES 约束 + default='720p'
- migrations/0020: 含 RunPython data migration 回填历史 resolution='' → '720p'
- utils/billing.py:
* RESOLUTION_MAP 加 1080P 六种宽高比(21:9 是 2206×946,不是 seedance 1.0 值)
* get_resolution 去掉 tier 默认值,非法组合 raise KeyError 不静默降级
* estimate_tokens 纯官方公式,加 input_video_duration 参数(公式完整)
- utils/airdrama_client.py: create_task 加 resolution 必填参数(无默认值)
- apps/generation/serializers.py:
* VideoGenerateSerializer 加 resolution ChoiceField
* aspect_ratio 改 ChoiceField 显式拒绝 adaptive
* SystemSettingsSerializer 加 2 个 1080P 单价
- apps/generation/views.py:
* _get_token_price 加 resolution 必填参数,Fast+1080P raise ValueError
* _sum_video_duration 累加视频参考时长
* video_generate_view 读 resolution、400 拒绝 Fast+1080P 组合、
传给 get_resolution/estimate_tokens/_get_token_price/create_task/
GenerationRecord.resolution(移除 L450 硬编码 '720p')
* _settle_payment 按 record.resolution 取单价(1080P 结算按 1080P 价)
* _serialize_task + 5 处手工序列化加 resolution 字段(无 `or '720p'`)
- apps/accounts/views.py: team 接口返回 token_price_1080p/_video
## 前端(10 处)
- types/index.ts: Resolution 类型;GenerationTask/BackendTask/Team/
QuotaConfig/AssetVideo 加字段(全部必填,无 optional)
- store/inputBar.ts: resolution state;setModel/setResolution 双向拦截
Fast+1080P 组合,toast 提示引导,不静默降级
- store/generation.ts: addTask/backendToFrontend/reEdit/regenerate 全链路
携带 resolution;mapErrorMessage 改 '今日生成次数或团队余额不足'
- components/Toolbar.tsx:
* 加分辨率选择器 Dropdown(位置:比例和时长之间)
* modelItems/resolutionItems 双向 disabled(Fast 下 1080P 灰 / 1080P 下 Fast 灰)
* estimatedTokens 对齐后端公式(含输入视频时长 + assetMentions 视频时长)
* estimatedCost 按 resolution 选单价(Fast→fast_*、1080p→1080p_*、其他→基础)
* tooltip 明示"实际费用以火山 API 返回的 token 数为准"
- components/Dropdown.tsx: 加 disabled 属性支持
- components/VideoDetailModal.tsx: 重新编辑恢复 resolution
- components/GenerationCard.tsx: 动态显示 task.resolution.toUpperCase()
- pages/SettingsPage.tsx: 加 2 个 1080P 单价输入框(独立分组)
- pages/AdminAssetsPage.tsx / TeamAssetsPage.tsx: 去 || '720p' 兜底
- lib/api.ts: videoApi.generate 参数 resolution 必填
## 测试(47 个用例)
### 后端(28 个)
- tests/test_1080p_billing.py(23): RESOLUTION_MAP 像素、estimate_tokens
公式(含/不含输入视频、不做最低 token 修正)、_get_token_price 六种
组合、Fast+1080P 抛异常、calculate_cost 对齐官方示例 4.97 / 12.39 元
- tests/test_1080p_api.py(5): video_generate_view 拒绝 Fast+1080P (400)
+ 拒绝 adaptive + 拒绝非法 resolution + 默认值兼容 + 合法组合通过
### 前端(19 个)
- test/unit/resolution1080p.test.ts(14): store 状态、双向拦截
(1080P 下切 Fast 被阻止 model 不变、反向同样)、官方像素契约测试、
价格示例对齐(720P 4.97 / 1080P 12.39)
- test/e2e/resolution-1080p.spec.ts(5): 真实浏览器验证默认 720P、
Dropdown 双向置灰、tooltip 明示以火山为准
## 与官方文档对齐
- 参数:resolution (480p/720p/1080p 小写)、ratio、duration、generate_audio
- 像素:来自 docs/API文档/创建视频生成任务API.md Seedance 2.0 & 2.0 fast 列
- 单价:来自 docs/API文档/seedance模型价格.md (46/28/51/31/37/22)
- Fast 不支持 1080P:来自 docs/API文档/Seedance 2.0 1080P.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
10 KiB
Python
180 lines
10 KiB
Python
import uuid
|
||
from django.db import models
|
||
from django.conf import settings
|
||
|
||
|
||
class GenerationRecord(models.Model):
|
||
"""Video generation call record."""
|
||
MODE_CHOICES = [
|
||
('universal', '全能参考'),
|
||
('keyframe', '首尾帧'),
|
||
]
|
||
MODEL_CHOICES = [
|
||
('seedance_2.0', 'AirDrama'),
|
||
('seedance_2.0_fast', 'AirDrama Fast'),
|
||
]
|
||
STATUS_CHOICES = [
|
||
('queued', '排队中'),
|
||
('processing', '生成中'),
|
||
('completed', '已完成'),
|
||
('failed', '失败'),
|
||
]
|
||
RESOLUTION_CHOICES = [
|
||
('480p', '480P'),
|
||
('720p', '720P'),
|
||
('1080p', '1080P'),
|
||
]
|
||
|
||
user = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name='generation_records',
|
||
verbose_name='用户',
|
||
)
|
||
task_id = models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='任务ID')
|
||
ark_task_id = models.CharField(max_length=100, blank=True, default='', verbose_name='火山ARK任务ID')
|
||
prompt = models.TextField(blank=True, verbose_name='提示词')
|
||
mode = models.CharField(max_length=20, choices=MODE_CHOICES, verbose_name='创作模式')
|
||
model = models.CharField(max_length=30, choices=MODEL_CHOICES, verbose_name='模型')
|
||
aspect_ratio = models.CharField(max_length=10, verbose_name='宽高比')
|
||
duration = models.IntegerField(verbose_name='视频时长(秒)')
|
||
seconds_consumed = models.FloatField(default=0, verbose_name='消费秒数')
|
||
# ── 金额计费字段(v0.10.0 新增) ──
|
||
tokens_consumed = models.IntegerField(default=0, verbose_name='消耗tokens')
|
||
cost_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='用户费用(元)')
|
||
base_cost_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='平台成本(元)')
|
||
frozen_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='冻结金额(元)')
|
||
resolution = models.CharField(max_length=10, choices=RESOLUTION_CHOICES, default='720p', verbose_name='分辨率')
|
||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态')
|
||
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
|
||
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='视频缩略图URL')
|
||
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
|
||
raw_error = models.TextField(blank=True, default='', verbose_name='原始错误信息')
|
||
reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息')
|
||
is_favorited = models.BooleanField(default=False, verbose_name='已收藏')
|
||
is_deleted = models.BooleanField(default=False, verbose_name='用户已删除')
|
||
seed = models.BigIntegerField(default=-1, verbose_name='种子值')
|
||
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')
|
||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||
completed_at = models.DateTimeField(null=True, blank=True, verbose_name='完成时间')
|
||
|
||
class Meta:
|
||
verbose_name = '生成记录'
|
||
verbose_name_plural = '生成记录'
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['user', 'created_at']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f'{self.user.username} - {self.task_id}'
|
||
|
||
|
||
class QuotaConfig(models.Model):
|
||
"""Global quota configuration (singleton) — Phase 3: seconds + announcement + anomaly detection."""
|
||
default_daily_seconds_limit = models.IntegerField(default=600, verbose_name='默认每日秒数上限')
|
||
default_monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='默认每月秒数上限')
|
||
announcement = models.TextField(blank=True, default='', verbose_name='系统公告')
|
||
announcement_enabled = models.BooleanField(default=False, verbose_name='启用公告')
|
||
max_desktop_sessions = models.IntegerField(default=1, verbose_name='每用户最大桌面端会话数')
|
||
max_mobile_sessions = models.IntegerField(default=0, verbose_name='每用户最大移动端会话数')
|
||
# ── 异常检测全局默认配置 ──
|
||
anomaly_detection_enabled = models.BooleanField(default=False, verbose_name='异常检测总开关')
|
||
r1_enabled_default = models.BooleanField(default=True, verbose_name='R1 默认开关')
|
||
r2_enabled_default = models.BooleanField(default=True, verbose_name='R2 默认开关')
|
||
r2_window_seconds = models.IntegerField(default=3600, verbose_name='R2 默认时间窗口(秒)')
|
||
r3_enabled_default = models.BooleanField(default=True, verbose_name='R3 默认开关')
|
||
r3_window_seconds = models.IntegerField(default=3600, verbose_name='R3 默认时间窗口(秒)')
|
||
r3_max_count = models.IntegerField(default=10, verbose_name='R3 默认最大登录次数')
|
||
r4_enabled_default = models.BooleanField(default=True, verbose_name='R4 默认开关')
|
||
r4_window_seconds = models.IntegerField(default=3600, verbose_name='R4 默认时间窗口(秒)')
|
||
r4_city_count = models.IntegerField(default=5, verbose_name='R4 默认预期外城市数')
|
||
r5_enabled_default = models.BooleanField(default=True, verbose_name='R5 默认开关')
|
||
r5_days = models.IntegerField(default=7, verbose_name='R5 默认统计天数')
|
||
r5_country_count = models.IntegerField(default=10, verbose_name='R5 默认海外国家数')
|
||
feishu_alert_mobiles = models.CharField(max_length=500, blank=True, default='', verbose_name='飞书告警接收人手机号')
|
||
sms_alert_mobiles = models.CharField(max_length=500, blank=True, default='', verbose_name='短信告警手机号(预留)')
|
||
alert_cooldown_seconds = models.IntegerField(default=1800, verbose_name='告警冷却时间(秒)')
|
||
# ── 计费全局配置(v0.10.0 新增) ──
|
||
default_daily_generation_limit = models.IntegerField(default=50, verbose_name='默认每日生成次数')
|
||
default_monthly_generation_limit = models.IntegerField(default=1500, verbose_name='默认每月生成次数')
|
||
base_token_price = models.DecimalField(max_digits=10, decimal_places=2, default=46, 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_video = models.DecimalField(max_digits=10, decimal_places=2, default=22, verbose_name='Fast单价-含视频(元/百万tokens)')
|
||
base_token_price_1080p = models.DecimalField(max_digits=10, decimal_places=2, default=51, verbose_name='1080P单价-不含视频(元/百万tokens)')
|
||
base_token_price_1080p_video = models.DecimalField(max_digits=10, decimal_places=2, default=31, verbose_name='1080P单价-含视频(元/百万tokens)')
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = '系统配置'
|
||
verbose_name_plural = '系统配置'
|
||
|
||
def save(self, *args, **kwargs):
|
||
self.pk = 1
|
||
super().save(*args, **kwargs)
|
||
|
||
def __str__(self):
|
||
return f'全局配额: {self.default_daily_seconds_limit}s/日, {self.default_monthly_seconds_limit}s/月'
|
||
|
||
|
||
class AssetGroup(models.Model):
|
||
"""虚拟人像素材组 — 一个角色对应一个组。"""
|
||
team = models.ForeignKey(
|
||
'accounts.Team', on_delete=models.CASCADE,
|
||
related_name='asset_groups', verbose_name='所属团队',
|
||
)
|
||
remote_group_id = models.CharField(max_length=100, default='', verbose_name='火山Group ID')
|
||
name = models.CharField(max_length=100, default='', verbose_name='角色名')
|
||
description = models.CharField(max_length=300, blank=True, default='', verbose_name='描述')
|
||
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='缩略图URL')
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||
null=True, blank=True, related_name='created_asset_groups', verbose_name='创建人',
|
||
)
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||
|
||
class Meta:
|
||
verbose_name = '素材组'
|
||
verbose_name_plural = '素材组'
|
||
ordering = ['-created_at']
|
||
|
||
def __str__(self):
|
||
return f'{self.team.name} - {self.name}'
|
||
|
||
|
||
class Asset(models.Model):
|
||
"""虚拟人像素材 — 图片/视频/音频。"""
|
||
STATUS_CHOICES = [
|
||
('processing', '处理中'),
|
||
('active', '可用'),
|
||
('failed', '失败'),
|
||
]
|
||
ASSET_TYPE_CHOICES = [
|
||
('Image', '图像'),
|
||
('Video', '视频'),
|
||
('Audio', '音频'),
|
||
]
|
||
|
||
group = models.ForeignKey(
|
||
AssetGroup, on_delete=models.CASCADE,
|
||
related_name='assets', verbose_name='所属素材组',
|
||
)
|
||
remote_asset_id = models.CharField(max_length=100, default='', verbose_name='火山Asset ID')
|
||
name = models.CharField(max_length=100, default='', verbose_name='素材名称')
|
||
url = models.CharField(max_length=1000, blank=True, default='', verbose_name='素材URL')
|
||
asset_type = models.CharField(max_length=10, choices=ASSET_TYPE_CHOICES, default='Image', verbose_name='素材类型')
|
||
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='缩略图URL')
|
||
duration = models.FloatField(null=True, default=None, verbose_name='时长(秒)')
|
||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='processing', verbose_name='状态')
|
||
error_message = models.CharField(max_length=500, blank=True, default='', verbose_name='错误信息')
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||
|
||
class Meta:
|
||
verbose_name = '素材'
|
||
verbose_name_plural = '素材'
|
||
ordering = ['-created_at']
|
||
|
||
def __str__(self):
|
||
return f'{self.group.name} - {self.name}'
|