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
+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)})