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") 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
+46
View File
@@ -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 张"):
+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 _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):
+69 -1
View File
@@ -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 的真实视频资产)。"""
+2
View File
@@ -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
+3
View File
@@ -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);
+19 -3
View File
@@ -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;
+40 -6
View File
@@ -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}