import { useMemo } from "react"; 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"; import { ASSET_CATEGORY_LABELS, normalizeAssetCategory } from "@/lib/asset-category"; import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; import type { ProjectAsset, ProjectDetail } from "@/services/api/projects"; import { getRemoteAsset } from "@/services/api/user-data"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; const categoryLabels: Record = { all: "全部资产", ...ASSET_CATEGORY_LABELS }; type ProjectPickerItem = { id: string; category: string; folderId?: string; project?: ProjectAsset; character?: ProjectAsset; media?: Asset }; export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder, }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise | void; onInsertFolder?: (folderId: string) => Promise | void; }) { const mediaAssets = useAssetStore((state) => state.assets); const externalAssetSources = useExternalAssetSources(open); const items = useMemo(() => { const mediaById = new Map(mediaAssets.map((asset) => [asset.id, asset])); const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => { if (asset.category === "character" && asset.character) return [{ id: asset.id, category: "character", folderId: asset.folderId, project: asset, character: asset }]; const media = mediaById.get(asset.id); if (media?.status === "archived") return []; return asset.mediaType === "model" || asset.mediaType === "entity" ? [] : [{ id: asset.id, category: normalizeAssetCategory(asset.category || media?.category), folderId: asset.folderId, project: asset, media }]; }); if (detail) return projectItems; // 自由画布未关联项目时回退到个人素材库。 return mediaAssets.filter((asset) => asset.kind !== "model" && asset.kind !== "entity" && asset.status !== "archived").map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media })); }, [detail?.assets, mediaAssets]); const localPickerItems = useMemo( () => items.map((item) => { const character = item.character; const project = item.project; const media = item.media; const coverRepresentation = character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") || 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 ? "角色卡" : 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}` : remoteResourceId ? `resource:${remoteResourceId}` : undefined, imageFit: character ? "contain" : "cover", description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText, searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "), }; }), [items], ); const pickerItems = useMemo(() => [...localPickerItems, ...externalAssetSources.items], [externalAssetSources.items, localPickerItems]); return ( `引入已选资产${count ? `(${count})` : ""}`} emptyTitle="此分类没有可引用资产" emptyDescription={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,可先在素材库添加内容。"} footerNote={externalAssetSources.error || "角色引用会在生成时解析当前角色版本"} onFolderAction={ onInsertFolder ? async (folderId) => { await onInsertFolder(folderId); onClose(); } : undefined } onClose={onClose} onConfirm={async (ids) => { const payloads = await Promise.all( ids.map(async (id) => { const external = externalAssetSources.items.find((item) => item.id === id)?.external; if (external) return externalAssetToInsertPayload(external); const item = items.find((candidate) => candidate.id === id); if (!item) throw new Error("所选资产已不存在,请重新选择"); if (item.media || item.character || !item.project) return toInsertPayload(item); const { asset } = await getRemoteAsset(item.project.id); return toInsertPayload({ ...item, media: asset }); }), ); if (!payloads.length) return; await onInsert(payloads); onClose(); }} /> ); } 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); } const asset = item.media; const project = item.project; if (!asset && project) { const resourceId = resourceIdFromStorageKey(project.storageKey); const remoteUrl = resourceId ? resourceFileUrl(resourceId) : ""; if (project.mediaType === "text" && project.previewText) return { kind: "text", content: project.previewText, title: project.title, assetId: project.id }; if (project.mediaType === "video" && remoteUrl) return { kind: "video", url: remoteUrl, storageKey: project.storageKey, title: project.title, assetId: project.id }; if (project.mediaType === "audio" && remoteUrl) return { kind: "audio", url: remoteUrl, storageKey: project.storageKey, title: project.title, assetId: project.id }; if (project.mediaType === "image" && remoteUrl) return { kind: "image", dataUrl: remoteUrl, storageKey: project.storageKey, title: project.title, assetId: project.id }; throw new Error(`“${project.title}”缺少可读取的内容`); } if (!asset) throw new Error("项目资产不可用"); if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id }; if (asset.kind === "video") return { kind: "video", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id, }; if (asset.kind === "audio") return { kind: "audio", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id, }; if (asset.kind === "image") return { kind: "image", dataUrl: projectAssetMediaUrl(asset.data.storageKey, asset.data.dataUrl), storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id }; throw new Error("当前项目资产不能直接插入画布"); } function projectAssetMediaUrl(storageKey?: string, fallback = "") { const resourceId = resourceIdFromStorageKey(storageKey); return resourceId ? resourceFileUrl(resourceId) : fallback; } export function projectCharacterToInsertPayload(asset: ProjectAsset): InsertAssetPayload { if (!asset.character) throw new Error("项目角色信息不完整"); const card = asset.character; const definition = card.definition; const cover = card.representations.find((representation) => representation.role === "turnaround_sheet") || card.representations.find((representation) => representation.role === "primary") || card.representations.find((representation) => representation.role === "front"); return { kind: "character", title: asset.title, assetId: asset.id, versionId: card.versionId, prompt: compileCharacterReferencePrompt(asset.title, definition), aliases: Array.isArray(definition.aliases) ? definition.aliases.filter((alias): alias is string => typeof alias === "string") : [], definition, coverUrl: cover ? resourceFileUrl(cover.resourceId) : undefined, visualStatus: card.visualStatus, voiceStatus: card.voiceStatus, voiceName: card.voice?.profile.name, voiceProfile: card.voice ? { name: card.voice.profile.name, provider: card.voice.profile.provider, language: card.voice.profile.language, timbre: card.voice.profile.timbre, } : undefined, voiceInstructions: card.voice?.instructions, }; }