Files

352 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""自由创作·人物素材库端点(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
DEFAULT_GROUP_NAME = "默认素材"
DEFAULT_GROUP_MARKER = "__airshelf_free_asset_default__"
OTHER_REMOTE_ASSET_MESSAGE = "该素材属于其他火山账号,请重新上传后再编辑"
OTHER_REMOTE_GROUP_MESSAGE = "该素材组属于其他火山账号,请新建素材组后重新上传"
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)
def _create_default_group(*, team, user) -> FreeAssetGroup:
remote_group_id = assets_client.create_asset_group(DEFAULT_GROUP_NAME, DEFAULT_GROUP_MARKER)
return FreeAssetGroup.objects.create(
team=team,
created_by=user,
name=DEFAULT_GROUP_NAME,
description=DEFAULT_GROUP_MARKER,
remote_group_id=remote_group_id,
)
def _get_or_create_default_group(*, team, user) -> FreeAssetGroup:
group = FreeAssetGroup.objects.filter(team=team, is_deleted=False, description=DEFAULT_GROUP_MARKER).first()
if group is not None:
try:
assets_client.get_asset_group(group.remote_group_id)
except AssetsAPIError as exc:
if not assets_client.is_group_not_found_code(exc.code):
raise
return _create_default_group(team=team, user=user)
return group
return _create_default_group(team=team, user=user)
def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only: bool = False) -> Response:
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 image_only:
return Response({"detail": "快捷上传仅支持图片 JPG/PNG/WebP"}, 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 duration_in_ref_range, 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 duration_in_ref_range(duration):
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:
if assets_client.is_group_not_found_code(exc.code):
return Response({"detail": OTHER_REMOTE_GROUP_MESSAGE}, status=status.HTTP_400_BAD_REQUEST)
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({"group": _serialize_group(group), "asset": _serialize_asset(fa)}, status=status.HTTP_201_CREATED)
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 assets_client.is_group_not_found_code(exc.code):
return Response({"detail": OTHER_REMOTE_GROUP_MESSAGE}, status=status.HTTP_400_BAD_REQUEST)
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 not assets_client.is_group_not_found_code(exc.code):
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)
response = _upload_asset_to_group(request, team=team, group=group)
if response.status_code == status.HTTP_201_CREATED:
response.data.pop("group", None)
return response
class FreeAssetQuickUploadView(APIView):
"""POST /free-assets/quick-upload/ —— 上传图片到默认组,减少首尾帧用户建组步骤。"""
parser_classes = [MultiPartParser, FormParser]
def post(self, request):
if not assets_client.is_enabled():
return _assets_api_unavailable()
team = get_current_team(request.user)
try:
group = _get_or_create_default_group(team=team, user=request.user)
except AssetsAPIError as exc:
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
return _upload_asset_to_group(request, team=team, group=group, image_only=True)
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 assets_client.is_asset_not_found_code(exc.code):
return Response({"detail": OTHER_REMOTE_ASSET_MESSAGE}, status=status.HTTP_400_BAD_REQUEST)
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 not assets_client.is_asset_not_found_code(exc.code):
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)})