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
+51 -1
View File
@@ -59,7 +59,11 @@ def _get_service():
Credentials(ak, sk, SERVICE, REGION),
10, 30,
)
actions = ["CreateAssetGroup", "CreateAsset", "ListAssetGroups", "ListAssets", "GetAsset", "DeleteAsset"]
actions = [
"CreateAssetGroup", "CreateAsset", "ListAssetGroups", "ListAssets", "GetAsset", "DeleteAsset",
# 自由创作素材库(组/素材全生命周期)
"GetAssetGroup", "UpdateAssetGroup", "UpdateAsset", "DeleteAssetGroup",
]
api_info = {a: ApiInfo("POST", "/", {"Action": a, "Version": API_VERSION}, {}, {}) for a in actions}
return Service(service_info, api_info)
@@ -117,3 +121,49 @@ def list_asset_groups(page: int = 1, page_size: int = 20, name: str | None = Non
{"Filter": filter_dict, "PageNumber": page, "PageSize": page_size, "ProjectName": _project()},
)
return result.get("Items", []), result.get("TotalCount", 0)
def list_assets(group_ids: list | None = None, status: str | None = None,
name: str | None = None, page: int = 1, page_size: int = 20) -> tuple:
"""列组内素材。返回 (items, total_count)。"""
filter_dict: dict = {"GroupType": "AIGC"}
if group_ids:
filter_dict["GroupIds"] = group_ids
if status:
filter_dict["Statuses"] = [status]
if name:
filter_dict["Name"] = name
result = _do_request(
"ListAssets",
{"Filter": filter_dict, "PageNumber": page, "PageSize": page_size, "ProjectName": _project()},
)
return result.get("Items", []), result.get("TotalCount", 0)
def get_asset_group(group_id: str) -> dict:
return _do_request("GetAssetGroup", {"Id": group_id, "ProjectName": _project()})
def update_asset_group(group_id: str, name: str | None = None, description: str | None = None) -> None:
body: dict = {"Id": group_id, "ProjectName": _project()}
if name is not None:
body["Name"] = name
if description is not None:
body["Description"] = description
_do_request("UpdateAssetGroup", body)
def update_asset(asset_id: str, name: str | None = None) -> None:
body: dict = {"Id": asset_id, "ProjectName": _project()}
if name is not None:
body["Name"] = name
_do_request("UpdateAsset", body)
def delete_asset(asset_id: str) -> None:
_do_request("DeleteAsset", {"Id": asset_id, "ProjectName": _project()})
def delete_asset_group(group_id: str) -> None:
"""删组(远程级联删组内素材)。"""
_do_request("DeleteAssetGroup", {"Id": group_id, "ProjectName": _project()})
+296
View File
@@ -0,0 +1,296 @@
"""自由创作·人物素材库端点(FreeAssetGroup / FreeAsset ←→ 火山 Assets API)。
移植自 jimeng-clone 素材库:建组=火山 CreateAssetGroup+本地记录;传素材=TOS 上传→CreateAsset(URL)
→FreeAsset(processing);状态轮询=GetAsset 刷新 active/failed;删除=远程 NotFound 幂等继续清本地。
生成时以 asset://{remote_asset_id} 引用(见 apps/ai/free_video.build_content_items)。
"""
import logging
import tempfile
import uuid
from io import BytesIO
from pathlib import Path
from rest_framework import status
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.common.api import get_current_team
from . import assets_client
from .assets_client import AssetsAPIError
from .models import FreeAsset, FreeAssetGroup
from .storage import TosStorage
logger = logging.getLogger(__name__)
_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
_VIDEO_TYPES = {"video/mp4": ".mp4", "video/quicktime": ".mov"}
_AUDIO_TYPES = {"audio/mpeg": ".mp3", "audio/wav": ".wav", "audio/x-wav": ".wav", "audio/wave": ".wav"}
_IMAGE_MAX = 30 * 1024 * 1024
_VIDEO_MAX = 50 * 1024 * 1024
_AUDIO_MAX = 15 * 1024 * 1024
def _serialize_asset(fa: FreeAsset) -> dict:
return {
"id": str(fa.id),
"name": fa.name,
"url": fa.url,
"type": fa.asset_type.lower(),
"thumb_url": fa.thumbnail_url or (fa.url if fa.asset_type == FreeAsset.Type.IMAGE else ""),
"duration": fa.duration,
"status": fa.status,
"error_message": fa.error_message,
"created_at": fa.created_at.isoformat(),
}
def _serialize_group(group: FreeAssetGroup, asset_count: int | None = None) -> dict:
return {
"id": str(group.id),
"name": group.name,
"description": group.description,
"thumbnail_url": group.thumbnail_url,
"asset_count": asset_count if asset_count is not None else group.assets.count(),
"created_at": group.created_at.isoformat(),
}
def _assets_api_unavailable() -> Response:
return Response({"detail": "素材库服务未配置,请联系管理员"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
class FreeAssetGroupListView(APIView):
"""GET 列素材组 / POST 建组。"""
def get(self, request):
team = get_current_team(request.user)
groups = FreeAssetGroup.objects.filter(team=team, is_deleted=False).order_by("-created_at")
counts = {str(g.id): g.assets.count() for g in groups}
return Response({"results": [_serialize_group(g, counts[str(g.id)]) for g in groups]})
def post(self, request):
if not assets_client.is_enabled():
return _assets_api_unavailable()
team = get_current_team(request.user)
name = str(request.data.get("name") or "").strip()
description = str(request.data.get("description") or "").strip()
if not name:
return Response({"detail": "素材组名称不能为空"}, status=status.HTTP_400_BAD_REQUEST)
try:
remote_group_id = assets_client.create_asset_group(name, description)
except AssetsAPIError as exc:
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
group = FreeAssetGroup.objects.create(
team=team, created_by=request.user, name=name, description=description, remote_group_id=remote_group_id
)
return Response({"group": _serialize_group(group, 0)}, status=status.HTTP_201_CREATED)
class FreeAssetGroupDetailView(APIView):
"""GET 组详情+素材列表 / PATCH 改名 / DELETE 删组(远程级联,NotFound 幂等清本地)。"""
def _get_group(self, request, group_id) -> FreeAssetGroup | None:
team = get_current_team(request.user)
return FreeAssetGroup.objects.filter(id=group_id, team=team, is_deleted=False).first()
def get(self, request, group_id):
group = self._get_group(request, group_id)
if group is None:
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
assets = list(group.assets.order_by("-created_at"))
return Response({"group": _serialize_group(group, len(assets)), "assets": [_serialize_asset(a) for a in assets]})
def patch(self, request, group_id):
group = self._get_group(request, group_id)
if group is None:
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
name = request.data.get("name")
description = request.data.get("description")
update_fields = ["updated_at"]
if name is not None and str(name).strip():
group.name = str(name).strip()
update_fields.append("name")
if description is not None:
group.description = str(description)
update_fields.append("description")
if len(update_fields) == 1:
return Response({"group": _serialize_group(group)})
try:
assets_client.update_asset_group(group.remote_group_id, name=group.name, description=group.description)
except AssetsAPIError as exc:
if exc.code != "NotFound":
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
group.save(update_fields=update_fields)
return Response({"group": _serialize_group(group)})
def delete(self, request, group_id):
group = self._get_group(request, group_id)
if group is None:
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
try:
assets_client.delete_asset_group(group.remote_group_id)
except AssetsAPIError as exc:
# 远程已不存在 → 幂等继续清本地;其它错误如实反馈
if exc.code != "NotFound":
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
group.is_deleted = True
group.save(update_fields=["is_deleted", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
class FreeAssetUploadView(APIView):
"""POST /free-groups/<id>/assets/ —— 上传素材进组:TOS → 火山 CreateAsset → FreeAsset(processing)。"""
parser_classes = [MultiPartParser, FormParser]
def post(self, request, group_id):
if not assets_client.is_enabled():
return _assets_api_unavailable()
team = get_current_team(request.user)
group = FreeAssetGroup.objects.filter(id=group_id, team=team, is_deleted=False).first()
if group is None:
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
upload = request.FILES.get("file")
if upload is None:
return Response({"detail": "缺少文件"}, status=status.HTTP_400_BAD_REQUEST)
content_type = (upload.content_type or "").lower()
size = upload.size or 0
if content_type in _IMAGE_TYPES:
kind, suffix, asset_type = "image", _IMAGE_TYPES[content_type], FreeAsset.Type.IMAGE
if size > _IMAGE_MAX:
return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST)
elif content_type in _VIDEO_TYPES:
kind, suffix, asset_type = "video", _VIDEO_TYPES[content_type], FreeAsset.Type.VIDEO
if size > _VIDEO_MAX:
return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST)
elif content_type in _AUDIO_TYPES:
kind, suffix, asset_type = "audio", _AUDIO_TYPES[content_type], FreeAsset.Type.AUDIO
if size > _AUDIO_MAX:
return Response({"detail": "音频大小不能超过 15MB"}, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
{"detail": "不支持的文件格式(图片 JPG/PNG/WebP,视频 MP4/MOV,音频 MP3/WAV)"},
status=status.HTTP_400_BAD_REQUEST,
)
raw = upload.read()
duration = None
thumb_url = ""
storage = TosStorage()
file_id = uuid.uuid4()
if kind != "image":
from apps.ai.media_probe import extract_video_poster, probe_duration
with tempfile.TemporaryDirectory(prefix="airshelf-fc-lib-") as tmp:
tmp_path = Path(tmp) / f"in{suffix}"
tmp_path.write_bytes(raw)
duration = probe_duration(str(tmp_path))
if duration is None:
return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
if not (2 <= duration <= 15):
label = "视频" if kind == "video" else "音频"
return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST)
if kind == "video":
poster = extract_video_poster(str(tmp_path))
if poster:
poster_key = f"teams/{team.id}/free-create/library/{file_id}-poster.jpg"
storage.upload_fileobj(fileobj=BytesIO(poster), object_key=poster_key, content_type="image/jpeg")
thumb_url = storage.public_url(object_key=poster_key)
object_key = f"teams/{team.id}/free-create/library/{file_id}{suffix}"
stored = storage.upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type)
url = storage.public_url(object_key=stored.object_key)
name = str(request.data.get("name") or "").strip() or (upload.name or f"素材{suffix}")
name = name[:128]
try:
remote_asset_id = assets_client.create_asset(
group.remote_group_id, url, name=name, asset_type=asset_type
)
except AssetsAPIError as exc:
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
fa = FreeAsset.objects.create(
group=group,
remote_asset_id=remote_asset_id,
name=name,
url=url,
asset_type=asset_type,
thumbnail_url=thumb_url or (url if kind == "image" else ""),
duration=duration,
status=FreeAsset.Status.PROCESSING,
)
if not group.thumbnail_url and fa.thumbnail_url:
group.thumbnail_url = fa.thumbnail_url
group.save(update_fields=["thumbnail_url", "updated_at"])
return Response({"asset": _serialize_asset(fa)}, status=status.HTTP_201_CREATED)
class FreeAssetDetailView(APIView):
"""PATCH 改名 / DELETE 删素材(远程 NotFound 幂等清本地)。"""
def _get_asset(self, request, asset_id) -> FreeAsset | None:
team = get_current_team(request.user)
return FreeAsset.objects.filter(id=asset_id, group__team=team, group__is_deleted=False).select_related("group").first()
def patch(self, request, asset_id):
fa = self._get_asset(request, asset_id)
if fa is None:
return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND)
name = str(request.data.get("name") or "").strip()
if not name:
return Response({"detail": "素材名称不能为空"}, status=status.HTTP_400_BAD_REQUEST)
try:
assets_client.update_asset(fa.remote_asset_id, name=name)
except AssetsAPIError as exc:
if exc.code != "NotFound":
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
fa.name = name[:128]
fa.save(update_fields=["name", "updated_at"])
return Response({"asset": _serialize_asset(fa)})
def delete(self, request, asset_id):
fa = self._get_asset(request, asset_id)
if fa is None:
return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND)
try:
assets_client.delete_asset(fa.remote_asset_id)
except AssetsAPIError as exc:
if exc.code != "NotFound":
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
fa.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class FreeAssetPollView(APIView):
"""POST /free-assets/<id>/poll/ —— 查火山刷新审核状态(processing → active/failed)。"""
def post(self, request, asset_id):
team = get_current_team(request.user)
fa = FreeAsset.objects.filter(id=asset_id, group__team=team, group__is_deleted=False).first()
if fa is None:
return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND)
if fa.status == FreeAsset.Status.PROCESSING:
try:
result = assets_client.get_asset(fa.remote_asset_id)
remote_status = str(result.get("Status") or "")
if remote_status == "Active":
fa.status = FreeAsset.Status.ACTIVE
fa.url = result.get("Url") or fa.url
fa.save(update_fields=["status", "url", "updated_at"])
elif remote_status == "Failed":
fa.status = FreeAsset.Status.FAILED
fa.error_message = str(result.get("ErrorMessage") or "素材审核未通过")
fa.save(update_fields=["status", "error_message", "updated_at"])
except AssetsAPIError as exc:
if exc.code == "NotFound":
fa.status = FreeAsset.Status.FAILED
fa.error_message = "素材在远程已不存在"
fa.save(update_fields=["status", "error_message", "updated_at"])
else:
logger.warning("free asset %s poll failed: %s", asset_id, exc)
return Response({"asset": _serialize_asset(fa)})
@@ -0,0 +1,135 @@
# Generated by Django 5.1.15 on 2026-07-02 07:23
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0007_team_monthly_credit_limit"),
("assets", "0008_backfill_workbench_in_library"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="FreeAssetGroup",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=128)),
("description", models.TextField(blank=True)),
("remote_group_id", models.CharField(max_length=128)),
("thumbnail_url", models.URLField(blank=True)),
("is_deleted", models.BooleanField(default=False)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="FreeAsset",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("remote_asset_id", models.CharField(max_length=128)),
("name", models.CharField(blank=True, max_length=128)),
("url", models.URLField(blank=True, max_length=1024)),
(
"asset_type",
models.CharField(
choices=[
("Image", "Image"),
("Video", "Video"),
("Audio", "Audio"),
],
default="Image",
max_length=16,
),
),
("thumbnail_url", models.URLField(blank=True, max_length=1024)),
("duration", models.FloatField(blank=True, null=True)),
(
"status",
models.CharField(
choices=[
("processing", "Processing"),
("active", "Active"),
("failed", "Failed"),
],
default="processing",
max_length=16,
),
),
("error_message", models.TextField(blank=True)),
(
"group",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="assets",
to="assets.freeassetgroup",
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="freeassetgroup",
index=models.Index(
fields=["team", "-created_at"], name="assets_free_team_id_c0cbb2_idx"
),
),
migrations.AddIndex(
model_name="freeasset",
index=models.Index(
fields=["group", "-created_at"], name="assets_free_group_i_b2a9ec_idx"
),
),
migrations.AddIndex(
model_name="freeasset",
index=models.Index(
fields=["remote_asset_id"], name="assets_free_remote__49909d_idx"
),
),
]
+58
View File
@@ -178,3 +178,61 @@ class AssetUsage(TimeStampedModel):
usage_type = models.CharField(max_length=64)
context = models.JSONField(default=dict, blank=True)
class FreeAssetGroup(TeamOwnedModel):
"""自由创作·人物素材组(一角色一组)。
本质是「火山 Assets API 引用登记表」:组内素材登记到火山素材库拿 remote_asset_id,
生成时以 asset://{remote_asset_id} 引用(免重复上传 + 火山侧预审)。
与 AssetReviewGroup(一团队一审核组)职责不同,与资产库 Asset(TOS 文件资产)也不同,独立建表。
"""
name = models.CharField(max_length=128)
description = models.TextField(blank=True)
remote_group_id = models.CharField(max_length=128)
thumbnail_url = models.URLField(blank=True)
is_deleted = models.BooleanField(default=False)
class Meta:
indexes = [
models.Index(fields=["team", "-created_at"]),
]
ordering = ["-created_at"]
def __str__(self) -> str:
return f"free-group:{self.name}"
class FreeAsset(TimeStampedModel):
"""自由创作·素材组内单素材(火山 Assets API 登记项)。"""
class Type(models.TextChoices):
IMAGE = "Image", "Image"
VIDEO = "Video", "Video"
AUDIO = "Audio", "Audio"
class Status(models.TextChoices):
PROCESSING = "processing", "Processing" # 火山侧处理/审核中
ACTIVE = "active", "Active" # 可用,可 asset:// 引用
FAILED = "failed", "Failed" # 审核/处理失败
group = models.ForeignKey(FreeAssetGroup, on_delete=models.CASCADE, related_name="assets")
remote_asset_id = models.CharField(max_length=128)
name = models.CharField(max_length=128, blank=True)
url = models.URLField(max_length=1024, blank=True) # TOS 公读直链(火山从这里拉源文件)
asset_type = models.CharField(max_length=16, choices=Type.choices, default=Type.IMAGE)
thumbnail_url = models.URLField(max_length=1024, blank=True)
duration = models.FloatField(null=True, blank=True) # 视频/音频时长(秒)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PROCESSING)
error_message = models.TextField(blank=True)
class Meta:
indexes = [
models.Index(fields=["group", "-created_at"]),
models.Index(fields=["remote_asset_id"]),
]
ordering = ["-created_at"]
def __str__(self) -> str:
return f"free-asset:{self.name or self.remote_asset_id}"
+13
View File
@@ -1,6 +1,13 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .free_library import (
FreeAssetDetailView,
FreeAssetGroupDetailView,
FreeAssetGroupListView,
FreeAssetPollView,
FreeAssetUploadView,
)
from .views import AssetUploadView, AssetViewSet
router = DefaultRouter()
@@ -8,4 +15,10 @@ router.register("", AssetViewSet, basename="asset")
urlpatterns = [
path("upload/", AssetUploadView.as_view(), name="asset-upload"),
# 自由创作·人物素材库(火山 Assets API 引用登记)。必须在 router 通配之前注册。
path("free-groups/", FreeAssetGroupListView.as_view(), name="free-asset-group-list"),
path("free-groups/<uuid:group_id>/", FreeAssetGroupDetailView.as_view(), name="free-asset-group-detail"),
path("free-groups/<uuid:group_id>/assets/", FreeAssetUploadView.as_view(), name="free-asset-upload"),
path("free-assets/<uuid:asset_id>/", FreeAssetDetailView.as_view(), name="free-asset-detail"),
path("free-assets/<uuid:asset_id>/poll/", FreeAssetPollView.as_view(), name="free-asset-poll"),
] + router.urls