feat(assets): 素材选择器 - 支持按媒体类型筛选并覆盖远程项目资产
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,12 +14,20 @@ import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
|
||||
import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { deleteAssetWithRemoteSync, loadAssetLibraryPage, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
|
||||
export type AssetPickerMediaKind = "image" | "video" | "audio" | "text";
|
||||
|
||||
export const ASSET_PICKER_MEDIA_KIND_LABELS: Record<AssetPickerMediaKind, string> = { image: "图片", video: "视频", audio: "音频", text: "文本" };
|
||||
|
||||
const DEFAULT_MEDIA_KINDS: AssetPickerMediaKind[] = ["image", "video", "audio"];
|
||||
|
||||
export type AssetLibraryPickerItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
archived?: boolean;
|
||||
kindLabel: string;
|
||||
/** 媒体类型筛选依据;缺省时按本地素材或插件素材的 kind 推断。 */
|
||||
mediaKind?: AssetPickerMediaKind;
|
||||
asset?: Asset;
|
||||
imageUrl?: string;
|
||||
imageStorageKey?: string;
|
||||
@@ -40,6 +48,8 @@ export type AssetLibraryPickerFolder = {
|
||||
type Props = {
|
||||
remoteLibrary?: boolean;
|
||||
remoteKind?: string;
|
||||
/** 左侧「媒体类型」筛选项;只有一种类型或已由 remoteKind 固定时不展示该分组。 */
|
||||
mediaKinds?: AssetPickerMediaKind[];
|
||||
open: boolean;
|
||||
items: AssetLibraryPickerItem[];
|
||||
categoryLabels: Record<string, string>;
|
||||
@@ -76,6 +86,7 @@ type Props = {
|
||||
export function AssetLibraryPickerModal({
|
||||
remoteLibrary = false,
|
||||
remoteKind,
|
||||
mediaKinds = DEFAULT_MEDIA_KINDS,
|
||||
open,
|
||||
items,
|
||||
categoryLabels,
|
||||
@@ -101,6 +112,7 @@ export function AssetLibraryPickerModal({
|
||||
}: Props) {
|
||||
const { message } = App.useApp();
|
||||
const [category, setCategory] = useState(initialCategory);
|
||||
const [mediaKind, setMediaKind] = useState<AssetPickerMediaKind | "all">("all");
|
||||
const [folderId, setFolderId] = useState(initialFolderId);
|
||||
const [source, setSource] = useState<"local" | "plugin">("local");
|
||||
const [sourceMenuOpen, setSourceMenuOpen] = useState(false);
|
||||
@@ -120,10 +132,12 @@ export function AssetLibraryPickerModal({
|
||||
const timer = window.setTimeout(() => setRemoteKeyword(keyword.trim()), 250);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [keyword]);
|
||||
useEffect(() => setRemotePage(1), [category, remoteKeyword, open]);
|
||||
useEffect(() => setRemotePage(1), [category, mediaKind, remoteKeyword, open]);
|
||||
// remoteKind 是调用方写死的能力约束;媒体类型筛选只在没有该约束时参与服务端查询。
|
||||
const remoteQueryKind = remoteKind || (mediaKind === "all" ? undefined : mediaKind);
|
||||
const remoteQuery = useQuery({
|
||||
queryKey: ["asset-picker", userId, remotePage, remotePageSize, category, remoteKeyword, remoteKind],
|
||||
queryFn: ({ signal }) => loadAssetLibraryPage({ page: remotePage, pageSize: remotePageSize, kind: remoteKind, category: category === "all" || category === "archived" || category === remoteKind ? undefined : category, status: category === "archived" ? "archived" : "active", query: remoteKeyword, signal }),
|
||||
queryKey: ["asset-picker", userId, remotePage, remotePageSize, category, remoteKeyword, remoteQueryKind],
|
||||
queryFn: ({ signal }) => loadAssetLibraryPage({ page: remotePage, pageSize: remotePageSize, kind: remoteQueryKind, category: category === "all" || category === "archived" || category === remoteQueryKind ? undefined : category, status: category === "archived" ? "archived" : "active", query: remoteKeyword, signal }),
|
||||
enabled: remoteEnabled && open && sessionHydrated,
|
||||
});
|
||||
const remoteItems = useMemo<AssetLibraryPickerItem[]>(() => (remoteQuery.data?.assets || []).filter((asset) => asset.kind !== "entity" && asset.kind !== "model").map((asset) => ({
|
||||
@@ -144,9 +158,15 @@ export function AssetLibraryPickerModal({
|
||||
const localItems = useMemo(() => allItems.filter((item) => !item.external), [allItems]);
|
||||
const pluginItems = useMemo(() => allItems.filter((item) => Boolean(item.external)), [allItems]);
|
||||
const hasPluginSource = useMemo(() => Object.keys(categoryLabels).some((value) => value.startsWith("external:")) || pluginItems.some((item) => item.category.startsWith("external:")), [categoryLabels, pluginItems]);
|
||||
const sourceItems = source === "plugin" ? pluginItems : remoteEnabled ? remoteItems : localItems;
|
||||
// 媒体类型在分类之前收窄数据源,让左侧分类计数、网格和分页始终描述同一批素材。
|
||||
const sourceItems = useMemo(() => {
|
||||
const base = source === "plugin" ? pluginItems : remoteEnabled ? remoteItems : localItems;
|
||||
if (mediaKind === "all") return base;
|
||||
return base.filter((item) => pickerItemMediaKind(item) === mediaKind);
|
||||
}, [localItems, mediaKind, pluginItems, remoteEnabled, remoteItems, source]);
|
||||
const activeSourceItems = useMemo(() => sourceItems.filter((item) => !item.archived), [sourceItems]);
|
||||
const archivedItems = useMemo(() => sourceItems.filter((item) => item.archived), [sourceItems]);
|
||||
const mediaKindOptions = useMemo(() => (remoteKind ? [] : Array.from(new Set(mediaKinds))), [mediaKinds, remoteKind]);
|
||||
const sourceFolders = source === "plugin" ? folders : [];
|
||||
const showCategories = source === "local" || !sourceFolders.length;
|
||||
const normalCategories = useMemo(() => remoteEnabled ? Object.keys(categoryLabels).filter((value) => value !== "archived" && !value.startsWith("external:")) : ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")], [activeSourceItems, categoryLabels, remoteEnabled]);
|
||||
@@ -180,6 +200,7 @@ export function AssetLibraryPickerModal({
|
||||
|
||||
setFolderId(initialFolderId);
|
||||
setCategory(initialCategory);
|
||||
setMediaKind("all");
|
||||
setSource("local");
|
||||
setKeyword("");
|
||||
setUploadedItems([]);
|
||||
@@ -200,6 +221,11 @@ export function AssetLibraryPickerModal({
|
||||
setSource("local");
|
||||
}, [hasPluginSource, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaKind === "all" || mediaKindOptions.includes(mediaKind)) return;
|
||||
setMediaKind("all");
|
||||
}, [mediaKind, mediaKindOptions]);
|
||||
|
||||
const selectSource = (nextSource: "local" | "plugin") => {
|
||||
if (nextSource === "plugin" && !hasPluginSource) return;
|
||||
setSource(nextSource);
|
||||
@@ -404,6 +430,16 @@ export function AssetLibraryPickerModal({
|
||||
</header>
|
||||
<div className="asset-picker-body">
|
||||
<nav className="asset-picker-categories" aria-label="素材分类">
|
||||
{mediaKindOptions.length > 1 && !isRecycleBin ? (
|
||||
<>
|
||||
<span className="asset-picker-nav-label">媒体类型</span>
|
||||
{(["all", ...mediaKindOptions] as const).map((value) => (
|
||||
<button key={value} type="button" className={cn("assets-filter-item", mediaKind === value && "is-active")} aria-pressed={mediaKind === value} onClick={() => setMediaKind(value)}>
|
||||
<span className="assets-filter-item-label">{value === "all" ? "全部类型" : ASSET_PICKER_MEDIA_KIND_LABELS[value]}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{sourceFolders.length ? (
|
||||
<>
|
||||
<span className="asset-picker-nav-label">文件夹</span>
|
||||
@@ -527,6 +563,13 @@ export function AssetLibraryPickerModal({
|
||||
);
|
||||
}
|
||||
|
||||
// 角色卡、3D 模型等非媒体条目没有媒体类型,媒体筛选生效时不参与匹配。
|
||||
export function pickerItemMediaKind(item: AssetLibraryPickerItem): AssetPickerMediaKind | undefined {
|
||||
if (item.mediaKind) return item.mediaKind;
|
||||
const kind = item.external?.item.kind || item.asset?.kind;
|
||||
return kind === "image" || kind === "video" || kind === "audio" || kind === "text" ? kind : undefined;
|
||||
}
|
||||
|
||||
function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem; selected: boolean; onToggle: () => void }) {
|
||||
const disabled = Boolean(item.disabledReason);
|
||||
return (
|
||||
|
||||
@@ -50,6 +50,7 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P
|
||||
category: normalizeAssetCategory(asset.category),
|
||||
archived: asset.status === "archived",
|
||||
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
|
||||
mediaKind: asset.kind,
|
||||
asset,
|
||||
searchText: (asset.tags || []).join(" "),
|
||||
})),
|
||||
@@ -62,6 +63,7 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P
|
||||
<AssetLibraryPickerModal
|
||||
remoteLibrary
|
||||
open={open}
|
||||
mediaKinds={["image", "video", "audio", "text"]}
|
||||
items={items}
|
||||
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
folders={externalAssetSources.folders}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem, type AssetPickerMediaKind } from "@/components/assets/asset-library-picker-modal";
|
||||
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
|
||||
import { externalAssetToInsertPayload, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
|
||||
@@ -55,12 +55,15 @@ export function CanvasProjectAssetModal({
|
||||
character?.character?.representations.find((representation) => representation.role === "primary") ||
|
||||
character?.character?.representations.find((representation) => representation.role === "front");
|
||||
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
|
||||
// 项目资产可能只有服务端记录、没有本地素材,媒体类型要显式声明,筛选才能覆盖它们。
|
||||
const mediaKind = character ? undefined : pickerMediaKind(media?.kind || project?.mediaType);
|
||||
return {
|
||||
id: item.id,
|
||||
title: character?.title || project?.title || media?.title || "未命名资产",
|
||||
category: item.category,
|
||||
folderId: item.folderId,
|
||||
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
|
||||
kindLabel: character ? "角色卡" : mediaKind === "video" ? "视频" : mediaKind === "audio" ? "音频" : mediaKind === "text" ? "文本" : "图片",
|
||||
mediaKind,
|
||||
asset: media,
|
||||
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : project?.mediaType === "image" && remoteResourceId ? resourceFileUrl(remoteResourceId) : undefined,
|
||||
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
|
||||
@@ -77,6 +80,7 @@ export function CanvasProjectAssetModal({
|
||||
<AssetLibraryPickerModal
|
||||
remoteLibrary={!detail}
|
||||
open={open}
|
||||
mediaKinds={["image", "video", "audio", "text"]}
|
||||
items={pickerItems}
|
||||
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
initialCategory={initialCategory}
|
||||
@@ -117,6 +121,10 @@ export function CanvasProjectAssetModal({
|
||||
);
|
||||
}
|
||||
|
||||
function pickerMediaKind(value?: string): AssetPickerMediaKind {
|
||||
return value === "video" || value === "audio" || value === "text" ? value : "image";
|
||||
}
|
||||
|
||||
function toInsertPayload(item: ProjectPickerItem): InsertAssetPayload {
|
||||
if (item.character?.character) {
|
||||
return projectCharacterToInsertPayload(item.character);
|
||||
|
||||
@@ -442,6 +442,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
<AssetLibraryPickerModal
|
||||
open={addOpen}
|
||||
remoteLibrary
|
||||
mediaKinds={["image", "video", "audio", "text"]}
|
||||
items={availablePickerItems}
|
||||
categoryLabels={{ ...pickerCategoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
folders={externalAssetSources.folders}
|
||||
|
||||
@@ -166,6 +166,7 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
<AssetLibraryPickerModal
|
||||
open={coverPickerOpen}
|
||||
items={coverPickerItems}
|
||||
mediaKinds={["image"]}
|
||||
categoryLabels={{ all: "全部图片", image: "图片" }}
|
||||
initialCategory="image"
|
||||
initialSelectedIds={currentCoverItemId ? [currentCoverItemId] : []}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resolve } from "node:path";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { assetPickerItemsToInsertPayloads } from "@/components/canvas/asset-picker-modal";
|
||||
import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { pickerItemMediaKind, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import type { ImageAsset, TextAsset } from "@/stores/use-asset-store";
|
||||
|
||||
const timestamp = "2026-08-28T00:00:00.000Z";
|
||||
@@ -59,6 +59,36 @@ describe("canvas asset picker", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("asset picker media kind", () => {
|
||||
const base: AssetLibraryPickerItem = { id: "item", title: "素材", category: "other", kindLabel: "图片" };
|
||||
const image: ImageAsset = {
|
||||
id: "asset",
|
||||
kind: "image",
|
||||
title: "图",
|
||||
coverUrl: "",
|
||||
tags: [],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
data: { dataUrl: "", width: 1, height: 1, bytes: 1, mimeType: "image/png" },
|
||||
};
|
||||
|
||||
test("prefers the declared media kind over the underlying record", () => {
|
||||
expect(pickerItemMediaKind({ ...base, mediaKind: "video", asset: image })).toBe("video");
|
||||
expect(pickerItemMediaKind({ ...base, asset: image })).toBe("image");
|
||||
});
|
||||
|
||||
test("resolves external plugin items and skips records without a media kind", () => {
|
||||
const external: AssetLibraryPickerItem = {
|
||||
...base,
|
||||
external: { sourceId: "eagle", sourceName: "Eagle", item: { id: "audio-1", title: "外部音频", kind: "audio" } },
|
||||
};
|
||||
|
||||
expect(pickerItemMediaKind(external)).toBe("audio");
|
||||
// 角色卡、3D 模型这类条目没有媒体类型,媒体筛选生效时不应被当成图片留在列表里。
|
||||
expect(pickerItemMediaKind(base)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas context menu motion", () => {
|
||||
test("keeps hover feedback stationary while retaining spotlight surfaces", () => {
|
||||
const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
|
||||
|
||||
Reference in New Issue
Block a user