"""自由创作·人物素材库端点(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 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: 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//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//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)})