补齐 AGC V3 C9 参考图选择弹窗与提交快照
- 新增 ImageCanvasProjectAssetPickerDialog:独立弹窗,网格卡片、多选、筛选+搜索、清空/确认/取消,筛选状态自持 - 新增 projectAssetReferencePickerModel:素材分类与筛选判据、素材 id 快照引用构造、弹窗引用前缀判定 - 生成 composer 参考图来源菜单新增「从项目素材中选择」,由宿主决定是否出现,不改变原有从画布选择与上传两条路径 - 新增 replaceProjectAssetPickerReferences:确认时整组替换素材库来源的参考图,保留画布点选与上传的参考图 - 素材 id 经 sourceAssetId 走既有 createGenerationInputReference 落进 generationInputs.references,不新增后端接口 - 新增 13 条测试覆盖弹窗交互、筛选叠加、快照落库与整组替换语义
This commit is contained in:
@@ -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({
|
||||
>
|
||||
上传图片
|
||||
</PlatformFloatingMenuItem>
|
||||
{onRequestProjectAssetPicker ? (
|
||||
<PlatformFloatingMenuItem
|
||||
onClick={() => {
|
||||
setIsGenerationReferenceMenuOpen?.(false);
|
||||
setIsPickingGenerationReferenceFromCanvas?.(false);
|
||||
onRequestProjectAssetPicker();
|
||||
}}
|
||||
>
|
||||
从项目素材中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
) : null}
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
|
||||
@@ -1465,6 +1465,7 @@ export function ImageCanvasEditorView({
|
||||
setMetadataLayer,
|
||||
setImageContextMenu,
|
||||
requestUpload,
|
||||
projectAssets: assets,
|
||||
persistGeneratedAsset,
|
||||
persistUpdatedLayerResource,
|
||||
projectId,
|
||||
|
||||
@@ -173,6 +173,8 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
setIsPickingUiDesignSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>): 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(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={[]}
|
||||
onCancel={() => {}}
|
||||
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(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={['asset-bgm']}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={['asset-hero', 'asset-town']}
|
||||
onCancel={() => {}}
|
||||
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(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={[]}
|
||||
onCancel={() => {}}
|
||||
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(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={['asset-hero']}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空' }));
|
||||
expect(screen.getByText('已选 0 个')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open={false}
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={['asset-hero']}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={ASSETS}
|
||||
selectedAssetIds={['asset-hero']}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('已选 1 个')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows an empty state when the project has no registered assets', () => {
|
||||
render(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={[]}
|
||||
selectedAssetIds={[]}
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('当前项目还没有已登记素材')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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 <Video className="h-4 w-4" aria-hidden="true" />;
|
||||
}
|
||||
if (category === 'audio') {
|
||||
return <Music className="h-4 w-4" aria-hidden="true" />;
|
||||
}
|
||||
return <ImageIcon className="h-4 w-4" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参考图选择弹窗。
|
||||
*
|
||||
* 画布筛选条在这里被完整复用,但筛选状态由本弹窗自己持有,
|
||||
* 与资源画布、`@` 面板互不影响。确认时只回传素材 id 列表,
|
||||
* 由调用方把 id 快照写进本次提交数据。
|
||||
*/
|
||||
export function ImageCanvasProjectAssetPickerDialog({
|
||||
open,
|
||||
assets,
|
||||
selectedAssetIds,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ImageCanvasProjectAssetPickerDialogProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ProjectAssetPickerCategory>('all');
|
||||
const [selection, setSelection] = useState<string[]>([]);
|
||||
|
||||
// 每次打开都从调用方给的初始选择重新开始,取消不写回。
|
||||
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 (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title="选择参考图"
|
||||
size="lg"
|
||||
portalTheme="light"
|
||||
closeLabel="关闭选择参考图"
|
||||
onClose={onCancel}
|
||||
panelClassName="image-canvas-editor__project-asset-picker"
|
||||
bodyClassName="image-canvas-editor__project-asset-picker-body"
|
||||
footer={
|
||||
<>
|
||||
<span className="mr-auto text-xs text-[var(--platform-text-base)]">
|
||||
已选 {selection.length} 个
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
disabled={selection.length === 0}
|
||||
onClick={() => setSelection([])}
|
||||
>
|
||||
清空
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-label="确认选择参考图"
|
||||
onClick={() => onConfirm(selection)}
|
||||
>
|
||||
确认
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{selectedAssets.length > 0 ? (
|
||||
<div className="mb-3 flex flex-wrap gap-1.5" aria-label="已选参考图">
|
||||
{selectedAssets.map((asset) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
aria-label={`取消参考图${asset.label}`}
|
||||
className="platform-category-chip gap-1 px-2 text-xs"
|
||||
onClick={() => toggleAsset(asset.id)}
|
||||
>
|
||||
{assetIcon(asset)}
|
||||
<span>{asset.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<PlatformResourceFilterBar
|
||||
ariaLabel="参考图筛选"
|
||||
search={{
|
||||
value: query,
|
||||
label: '搜索参考图素材',
|
||||
placeholder: '搜索素材名称',
|
||||
onChange: setQuery,
|
||||
}}
|
||||
categoryItems={PROJECT_ASSET_PICKER_CATEGORY_OPTIONS}
|
||||
activeCategoryId={category}
|
||||
onCategoryChange={(nextCategory) => setCategory(nextCategory)}
|
||||
className="mb-3"
|
||||
/>
|
||||
{visibleAssets.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-[var(--platform-text-base)]">
|
||||
{assets.length === 0 ? '当前项目还没有已登记素材' : '没有匹配的素材'}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4"
|
||||
role="listbox"
|
||||
aria-label="参考图素材"
|
||||
aria-multiselectable
|
||||
>
|
||||
{visibleAssets.map((asset) => {
|
||||
const selected = selection.includes(asset.id);
|
||||
return (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={`选择参考图${asset.label}`}
|
||||
className={[
|
||||
'relative flex min-h-[7.5rem] flex-col overflow-hidden rounded-[0.9rem] border text-left transition',
|
||||
selected
|
||||
? 'border-[var(--platform-accent)] bg-white shadow-sm'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:bg-white',
|
||||
].join(' ')}
|
||||
onClick={() => toggleAsset(asset.id)}
|
||||
>
|
||||
<span className="flex h-20 w-full items-center justify-center overflow-hidden bg-white/60">
|
||||
{projectAssetPickerCategory(asset) === 'image' ? (
|
||||
<img
|
||||
src={asset.thumbnailSrc || asset.src}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
assetIcon(asset)
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1 px-2 py-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--platform-text-strong)]">
|
||||
{asset.label}
|
||||
</span>
|
||||
{selected ? (
|
||||
<Check
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{visibleAssets.length > 0 ? (
|
||||
<p className="mt-2 text-center text-xs text-[var(--platform-text-base)]">
|
||||
<Search className="mr-1 inline h-3 w-3" aria-hidden="true" />
|
||||
{visibleAssets.length} / {assets.length}
|
||||
</p>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
@@ -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>): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<ProjectAssetPickerCategory>[] =
|
||||
[
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'image', label: '图片' },
|
||||
{ id: 'video', label: '视频' },
|
||||
{ id: 'audio', label: '音频' },
|
||||
];
|
||||
|
||||
export function projectAssetPickerCategory(
|
||||
asset: Pick<EditorAsset, 'mediaType'>,
|
||||
): 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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<SetStateAction<CanvasLayer | null>>;
|
||||
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||
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({
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open={projectAssetPickerOpen}
|
||||
assets={projectAssets}
|
||||
selectedAssetIds={projectAssetPickerSelectedIds}
|
||||
onCancel={() => setProjectAssetPickerOpen(false)}
|
||||
onConfirm={applyProjectAssetPickerSelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user