WIP: AGC 资源画布与替换改造 V3.0 #316

Draft
suzmii wants to merge 131 commits from feat/agc-canvas-resource-workbench-v3 into master
9 changed files with 828 additions and 0 deletions
Showing only changes of commit 3508957eda - Show all commits
@@ -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}
/>
</>
);