feat(ai): 自由创作视频生成全量移植(/free-create)——Seedance 文/图生视频+按时长计费+人物素材库

后端:
- free_video.py: 提交/轮询/收藏/软删全链路,复用 AITask(新增 is_deleted/is_favorited, migration 0022)
- video_pricing.py: 按时长 token 计费(×1.10 buffer+clamp); video_errors.py 错误归一; media_probe.py 时长探测
- catalog/volcano: Seedance free-video 模型接入+seed(migration 0023)
- 素材库: FreeAssetGroup/FreeAsset(火山 Assets API 引用登记, migration 0009)+ 上传/轮询/删除接口
- settings: FREE_VIDEO_MAX_CONCURRENT 团队并发闸(默认3); CELERY_TASK_ALWAYS_EAGER 本地联调开关(生产恒关)
- 测试: test_free_video.py 新增; billing/products/projects tests 配套调整

前端:
- /free-create 页面+components/free-create/ 全套(输入栏/@mention 素材引用/生成卡/视频详情弹窗/素材库弹窗)
- api.ts/types.ts 扩展 free-video 与 free-assets 接口; 路由/侧边栏入口接入

bug/: 测试清单 (11)(12) 与截图归档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-06 10:58:36 +08:00
co-authored by Claude Fable 5
parent 94202e7bce
commit c5f7eb2acc
45 changed files with 5186 additions and 21 deletions
@@ -0,0 +1,54 @@
# Generated by Django 5.1.15 on 2026-07-02 07:23
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0007_team_monthly_credit_limit"),
("ai", "0021_rename_ai_aitask_team_read_idx_ai_aitask_team_id_d668d5_idx"),
("projects", "0006_migrate_storyboard_to_shots"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="aitask",
name="is_deleted",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="aitask",
name="is_favorited",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="aitask",
name="task_type",
field=models.CharField(
choices=[
("script_generation", "Script Generation"),
("script_optimization", "Script Optimization"),
("entity_extraction", "Entity Extraction"),
("product_image", "Product Image"),
("person_image", "Person Image"),
("scene_image", "Scene Image"),
("storyboard", "Storyboard"),
("video_segment", "Video Segment"),
("voiceover", "Voiceover"),
("export", "Export"),
("free_video", "Free Video"),
],
max_length=48,
),
),
migrations.AddIndex(
model_name="aitask",
index=models.Index(
fields=["team", "task_type", "-created_at"],
name="ai_aitask_team_id_6a4627_idx",
),
),
]
@@ -0,0 +1,119 @@
"""Seed 自由创作视频三档模型(Seedance 2.0 标准/Fast/Mini)+ token 定价表。
- 定价存 ModelConfig.metadata["pricing"](元/百万tokens,按 分辨率档 × 是否含视频参考 取价),
与 apps/ai/catalog.py 保持一致;数字来源火山 Seedance 2.0 官方价目。
- 标准档已存在(catalog bootstrap 种的),只 merge pricing/resolutions 进 metadata,不覆盖其它键;
fast/mini 新建,is_default=False 且 created_at 晚于现有视频模型 →
get_default_model(VIDEO) 仍取原默认,pipeline 视频链路零回归。
- 幂等:可重复 apply。
"""
from django.db import migrations
STANDARD_PRICING = {
"unit": "cny_per_million_tokens",
"default": {"no_ref_video": 46, "with_ref_video": 28},
"1080p": {"no_ref_video": 51, "with_ref_video": 31},
"4k": {"no_ref_video": 26, "with_ref_video": 16},
}
NEW_MODELS = [
# name, display_name, pricing
(
"doubao-seedance-2-0-fast-260128",
"Seedance-2.0-Fast",
{"unit": "cny_per_million_tokens", "default": {"no_ref_video": 37, "with_ref_video": 22}},
),
(
"doubao-seedance-2-0-mini-260615",
"Seedance-2.0-Mini",
{"unit": "cny_per_million_tokens", "default": {"no_ref_video": 23, "with_ref_video": 14}},
),
]
COMMON_METADATA = {
"audio": "optional",
"modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"],
"durations": list(range(4, 16)),
"resolutions": ["480p", "720p"],
"watermark": False,
"source": "jimeng-clone/backend/utils/airdrama_client.py",
}
def seed(apps, schema_editor):
ModelProvider = apps.get_model("ai", "ModelProvider")
ModelConfig = apps.get_model("ai", "ModelConfig")
provider, _ = ModelProvider.objects.get_or_create(
name="volcengine",
defaults={
"display_name": "火山引擎(豆包)",
"status": "active",
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
},
)
# 标准档:merge pricing + 扩分辨率,不动 status/is_default/unit_price/其它 metadata
standard = ModelConfig.objects.filter(
provider=provider, name="doubao-seedance-2-0-260128", capability="video"
).first()
if standard:
meta = dict(standard.metadata or {})
meta["pricing"] = STANDARD_PRICING
meta["resolutions"] = ["480p", "720p", "1080p", "4k"]
standard.metadata = meta
standard.save(update_fields=["metadata"])
else:
meta = dict(COMMON_METADATA)
meta["pricing"] = STANDARD_PRICING
meta["resolutions"] = ["480p", "720p", "1080p", "4k"]
ModelConfig.objects.create(
provider=provider,
name="doubao-seedance-2-0-260128",
capability="video",
display_name="Seedance-2.0",
endpoint="contents/generations/tasks",
status="active",
metadata=meta,
)
for name, display, pricing in NEW_MODELS:
meta = dict(COMMON_METADATA)
meta["pricing"] = pricing
obj, created = ModelConfig.objects.get_or_create(
provider=provider,
name=name,
capability="video",
defaults={
"display_name": display,
"endpoint": "contents/generations/tasks",
"status": "active",
"metadata": meta,
},
)
if not created:
existing = dict(obj.metadata or {})
existing.setdefault("resolutions", meta["resolutions"])
existing.setdefault("durations", meta["durations"])
existing["pricing"] = pricing
obj.metadata = existing
if obj.status != "active":
obj.status = "active"
obj.save(update_fields=["metadata", "status"])
else:
obj.save(update_fields=["metadata"])
def unseed(apps, schema_editor):
# 保守反向:只停用本迁移新建的两档,不删数据、不动标准档 metadata
ModelConfig = apps.get_model("ai", "ModelConfig")
ModelConfig.objects.filter(
provider__name="volcengine",
capability="video",
name__in=[n for n, _, _ in NEW_MODELS],
).update(status="disabled")
class Migration(migrations.Migration):
dependencies = [("ai", "0022_aitask_is_deleted_aitask_is_favorited_and_more")]
operations = [migrations.RunPython(seed, unseed)]