diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index 2a58554a1..fad81478e 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -60,6 +60,11 @@ type ImageCanvasBasicGenerationComposerViewProps = { ) => CSSProperties; onRequestUpload: (target: UploadTarget) => void; onPickReferenceFromCanvas?: () => void; + /** + * 从项目素材库选择参考图。传入时参考图来源菜单多一项, + * 点击由宿主打开独立的参考图选择弹窗。 + */ + onRequestProjectAssetPicker?: () => void; onToggleReferenceMenu?: () => void; onRememberImageModel?: (model: string) => void; hasPendingImageReferenceUploads?: boolean; @@ -115,6 +120,7 @@ export function ImageCanvasBasicGenerationComposerView({ buildPortalMenuStyle = () => ({}), onRequestUpload, onPickReferenceFromCanvas, + onRequestProjectAssetPicker, onToggleReferenceMenu, onRememberImageModel = () => {}, hasPendingImageReferenceUploads = false, @@ -419,6 +425,17 @@ export function ImageCanvasBasicGenerationComposerView({ > 上传图片 + {onRequestProjectAssetPicker ? ( + { + setIsGenerationReferenceMenuOpen?.(false); + setIsPickingGenerationReferenceFromCanvas?.(false); + onRequestProjectAssetPicker(); + }} + > + 从项目素材中选择 + + ) : null} , ) : null} diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 5abf3d6cf..8aca4173e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1465,6 +1465,7 @@ export function ImageCanvasEditorView({ setMetadataLayer, setImageContextMenu, requestUpload, + projectAssets: assets, persistGeneratedAsset, persistUpdatedLayerResource, projectId, diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index e0a5406ae..883a8499f 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -173,6 +173,8 @@ type ImageCanvasGenerationComposerViewProps = { setIsPickingUiDesignSpecFromCanvas: Dispatch>; onOpenSpecDialog: (specType: SpecGenerationType) => void; onRequestUpload: (target: UploadTarget) => void; + /** 参考图来源菜单里的「从项目素材中选择」;不传则不显示该项。 */ + onRequestProjectAssetPicker?: () => void; onSubmitImageGeneration: (dialog: GenerateDialogState) => void; onSubmitIconSpritesheetGeneration: (dialog: GenerateDialogState) => void; onSubmitQuickEdit: () => void; @@ -1489,6 +1491,7 @@ export function ImageCanvasGenerationComposerView({ hasPendingImageReferenceUploads = false, onOpenSpecDialog, onRequestUpload, + onRequestProjectAssetPicker, onSubmitImageGeneration, onSubmitIconSpritesheetGeneration, onSubmitQuickEdit, @@ -1563,6 +1566,7 @@ export function ImageCanvasGenerationComposerView({ renderEditorPortal={renderEditorPortal} buildPortalMenuStyle={buildPortalMenuStyle} onRequestUpload={onRequestUpload} + onRequestProjectAssetPicker={onRequestProjectAssetPicker} onToggleReferenceMenu={() => setIsGenerationReferenceMenuOpen((open) => !open) } diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index b59c7187b..6c6a5fb0f 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -13,6 +13,7 @@ import type { CanvasLayer, CanvasViewport, CharacterAnimationPanelState, + CharacterReferenceImage, GenerateDialogState, PublicationMaterialsWorkflowId, QuickEditPanelState, @@ -56,6 +57,7 @@ import { SPEC_FRAME_ORIGINAL_SIZE, } from './ImageCanvasGenerationModel'; import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; +import { isProjectAssetPickerReference } from './projectAssetReferencePickerModel'; type CanvasSize = { width: number; height: number }; type SourceGenerationDialogDraftContext = { @@ -2208,6 +2210,38 @@ export function appendGenerationReference( : dialog; } +/** + * 参考图弹窗确认:把「从项目素材中选择」进来的参考图整组替换, + * 画布点选与上传得到的参考图保持原样。 + * 素材 id 通过 `sourceAssetId` 随引用进入提交数据,即本次任务的参考图 ID 快照。 + */ +export function replaceProjectAssetPickerReferences( + dialog: GenerateDialogState | null, + references: CharacterReferenceImage[], +): GenerateDialogState | null { + if ( + !dialog || + (dialog.mode !== 'generate' && + dialog.mode !== 'scene' && + dialog.mode !== 'quick-edit' && + dialog.mode !== 'icon' && + dialog.mode !== 'ui-design') + ) { + return dialog; + } + return { + ...resetFailedGenerationDialog(dialog), + generationReferences: appendLimitedImageReferences( + (dialog.generationReferences ?? []).filter( + (reference) => !isProjectAssetPickerReference(reference), + ), + references, + resolveDialogExtraImageReferenceLimit(dialog), + ), + composerOpen: true, + }; +} + export function appendPublicationReference( dialog: GenerateDialogState | null, layer: CanvasLayer, diff --git a/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx new file mode 100644 index 000000000..89dca93c4 --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.test.tsx @@ -0,0 +1,189 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { EditorAsset } from './ImageCanvasEditorTypes'; +import { ImageCanvasProjectAssetPickerDialog } from './ImageCanvasProjectAssetPickerDialog'; + +function createAsset(overrides: Partial): EditorAsset { + return { + id: 'asset-1', + label: '素材 1', + src: 'https://example.test/asset-1.png', + mediaType: 'image', + width: 64, + height: 64, + folderId: 'folder-1', + sourceKind: 'uploaded', + sourceType: 'uploaded', + persisted: true, + ...overrides, + }; +} + +const ASSETS: EditorAsset[] = [ + createAsset({ id: 'asset-hero', label: '主角立绘' }), + createAsset({ + id: 'asset-town', + label: '小镇背景', + mediaType: 'video', + src: 'https://example.test/town.mp4', + }), + createAsset({ + id: 'asset-bgm', + label: '背景音乐', + mediaType: 'audio', + src: 'https://example.test/bgm.mp3', + }), +]; + +describe('ImageCanvasProjectAssetPickerDialog', () => { + it('selects several assets, shows the count and returns the id snapshot', () => { + const onConfirm = vi.fn(); + render( + {}} + onConfirm={onConfirm} + />, + ); + + fireEvent.click(screen.getByRole('option', { name: '选择参考图主角立绘' })); + fireEvent.click(screen.getByRole('option', { name: '选择参考图小镇背景' })); + + expect(screen.getByText('已选 2 个')).toBeTruthy(); + expect( + screen + .getByRole('option', { name: '选择参考图主角立绘' }) + .getAttribute('aria-selected'), + ).toBe('true'); + + fireEvent.click(screen.getByRole('button', { name: '确认选择参考图' })); + expect(onConfirm).toHaveBeenCalledWith(['asset-hero', 'asset-town']); + }); + + it('clears only the current selection and keeps cancel free of side effects', () => { + const onCancel = vi.fn(); + const onConfirm = vi.fn(); + render( + , + ); + + expect(screen.getByText('已选 1 个')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '清空' })); + expect(screen.getByText('已选 0 个')).toBeTruthy(); + expect(onConfirm).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: '取消' })); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('deselects one asset from the selected chips', () => { + render( + {}} + onConfirm={() => {}} + />, + ); + + const selectedRow = screen.getByLabelText('已选参考图'); + fireEvent.click( + within(selectedRow).getByRole('button', { name: '取消参考图主角立绘' }), + ); + + expect(screen.getByText('已选 1 个')).toBeTruthy(); + }); + + it('filters by category and search text with dialog-local state', () => { + render( + {}} + onConfirm={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '视频' })); + expect( + screen.queryByRole('option', { name: '选择参考图主角立绘' }), + ).toBeNull(); + expect( + screen.getByRole('option', { name: '选择参考图小镇背景' }), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '全部' })); + fireEvent.change(screen.getByLabelText('搜索参考图素材'), { + target: { value: '音乐' }, + }); + expect( + screen.getByRole('option', { name: '选择参考图背景音乐' }), + ).toBeTruthy(); + expect( + screen.queryByRole('option', { name: '选择参考图小镇背景' }), + ).toBeNull(); + }); + + it('restarts from the caller-provided selection each time it opens', () => { + const { rerender } = render( + {}} + onConfirm={() => {}} + />, + ); + fireEvent.click(screen.getByRole('button', { name: '清空' })); + expect(screen.getByText('已选 0 个')).toBeTruthy(); + + rerender( + {}} + onConfirm={() => {}} + />, + ); + rerender( + {}} + onConfirm={() => {}} + />, + ); + + expect(screen.getByText('已选 1 个')).toBeTruthy(); + }); + + it('shows an empty state when the project has no registered assets', () => { + render( + {}} + onConfirm={() => {}} + />, + ); + + expect(screen.getByText('当前项目还没有已登记素材')).toBeTruthy(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx new file mode 100644 index 000000000..b49a1a872 --- /dev/null +++ b/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx @@ -0,0 +1,221 @@ +import { Check, ImageIcon, Music, Search, Video } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; + +import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton'; +import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar'; +import { UnifiedModal } from '../common/UnifiedModal'; +import type { EditorAsset } from './ImageCanvasEditorTypes'; +import { + PROJECT_ASSET_PICKER_CATEGORY_OPTIONS, + projectAssetMatchesPickerFilter, + type ProjectAssetPickerCategory, + projectAssetPickerCategory, +} from './projectAssetReferencePickerModel'; + +type ImageCanvasProjectAssetPickerDialogProps = { + open: boolean; + /** 项目已登记素材;弹窗只负责选择,不改素材本身。 */ + assets: readonly EditorAsset[]; + /** 打开时的初始选择,取消时原选择保持不变。 */ + selectedAssetIds: readonly string[]; + onCancel: () => void; + onConfirm: (assetIds: string[]) => void; +}; + +function assetIcon(asset: EditorAsset) { + const category = projectAssetPickerCategory(asset); + if (category === 'video') { + return ; + } + if (category === 'audio') { + return ; + } + return ; +} + +/** + * 参考图选择弹窗。 + * + * 画布筛选条在这里被完整复用,但筛选状态由本弹窗自己持有, + * 与资源画布、`@` 面板互不影响。确认时只回传素材 id 列表, + * 由调用方把 id 快照写进本次提交数据。 + */ +export function ImageCanvasProjectAssetPickerDialog({ + open, + assets, + selectedAssetIds, + onCancel, + onConfirm, +}: ImageCanvasProjectAssetPickerDialogProps) { + const [query, setQuery] = useState(''); + const [category, setCategory] = useState('all'); + const [selection, setSelection] = useState([]); + + // 每次打开都从调用方给的初始选择重新开始,取消不写回。 + useEffect(() => { + if (!open) return; + setQuery(''); + setCategory('all'); + setSelection([...selectedAssetIds]); + }, [open, selectedAssetIds]); + + const visibleAssets = useMemo( + () => + assets.filter((asset) => + projectAssetMatchesPickerFilter(asset, { category, query }), + ), + [assets, category, query], + ); + const assetById = useMemo( + () => new Map(assets.map((asset) => [asset.id, asset])), + [assets], + ); + const selectedAssets = selection.flatMap((assetId) => { + const asset = assetById.get(assetId); + return asset ? [asset] : []; + }); + + function toggleAsset(assetId: string) { + setSelection((current) => + current.includes(assetId) + ? current.filter((item) => item !== assetId) + : [...current, assetId], + ); + } + + return ( + + + 已选 {selection.length} 个 + + setSelection([])} + > + 清空 + + + 取消 + + onConfirm(selection)} + > + 确认 + + > + } + > + {selectedAssets.length > 0 ? ( + + {selectedAssets.map((asset) => ( + toggleAsset(asset.id)} + > + {assetIcon(asset)} + {asset.label} + + ))} + + ) : null} + setCategory(nextCategory)} + className="mb-3" + /> + {visibleAssets.length === 0 ? ( + + {assets.length === 0 ? '当前项目还没有已登记素材' : '没有匹配的素材'} + + ) : ( + + {visibleAssets.map((asset) => { + const selected = selection.includes(asset.id); + return ( + toggleAsset(asset.id)} + > + + {projectAssetPickerCategory(asset) === 'image' ? ( + + ) : ( + assetIcon(asset) + )} + + + + {asset.label} + + {selected ? ( + + ) : null} + + + ); + })} + + )} + {visibleAssets.length > 0 ? ( + + + {visibleAssets.length} / {assets.length} + + ) : null} + + ); +} diff --git a/src/components/image-editor/projectAssetReferencePickerModel.test.ts b/src/components/image-editor/projectAssetReferencePickerModel.test.ts new file mode 100644 index 000000000..5555c3c85 --- /dev/null +++ b/src/components/image-editor/projectAssetReferencePickerModel.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; + +import type { + EditorAsset, + GenerateDialogState, +} from './ImageCanvasEditorTypes'; +import { replaceProjectAssetPickerReferences } from './ImageCanvasGenerationDialogModel'; +import { createGenerationInputReference } from './ImageCanvasGenerationModel'; +import { + PROJECT_ASSET_PICKER_CATEGORY_OPTIONS, + projectAssetMatchesPickerFilter, + projectAssetPickerCategory, + projectAssetPickerReferences, +} from './projectAssetReferencePickerModel'; + +function createAsset(overrides: Partial): EditorAsset { + return { + id: 'asset-1', + label: '素材 1', + src: 'https://example.test/asset-1.png', + mediaType: 'image', + width: 64, + height: 64, + folderId: 'folder-1', + sourceKind: 'uploaded', + sourceType: 'uploaded', + persisted: true, + ...overrides, + }; +} + +describe('参考图弹窗素材筛选与快照', () => { + it('derives the category from the asset media type', () => { + expect(projectAssetPickerCategory({ mediaType: 'image' })).toBe('image'); + expect(projectAssetPickerCategory({ mediaType: 'video' })).toBe('video'); + expect(projectAssetPickerCategory({ mediaType: 'audio' })).toBe('audio'); + expect(projectAssetPickerCategory({})).toBe('image'); + expect( + PROJECT_ASSET_PICKER_CATEGORY_OPTIONS.map((item) => item.id), + ).toEqual(['all', 'image', 'video', 'audio']); + }); + + it('stacks the category filter with the search query', () => { + const asset = createAsset({ label: '小镇背景', assetKind: 'scene' }); + + expect( + projectAssetMatchesPickerFilter(asset, { category: 'all', query: '' }), + ).toBe(true); + expect( + projectAssetMatchesPickerFilter(asset, { + category: 'image', + query: '小镇', + }), + ).toBe(true); + expect( + projectAssetMatchesPickerFilter(asset, { + category: 'image', + query: 'scene', + }), + ).toBe(true); + expect( + projectAssetMatchesPickerFilter(asset, { + category: 'audio', + query: '小镇', + }), + ).toBe(false); + expect( + projectAssetMatchesPickerFilter(asset, { + category: 'all', + query: '主角', + }), + ).toBe(false); + }); + + it('builds references that carry the asset id snapshot', () => { + const assets = [ + createAsset({ + id: 'asset-hero', + label: '主角立绘', + objectKey: 'oss/hero.png', + }), + createAsset({ + id: 'asset-bgm', + label: '背景音乐', + mediaType: 'audio', + sourceResourceId: 'resource-bgm', + }), + ]; + + const references = projectAssetPickerReferences(assets, [ + 'asset-hero', + 'asset-bgm', + 'asset-missing', + ]); + + expect(references).toEqual([ + { + id: 'project-asset-asset-hero', + label: '主角立绘', + src: 'oss/hero.png', + mediaType: 'image', + objectKey: 'oss/hero.png', + resourceId: null, + sourceAssetId: 'asset-hero', + }, + { + id: 'project-asset-asset-bgm', + label: '背景音乐', + src: 'https://example.test/asset-1.png', + mediaType: 'audio', + resourceId: 'resource-bgm', + sourceAssetId: 'asset-bgm', + }, + ]); + }); + + it('records the selected asset ids as the submitted task reference snapshot', () => { + const assets = [ + createAsset({ id: 'asset-hero', label: '主角立绘' }), + createAsset({ id: 'asset-town', label: '小镇背景' }), + ]; + const references = projectAssetPickerReferences(assets, [ + 'asset-hero', + 'asset-town', + ]); + + const snapshot = references.flatMap((reference) => + createGenerationInputReference('参考图', reference), + ); + + expect(snapshot).toEqual([ + { + title: '参考图', + label: '主角立绘', + refType: 'asset', + refId: 'asset-hero', + }, + { + title: '参考图', + label: '小镇背景', + refType: 'asset', + refId: 'asset-town', + }, + ]); + }); + + it('returns no references and dedupes repeated ids', () => { + const assets = [createAsset({ id: 'asset-hero', label: '主角立绘' })]; + + expect(projectAssetPickerReferences(assets, [])).toEqual([]); + expect( + projectAssetPickerReferences(assets, ['asset-hero', 'asset-hero']), + ).toHaveLength(1); + }); + + it('replaces the library-sourced references and keeps canvas-picked ones', () => { + const assets = [ + createAsset({ id: 'asset-hero', label: '主角立绘' }), + createAsset({ id: 'asset-town', label: '小镇背景' }), + ]; + const dialog = { + mode: 'generate', + prompt: '画一张封面', + status: 'idle', + generationReferences: [ + { + id: 'canvas-layer-1', + label: '画布点选', + src: 'https://example.test/canvas.png', + mediaType: 'image', + }, + { + id: 'project-asset-asset-old', + label: '已移除素材', + src: 'https://example.test/old.png', + mediaType: 'image', + sourceAssetId: 'asset-old', + }, + ], + } satisfies GenerateDialogState; + + const next = replaceProjectAssetPickerReferences( + dialog, + projectAssetPickerReferences(assets, ['asset-hero', 'asset-town']), + ); + + expect( + next?.generationReferences?.map((reference) => reference.id), + ).toEqual([ + 'canvas-layer-1', + 'project-asset-asset-hero', + 'project-asset-asset-town', + ]); + expect(next?.composerOpen).toBe(true); + }); + + it('leaves dialogs outside the reference-composer modes untouched', () => { + const dialog = { + mode: 'video', + prompt: '', + status: 'idle', + } satisfies GenerateDialogState; + + expect(replaceProjectAssetPickerReferences(dialog, [])).toBe(dialog); + expect(replaceProjectAssetPickerReferences(null, [])).toBeNull(); + }); +}); diff --git a/src/components/image-editor/projectAssetReferencePickerModel.ts b/src/components/image-editor/projectAssetReferencePickerModel.ts new file mode 100644 index 000000000..993e3f529 --- /dev/null +++ b/src/components/image-editor/projectAssetReferencePickerModel.ts @@ -0,0 +1,93 @@ +import type { PlatformResourceFilterOption } from '../../../packages/shared/src/components/PlatformResourceFilterBar'; +import { + type CharacterReferenceImage, + type EditorAsset, +} from './ImageCanvasEditorTypes'; + +/** + * 参考图弹窗的分类口径:只按素材的媒体类型分组,与画布的「功能分类」各自独立 + * (画布用 manifest `category`,这里是编辑器素材库自己的可读分类)。 + */ +export type ProjectAssetPickerMediaCategory = 'image' | 'video' | 'audio'; + +export type ProjectAssetPickerCategory = + | 'all' + | ProjectAssetPickerMediaCategory; + +export const PROJECT_ASSET_PICKER_CATEGORY_OPTIONS: readonly PlatformResourceFilterOption[] = + [ + { id: 'all', label: '全部' }, + { id: 'image', label: '图片' }, + { id: 'video', label: '视频' }, + { id: 'audio', label: '音频' }, + ]; + +export function projectAssetPickerCategory( + asset: Pick, +): ProjectAssetPickerMediaCategory { + const mediaType = asset.mediaType ?? 'image'; + if (mediaType === 'video') return 'video'; + if (mediaType === 'audio') return 'audio'; + return 'image'; +} + +/** + * 弹窗筛选判据:分类 + 搜索词,两者叠加。 + * 与资源画布、`@` 面板的筛选状态无关,调用方各自持有输入值。 + */ +export function projectAssetMatchesPickerFilter( + asset: EditorAsset, + { category, query }: { category: ProjectAssetPickerCategory; query: string }, +): boolean { + if (category !== 'all' && projectAssetPickerCategory(asset) !== category) { + return false; + } + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return true; + return [asset.label, asset.assetKind ?? ''].some((value) => + value.toLowerCase().includes(normalizedQuery), + ); +} + +/** + * 从项目素材弹窗加进参考图列表的引用 id 前缀。 + * 用于在确认时整组替换,避免画布点选 / 上传来的参考图被一并清掉。 + */ +export const PROJECT_ASSET_REFERENCE_ID_PREFIX = 'project-asset-'; + +export function isProjectAssetPickerReference(reference: { + id: string; +}): boolean { + return reference.id.startsWith(PROJECT_ASSET_REFERENCE_ID_PREFIX); +} + +/** + * 把弹窗里选中的素材转成参考图。`sourceAssetId` 用素材自身 id, + * 因此提交时 `createGenerationInputReference` 会按 `asset` 记下这份 ID 快照, + * 源素材后续变化不影响已提交任务。 + */ +export function projectAssetPickerReferences( + assets: readonly EditorAsset[], + selectedAssetIds: readonly string[], +): CharacterReferenceImage[] { + if (selectedAssetIds.length === 0) return []; + const assetById = new Map(assets.map((asset) => [asset.id, asset])); + const references: CharacterReferenceImage[] = []; + const seen = new Set(); + for (const assetId of selectedAssetIds) { + const asset = assetById.get(assetId); + if (!asset || seen.has(asset.id)) continue; + seen.add(asset.id); + references.push({ + id: `${PROJECT_ASSET_REFERENCE_ID_PREFIX}${asset.id}`, + label: asset.label, + src: asset.objectKey?.trim() || asset.src, + mediaType: asset.mediaType ?? 'image', + ...(asset.objectKey ? { objectKey: asset.objectKey } : {}), + ...(asset.assetObjectId ? { assetObjectId: asset.assetObjectId } : {}), + resourceId: asset.sourceResourceId ?? null, + sourceAssetId: asset.id, + }); + } + return references; +} diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index 763d0e87d..25c9e2dc9 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -9,6 +9,7 @@ import { useCallback, useEffect, useRef, + useState, } from 'react'; import type { @@ -27,6 +28,7 @@ import type { CanvasLayer, CanvasTool, CanvasViewport, + EditorAsset, GenerateDialogState, ImageContextMenuState, PublicationMaterialsWorkflowId, @@ -34,6 +36,7 @@ import type { UploadTarget, } from './ImageCanvasEditorTypes'; import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView'; +import { replaceProjectAssetPickerReferences } from './ImageCanvasGenerationDialogModel'; import { resolveCharacterAnimationPanelStyle, resolveCropExpandPanelStyle, @@ -42,7 +45,12 @@ import { resolveIconComposerStyle, resolveQuickEditPanelStyle, } from './ImageCanvasOverlayModel'; +import { ImageCanvasProjectAssetPickerDialog } from './ImageCanvasProjectAssetPickerDialog'; import { PUBLICATION_MATERIALS_WORKFLOWS } from './ImageCanvasPublicationMaterialsModel'; +import { + isProjectAssetPickerReference, + projectAssetPickerReferences, +} from './projectAssetReferencePickerModel'; import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; type CanvasGenerationDialogUpdater = ( @@ -103,6 +111,8 @@ type ImageCanvasGenerationSurfaceOptions = { setMetadataLayer: Dispatch>; setImageContextMenu: Dispatch>; requestUpload: (target: UploadTarget) => void; + /** 项目已登记素材,用于参考图弹窗;为空时参考图来源菜单不出现该入口。 */ + projectAssets?: readonly EditorAsset[]; persistGeneratedAsset?: (layer: CanvasLayer) => void; persistUpdatedLayerResource?: (layer: CanvasLayer) => void; projectId?: string | null; @@ -192,6 +202,7 @@ export function useImageCanvasGenerationSurface({ setMetadataLayer, setImageContextMenu, requestUpload, + projectAssets = [], persistGeneratedAsset, persistUpdatedLayerResource, projectId, @@ -375,6 +386,36 @@ export function useImageCanvasGenerationSurface({ [generationWorkflow, openToolbarOptionMenu, setActiveTool], ); + /** + * 参考图弹窗的开关与初始选择。选择列表来自「项目素材」这一组引用, + * 与画布点选 / 上传来的参考图互不覆盖。 + */ + const [projectAssetPickerOpen, setProjectAssetPickerOpen] = useState(false); + const projectAssetPickerSelectedIds = ( + generateDialog?.generationReferences ?? [] + ) + .filter((reference) => isProjectAssetPickerReference(reference)) + .flatMap((reference) => { + const assetId = reference.sourceAssetId?.trim(); + return assetId ? [assetId] : []; + }); + const applyProjectAssetPickerSelection = useCallback( + (assetIds: string[]) => { + const references = projectAssetPickerReferences(projectAssets, assetIds); + const nonImageCount = references.filter( + (reference) => (reference.mediaType ?? 'image') !== 'image', + ).length; + if (nonImageCount > 0) { + generationWorkflow.showGenerationWarning('参考图只支持图片素材'); + } + setGenerateDialog((currentDialog) => + replaceProjectAssetPickerReferences(currentDialog, references), + ); + setProjectAssetPickerOpen(false); + }, + [generationWorkflow, projectAssets, setGenerateDialog], + ); + const generationComposerNode = ( <> {generationWorkflow.generationWarning ? ( @@ -502,6 +543,20 @@ export function useImageCanvasGenerationSurface({ } onOpenSpecDialog={generationWorkflow.openSpecDialog} onRequestUpload={requestUpload} + onRequestProjectAssetPicker={ + projectAssets.length > 0 && + (generateDialog?.mode === 'generate' || + generateDialog?.mode === 'scene' || + generateDialog?.mode === 'quick-edit') + ? () => { + generationWorkflow.setIsGenerationReferenceMenuOpen(false); + generationWorkflow.setIsPickingGenerationReferenceFromCanvas( + false, + ); + setProjectAssetPickerOpen(true); + } + : undefined + } onSubmitImageGeneration={(dialog) => void generationWorkflow.submitImageGeneration(dialog) } @@ -581,6 +636,13 @@ export function useImageCanvasGenerationSurface({ , ) : null} + setProjectAssetPickerOpen(false)} + onConfirm={applyProjectAssetPickerSelection} + /> > );
+ {assets.length === 0 ? '当前项目还没有已登记素材' : '没有匹配的素材'} +
+ + {visibleAssets.length} / {assets.length} +