后端: - 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>
297 lines
14 KiB
Python
297 lines
14 KiB
Python
"""自由创作·人物素材库端点(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)})
|