fix: 修复自由创作首尾帧上传人脸图不通过问题

This commit is contained in:
hh
2026-07-09 11:39:54 +08:00
parent 5c9e62a165
commit a82f90d0e0
11 changed files with 357 additions and 100 deletions
+9 -2
View File
@@ -165,7 +165,8 @@ def build_content_items(*, team, prompt: str, mode: str, references: list) -> di
source = str(ref.get("source") or "upload")
duration = float(ref.get("duration") or 0)
dedupe_key = url or f"{source}:{ref.get('asset_id') or ref.get('group_id')}"
dedupe_base = url or f"{source}:{ref.get('asset_id') or ref.get('group_id')}"
dedupe_key = f"{role}:{dedupe_base}" if mode == "keyframe" else dedupe_base
if dedupe_key in seen_urls:
continue
seen_urls.add(dedupe_key)
@@ -197,7 +198,13 @@ def build_content_items(*, team, prompt: str, mode: str, references: list) -> di
raise ValueError(f"素材「{label or fa.name}」尚未就绪,请稍后重试")
resolved_url = f"asset://{_normalize_remote_asset_id(fa.remote_asset_id)}"
kind = {"Video": "video", "Audio": "audio"}.get(fa.asset_type, "image")
asset_type = _push(kind, resolved_url, "reference_video" if kind == "video" else ("reference_audio" if kind == "audio" else "reference_image"), fa.duration or 0.0)
if mode == "keyframe":
if kind != "image":
raise ValueError("首尾帧模式仅支持图片素材")
effective_role = role if role in {"first_frame", "last_frame"} else "first_frame"
else:
effective_role = "reference_video" if kind == "video" else ("reference_audio" if kind == "audio" else "reference_image")
asset_type = _push(kind, resolved_url, effective_role, fa.duration or 0.0)
if label and label not in label_to_placeholder:
label_to_placeholder[label] = _placeholder_for(asset_type)
continue
+46
View File
@@ -10,6 +10,7 @@ from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.assets.models import FreeAsset, FreeAssetGroup
from apps.ai.free_video import (
build_content_items,
finalize_free_video,
@@ -113,6 +114,51 @@ class BuildContentItemsTests(TestCase):
built = build_content_items(team=self.team, prompt="p", mode="keyframe", references=refs)
self.assertEqual([i["role"] for i in built["content_items"]], ["first_frame", "last_frame"])
def test_keyframe_library_image_keeps_role_and_uses_asset_url(self):
group = FreeAssetGroup.objects.create(team=self.team, name="角色", remote_group_id="Group-1")
first = FreeAsset.objects.create(
group=group,
name="",
url="http://x/first.png",
remote_asset_id="Asset-FIRST",
asset_type=FreeAsset.Type.IMAGE,
status=FreeAsset.Status.ACTIVE,
)
last = FreeAsset.objects.create(
group=group,
name="",
url="http://x/last.png",
remote_asset_id="asset-last",
asset_type=FreeAsset.Type.IMAGE,
status=FreeAsset.Status.ACTIVE,
)
refs = [
{"url": first.url, "type": "image", "role": "first_frame", "asset_id": str(first.id), "source": "library"},
{"url": last.url, "type": "image", "role": "last_frame", "asset_id": str(last.id), "source": "library"},
]
built = build_content_items(team=self.team, prompt="p", mode="keyframe", references=refs)
self.assertEqual([i["role"] for i in built["content_items"]], ["first_frame", "last_frame"])
self.assertEqual(
[i["image_url"]["url"] for i in built["content_items"]],
["asset://asset-FIRST", "asset://asset-last"],
)
def test_keyframe_library_video_rejected_even_if_ref_claims_image(self):
group = FreeAssetGroup.objects.create(team=self.team, name="角色", remote_group_id="Group-1")
asset = FreeAsset.objects.create(
group=group,
name="视频",
url="http://x/v.mp4",
remote_asset_id="Asset-VIDEO",
asset_type=FreeAsset.Type.VIDEO,
status=FreeAsset.Status.ACTIVE,
)
refs = [
{"url": asset.url, "type": "image", "role": "first_frame", "asset_id": str(asset.id), "source": "library"},
]
with self.assertRaisesMessage(ValueError, "仅支持图片素材"):
build_content_items(team=self.team, prompt="p", mode="keyframe", references=refs)
def test_too_many_images_rejected(self):
refs = [{"url": f"http://x/{i}.png", "type": "image"} for i in range(10)]
with self.assertRaisesMessage(ValueError, "最多 9 张"):
+108 -69
View File
@@ -30,6 +30,8 @@ _AUDIO_TYPES = {"audio/mpeg": ".mp3", "audio/wav": ".wav", "audio/x-wav": ".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__"
def _serialize_asset(fa: FreeAsset) -> dict:
@@ -61,6 +63,99 @@ def _assets_api_unavailable() -> Response:
return Response({"detail": "素材库服务未配置,请联系管理员"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
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:
return group
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 _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:
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 建组。"""
@@ -152,82 +247,26 @@ class FreeAssetUploadView(APIView):
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)
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
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()
class FreeAssetQuickUploadView(APIView):
"""POST /free-assets/quick-upload/ —— 上传图片到默认组,减少首尾帧用户建组步骤。"""
if kind != "image":
from apps.ai.media_probe import extract_video_poster, probe_duration
parser_classes = [MultiPartParser, FormParser]
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]
def post(self, request):
if not assets_client.is_enabled():
return _assets_api_unavailable()
team = get_current_team(request.user)
try:
remote_asset_id = assets_client.create_asset(
group.remote_group_id, url, name=name, asset_type=asset_type
)
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)
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)
return _upload_asset_to_group(request, team=team, group=group, image_only=True)
class FreeAssetDetailView(APIView):
+69 -1
View File
@@ -2,12 +2,13 @@ from io import BytesIO
from types import SimpleNamespace
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from .models import Asset, Model
from .models import Asset, FreeAsset, FreeAssetGroup, Model
def _mk_team(username, team_name):
@@ -151,6 +152,73 @@ class AssetTrashApiTests(TestCase):
self.assertEqual(self.client.post(f"/api/assets/{self.live.id}/restore/").status_code, 404)
class FreeAssetQuickUploadTests(TestCase):
"""人物素材库快捷上传:自动默认组,但底层仍走 FreeAssetGroup / FreeAsset。"""
def setUp(self):
self.user, self.team = _mk_team("ufq", "TeamFQ")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.stored = SimpleNamespace(object_key="teams/x/free-create/library/a.png", bucket="b", content_type="image/png", size_bytes=10)
def _image(self, name="a.png"):
return SimpleUploadedFile(name, b"img", content_type="image/png")
@patch("apps.assets.free_library.assets_client.is_enabled", return_value=True)
@patch("apps.assets.free_library.assets_client.create_asset_group", return_value="Group-1")
@patch("apps.assets.free_library.assets_client.create_asset", return_value="Asset-1")
@patch("apps.assets.free_library.TosStorage")
def test_quick_upload_creates_default_group(self, storage_cls, create_asset, create_group, _enabled):
storage = storage_cls.return_value
storage.upload_fileobj.return_value = self.stored
storage.public_url.return_value = "http://tos/a.png"
res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image()}, format="multipart")
self.assertEqual(res.status_code, 201)
group = FreeAssetGroup.objects.get(team=self.team)
self.assertEqual(group.name, "默认素材")
self.assertEqual(group.remote_group_id, "Group-1")
self.assertEqual(group.assets.count(), 1)
asset = group.assets.get()
self.assertEqual(asset.asset_type, FreeAsset.Type.IMAGE)
self.assertEqual(asset.status, FreeAsset.Status.PROCESSING)
create_group.assert_called_once()
create_asset.assert_called_once_with("Group-1", "http://tos/a.png", name="a.png", asset_type=FreeAsset.Type.IMAGE)
self.assertEqual(res.json()["group"]["id"], str(group.id))
@patch("apps.assets.free_library.assets_client.is_enabled", return_value=True)
@patch("apps.assets.free_library.assets_client.create_asset_group", return_value="Group-1")
@patch("apps.assets.free_library.assets_client.create_asset", side_effect=["Asset-1", "Asset-2"])
@patch("apps.assets.free_library.TosStorage")
def test_quick_upload_reuses_default_group(self, storage_cls, create_asset, create_group, _enabled):
storage = storage_cls.return_value
storage.upload_fileobj.return_value = self.stored
storage.public_url.return_value = "http://tos/a.png"
first = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("a.png")}, format="multipart")
second = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("b.png")}, format="multipart")
self.assertEqual(first.status_code, 201)
self.assertEqual(second.status_code, 201)
self.assertEqual(FreeAssetGroup.objects.filter(team=self.team).count(), 1)
self.assertEqual(FreeAsset.objects.filter(group__team=self.team).count(), 2)
create_group.assert_called_once()
self.assertEqual(create_asset.call_count, 2)
@patch("apps.assets.free_library.assets_client.is_enabled", return_value=True)
@patch("apps.assets.free_library.assets_client.create_asset_group", return_value="Group-1")
@patch("apps.assets.free_library.TosStorage")
def test_quick_upload_rejects_non_image(self, storage_cls, create_group, _enabled):
res = self.client.post(
"/api/assets/free-assets/quick-upload/",
{"file": SimpleUploadedFile("v.mp4", b"video", content_type="video/mp4")},
format="multipart",
)
self.assertEqual(res.status_code, 400)
self.assertIn("仅支持图片", res.json()["detail"])
self.assertFalse(storage_cls.return_value.upload_fileobj.called)
create_group.assert_called_once()
class VideoPacksTests(TestCase):
"""视频成品按项目素材包分组(与导出同源:取 VideoSegment.adopted_version 的真实视频资产)。"""
+2
View File
@@ -6,6 +6,7 @@ from .free_library import (
FreeAssetGroupDetailView,
FreeAssetGroupListView,
FreeAssetPollView,
FreeAssetQuickUploadView,
FreeAssetUploadView,
)
from .views import AssetUploadView, AssetViewSet
@@ -19,6 +20,7 @@ urlpatterns = [
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/quick-upload/", FreeAssetQuickUploadView.as_view(), name="free-asset-quick-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