diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index 1da5a3c..87a4815 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -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 diff --git a/core/backend/apps/ai/test_free_video.py b/core/backend/apps/ai/test_free_video.py index 59410cb..855fa2e 100644 --- a/core/backend/apps/ai/test_free_video.py +++ b/core/backend/apps/ai/test_free_video.py @@ -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 张"): diff --git a/core/backend/apps/assets/free_library.py b/core/backend/apps/assets/free_library.py index 0627a48..3668d49 100644 --- a/core/backend/apps/assets/free_library.py +++ b/core/backend/apps/assets/free_library.py @@ -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): diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index 9d7709b..7e4c60b 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -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 的真实视频资产)。""" diff --git a/core/backend/apps/assets/urls.py b/core/backend/apps/assets/urls.py index d6b0e66..8ea8b37 100644 --- a/core/backend/apps/assets/urls.py +++ b/core/backend/apps/assets/urls.py @@ -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//", FreeAssetGroupDetailView.as_view(), name="free-asset-group-detail"), path("free-groups//assets/", FreeAssetUploadView.as_view(), name="free-asset-upload"), + path("free-assets/quick-upload/", FreeAssetQuickUploadView.as_view(), name="free-asset-quick-upload"), path("free-assets//", FreeAssetDetailView.as_view(), name="free-asset-detail"), path("free-assets//poll/", FreeAssetPollView.as_view(), name="free-asset-poll"), ] + router.urls diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 5a54f56..f7b7f0c 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -718,6 +718,9 @@ export const api = { uploadFreeAsset(groupId: string, formData: 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) { return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) }); }, diff --git a/core/frontend/src/components/free-create/asset-library-modal.tsx b/core/frontend/src/components/free-create/asset-library-modal.tsx index 2bc5f78..5dcb6db 100644 --- a/core/frontend/src/components/free-create/asset-library-modal.tsx +++ b/core/frontend/src/components/free-create/asset-library-modal.tsx @@ -27,7 +27,9 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(""); const [uploading, setUploading] = useState(false); + const [quickUploading, setQuickUploading] = useState(false); const fileRef = useRef(null); + const quickFileRef = useRef(null); useBodyScrollLock(open); 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 name = window.prompt("素材名称(用于 @ 引用)", item.name); if (!name || name.trim() === item.name) return; @@ -191,6 +210,17 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { {!activeGroup ? ( <>
+ { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void quickUploadImage(file); + }} + /> {creating ? (
setCreating(false)}>取消
) : ( - + <> + + + // 自动进入默认素材组审核 + )}
{groups.length === 0 && !loading ? ( diff --git a/core/frontend/src/components/free-create/input-bar.tsx b/core/frontend/src/components/free-create/input-bar.tsx index b3455b2..5097a2d 100644 --- a/core/frontend/src/components/free-create/input-bar.tsx +++ b/core/frontend/src/components/free-create/input-bar.tsx @@ -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"; item: LocalRef | undefined; - onPick: () => void; + onPickLibrary: () => void; onRemove: () => void; }) { const label = role === "first_frame" ? "首帧" : "尾帧(可选)"; if (!item) { return ( - +
+ +
); } return ( -
- {label} - {item.uploading && } - {role === "first_frame" ? "首" : "尾"} +
+ +
); } @@ -74,7 +80,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r onResolutionChange: (resolution: string) => void; onDurationChange: (duration: number) => void; onSeedChange: (seed: number) => void; - onOpenLibrary: () => void; + onOpenLibrary: (role?: "first_frame" | "last_frame") => void; onClear: () => void; onSend: () => void; }) { @@ -125,7 +131,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r + - {refs.map((item) => ( @@ -134,16 +140,16 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
) : (
- pickFiles("first_frame")} onRemove={() => firstFrame && onRemoveRef(firstFrame.key)} /> + onOpenLibrary("first_frame")} onRemove={() => firstFrame && onRemoveRef(firstFrame.key)} /> - pickFiles("last_frame")} onRemove={() => lastFrame && onRemoveRef(lastFrame.key)} /> + onOpenLibrary("last_frame")} onRemove={() => lastFrame && onRemoveRef(lastFrame.key)} />
)} onOpenLibrary() : undefined} onTextChange={setHasPrompt} placeholder={mode === "keyframe" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频,@ 可引用参考素材…"} /> diff --git a/core/frontend/src/components/free-create/prompt-input.tsx b/core/frontend/src/components/free-create/prompt-input.tsx index e2f904c..97960de 100644 --- a/core/frontend/src/components/free-create/prompt-input.tsx +++ b/core/frontend/src/components/free-create/prompt-input.tsx @@ -25,7 +25,7 @@ type Props = { placeholder?: string; disabled?: boolean; onSubmit: () => void; - onOpenLibrary: () => void; + onOpenLibrary?: () => void; onTextChange?: (hasText: boolean) => void; }; @@ -247,7 +247,7 @@ export const PromptInput = forwardRef(function PromptI thumb: r.thumb_url || (r.type === "image" ? r.url : ""), 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]) => { @@ -256,7 +256,7 @@ export const PromptInput = forwardRef(function PromptI const trigger = findTrigger(); trigger?.range.deleteContents(); closeMenu(); - onOpenLibrary(); + onOpenLibrary?.(); return; } insertChipAtTrigger(item.label, item.thumb); diff --git a/core/frontend/src/free-create-page.css b/core/frontend/src/free-create-page.css index 8f939c6..670eff8 100644 --- a/core/frontend/src/free-create-page.css +++ b/core/frontend/src/free-create-page.css @@ -220,10 +220,11 @@ align-items: 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) */ .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 { position: relative; width: 88px; @@ -242,10 +243,25 @@ overflow: hidden; 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.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 .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 { position: absolute; left: 3px; @@ -488,7 +504,7 @@ .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-back { display: inline-flex; diff --git a/core/frontend/src/routes/free-create.tsx b/core/frontend/src/routes/free-create.tsx index 2f7470d..989cf08 100644 --- a/core/frontend/src/routes/free-create.tsx +++ b/core/frontend/src/routes/free-create.tsx @@ -91,6 +91,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: { // —— 弹窗 —— const [detailId, setDetailId] = useState(null); const [libraryOpen, setLibraryOpen] = useState(false); + const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null); const [deleteTarget, setDeleteTarget] = useState(null); // —— 轮询/进度 —— @@ -441,15 +442,48 @@ export function FreeCreatePage({ modelConfigs, onNotify }: { } }, [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) => { - 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 n = 2; while (refs.some((r) => r.label === label)) label = `${ref.label}${n++}`; setRefs((prev) => [...prev, { ...ref, label, key: nextRefKey() }]); 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(() => { promptRef.current?.clear(); @@ -471,7 +505,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
-
@@ -525,7 +559,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: { onResolutionChange={setResolution} onDurationChange={setDuration} onSeedChange={setSeed} - onOpenLibrary={() => setLibraryOpen(true)} + onOpenLibrary={openLibrary} onClear={clearInput} onSend={() => void handleSend()} /> @@ -545,7 +579,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: { /> )} - setLibraryOpen(false)} onPick={handleLibraryPick} notify={notify} /> +