fix: 修复自由创作首尾帧上传人脸图不通过问题
This commit is contained in:
@@ -165,7 +165,8 @@ def build_content_items(*, team, prompt: str, mode: str, references: list) -> di
|
|||||||
source = str(ref.get("source") or "upload")
|
source = str(ref.get("source") or "upload")
|
||||||
duration = float(ref.get("duration") or 0)
|
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:
|
if dedupe_key in seen_urls:
|
||||||
continue
|
continue
|
||||||
seen_urls.add(dedupe_key)
|
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}」尚未就绪,请稍后重试")
|
raise ValueError(f"素材「{label or fa.name}」尚未就绪,请稍后重试")
|
||||||
resolved_url = f"asset://{_normalize_remote_asset_id(fa.remote_asset_id)}"
|
resolved_url = f"asset://{_normalize_remote_asset_id(fa.remote_asset_id)}"
|
||||||
kind = {"Video": "video", "Audio": "audio"}.get(fa.asset_type, "image")
|
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:
|
if label and label not in label_to_placeholder:
|
||||||
label_to_placeholder[label] = _placeholder_for(asset_type)
|
label_to_placeholder[label] = _placeholder_for(asset_type)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from django.test import TestCase
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.accounts.models import Team, TeamMember, User
|
from apps.accounts.models import Team, TeamMember, User
|
||||||
|
from apps.assets.models import FreeAsset, FreeAssetGroup
|
||||||
from apps.ai.free_video import (
|
from apps.ai.free_video import (
|
||||||
build_content_items,
|
build_content_items,
|
||||||
finalize_free_video,
|
finalize_free_video,
|
||||||
@@ -113,6 +114,51 @@ class BuildContentItemsTests(TestCase):
|
|||||||
built = build_content_items(team=self.team, prompt="p", mode="keyframe", references=refs)
|
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["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):
|
def test_too_many_images_rejected(self):
|
||||||
refs = [{"url": f"http://x/{i}.png", "type": "image"} for i in range(10)]
|
refs = [{"url": f"http://x/{i}.png", "type": "image"} for i in range(10)]
|
||||||
with self.assertRaisesMessage(ValueError, "最多 9 张"):
|
with self.assertRaisesMessage(ValueError, "最多 9 张"):
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ _AUDIO_TYPES = {"audio/mpeg": ".mp3", "audio/wav": ".wav", "audio/x-wav": ".wav"
|
|||||||
_IMAGE_MAX = 30 * 1024 * 1024
|
_IMAGE_MAX = 30 * 1024 * 1024
|
||||||
_VIDEO_MAX = 50 * 1024 * 1024
|
_VIDEO_MAX = 50 * 1024 * 1024
|
||||||
_AUDIO_MAX = 15 * 1024 * 1024
|
_AUDIO_MAX = 15 * 1024 * 1024
|
||||||
|
DEFAULT_GROUP_NAME = "默认素材"
|
||||||
|
DEFAULT_GROUP_MARKER = "__airshelf_free_asset_default__"
|
||||||
|
|
||||||
|
|
||||||
def _serialize_asset(fa: FreeAsset) -> dict:
|
def _serialize_asset(fa: FreeAsset) -> dict:
|
||||||
@@ -61,6 +63,99 @@ def _assets_api_unavailable() -> Response:
|
|||||||
return Response({"detail": "素材库服务未配置,请联系管理员"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
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):
|
class FreeAssetGroupListView(APIView):
|
||||||
"""GET 列素材组 / POST 建组。"""
|
"""GET 列素材组 / POST 建组。"""
|
||||||
|
|
||||||
@@ -152,82 +247,26 @@ class FreeAssetUploadView(APIView):
|
|||||||
group = FreeAssetGroup.objects.filter(id=group_id, team=team, is_deleted=False).first()
|
group = FreeAssetGroup.objects.filter(id=group_id, team=team, is_deleted=False).first()
|
||||||
if group is None:
|
if group is None:
|
||||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
upload = request.FILES.get("file")
|
response = _upload_asset_to_group(request, team=team, group=group)
|
||||||
if upload is None:
|
if response.status_code == status.HTTP_201_CREATED:
|
||||||
return Response({"detail": "缺少文件"}, status=status.HTTP_400_BAD_REQUEST)
|
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()
|
class FreeAssetQuickUploadView(APIView):
|
||||||
duration = None
|
"""POST /free-assets/quick-upload/ —— 上传图片到默认组,减少首尾帧用户建组步骤。"""
|
||||||
thumb_url = ""
|
|
||||||
storage = TosStorage()
|
|
||||||
file_id = uuid.uuid4()
|
|
||||||
|
|
||||||
if kind != "image":
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
from apps.ai.media_probe import extract_video_poster, probe_duration
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(prefix="airshelf-fc-lib-") as tmp:
|
def post(self, request):
|
||||||
tmp_path = Path(tmp) / f"in{suffix}"
|
if not assets_client.is_enabled():
|
||||||
tmp_path.write_bytes(raw)
|
return _assets_api_unavailable()
|
||||||
duration = probe_duration(str(tmp_path))
|
team = get_current_team(request.user)
|
||||||
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:
|
try:
|
||||||
remote_asset_id = assets_client.create_asset(
|
group = _get_or_create_default_group(team=team, user=request.user)
|
||||||
group.remote_group_id, url, name=name, asset_type=asset_type
|
|
||||||
)
|
|
||||||
except AssetsAPIError as exc:
|
except AssetsAPIError as exc:
|
||||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
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)
|
||||||
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):
|
class FreeAssetDetailView(APIView):
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ from io import BytesIO
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.accounts.models import Team, TeamMember, User
|
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):
|
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)
|
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):
|
class VideoPacksTests(TestCase):
|
||||||
"""视频成品按项目素材包分组(与导出同源:取 VideoSegment.adopted_version 的真实视频资产)。"""
|
"""视频成品按项目素材包分组(与导出同源:取 VideoSegment.adopted_version 的真实视频资产)。"""
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from .free_library import (
|
|||||||
FreeAssetGroupDetailView,
|
FreeAssetGroupDetailView,
|
||||||
FreeAssetGroupListView,
|
FreeAssetGroupListView,
|
||||||
FreeAssetPollView,
|
FreeAssetPollView,
|
||||||
|
FreeAssetQuickUploadView,
|
||||||
FreeAssetUploadView,
|
FreeAssetUploadView,
|
||||||
)
|
)
|
||||||
from .views import AssetUploadView, AssetViewSet
|
from .views import AssetUploadView, AssetViewSet
|
||||||
@@ -19,6 +20,7 @@ urlpatterns = [
|
|||||||
path("free-groups/", FreeAssetGroupListView.as_view(), name="free-asset-group-list"),
|
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>/", FreeAssetGroupDetailView.as_view(), name="free-asset-group-detail"),
|
||||||
path("free-groups/<uuid:group_id>/assets/", FreeAssetUploadView.as_view(), name="free-asset-upload"),
|
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>/", FreeAssetDetailView.as_view(), name="free-asset-detail"),
|
||||||
path("free-assets/<uuid:asset_id>/poll/", FreeAssetPollView.as_view(), name="free-asset-poll"),
|
path("free-assets/<uuid:asset_id>/poll/", FreeAssetPollView.as_view(), name="free-asset-poll"),
|
||||||
] + router.urls
|
] + router.urls
|
||||||
|
|||||||
@@ -718,6 +718,9 @@ export const api = {
|
|||||||
uploadFreeAsset(groupId: string, formData: FormData) {
|
uploadFreeAsset(groupId: string, formData: FormData) {
|
||||||
return request<{ asset: FreeAssetItem }>(`/api/assets/free-groups/${groupId}/assets/`, { method: "POST", body: formData });
|
return request<{ asset: FreeAssetItem }>(`/api/assets/free-groups/${groupId}/assets/`, { method: "POST", body: formData });
|
||||||
},
|
},
|
||||||
|
quickUploadFreeAsset(formData: FormData) {
|
||||||
|
return request<{ group: FreeAssetGroup; asset: FreeAssetItem }>("/api/assets/free-assets/quick-upload/", { method: "POST", body: formData });
|
||||||
|
},
|
||||||
renameFreeAsset(id: string, name: string) {
|
renameFreeAsset(id: string, name: string) {
|
||||||
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) });
|
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [quickUploading, setQuickUploading] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const quickFileRef = useRef<HTMLInputElement>(null);
|
||||||
useBodyScrollLock(open);
|
useBodyScrollLock(open);
|
||||||
|
|
||||||
const loadGroups = useCallback(async () => {
|
const loadGroups = useCallback(async () => {
|
||||||
@@ -109,6 +111,23 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const quickUploadImage = async (file: File) => {
|
||||||
|
setQuickUploading(true);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const data = await api.quickUploadFreeAsset(form);
|
||||||
|
setActiveGroup(data.group);
|
||||||
|
setAssets([data.asset]);
|
||||||
|
notify("success", "图片已上传到默认素材组,审核中");
|
||||||
|
void loadGroupDetail(data.group);
|
||||||
|
} catch (error) {
|
||||||
|
notify("error", error instanceof Error ? error.message : "上传失败");
|
||||||
|
} finally {
|
||||||
|
setQuickUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renameAsset = async (item: FreeAssetItem) => {
|
const renameAsset = async (item: FreeAssetItem) => {
|
||||||
const name = window.prompt("素材名称(用于 @ 引用)", item.name);
|
const name = window.prompt("素材名称(用于 @ 引用)", item.name);
|
||||||
if (!name || name.trim() === item.name) return;
|
if (!name || name.trim() === item.name) return;
|
||||||
@@ -191,6 +210,17 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
{!activeGroup ? (
|
{!activeGroup ? (
|
||||||
<>
|
<>
|
||||||
<div className="fc-lib-toolbar">
|
<div className="fc-lib-toolbar">
|
||||||
|
<input
|
||||||
|
ref={quickFileRef}
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (file) void quickUploadImage(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<div className="fc-lib-create">
|
<div className="fc-lib-create">
|
||||||
<input
|
<input
|
||||||
@@ -205,7 +235,13 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setCreating(false)}>取消</button>
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setCreating(false)}>取消</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)}><FolderPlus size={13} /> 新建素材组</button>
|
<>
|
||||||
|
<button type="button" className="btn btn-sm btn-primary" disabled={quickUploading} onClick={() => quickFileRef.current?.click()}>
|
||||||
|
<Upload size={13} /> {quickUploading ? "上传中…" : "上传图片"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)}><FolderPlus size={13} /> 新建素材组</button>
|
||||||
|
<span className="mono fc-lib-hint">// 自动进入默认素材组审核</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{groups.length === 0 && !loading ? (
|
{groups.length === 0 && !loading ? (
|
||||||
|
|||||||
@@ -28,27 +28,33 @@ function RefThumb({ item, onRemove }: { item: LocalRef; onRemove: () => void })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeyframeSlot({ role, item, onPick, onRemove }: {
|
function KeyframeSlot({ role, item, onPickLibrary, onRemove }: {
|
||||||
role: "first_frame" | "last_frame";
|
role: "first_frame" | "last_frame";
|
||||||
item: LocalRef | undefined;
|
item: LocalRef | undefined;
|
||||||
onPick: () => void;
|
onPickLibrary: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const label = role === "first_frame" ? "首帧" : "尾帧(可选)";
|
const label = role === "first_frame" ? "首帧" : "尾帧(可选)";
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return (
|
return (
|
||||||
<button type="button" className="fc-kf-slot" onClick={onPick}>
|
<div className="fc-kf-slot-wrap">
|
||||||
<IconKitSvg name="images" size={20} />
|
<button type="button" className="fc-kf-slot" onClick={onPickLibrary}>
|
||||||
<span>{label}</span>
|
<IconKitSvg name="images" size={20} />
|
||||||
</button>
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={`fc-kf-slot filled${item.uploading ? " uploading" : ""}`}>
|
<div className="fc-kf-slot-wrap">
|
||||||
<img src={item.thumb_url || item.url} alt={label} />
|
<button type="button" className={`fc-kf-slot filled${item.uploading ? " uploading" : ""}`} title={`从人物素材库替换${label}`} onClick={onPickLibrary}>
|
||||||
{item.uploading && <span className="spinner" />}
|
<img src={item.thumb_url || item.url} alt={label} />
|
||||||
<span className="fc-kf-tag mono">{role === "first_frame" ? "首" : "尾"}</span>
|
{item.uploading && <span className="spinner" />}
|
||||||
|
<span className="fc-kf-tag mono">{role === "first_frame" ? "首" : "尾"}</span>
|
||||||
|
</button>
|
||||||
<button type="button" className="fc-ref-x" aria-label={`删除${label}`} onClick={onRemove}>×</button>
|
<button type="button" className="fc-ref-x" aria-label={`删除${label}`} onClick={onRemove}>×</button>
|
||||||
|
<button type="button" className="fc-kf-lib" title={`从人物素材库替换${label}`} aria-label={`从人物素材库替换${label}`} onClick={onPickLibrary}>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,7 +80,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
|||||||
onResolutionChange: (resolution: string) => void;
|
onResolutionChange: (resolution: string) => void;
|
||||||
onDurationChange: (duration: number) => void;
|
onDurationChange: (duration: number) => void;
|
||||||
onSeedChange: (seed: number) => void;
|
onSeedChange: (seed: number) => void;
|
||||||
onOpenLibrary: () => void;
|
onOpenLibrary: (role?: "first_frame" | "last_frame") => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -125,7 +131,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
|||||||
<IconKitSvg name="images" size={18} />
|
<IconKitSvg name="images" size={18} />
|
||||||
<span>+</span>
|
<span>+</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="fc-add fc-lib" title="人物素材库" onClick={onOpenLibrary}>
|
<button type="button" className="fc-add fc-lib" title="人物素材库" onClick={() => onOpenLibrary()}>
|
||||||
<IconKitSvg name="users" size={18} />
|
<IconKitSvg name="users" size={18} />
|
||||||
</button>
|
</button>
|
||||||
{refs.map((item) => (
|
{refs.map((item) => (
|
||||||
@@ -134,16 +140,16 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="fc-keyframes">
|
<div className="fc-keyframes">
|
||||||
<KeyframeSlot role="first_frame" item={firstFrame} onPick={() => pickFiles("first_frame")} onRemove={() => firstFrame && onRemoveRef(firstFrame.key)} />
|
<KeyframeSlot role="first_frame" item={firstFrame} onPickLibrary={() => onOpenLibrary("first_frame")} onRemove={() => firstFrame && onRemoveRef(firstFrame.key)} />
|
||||||
<span className="fc-kf-arrow mono">→</span>
|
<span className="fc-kf-arrow mono">→</span>
|
||||||
<KeyframeSlot role="last_frame" item={lastFrame} onPick={() => pickFiles("last_frame")} onRemove={() => lastFrame && onRemoveRef(lastFrame.key)} />
|
<KeyframeSlot role="last_frame" item={lastFrame} onPickLibrary={() => onOpenLibrary("last_frame")} onRemove={() => lastFrame && onRemoveRef(lastFrame.key)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PromptInput
|
<PromptInput
|
||||||
ref={promptRef}
|
ref={promptRef}
|
||||||
refs={refs}
|
refs={refs}
|
||||||
onSubmit={onSend}
|
onSubmit={onSend}
|
||||||
onOpenLibrary={onOpenLibrary}
|
onOpenLibrary={mode === "universal" ? () => onOpenLibrary() : undefined}
|
||||||
onTextChange={setHasPrompt}
|
onTextChange={setHasPrompt}
|
||||||
placeholder={mode === "keyframe" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频,@ 可引用参考素材…"}
|
placeholder={mode === "keyframe" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频,@ 可引用参考素材…"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type Props = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
onOpenLibrary: () => void;
|
onOpenLibrary?: () => void;
|
||||||
onTextChange?: (hasText: boolean) => void;
|
onTextChange?: (hasText: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptI
|
|||||||
thumb: r.thumb_url || (r.type === "image" ? r.url : ""),
|
thumb: r.thumb_url || (r.type === "image" ? r.url : ""),
|
||||||
kind: "ref" as const
|
kind: "ref" as const
|
||||||
})),
|
})),
|
||||||
{ key: "__library__", label: "从素材库选择…", kind: "library" as const }
|
...(onOpenLibrary ? [{ key: "__library__", label: "从素材库选择…", kind: "library" as const }] : [])
|
||||||
];
|
];
|
||||||
|
|
||||||
const pickMenuItem = (item: (typeof menuItems)[number]) => {
|
const pickMenuItem = (item: (typeof menuItems)[number]) => {
|
||||||
@@ -256,7 +256,7 @@ export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptI
|
|||||||
const trigger = findTrigger();
|
const trigger = findTrigger();
|
||||||
trigger?.range.deleteContents();
|
trigger?.range.deleteContents();
|
||||||
closeMenu();
|
closeMenu();
|
||||||
onOpenLibrary();
|
onOpenLibrary?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
insertChipAtTrigger(item.label, item.thumb);
|
insertChipAtTrigger(item.label, item.thumb);
|
||||||
|
|||||||
@@ -220,10 +220,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.fc-ref:hover .fc-ref-x, .fc-kf-slot:hover .fc-ref-x { display: inline-flex; }
|
.fc-ref:hover .fc-ref-x, .fc-kf-slot-wrap:hover .fc-ref-x { display: inline-flex; }
|
||||||
|
|
||||||
/* 首尾帧(keyframe) */
|
/* 首尾帧(keyframe) */
|
||||||
.fc-keyframes { display: flex; align-items: center; gap: 8px; }
|
.fc-keyframes { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.fc-kf-slot-wrap { position: relative; width: 88px; height: 66px; flex: 0 0 auto; }
|
||||||
.fc-kf-slot {
|
.fc-kf-slot {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 88px;
|
width: 88px;
|
||||||
@@ -242,10 +243,25 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: border-color 0.2s, color 0.2s;
|
transition: border-color 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
.fc-kf-slot-wrap > .fc-kf-slot { width: 100%; height: 100%; }
|
||||||
.fc-kf-slot:hover { border-color: var(--heat-40); color: var(--heat); }
|
.fc-kf-slot:hover { border-color: var(--heat-40); color: var(--heat); }
|
||||||
.fc-kf-slot.filled { border-style: solid; border-color: var(--border-faint); cursor: default; }
|
.fc-kf-slot.filled { border-style: solid; border-color: var(--border-faint); }
|
||||||
.fc-kf-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
.fc-kf-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
.fc-kf-slot .spinner { position: absolute; }
|
.fc-kf-slot .spinner { position: absolute; }
|
||||||
|
.fc-kf-lib {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: transparent;
|
||||||
|
color: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.fc-kf-slot-wrap > .fc-ref-x { z-index: 2; }
|
||||||
.fc-kf-tag {
|
.fc-kf-tag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 3px;
|
left: 3px;
|
||||||
@@ -488,7 +504,7 @@
|
|||||||
.fc-player-actions { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
|
.fc-player-actions { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
|
||||||
|
|
||||||
/* —— 素材库弹窗 —— */
|
/* —— 素材库弹窗 —— */
|
||||||
.fc-lib-modal { width: min(720px, 92vw); }
|
.fc-lib-modal { width: min(720px, 92vw); max-width: min(720px, 92vw); }
|
||||||
.fc-lib-body { max-height: 60vh; overflow-y: auto; }
|
.fc-lib-body { max-height: 60vh; overflow-y: auto; }
|
||||||
.fc-lib-back {
|
.fc-lib-back {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
|
|||||||
// —— 弹窗 ——
|
// —— 弹窗 ——
|
||||||
const [detailId, setDetailId] = useState<string | null>(null);
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||||
|
const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null);
|
||||||
|
|
||||||
// —— 轮询/进度 ——
|
// —— 轮询/进度 ——
|
||||||
@@ -441,15 +442,48 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
|
|||||||
}
|
}
|
||||||
}, [deleteTarget, detailId, stopPolling, notify]);
|
}, [deleteTarget, detailId, stopPolling, notify]);
|
||||||
|
|
||||||
// 素材库选中 → 注入输入条 + 插 mention chip
|
const openLibrary = useCallback((role?: "first_frame" | "last_frame") => {
|
||||||
|
if (mode === "keyframe" && !role) {
|
||||||
|
notify("info", "请点击首帧或尾帧槽位上的人物素材库入口");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLibraryTargetRole(role || null);
|
||||||
|
setLibraryOpen(true);
|
||||||
|
}, [mode, notify]);
|
||||||
|
|
||||||
|
const closeLibrary = useCallback(() => {
|
||||||
|
setLibraryOpen(false);
|
||||||
|
setLibraryTargetRole(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 素材库选中 → 全能参考插 mention;首尾帧写入对应槽位
|
||||||
const handleLibraryPick = useCallback((ref: FreeVideoRef) => {
|
const handleLibraryPick = useCallback((ref: FreeVideoRef) => {
|
||||||
if (mode === "keyframe") { notify("info", "首尾帧模式请直接上传图片"); return; }
|
if (mode === "keyframe") {
|
||||||
|
if (!libraryTargetRole) {
|
||||||
|
notify("info", "请先指定首帧或尾帧槽位");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ref.type !== "image") {
|
||||||
|
notify("error", "首尾帧仅支持图片素材");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const local: LocalRef = {
|
||||||
|
...ref,
|
||||||
|
key: nextRefKey(),
|
||||||
|
role: libraryTargetRole,
|
||||||
|
label: undefined,
|
||||||
|
source: "library"
|
||||||
|
};
|
||||||
|
setRefs((prev) => [...prev.filter((r) => r.role !== libraryTargetRole), local]);
|
||||||
|
setLibraryTargetRole(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let label = ref.label || "素材";
|
let label = ref.label || "素材";
|
||||||
let n = 2;
|
let n = 2;
|
||||||
while (refs.some((r) => r.label === label)) label = `${ref.label}${n++}`;
|
while (refs.some((r) => r.label === label)) label = `${ref.label}${n++}`;
|
||||||
setRefs((prev) => [...prev, { ...ref, label, key: nextRefKey() }]);
|
setRefs((prev) => [...prev, { ...ref, label, key: nextRefKey() }]);
|
||||||
window.setTimeout(() => promptRef.current?.insertMention({ label, thumb: ref.thumb_url || (ref.type === "image" ? ref.url : "") }), 0);
|
window.setTimeout(() => promptRef.current?.insertMention({ label, thumb: ref.thumb_url || (ref.type === "image" ? ref.url : "") }), 0);
|
||||||
}, [mode, refs, notify]);
|
}, [mode, refs, libraryTargetRole, notify]);
|
||||||
|
|
||||||
const clearInput = useCallback(() => {
|
const clearInput = useCallback(() => {
|
||||||
promptRef.current?.clear();
|
promptRef.current?.clear();
|
||||||
@@ -471,7 +505,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button type="button" className="btn" onClick={() => setLibraryOpen(true)}>
|
<button type="button" className="btn" onClick={() => openLibrary()}>
|
||||||
<Users size={14} /> 人物素材库
|
<Users size={14} /> 人物素材库
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -525,7 +559,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
|
|||||||
onResolutionChange={setResolution}
|
onResolutionChange={setResolution}
|
||||||
onDurationChange={setDuration}
|
onDurationChange={setDuration}
|
||||||
onSeedChange={setSeed}
|
onSeedChange={setSeed}
|
||||||
onOpenLibrary={() => setLibraryOpen(true)}
|
onOpenLibrary={openLibrary}
|
||||||
onClear={clearInput}
|
onClear={clearInput}
|
||||||
onSend={() => void handleSend()}
|
onSend={() => void handleSend()}
|
||||||
/>
|
/>
|
||||||
@@ -545,7 +579,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AssetLibraryModal open={libraryOpen} onClose={() => setLibraryOpen(false)} onPick={handleLibraryPick} notify={notify} />
|
<AssetLibraryModal open={libraryOpen} onClose={closeLibrary} onPick={handleLibraryPick} notify={notify} />
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
|
|||||||
Reference in New Issue
Block a user