合并主分支
合并远端主线,保留钱包快照竞态修复。
This commit is contained in:
@@ -54,6 +54,7 @@ type ImageCanvasBasicGenerationComposerViewProps = {
|
||||
onPickReferenceFromCanvas?: () => void;
|
||||
onToggleReferenceMenu?: () => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
dialogLabel?: string;
|
||||
includeDimensions?: boolean;
|
||||
@@ -107,6 +108,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
onPickReferenceFromCanvas,
|
||||
onToggleReferenceMenu,
|
||||
onRememberImageModel = () => {},
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
dialogLabel,
|
||||
includeDimensions = true,
|
||||
@@ -271,6 +273,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
includeDimensions={shouldIncludeDimensions}
|
||||
includeModel={includeModel}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
|
||||
dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix}
|
||||
optionLabelPrefix={resolvedOptionLabelPrefix}
|
||||
|
||||
@@ -44,6 +44,7 @@ type ImageCanvasCharacterGenerationComposerViewProps = {
|
||||
onOpenSpecDialog: (specType: SpecGenerationType) => void;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
@@ -97,6 +98,7 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
onOpenSpecDialog,
|
||||
onRequestUpload,
|
||||
onRememberImageModel,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasCharacterGenerationComposerViewProps) {
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
@@ -289,6 +291,7 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
kind: 'character',
|
||||
|
||||
@@ -40,6 +40,7 @@ const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -74,7 +75,10 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
uploadEditorMediaAssetObjectFile: vi.fn(),
|
||||
// 中文注释:inline 源图上传走的是 object-only 版本,不是带签名 URL 的那个。留成裸 vi.fn()
|
||||
// 会返回 undefined,取 objectKey 抛的 TypeError 被 extractUiDesignAssets 的 catch 吞成
|
||||
// window.alert,最终只表现为「提取接口一次都没调」,报错里看不到任何线索。
|
||||
uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock,
|
||||
}));
|
||||
|
||||
vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({
|
||||
@@ -113,6 +117,17 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
assetObjectId: 'asset-object-marked-ui-design',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
src: 'https://oss.example.com/marked-ui-design.png',
|
||||
});
|
||||
uploadEditorMediaAssetObjectFileMock.mockReset();
|
||||
uploadEditorMediaAssetObjectFileMock.mockResolvedValue({
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
assetObjectId: 'asset-object-marked-ui-design',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3387,7 +3402,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
'https://assets.test/generated-character-drafts/editor/frame1.png',
|
||||
);
|
||||
});
|
||||
expect(screen.getByText('1/48')).toBeTruthy();
|
||||
// 中文注释:分子不能钉死。序列帧图层默认自动播放(frames.length > 1 即 isPlaying),
|
||||
// 48 帧 6 秒算出 125ms 的真实 setInterval,而这里用的是真实定时器下的 waitFor——
|
||||
// 上一条 waitFor 在 CI 上多轮询一次就已经越过 125ms,计数器变成 2/48。要断言的是
|
||||
// 「按序列帧播放器渲染、总帧数 48」(下一行排除视频元素),分母才是这条断言的意义。
|
||||
expect(screen.getByText(/^\d+\/48$/u)).toBeTruthy();
|
||||
expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull();
|
||||
expect(screen.getByText('动作')).toBeTruthy();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -216,6 +216,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
onPerfectPixel: vi.fn(),
|
||||
onSplitIconSpritesheet: vi.fn(),
|
||||
onExtractUiDesignAssets: vi.fn(),
|
||||
onUiAssetExtractionToolChange: vi.fn(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
EditorImageGenerationStyle,
|
||||
EditorPixelArtSnapInput,
|
||||
EditorVideoAspectRatio,
|
||||
EditorVideoModel,
|
||||
EditorVideoResolution,
|
||||
@@ -186,6 +187,16 @@ export type PublicationMaterialsWorkflowId =
|
||||
| 'publication-detail-gallery'
|
||||
| 'publication-promo-poster';
|
||||
|
||||
export type PerfectPixelOperationSnapshot = {
|
||||
version: 1;
|
||||
kind: 'perfect-pixel';
|
||||
operationId: string;
|
||||
taskId: string;
|
||||
request: EditorPixelArtSnapInput;
|
||||
submittedAt: number;
|
||||
reconcileUntil: number;
|
||||
};
|
||||
|
||||
export type GenerateDialogState = {
|
||||
id?: string;
|
||||
mode:
|
||||
@@ -203,7 +214,7 @@ export type GenerateDialogState = {
|
||||
| 'audio-background-music';
|
||||
prompt: string;
|
||||
assetLabel?: string;
|
||||
status: 'idle' | 'generating' | 'failed';
|
||||
status: 'idle' | 'generating' | 'pending-confirmation' | 'failed';
|
||||
composerOpen?: boolean;
|
||||
sourceLayerId?: string;
|
||||
generatedLayerId?: string;
|
||||
@@ -241,6 +252,17 @@ export type GenerateDialogState = {
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
errorMessage?: string;
|
||||
// 中文注释:标记该占位的收口只能由创建它的页面会话完成——链路是同步 HTTP、服务端没有
|
||||
// durable job,进程一死就再没有任何东西会把它推向终态。队列型占位(去除背景恒队列、
|
||||
// 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后
|
||||
// 必须原样恢复。
|
||||
requiresLiveSession?: boolean;
|
||||
// 中文注释:请求账本只存在于本机(见 perfectPixelOperationStore),布局里只留这个 id
|
||||
// 标记「该占位是一次完美像素操作」。换设备打开时标记还在、账本读不到,占位收口为可删除
|
||||
// 的失败态——这是明确设计,不得据此阻断用户删除或重做。
|
||||
perfectPixelOperationId?: string;
|
||||
perfectPixelOperation?: PerfectPixelOperationSnapshot;
|
||||
perfectPixelOperationInvalid?: boolean;
|
||||
generationStartedAt?: number;
|
||||
generationFinishedAt?: number;
|
||||
placeholder?: {
|
||||
@@ -293,6 +315,7 @@ export type CanvasHistoryActionType =
|
||||
| 'generate-image'
|
||||
| 'expand-image'
|
||||
| 'remove-background'
|
||||
| 'perfect-pixel'
|
||||
| 'split-atlas'
|
||||
| 'replace-image'
|
||||
| 'show-image'
|
||||
@@ -367,6 +390,7 @@ export type CanvasContextMenuState =
|
||||
};
|
||||
|
||||
export type QuickEditPanelState = {
|
||||
referenceUploadContextId?: string;
|
||||
mode?: 'quick-edit' | 'redraw';
|
||||
sourceLayerId: string;
|
||||
prompt: string;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,6 @@ function mockStateSetter<T>() {
|
||||
return vi.fn() as unknown as Dispatch<SetStateAction<T>>;
|
||||
}
|
||||
|
||||
|
||||
function createComposerProps(
|
||||
generateDialog: GenerateDialogState,
|
||||
overrides: Partial<
|
||||
@@ -117,6 +116,118 @@ function renderComposer(
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationComposerView', () => {
|
||||
it.each([
|
||||
{
|
||||
status: 'pending-confirmation' as const,
|
||||
message: '结果尚未确认,系统不会自动重复提交。',
|
||||
},
|
||||
{
|
||||
status: 'failed' as const,
|
||||
message: '完美像素请求尚未发出,请重试同一操作。',
|
||||
},
|
||||
])('$status 的完美像素操作只展示原操作重试', ({ status, message }) => {
|
||||
const dialogId = `dialog-perfect-pixel-${status}`;
|
||||
const onRetryPerfectPixelOperation = vi.fn();
|
||||
const onSubmitImageGeneration = vi.fn();
|
||||
|
||||
renderComposer(
|
||||
{
|
||||
id: dialogId,
|
||||
mode: 'generate',
|
||||
prompt: '不应重新提交的普通提示词',
|
||||
status,
|
||||
composerOpen: true,
|
||||
imageModel: 'gpt-image-2',
|
||||
...(status === 'failed' ? { errorMessage: message } : {}),
|
||||
perfectPixelOperation: {
|
||||
version: 1,
|
||||
kind: 'perfect-pixel',
|
||||
operationId: dialogId,
|
||||
taskId: `pixel-art-snap-${dialogId}`,
|
||||
request: {
|
||||
sourceImageSrc: 'ref:project-resource:resource-source',
|
||||
projectId: 'project-1',
|
||||
sourceResourceId: 'resource-source',
|
||||
assetKind: 'character',
|
||||
assetLabel: '角色 · 完美像素',
|
||||
canvasCompletion: {
|
||||
dialogId,
|
||||
title: '角色 · 完美像素',
|
||||
placeholder: {
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: 320,
|
||||
height: 320,
|
||||
originalWidth: 640,
|
||||
originalHeight: 640,
|
||||
},
|
||||
},
|
||||
},
|
||||
submittedAt: 1_700_000_000_000,
|
||||
reconcileUntil: 1_700_000_120_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
onRetryPerfectPixelOperation,
|
||||
onSubmitImageGeneration,
|
||||
},
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '完美像素操作' });
|
||||
expect(within(panel).getByText(message)).toBeTruthy();
|
||||
expect(within(panel).queryByRole('textbox')).toBeNull();
|
||||
expect(within(panel).queryByText('不应重新提交的普通提示词')).toBeNull();
|
||||
expect(within(panel).queryByText('gpt-image-2')).toBeNull();
|
||||
expect(within(panel).queryByRole('button', { name: '修改' })).toBeNull();
|
||||
|
||||
const retryButton = within(panel).getByRole('button', {
|
||||
name: '重试同一完美像素操作',
|
||||
});
|
||||
expect(within(panel).getAllByRole('button')).toEqual([retryButton]);
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
expect(onRetryPerfectPixelOperation).toHaveBeenCalledOnce();
|
||||
expect(onRetryPerfectPixelOperation).toHaveBeenCalledWith(dialogId);
|
||||
expect(onSubmitImageGeneration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('无效完美像素标记只读展示错误,不暴露普通生成提交入口', () => {
|
||||
const onRetryPerfectPixelOperation = vi.fn();
|
||||
const onSubmitImageGeneration = vi.fn();
|
||||
|
||||
renderComposer(
|
||||
{
|
||||
id: 'dialog-perfect-pixel-invalid',
|
||||
mode: 'generate',
|
||||
prompt: '不得重新生成',
|
||||
status: 'failed',
|
||||
composerOpen: true,
|
||||
imageModel: 'gpt-image-2',
|
||||
perfectPixelOperationInvalid: true,
|
||||
errorMessage: '完美像素操作快照身份不匹配。',
|
||||
},
|
||||
{
|
||||
onRetryPerfectPixelOperation,
|
||||
onSubmitImageGeneration,
|
||||
},
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '完美像素操作' });
|
||||
expect(within(panel).getByRole('alert').textContent).toBe(
|
||||
'完美像素操作快照身份不匹配。',
|
||||
);
|
||||
expect(within(panel).queryByRole('textbox')).toBeNull();
|
||||
expect(within(panel).queryByRole('button')).toBeNull();
|
||||
expect(within(panel).queryByText('不得重新生成')).toBeNull();
|
||||
expect(within(panel).queryByText('gpt-image-2')).toBeNull();
|
||||
|
||||
fireEvent.submit(panel);
|
||||
|
||||
expect(onRetryPerfectPixelOperation).not.toHaveBeenCalled();
|
||||
expect(onSubmitImageGeneration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('让快速编辑显示提示词、尺寸和模型选择', () => {
|
||||
renderComposer({
|
||||
mode: 'quick-edit',
|
||||
@@ -140,8 +251,9 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
expect(panel.className).not.toContain(
|
||||
'image-canvas-editor__quick-edit-panel',
|
||||
);
|
||||
expect(within(panel).getByRole('textbox', { name: '快速编辑提示词' }))
|
||||
.toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: '快速编辑提示词' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).queryByRole('button', { name: '添加参考图' }),
|
||||
).toBeNull();
|
||||
@@ -163,22 +275,24 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
|
||||
it('让生成UI设计图面板复用普通图片生成面板的纵向结构', () => {
|
||||
const setGenerateDialog = vi.fn();
|
||||
renderComposer({
|
||||
mode: 'ui-design',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
uiDesignSpecReference: null,
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
}, {
|
||||
isUiDesignSpecMenuOpen: true,
|
||||
setGenerateDialog:
|
||||
setGenerateDialog as unknown as Dispatch<
|
||||
renderComposer(
|
||||
{
|
||||
mode: 'ui-design',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
uiDesignSpecReference: null,
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
},
|
||||
{
|
||||
isUiDesignSpecMenuOpen: true,
|
||||
setGenerateDialog: setGenerateDialog as unknown as Dispatch<
|
||||
SetStateAction<GenerateDialogState | null>
|
||||
>,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成UI设计图' });
|
||||
expect(
|
||||
@@ -201,9 +315,12 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
fireEvent.change(within(panel).getByRole('textbox', { name: 'UI设计要求' }), {
|
||||
target: { value: '主界面和结算弹窗' },
|
||||
});
|
||||
fireEvent.change(
|
||||
within(panel).getByRole('textbox', { name: 'UI设计要求' }),
|
||||
{
|
||||
target: { value: '主界面和结算弹窗' },
|
||||
},
|
||||
);
|
||||
expect(setGenerateDialog).toHaveBeenCalled();
|
||||
const menu = screen.getByRole('menu', { name: '参考图来源' });
|
||||
expect(
|
||||
@@ -279,47 +396,56 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
['publication-cover-image', '游戏首图', '720 x 540'],
|
||||
['publication-detail-gallery', '详情五图', '720 x 1280'],
|
||||
['publication-promo-poster', '运营海报', '1280 x 720'],
|
||||
] as const)('让%s 宣发素材面板对齐生成角色面板结构', (workflowId, label, sizeLabel) => {
|
||||
renderComposer({
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
publicationWorkflowId: workflowId,
|
||||
publicationGameInfo: {
|
||||
gameName: '',
|
||||
gameCategories: '',
|
||||
gameDescription: '',
|
||||
},
|
||||
publicationReferences: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
});
|
||||
] as const)(
|
||||
'让%s 宣发素材面板对齐生成角色面板结构',
|
||||
(workflowId, label, sizeLabel) => {
|
||||
renderComposer({
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
publicationWorkflowId: workflowId,
|
||||
publicationGameInfo: {
|
||||
gameName: '',
|
||||
gameCategories: '',
|
||||
gameDescription: '',
|
||||
},
|
||||
publicationReferences: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: `${label}生成卡片` });
|
||||
expect(panel.className).toContain('image-canvas-editor__character-composer');
|
||||
expect(panel.className).toContain('image-canvas-editor__publication-composer');
|
||||
expect(
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(within(panel).getByRole('button', { name: '添加参考图' })).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: `${label}游戏名` }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }),
|
||||
).toBeTruthy();
|
||||
expect(panel.textContent).toContain(sizeLabel);
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '生成' }).textContent).toBe(
|
||||
'生成3泥点',
|
||||
);
|
||||
});
|
||||
const panel = screen.getByRole('dialog', { name: `${label}生成卡片` });
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__character-composer',
|
||||
);
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__publication-composer',
|
||||
);
|
||||
expect(
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '添加参考图' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: `${label}游戏名` }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }),
|
||||
).toBeTruthy();
|
||||
expect(panel.textContent).toContain(sizeLabel);
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '生成' }).textContent,
|
||||
).toBe('生成3泥点');
|
||||
},
|
||||
);
|
||||
|
||||
it('恢复宣发素材生成卡片的字段和参考图', () => {
|
||||
renderComposer({
|
||||
@@ -347,19 +473,25 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '运营海报生成卡片' });
|
||||
expect(
|
||||
(within(panel).getByRole('textbox', {
|
||||
name: '运营海报游戏名',
|
||||
}) as HTMLInputElement).value,
|
||||
(
|
||||
within(panel).getByRole('textbox', {
|
||||
name: '运营海报游戏名',
|
||||
}) as HTMLInputElement
|
||||
).value,
|
||||
).toBe('马戏团午夜惊魂');
|
||||
expect(
|
||||
(within(panel).getByRole('textbox', {
|
||||
name: '运营海报游戏分类',
|
||||
}) as HTMLInputElement).value,
|
||||
(
|
||||
within(panel).getByRole('textbox', {
|
||||
name: '运营海报游戏分类',
|
||||
}) as HTMLInputElement
|
||||
).value,
|
||||
).toBe('非对称对抗');
|
||||
expect(
|
||||
(within(panel).getByRole('textbox', {
|
||||
name: '运营海报一句话描述游戏',
|
||||
}) as HTMLTextAreaElement).value,
|
||||
(
|
||||
within(panel).getByRole('textbox', {
|
||||
name: '运营海报一句话描述游戏',
|
||||
}) as HTMLTextAreaElement
|
||||
).value,
|
||||
).toBe('找到钥匙,开门逃离马戏团');
|
||||
expect(within(panel).getByLabelText('首图参考')).toBeTruthy();
|
||||
expect(
|
||||
@@ -510,10 +642,14 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
<>
|
||||
<ImageCanvasGenerationComposerView
|
||||
{...createComposerProps(dialog)}
|
||||
setGenerateDialog={setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>}
|
||||
setGenerateDialog={
|
||||
setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>
|
||||
}
|
||||
/>
|
||||
<output aria-label="当前视频模型">{dialog.videoModel}</output>
|
||||
<output aria-label="当前视频时长">{dialog.videoDurationSeconds}</output>
|
||||
<output aria-label="当前视频时长">
|
||||
{dialog.videoDurationSeconds}
|
||||
</output>
|
||||
<output aria-label="当前视频清晰度">{dialog.videoResolution}</output>
|
||||
<output aria-label="当前视频占位">
|
||||
{dialog.placeholder
|
||||
@@ -545,9 +681,9 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '模型 Seedance 2.0 Fast' }),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '生成视频' }).textContent).toBe(
|
||||
'生成40泥点',
|
||||
);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '生成视频' }).textContent,
|
||||
).toBe('生成40泥点');
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
@@ -579,15 +715,18 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
.querySelector('.image-canvas-editor__ratio-wireframe')
|
||||
?.getAttribute('data-ratio'),
|
||||
).toBe('21:9');
|
||||
fireEvent.change(within(paramsPanel).getByRole('slider', { name: '视频时长' }), {
|
||||
target: { value: '5' },
|
||||
});
|
||||
fireEvent.click(within(paramsPanel).getByRole('button', { name: '清晰度 720p' }));
|
||||
fireEvent.change(
|
||||
within(paramsPanel).getByRole('slider', { name: '视频时长' }),
|
||||
{
|
||||
target: { value: '5' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(
|
||||
within(paramsPanel).getByRole('button', { name: '清晰度 720p' }),
|
||||
);
|
||||
fireEvent.click(within(paramsPanel).getByRole('button', { name: '静音' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '视频参数选项' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('menu', { name: '视频参数选项' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前视频时长').textContent).toBe('5');
|
||||
expect(screen.getByLabelText('当前视频清晰度').textContent).toBe('720p');
|
||||
expect(screen.getByLabelText('当前视频占位').textContent).toBe(
|
||||
@@ -605,10 +744,16 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
'生成100泥点',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '模型 Seedance 2.0 Fast' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '模型 Seedance 2.0 Fast' }),
|
||||
);
|
||||
const modelPanel = screen.getByRole('menu', { name: '视频模型选项' });
|
||||
expect(within(modelPanel).queryByRole('button', { name: /Veo/i })).toBeNull();
|
||||
fireEvent.click(within(modelPanel).getByRole('button', { name: 'Kling 3.0' }));
|
||||
expect(
|
||||
within(modelPanel).queryByRole('button', { name: /Veo/i }),
|
||||
).toBeNull();
|
||||
fireEvent.click(
|
||||
within(modelPanel).getByRole('button', { name: 'Kling 3.0' }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('menu', { name: '视频模型选项' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前视频模型').textContent).toBe('kling3.0');
|
||||
@@ -677,9 +822,9 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
expect(within(panel).queryByText('单次')).toBeNull();
|
||||
expect(within(panel).queryByText('循环')).toBeNull();
|
||||
expect(within(panel).queryByText(/BPM/u)).toBeNull();
|
||||
expect(within(panel).getByRole('button', { name: '生成游戏音效' }).textContent).toBe(
|
||||
'生成5泥点',
|
||||
);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '生成游戏音效' }).textContent,
|
||||
).toBe('生成5泥点');
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '音效时长 5秒' }),
|
||||
@@ -699,9 +844,7 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
fireEvent.change(durationSlider, { target: { value: '8' } });
|
||||
|
||||
expect(screen.getByLabelText('当前音效时长').textContent).toBe('8');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '音效时长 8秒' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '音效时长 8秒' })).toBeTruthy();
|
||||
|
||||
fireEvent.submit(panel);
|
||||
expect(onSubmitImageGeneration).toHaveBeenCalledWith(
|
||||
@@ -742,9 +885,10 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
);
|
||||
expect(within(panel).getByText('Suno')).toBeTruthy();
|
||||
expect(within(panel).queryByText('make_instrumental')).toBeNull();
|
||||
expect(within(panel).getByRole('button', { name: '生成游戏背景音乐' }).textContent).toBe(
|
||||
'生成12泥点',
|
||||
);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '生成游戏背景音乐' })
|
||||
.textContent,
|
||||
).toBe('生成12泥点');
|
||||
});
|
||||
it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => {
|
||||
const onRequestUpload = vi.fn();
|
||||
@@ -779,7 +923,11 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
expect(onRequestUpload).not.toHaveBeenCalled();
|
||||
expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled();
|
||||
const menu = screen.getByRole('menu', { name: '参考图来源' });
|
||||
expect(within(menu).getByRole('menuitem', { name: '从画布中选择' })).toBeTruthy();
|
||||
expect(within(menu).getByRole('menuitem', { name: '上传图片' })).toBeTruthy();
|
||||
expect(
|
||||
within(menu).getByRole('menuitem', { name: '从画布中选择' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(menu).getByRole('menuitem', { name: '上传图片' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,7 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
isPickingCharacterReferenceFromCanvas: boolean;
|
||||
isPickingIconSpecFromCanvas: boolean;
|
||||
isPickingUiDesignSpecFromCanvas: boolean;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
generateDialog: GenerateDialogState | null;
|
||||
generationComposerStyle: CSSProperties | null;
|
||||
iconComposerStyle: CSSProperties | null;
|
||||
@@ -108,9 +109,7 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
setIsUiDesignSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingQuickEditReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingPublicationReferenceFromCanvas: Dispatch<
|
||||
SetStateAction<boolean>
|
||||
>;
|
||||
setIsPickingPublicationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -122,6 +121,7 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
onSubmitQuickEdit: () => void;
|
||||
onSubmitCropExpand: () => void;
|
||||
onSubmitCharacterAnimation: () => void;
|
||||
onRetryPerfectPixelOperation?: (dialogId: string) => void;
|
||||
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
|
||||
onUpdateIconDescriptionText: (value: string) => void;
|
||||
onUpdateCharacterAnimationDuration: (frameCountValue: string) => void;
|
||||
@@ -130,6 +130,66 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
onSpecMenuPointerLeave?: () => void;
|
||||
};
|
||||
|
||||
function PerfectPixelOperationPanel({
|
||||
dialog,
|
||||
style,
|
||||
onRetry,
|
||||
}: {
|
||||
dialog: GenerateDialogState;
|
||||
style: CSSProperties;
|
||||
onRetry?: (dialogId: string) => void;
|
||||
}) {
|
||||
const isPending = dialog.status === 'pending-confirmation';
|
||||
const isPreparedFailure =
|
||||
dialog.status === 'failed' && Boolean(dialog.perfectPixelOperation);
|
||||
const isGenerating = dialog.status === 'generating';
|
||||
const isInvalid = dialog.perfectPixelOperationInvalid === true;
|
||||
const message = isInvalid
|
||||
? (dialog.errorMessage ?? '完美像素操作快照无效,禁止自动重试。')
|
||||
: isPending
|
||||
? (dialog.errorMessage ?? '结果尚未确认,系统不会自动重复提交。')
|
||||
: isGenerating
|
||||
? '完美像素处理中'
|
||||
: dialog.status === 'failed'
|
||||
? (dialog.errorMessage ?? '完美像素处理失败')
|
||||
: '完美像素处理已完成';
|
||||
return (
|
||||
<section
|
||||
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--perfect-pixel"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label="完美像素操作"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<strong>完美像素</strong>
|
||||
<PlatformStatusMessage
|
||||
tone={isInvalid || dialog.status === 'failed' ? 'error' : 'info'}
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="image-canvas-editor__generate-status"
|
||||
role={isInvalid || dialog.status === 'failed' ? 'alert' : 'status'}
|
||||
>
|
||||
{message}
|
||||
</PlatformStatusMessage>
|
||||
{!isInvalid &&
|
||||
(isPending || isPreparedFailure) &&
|
||||
dialog.id &&
|
||||
dialog.perfectPixelOperation ? (
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={!onRetry}
|
||||
onClick={() => onRetry?.(dialog.id!)}
|
||||
>
|
||||
重试同一完美像素操作
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPortalMenuStyle(
|
||||
anchor: HTMLElement | null,
|
||||
placement: 'above' | 'below',
|
||||
@@ -334,7 +394,8 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
updateVideoDialog({
|
||||
videoModel: model,
|
||||
videoResolution:
|
||||
model === VIDEO_MODEL_SEEDANCE_2_FAST && dialog.videoResolution === '1080p'
|
||||
model === VIDEO_MODEL_SEEDANCE_2_FAST &&
|
||||
dialog.videoResolution === '1080p'
|
||||
? '720p'
|
||||
: dialog.videoResolution,
|
||||
...(isSeedanceVideoModel(model) ? {} : { generationReferences: [] }),
|
||||
@@ -484,7 +545,8 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{EDITOR_VIDEO_RESOLUTION_OPTIONS.map((option) => {
|
||||
const available =
|
||||
currentModel.value !== VIDEO_MODEL_SEEDANCE_2_FAST ||
|
||||
currentModel.value !==
|
||||
VIDEO_MODEL_SEEDANCE_2_FAST ||
|
||||
option !== '1080p';
|
||||
return (
|
||||
<VideoOptionChoice
|
||||
@@ -518,8 +580,7 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
disabled={isGenerating}
|
||||
onClick={() =>
|
||||
updateVideoDialog({
|
||||
videoSound:
|
||||
soundMode === 'off' ? 'on' : 'off',
|
||||
videoSound: soundMode === 'off' ? 'on' : 'off',
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -748,7 +809,9 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
};
|
||||
|
||||
const updateSoundDuration = (durationSeconds: number) => {
|
||||
updateAudioDialog({ soundDurationSeconds: normalizeSoundDuration(durationSeconds) });
|
||||
updateAudioDialog({
|
||||
soundDurationSeconds: normalizeSoundDuration(durationSeconds),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -770,9 +833,7 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
value={dialog.prompt}
|
||||
disabled={isGenerating}
|
||||
placeholder={
|
||||
isSoundEffect
|
||||
? '你希望生成什么音效?'
|
||||
: '你希望生成什么音乐?'
|
||||
isSoundEffect ? '你希望生成什么音效?' : '你希望生成什么音乐?'
|
||||
}
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) => updateAudioDialog({ prompt: event.target.value })}
|
||||
@@ -934,6 +995,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
setIsPickingUiDesignSpecFromCanvas,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onOpenSpecDialog,
|
||||
onRequestUpload,
|
||||
onSubmitImageGeneration,
|
||||
@@ -941,6 +1003,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
onSubmitQuickEdit,
|
||||
onSubmitCropExpand,
|
||||
onSubmitCharacterAnimation,
|
||||
onRetryPerfectPixelOperation,
|
||||
onUpdateSpecFormValue,
|
||||
onUpdateIconDescriptionText,
|
||||
onUpdateCharacterAnimationDuration,
|
||||
@@ -948,6 +1011,10 @@ export function ImageCanvasGenerationComposerView({
|
||||
onSpecMenuPointerEnter,
|
||||
onSpecMenuPointerLeave,
|
||||
}: ImageCanvasGenerationComposerViewProps) {
|
||||
const isPerfectPixelDialog = Boolean(
|
||||
generateDialog?.perfectPixelOperation ||
|
||||
generateDialog?.perfectPixelOperationInvalid,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{isSpecMenuOpen
|
||||
@@ -975,7 +1042,19 @@ export function ImageCanvasGenerationComposerView({
|
||||
)
|
||||
: null}
|
||||
|
||||
{(generateDialog?.mode === 'generate' ||
|
||||
{isPerfectPixelDialog &&
|
||||
generateDialog &&
|
||||
generateDialog.composerOpen !== false &&
|
||||
generationComposerStyle ? (
|
||||
<PerfectPixelOperationPanel
|
||||
dialog={generateDialog}
|
||||
style={generationComposerStyle}
|
||||
onRetry={onRetryPerfectPixelOperation}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isPerfectPixelDialog &&
|
||||
(generateDialog?.mode === 'generate' ||
|
||||
generateDialog?.mode === 'quick-edit') &&
|
||||
generateDialog.composerOpen !== false &&
|
||||
generationComposerStyle ? (
|
||||
@@ -996,6 +1075,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
setIsGenerationReferenceMenuOpen((open) => !open)
|
||||
}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1018,6 +1098,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
onOpenSpecDialog={onOpenSpecDialog}
|
||||
onUpdateSpecFormValue={onUpdateSpecFormValue}
|
||||
onRequestUpload={onRequestUpload}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1034,9 +1115,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
publicationReferenceButtonRef={publicationReferenceButtonRef}
|
||||
isPublicationReferenceMenuOpen={isPublicationReferenceMenuOpen}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
setIsPublicationReferenceMenuOpen={
|
||||
setIsPublicationReferenceMenuOpen
|
||||
}
|
||||
setIsPublicationReferenceMenuOpen={setIsPublicationReferenceMenuOpen}
|
||||
setIsPickingPublicationReferenceFromCanvas={
|
||||
setIsPickingPublicationReferenceFromCanvas
|
||||
}
|
||||
@@ -1044,6 +1123,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1067,6 +1147,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
onUpdateSpecFormValue={onUpdateSpecFormValue}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1127,6 +1208,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
onOpenSpecDialog={onOpenSpecDialog}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1148,6 +1230,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
onRequestUpload={onRequestUpload}
|
||||
onUpdateIconDescriptionText={onUpdateIconDescriptionText}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitIconSpritesheetGeneration}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1203,14 +1286,13 @@ export function ImageCanvasGenerationComposerView({
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
setQuickEditPanel={setQuickEditPanel}
|
||||
onSubmit={onSubmitQuickEdit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{cropExpandPanel &&
|
||||
cropExpandSourceLayer &&
|
||||
cropExpandPanelStyle ? (
|
||||
{cropExpandPanel && cropExpandSourceLayer && cropExpandPanelStyle ? (
|
||||
<ImageCanvasCropExpandPanelView
|
||||
panel={cropExpandPanel}
|
||||
style={cropExpandPanelStyle}
|
||||
|
||||
@@ -1134,6 +1134,93 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('caps image references before a sixth reference can enter submission state', () => {
|
||||
const existingReferences = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `reference-${index}`,
|
||||
label: `参考图${index + 1}`,
|
||||
src: `generated-character-drafts/editor/reference-${index}.png`,
|
||||
}));
|
||||
const imageDialog: GenerateDialogState = {
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
generationReferences: existingReferences,
|
||||
};
|
||||
expect(
|
||||
appendGenerationReference(
|
||||
imageDialog,
|
||||
createLayer({ id: 'reference-6', title: '参考图6' }),
|
||||
),
|
||||
).toMatchObject({
|
||||
generationReferences: existingReferences,
|
||||
});
|
||||
|
||||
const characterDialog: GenerateDialogState = {
|
||||
mode: 'character',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
characterSpecReference: existingReferences[0],
|
||||
characterReferences: existingReferences.slice(1),
|
||||
};
|
||||
expect(
|
||||
appendCharacterReference(
|
||||
characterDialog,
|
||||
createLayer({ id: 'character-reference-6', title: '角色参考图5' }),
|
||||
),
|
||||
).toMatchObject({
|
||||
characterReferences: existingReferences.slice(1),
|
||||
});
|
||||
|
||||
const iconDialog: GenerateDialogState = {
|
||||
mode: 'icon',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
generationReferences: existingReferences.slice(0, 4),
|
||||
};
|
||||
expect(
|
||||
appendGenerationReference(
|
||||
iconDialog,
|
||||
createLayer({ id: 'icon-reference-5', title: '图标参考图5' }),
|
||||
),
|
||||
).toMatchObject({
|
||||
generationReferences: existingReferences.slice(0, 4),
|
||||
});
|
||||
});
|
||||
|
||||
it('reserves the primary-image slot before character and UI specs are selected', () => {
|
||||
const references = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `reference-${index}`,
|
||||
label: `参考图${index + 1}`,
|
||||
src: `/reference-${index}.png`,
|
||||
}));
|
||||
const characterDialog: GenerateDialogState = {
|
||||
mode: 'character',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
characterReferences: references.slice(0, 4),
|
||||
};
|
||||
const uiDialog: GenerateDialogState = {
|
||||
mode: 'ui-design',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
generationReferences: references.slice(0, 4),
|
||||
};
|
||||
|
||||
expect(
|
||||
appendCharacterReference(
|
||||
characterDialog,
|
||||
createLayer({ id: 'character-reference-5' }),
|
||||
),
|
||||
).toMatchObject({ characterReferences: references.slice(0, 4) });
|
||||
expect(
|
||||
appendGenerationReference(
|
||||
uiDialog,
|
||||
createLayer({ id: 'ui-reference-5' }),
|
||||
),
|
||||
).toMatchObject({ generationReferences: references.slice(0, 4) });
|
||||
});
|
||||
|
||||
it('updates failed spec and icon dialog fields back to idle state', () => {
|
||||
const specDialog: GenerateDialogState = {
|
||||
mode: 'spec',
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SpecGenerationType,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
appendLimitedImageReferences,
|
||||
appendLimitedQuickEditReferences,
|
||||
AUDIO_FRAME_DISPLAY_SIZE,
|
||||
AUDIO_FRAME_ORIGINAL_SIZE,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
normalizeEditorImageModel,
|
||||
PUBLICATION_FRAME_ORIGINAL_SIZE,
|
||||
resizeGenerationPlaceholderToImageSelection,
|
||||
resolveDialogExtraImageReferenceLimit,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
resolveEditorVideoGenerationPixelSize,
|
||||
SPEC_FRAME_ORIGINAL_SIZE,
|
||||
@@ -644,10 +646,7 @@ function resolveGeneratedSourceDialogMode({
|
||||
if (sourceLayer.assetKind === 'character-animation') {
|
||||
return 'character-animation';
|
||||
}
|
||||
if (
|
||||
sourceLayer.assetKind === 'video' ||
|
||||
sourceLayer.mediaType === 'video'
|
||||
) {
|
||||
if (sourceLayer.assetKind === 'video' || sourceLayer.mediaType === 'video') {
|
||||
return 'video';
|
||||
}
|
||||
if (sourceLayer.assetKind === 'sound-effect') {
|
||||
@@ -1005,8 +1004,7 @@ export function createSameSourceGenerationDialogDraft({
|
||||
videoModel: sourceDialog?.videoModel ?? draft.videoModel,
|
||||
videoAspectRatio:
|
||||
sourceDialog?.videoAspectRatio ?? draft.videoAspectRatio,
|
||||
videoResolution:
|
||||
sourceDialog?.videoResolution ?? draft.videoResolution,
|
||||
videoResolution: sourceDialog?.videoResolution ?? draft.videoResolution,
|
||||
videoDurationSeconds:
|
||||
sourceDialog?.videoDurationSeconds ?? draft.videoDurationSeconds,
|
||||
videoMode: sourceDialog?.videoMode ?? draft.videoMode,
|
||||
@@ -1424,6 +1422,7 @@ export function assignCharacterSpecReference(
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
characterSpecReference: createCanvasLayerReference(layer),
|
||||
characterReferences: dialog.characterReferences,
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
@@ -1437,10 +1436,11 @@ export function appendCharacterReference(
|
||||
getReferenceMediaType(layer) === 'image'
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
characterReferences: [
|
||||
...(dialog.characterReferences ?? []),
|
||||
createCanvasLayerReference(layer),
|
||||
],
|
||||
characterReferences: appendLimitedImageReferences(
|
||||
dialog.characterReferences,
|
||||
[createCanvasLayerReference(layer)],
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
@@ -1474,10 +1474,11 @@ export function appendGenerationReference(
|
||||
}
|
||||
return {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
generationReferences: [
|
||||
...(dialog.generationReferences ?? []),
|
||||
createCanvasLayerReference(layer),
|
||||
],
|
||||
generationReferences: appendLimitedImageReferences(
|
||||
dialog.generationReferences,
|
||||
[createCanvasLayerReference(layer)],
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
composerOpen: true,
|
||||
};
|
||||
}
|
||||
@@ -1501,10 +1502,11 @@ export function appendPublicationReference(
|
||||
getReferenceMediaType(layer) === 'image'
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
publicationReferences: [
|
||||
...(dialog.publicationReferences ?? []),
|
||||
createCanvasLayerReference(layer),
|
||||
],
|
||||
publicationReferences: appendLimitedImageReferences(
|
||||
dialog.publicationReferences,
|
||||
[createCanvasLayerReference(layer)],
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
@@ -1521,6 +1523,11 @@ export function assignIconSpecReference(
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
iconSpecReference: createCanvasLayerReference(layer),
|
||||
generationReferences: appendLimitedImageReferences(
|
||||
[],
|
||||
dialog.generationReferences ?? [],
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
@@ -1537,6 +1544,7 @@ export function assignUiDesignSpecReference(
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
uiDesignSpecReference: createCanvasLayerReference(layer),
|
||||
generationReferences: dialog.generationReferences,
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
function ImageOptionsHarness({
|
||||
initialDialog,
|
||||
includeDimensions = true,
|
||||
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onRememberImageModel = vi.fn(),
|
||||
lockedModel,
|
||||
}: {
|
||||
initialDialog: GenerateDialogState;
|
||||
includeDimensions?: boolean;
|
||||
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
lockedModel?: string;
|
||||
}) {
|
||||
@@ -35,6 +35,7 @@ function ImageOptionsHarness({
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setDialog}
|
||||
includeDimensions={includeDimensions}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
lockedModel={lockedModel}
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
@@ -50,6 +51,13 @@ function ImageOptionsHarness({
|
||||
<output aria-label="当前风格">{dialog.style ?? '-'}</output>
|
||||
<output aria-label="当前状态">{dialog.status}</output>
|
||||
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
||||
<output aria-label="当前参考图数量">
|
||||
{dialog.mode === 'character'
|
||||
? (dialog.characterReferences?.length ?? 0)
|
||||
: dialog.mode === 'publication'
|
||||
? (dialog.publicationReferences?.length ?? 0)
|
||||
: (dialog.generationReferences?.length ?? 0)}
|
||||
</output>
|
||||
<output aria-label="当前占位">
|
||||
{dialog.placeholder
|
||||
? [
|
||||
@@ -122,9 +130,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('checkbox', { name: '像素艺术' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('checkbox', { name: '像素艺术' })).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -267,6 +273,90 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
expect(rememberImageModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a model switch that would discard existing references', () => {
|
||||
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
const rememberImageModel = vi.fn();
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
mode: 'icon',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
generationReferences: Array.from({ length: 8 }, (_, index) => ({
|
||||
id: `reference-${index + 1}`,
|
||||
label: `参考图${index + 1}`,
|
||||
src: `/reference-${index + 1}.png`,
|
||||
})),
|
||||
}}
|
||||
includeDimensions={false}
|
||||
onRememberImageModel={rememberImageModel}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8');
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
|
||||
'button',
|
||||
{ name: 'gpt-image-2' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(alertMock).toHaveBeenCalledWith(
|
||||
'当前已有 8 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换',
|
||||
);
|
||||
expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8');
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
);
|
||||
expect(rememberImageModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects model switching while reference uploads are pending', () => {
|
||||
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
const rememberImageModel = vi.fn();
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
}}
|
||||
hasPendingImageReferenceUploads
|
||||
onRememberImageModel={rememberImageModel}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
|
||||
'button',
|
||||
{ name: 'gpt-image-2' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(alertMock).toHaveBeenCalledWith(
|
||||
'参考图正在上传,请等待上传完成后再切换模型',
|
||||
);
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '生成角色形象',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
expect(rememberImageModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders option menus and shows mud point text in the submit button', () => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
countDialogExtraImageReferences,
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
getEditorImageModelDisplayName,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
normalizeEditorImageModel,
|
||||
QUICK_EDIT_MODEL_OPTIONS,
|
||||
resizeGenerationPlaceholderToImageSelection,
|
||||
resolveDialogExtraImageReferenceLimit,
|
||||
resolveEditorImageSizeLabel,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
@@ -30,6 +32,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
includeDimensions: boolean;
|
||||
includeModel?: boolean;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
optionLabelPrefix?: string;
|
||||
dimensionRatioAriaLabelPrefix?: string;
|
||||
@@ -121,6 +124,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
setGenerateDialog,
|
||||
includeDimensions,
|
||||
includeModel = true,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onRememberImageModel,
|
||||
optionLabelPrefix,
|
||||
dimensionRatioAriaLabelPrefix,
|
||||
@@ -192,7 +196,18 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
};
|
||||
|
||||
const updateImageModel = (model: string) => {
|
||||
onRememberImageModel(model);
|
||||
if (hasPendingImageReferenceUploads) {
|
||||
window.alert('参考图正在上传,请等待上传完成后再切换模型');
|
||||
return;
|
||||
}
|
||||
const referenceLimit = resolveDialogExtraImageReferenceLimit(dialog, model);
|
||||
const referenceCount = countDialogExtraImageReferences(dialog);
|
||||
if (referenceCount > referenceLimit) {
|
||||
window.alert(
|
||||
`当前已有 ${referenceCount} 张参考图,${getEditorImageModelDisplayName(model)} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setGenerateDialog((currentDialog) => {
|
||||
if (!currentDialog || currentDialog.mode !== dialog.mode) {
|
||||
return currentDialog;
|
||||
@@ -224,6 +239,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
};
|
||||
return resizeGenerationPlaceholderToImageSelection(nextDialog);
|
||||
});
|
||||
onRememberImageModel(model);
|
||||
};
|
||||
|
||||
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
|
||||
@@ -408,7 +424,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className={submitButtonClassName}
|
||||
disabled={isGenerating}
|
||||
disabled={isGenerating || hasPendingImageReferenceUploads}
|
||||
aria-label={submitAriaLabel}
|
||||
>
|
||||
{isGenerating ? (
|
||||
|
||||
@@ -416,6 +416,79 @@ export const SEEDANCE_VIDEO_REFERENCE_LIMITS = {
|
||||
audio: 3,
|
||||
} as const;
|
||||
export const QUICK_EDIT_REFERENCE_LIMIT = 8;
|
||||
export const IMAGE_GENERATION_REFERENCE_LIMIT = 5;
|
||||
export const ICON_EXTRA_REFERENCE_LIMIT = 8;
|
||||
export const UI_EXTRA_REFERENCE_LIMIT = 5;
|
||||
|
||||
export function resolveImageProviderReferenceLimit(
|
||||
model: string | null | undefined,
|
||||
) {
|
||||
return normalizeEditorImageModel(model) === IMAGE_MODEL_NANOBANANA2 ? 14 : 5;
|
||||
}
|
||||
|
||||
export function resolveExtraImageReferenceLimit(
|
||||
model: string | null | undefined,
|
||||
productLimit: number,
|
||||
primaryReferenceCount = 0,
|
||||
) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
productLimit,
|
||||
resolveImageProviderReferenceLimit(model) - primaryReferenceCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDialogExtraImageReferenceLimit(
|
||||
dialog: GenerateDialogState,
|
||||
model: string | null | undefined = dialog.imageModel,
|
||||
) {
|
||||
if (dialog.mode === 'icon') {
|
||||
return resolveExtraImageReferenceLimit(
|
||||
model,
|
||||
ICON_EXTRA_REFERENCE_LIMIT,
|
||||
1,
|
||||
);
|
||||
}
|
||||
if (dialog.mode === 'ui-design') {
|
||||
return resolveExtraImageReferenceLimit(
|
||||
model,
|
||||
IMAGE_GENERATION_REFERENCE_LIMIT - 1,
|
||||
1,
|
||||
);
|
||||
}
|
||||
if (dialog.mode === 'quick-edit') {
|
||||
return resolveExtraImageReferenceLimit(
|
||||
model,
|
||||
QUICK_EDIT_REFERENCE_LIMIT,
|
||||
1,
|
||||
);
|
||||
}
|
||||
if (dialog.mode === 'character') {
|
||||
return resolveExtraImageReferenceLimit(
|
||||
model,
|
||||
IMAGE_GENERATION_REFERENCE_LIMIT - 1,
|
||||
1,
|
||||
);
|
||||
}
|
||||
return IMAGE_GENERATION_REFERENCE_LIMIT;
|
||||
}
|
||||
|
||||
export function countDialogExtraImageReferences(dialog: GenerateDialogState) {
|
||||
const references =
|
||||
dialog.mode === 'character'
|
||||
? dialog.characterReferences
|
||||
: dialog.mode === 'publication'
|
||||
? dialog.publicationReferences
|
||||
: dialog.mode === 'video'
|
||||
? []
|
||||
: dialog.generationReferences;
|
||||
return (references ?? []).filter(
|
||||
(reference) => (reference.mediaType ?? 'image') === 'image',
|
||||
).length;
|
||||
}
|
||||
|
||||
export const CHARACTER_ANIMATION_ACTION_PROMPTS = [
|
||||
{ label: '待机', text: '待机动作,轻微呼吸起伏。' },
|
||||
{ label: '行走', text: '循环行走动作,步伐稳定。' },
|
||||
@@ -1004,6 +1077,20 @@ export function appendLimitedQuickEditReferences(
|
||||
);
|
||||
}
|
||||
|
||||
export function appendLimitedImageReferences(
|
||||
references: CharacterReferenceImage[] | undefined,
|
||||
nextReferences: CharacterReferenceImage[],
|
||||
limit: number,
|
||||
) {
|
||||
const imageReferences = nextReferences.filter(
|
||||
(reference) => (reference.mediaType ?? 'image') === 'image',
|
||||
);
|
||||
return [...(references ?? []), ...imageReferences].slice(
|
||||
0,
|
||||
Math.max(0, limit),
|
||||
);
|
||||
}
|
||||
|
||||
export function createGenerationInputField(
|
||||
title: string,
|
||||
value: string | null | undefined,
|
||||
@@ -1298,17 +1385,17 @@ export function isCanvasGenerationDialog(
|
||||
): dialog is CanvasGenerationDialogState {
|
||||
return Boolean(
|
||||
dialog?.id &&
|
||||
(dialog.mode === 'generate' ||
|
||||
dialog.mode === 'spec' ||
|
||||
dialog.mode === 'character' ||
|
||||
dialog.mode === 'icon' ||
|
||||
dialog.mode === 'publication' ||
|
||||
dialog.mode === 'ui-design' ||
|
||||
dialog.mode === 'quick-edit' ||
|
||||
dialog.mode === 'character-animation' ||
|
||||
dialog.mode === 'video' ||
|
||||
dialog.mode === 'audio-sound-effect' ||
|
||||
dialog.mode === 'audio-background-music'),
|
||||
(dialog.mode === 'generate' ||
|
||||
dialog.mode === 'spec' ||
|
||||
dialog.mode === 'character' ||
|
||||
dialog.mode === 'icon' ||
|
||||
dialog.mode === 'publication' ||
|
||||
dialog.mode === 'ui-design' ||
|
||||
dialog.mode === 'quick-edit' ||
|
||||
dialog.mode === 'character-animation' ||
|
||||
dialog.mode === 'video' ||
|
||||
dialog.mode === 'audio-sound-effect' ||
|
||||
dialog.mode === 'audio-background-music'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -625,6 +625,12 @@ describe('ImageCanvasHistoryModel', () => {
|
||||
expect(isProtectedCanvasHistoryAction({ type: 'replace-image' })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(formatCanvasHistoryAction({ type: 'perfect-pixel' })).toBe(
|
||||
'完美像素',
|
||||
);
|
||||
expect(isProtectedCanvasHistoryAction({ type: 'perfect-pixel' })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isProtectedCanvasHistoryAction({ type: 'move-image' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const CANVAS_HISTORY_ACTION_LABELS: Record<
|
||||
'generate-image': '生成图片',
|
||||
'expand-image': '扩展图片',
|
||||
'remove-background': '移除背景',
|
||||
'perfect-pixel': '完美像素',
|
||||
'split-atlas': '拆分图集',
|
||||
'replace-image': '替换图片',
|
||||
'show-image': '显示图片',
|
||||
@@ -46,6 +47,7 @@ const PROTECTED_CANVAS_HISTORY_ACTION_TYPES = new Set<
|
||||
'generate-image',
|
||||
'expand-image',
|
||||
'remove-background',
|
||||
'perfect-pixel',
|
||||
'split-atlas',
|
||||
'replace-image',
|
||||
]);
|
||||
|
||||
@@ -19,9 +19,7 @@ import type {
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import {
|
||||
calculateEditorIconSpritesheetPrice,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { calculateEditorIconSpritesheetPrice } from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
|
||||
@@ -42,6 +40,7 @@ type ImageCanvasIconSpritesheetComposerViewProps = {
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onUpdateIconDescriptionText: (value: string) => void;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
@@ -59,6 +58,7 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
onRequestUpload,
|
||||
onUpdateIconDescriptionText,
|
||||
onRememberImageModel,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasIconSpritesheetComposerViewProps) {
|
||||
const descriptionText =
|
||||
@@ -236,6 +236,7 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={calculateEditorIconSpritesheetPrice(
|
||||
dialog.imageModel,
|
||||
|
||||
@@ -47,6 +47,7 @@ type ImageCanvasPublicationMaterialsDemoPanelViewProps = {
|
||||
) => CSSProperties;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
@@ -116,6 +117,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
|
||||
buildPortalMenuStyle,
|
||||
onRequestUpload,
|
||||
onRememberImageModel,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasPublicationMaterialsDemoPanelViewProps) {
|
||||
const references = dialog.publicationReferences ?? [];
|
||||
@@ -302,6 +304,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions={false}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
|
||||
optionLabelPrefix="宣发素材"
|
||||
cost={calculateEditorImageModelPrice(
|
||||
|
||||
@@ -13,7 +13,10 @@ import type {
|
||||
QuickEditPanelState,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { QUICK_EDIT_REFERENCE_LIMIT } from './ImageCanvasGenerationModel';
|
||||
import {
|
||||
QUICK_EDIT_REFERENCE_LIMIT,
|
||||
resolveExtraImageReferenceLimit,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
export type ImageCanvasQuickEditPanelViewProps = {
|
||||
panel: QuickEditPanelState;
|
||||
@@ -31,6 +34,7 @@ export type ImageCanvasQuickEditPanelViewProps = {
|
||||
) => CSSProperties;
|
||||
onRequestUpload?: (target: UploadTarget) => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
@@ -78,7 +82,11 @@ function syncPanelFromDialogUpdate(
|
||||
model: normalizedDialog.imageModel ?? normalizedPanel.model,
|
||||
quickEditReferences: normalizedDialog.generationReferences,
|
||||
assetLabel: normalizedDialog.assetLabel,
|
||||
status: normalizedDialog.status,
|
||||
// 中文注释:dialog 由 createQuickEditDialog 从面板自身派生,组合器只会做
|
||||
// failed→idle 的重置,不会写入 'pending-confirmation' 这类完美像素专属状态。
|
||||
// 因此归一化后的 dialog 状态恒等于归一化后的面板状态,直接取面板侧即可,
|
||||
// 也让面板的三态联合类型不必在这里做运行时收窄。
|
||||
status: normalizedPanel.status,
|
||||
errorMessage: normalizedDialog.errorMessage,
|
||||
};
|
||||
}
|
||||
@@ -96,13 +104,20 @@ export function ImageCanvasQuickEditPanelView({
|
||||
buildPortalMenuStyle = () => ({}),
|
||||
onRequestUpload,
|
||||
onRememberImageModel = () => {},
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasQuickEditPanelViewProps) {
|
||||
const isRedraw = panel.mode === 'redraw';
|
||||
const isImageQuickEdit = !isRedraw && sourceLayer.mediaType !== 'video';
|
||||
const referenceCount = panel.quickEditReferences?.length ?? 0;
|
||||
const canAddReferences =
|
||||
!isRedraw && referenceCount < QUICK_EDIT_REFERENCE_LIMIT;
|
||||
!isRedraw &&
|
||||
referenceCount <
|
||||
resolveExtraImageReferenceLimit(
|
||||
panel.model,
|
||||
QUICK_EDIT_REFERENCE_LIMIT,
|
||||
1,
|
||||
);
|
||||
const quickEditDialog = createQuickEditDialog(panel);
|
||||
|
||||
return (
|
||||
@@ -137,6 +152,7 @@ export function ImageCanvasQuickEditPanelView({
|
||||
}
|
||||
onToggleReferenceMenu={() => setIsReferenceMenuOpen?.((open) => !open)}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmit}
|
||||
dialogLabel={isRedraw ? '重绘图片' : '快速编辑图片'}
|
||||
includeDimensions={isRedraw || isImageQuickEdit}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
type EditorBackgroundRemovalInput,
|
||||
type EditorBackgroundRemovalResult,
|
||||
type EditorPixelArtSnapInput,
|
||||
type EditorPixelArtSnapResult,
|
||||
removeEditorImageBackground,
|
||||
snapEditorImageToPixelArt,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
|
||||
export type CropExpandInsets = {
|
||||
@@ -222,3 +225,9 @@ export async function removeImageBackground(
|
||||
typeof input === 'string' ? { sourceImageSrc: input } : input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function snapImageToPerfectPixels(
|
||||
input: EditorPixelArtSnapInput,
|
||||
): Promise<EditorPixelArtSnapResult> {
|
||||
return snapEditorImageToPixelArt(input);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ function renderSelectedToolbar(
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
onOpenCropExpandPanel: vi.fn(),
|
||||
onRemoveBackground: vi.fn(),
|
||||
onPerfectPixel: vi.fn(),
|
||||
isPerfectPixelProcessing: false,
|
||||
isPerfectPixelPendingConfirmation: false,
|
||||
isSplittingIconSpritesheet: false,
|
||||
isPersistingAssetKind: false,
|
||||
onSplitIconSpritesheet: vi.fn(),
|
||||
@@ -66,6 +69,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
'快速编辑',
|
||||
'裁扩按钮',
|
||||
'去除背景按钮',
|
||||
'完美像素',
|
||||
'改造',
|
||||
'下载按钮',
|
||||
]);
|
||||
@@ -75,6 +79,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '去除背景按钮' }),
|
||||
);
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '完美像素' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '改造' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' }));
|
||||
|
||||
@@ -85,6 +90,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
props.selectedLayer,
|
||||
);
|
||||
expect(props.onRemoveBackground).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onPerfectPixel).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
|
||||
expect(
|
||||
@@ -104,6 +110,19 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
.getByRole('button', { name: '去除背景按钮' })
|
||||
.querySelector('.lucide-image-off'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '完美像素' })
|
||||
.querySelector('.lucide-grid-2x2'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '完美像素' }).textContent,
|
||||
).toContain('完美像素');
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '完美像素' })
|
||||
.getAttribute('title'),
|
||||
).toBe('自动识别并规整像素网格');
|
||||
});
|
||||
|
||||
it('renders UI design asset extraction after remove background', () => {
|
||||
@@ -119,6 +138,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
'快速编辑',
|
||||
'裁扩按钮',
|
||||
'去除背景按钮',
|
||||
'完美像素',
|
||||
'提取素材',
|
||||
'改造',
|
||||
'下载按钮',
|
||||
@@ -142,6 +162,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
'快速编辑',
|
||||
'裁扩按钮',
|
||||
'去除背景按钮',
|
||||
'完美像素',
|
||||
'拆分图集',
|
||||
'改造',
|
||||
'下载按钮',
|
||||
@@ -184,6 +205,74 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a disabled loading state while perfect pixel is processing', () => {
|
||||
const props = renderSelectedToolbar({
|
||||
isPerfectPixelProcessing: true,
|
||||
});
|
||||
const button = screen.getByRole('button', {
|
||||
name: '完美像素处理中',
|
||||
});
|
||||
|
||||
expect(button.getAttribute('aria-busy')).toBe('true');
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(
|
||||
button.querySelector('.lucide-loader-circle.animate-spin'),
|
||||
).toBeTruthy();
|
||||
expect(button.querySelector('.lucide-grid-2x2')).toBeNull();
|
||||
expect(button.textContent).toContain('处理中');
|
||||
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(props.onPerfectPixel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a distinct disabled state while perfect pixel is pending confirmation', () => {
|
||||
const props = renderSelectedToolbar({
|
||||
isPerfectPixelPendingConfirmation: true,
|
||||
});
|
||||
const button = screen.getByRole('button', {
|
||||
name: '完美像素结果待确认',
|
||||
});
|
||||
|
||||
expect(button.getAttribute('title')).toBe(
|
||||
'结果待确认,双击原占位继续核对或重试',
|
||||
);
|
||||
expect(button.getAttribute('aria-busy')).toBe('false');
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(button.textContent).toContain('待确认');
|
||||
expect(button.textContent).not.toContain('处理中');
|
||||
expect(button.querySelector('.lucide-grid-2x2')).toBeTruthy();
|
||||
expect(button.querySelector('.lucide-loader-circle')).toBeNull();
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(props.onPerfectPixel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invoke perfect pixel while the selected asset kind is persisting', () => {
|
||||
// 中文注释:请求同时带 assetKind 与 sourceResourceId,本地类型已改但资源尚未落库时
|
||||
// 两者不一致,后端会直接 400 并留下失败占位。名称与拆分图集按钮的「素材类型保存中」
|
||||
// 必须区分开——icon-spritesheet 图层上两个按钮会同时进入保存态。
|
||||
const layer = createLayer({ assetKind: 'icon-spritesheet' });
|
||||
const props = renderSelectedToolbar({
|
||||
selectedLayer: layer,
|
||||
isPersistingAssetKind: true,
|
||||
});
|
||||
const button = screen.getByRole('button', {
|
||||
name: '完美像素等待素材类型保存',
|
||||
});
|
||||
|
||||
expect(button.getAttribute('aria-busy')).toBe('true');
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(button.querySelector('.lucide-grid-2x2')).toBeNull();
|
||||
expect(button.textContent).toContain('保存中');
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(props.onPerfectPixel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invoke atlas splitting while the selected asset kind is persisting', () => {
|
||||
const layer = createLayer({ assetKind: 'icon-spritesheet' });
|
||||
const props = renderSelectedToolbar({
|
||||
@@ -227,6 +316,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '去除背景按钮' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '完美像素' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(toolbar).queryByRole('button', { name: '生成动画' }),
|
||||
).toBeNull();
|
||||
@@ -255,6 +347,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(
|
||||
within(videoToolbar).queryByRole('button', { name: '去除背景按钮' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(videoToolbar).queryByRole('button', { name: '完美像素' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(videoToolbar)
|
||||
.getAllByRole('button')
|
||||
@@ -278,6 +373,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
expect(
|
||||
within(actionToolbar).queryByRole('button', { name: '去除背景按钮' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(actionToolbar).queryByRole('button', { name: '完美像素' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(actionToolbar)
|
||||
.getAllByRole('button')
|
||||
@@ -341,6 +439,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
onOpenRedrawPanel={vi.fn()}
|
||||
onOpenCropExpandPanel={vi.fn()}
|
||||
onRemoveBackground={vi.fn()}
|
||||
onPerfectPixel={vi.fn()}
|
||||
isPerfectPixelProcessing={false}
|
||||
isPerfectPixelPendingConfirmation={false}
|
||||
isSplittingIconSpritesheet={false}
|
||||
isPersistingAssetKind={false}
|
||||
onSplitIconSpritesheet={vi.fn()}
|
||||
@@ -360,6 +461,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
onOpenRedrawPanel={vi.fn()}
|
||||
onOpenCropExpandPanel={vi.fn()}
|
||||
onRemoveBackground={vi.fn()}
|
||||
onPerfectPixel={vi.fn()}
|
||||
isPerfectPixelProcessing={false}
|
||||
isPerfectPixelPendingConfirmation={false}
|
||||
isSplittingIconSpritesheet={false}
|
||||
isPersistingAssetKind={false}
|
||||
onSplitIconSpritesheet={vi.fn()}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Crop,
|
||||
Download,
|
||||
Grid2X2,
|
||||
ImageOff,
|
||||
Loader2,
|
||||
PersonStanding,
|
||||
@@ -22,6 +23,9 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
onOpenRedrawPanel: (layer: CanvasLayer) => void;
|
||||
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
||||
onRemoveBackground: (layer: CanvasLayer) => void;
|
||||
onPerfectPixel: (layer: CanvasLayer) => void;
|
||||
isPerfectPixelProcessing: boolean;
|
||||
isPerfectPixelPendingConfirmation: boolean;
|
||||
isSplittingIconSpritesheet: boolean;
|
||||
isPersistingAssetKind: boolean;
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
|
||||
@@ -37,6 +41,9 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onOpenRedrawPanel,
|
||||
onOpenCropExpandPanel,
|
||||
onRemoveBackground,
|
||||
onPerfectPixel,
|
||||
isPerfectPixelProcessing,
|
||||
isPerfectPixelPendingConfirmation,
|
||||
isSplittingIconSpritesheet,
|
||||
isPersistingAssetKind,
|
||||
onSplitIconSpritesheet,
|
||||
@@ -120,6 +127,57 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
icon={ImageOff}
|
||||
onClick={() => onRemoveBackground(selectedLayer)}
|
||||
/>
|
||||
<PlatformIconButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
|
||||
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
|
||||
label={
|
||||
isPersistingAssetKind
|
||||
? '完美像素等待素材类型保存'
|
||||
: isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '完美像素结果待确认'
|
||||
: '完美像素'
|
||||
}
|
||||
title={
|
||||
isPersistingAssetKind
|
||||
? '完美像素等待素材类型保存'
|
||||
: isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '结果待确认,双击原占位继续核对或重试'
|
||||
: '自动识别并规整像素网格'
|
||||
}
|
||||
icon={
|
||||
isPersistingAssetKind || isPerfectPixelProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Grid2X2 className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
|
||||
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
|
||||
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
|
||||
// 与相邻的拆分图集按钮保持同一套门禁。
|
||||
disabled={
|
||||
isPersistingAssetKind ||
|
||||
isPerfectPixelProcessing ||
|
||||
isPerfectPixelPendingConfirmation
|
||||
}
|
||||
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
|
||||
onClick={() => onPerfectPixel(selectedLayer)}
|
||||
>
|
||||
<span>
|
||||
{isPersistingAssetKind
|
||||
? '保存中'
|
||||
: isPerfectPixelProcessing
|
||||
? '处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '待确认'
|
||||
: '完美像素'}
|
||||
</span>
|
||||
</PlatformIconButton>
|
||||
</>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'icon-spritesheet' ? (
|
||||
|
||||
@@ -58,6 +58,7 @@ type ImageCanvasSpecGenerationPanelViewProps = {
|
||||
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
@@ -79,6 +80,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
onUpdateSpecFormValue,
|
||||
onRequestUpload,
|
||||
onRememberImageModel = () => {},
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasSpecGenerationPanelViewProps) {
|
||||
const isUiDesignDialog = dialog.mode === 'ui-design';
|
||||
@@ -428,6 +430,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
setGenerateDialog={setGenerateDialog ?? (() => undefined)}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
|
||||
cost={calculateEditorUiDesignPrice(
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -473,7 +476,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
|
||||
disabled={isGenerating}
|
||||
disabled={isGenerating || hasPendingImageReferenceUploads}
|
||||
aria-label="提交生成规范"
|
||||
>
|
||||
{isGenerating ? (
|
||||
|
||||
@@ -76,9 +76,12 @@ export type ImageCanvasStageViewProps = {
|
||||
uiAssetExtractionSourceLayer: CanvasLayer | null;
|
||||
quickEditSelectionState: UiAssetExtractionState | null;
|
||||
quickEditSelectionSourceLayer: CanvasLayer | null;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
generationComposerStyle: CSSProperties | null;
|
||||
selectedToolbarStyle: CSSProperties | null;
|
||||
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
|
||||
perfectPixelLayerIds?: ReadonlySet<string>;
|
||||
pendingPerfectPixelLayerIds?: ReadonlySet<string>;
|
||||
persistingAssetKindLayerIds?: ReadonlySet<string>;
|
||||
uploadDropTarget: 'canvas' | 'assets' | null;
|
||||
contextMenu: CanvasContextMenuState | null;
|
||||
@@ -150,6 +153,7 @@ export type ImageCanvasStageViewProps = {
|
||||
onOpenRedrawPanel: (layer: CanvasLayer) => void;
|
||||
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
||||
onRemoveBackground: (layer: CanvasLayer) => void;
|
||||
onPerfectPixel: (layer: CanvasLayer) => void;
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
|
||||
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
|
||||
onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void;
|
||||
@@ -234,9 +238,12 @@ export function ImageCanvasStageView({
|
||||
uiAssetExtractionSourceLayer,
|
||||
quickEditSelectionState,
|
||||
quickEditSelectionSourceLayer,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
|
||||
perfectPixelLayerIds = EMPTY_LAYER_ID_SET,
|
||||
pendingPerfectPixelLayerIds = EMPTY_LAYER_ID_SET,
|
||||
persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET,
|
||||
uploadDropTarget,
|
||||
contextMenu,
|
||||
@@ -287,6 +294,7 @@ export function ImageCanvasStageView({
|
||||
onOpenRedrawPanel,
|
||||
onOpenCropExpandPanel,
|
||||
onRemoveBackground,
|
||||
onPerfectPixel,
|
||||
onSplitIconSpritesheet,
|
||||
onExtractUiDesignAssets,
|
||||
onUiAssetExtractionToolChange,
|
||||
@@ -411,10 +419,18 @@ export function ImageCanvasStageView({
|
||||
isPersistingAssetKind={Boolean(
|
||||
selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id),
|
||||
)}
|
||||
isPerfectPixelProcessing={Boolean(
|
||||
selectedLayer && perfectPixelLayerIds.has(selectedLayer.id),
|
||||
)}
|
||||
isPerfectPixelPendingConfirmation={Boolean(
|
||||
selectedLayer &&
|
||||
pendingPerfectPixelLayerIds.has(selectedLayer.id),
|
||||
)}
|
||||
onOpenQuickEditPanel={onOpenQuickEditPanel}
|
||||
onOpenRedrawPanel={onOpenRedrawPanel}
|
||||
onOpenCropExpandPanel={onOpenCropExpandPanel}
|
||||
onRemoveBackground={onRemoveBackground}
|
||||
onPerfectPixel={onPerfectPixel}
|
||||
onSplitIconSpritesheet={onSplitIconSpritesheet}
|
||||
onExtractUiDesignAssets={onExtractUiDesignAssets}
|
||||
onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel}
|
||||
@@ -433,6 +449,7 @@ export function ImageCanvasStageView({
|
||||
onRequestUpload={onRequestUiAssetExtractionReferenceUpload}
|
||||
onRemoveReference={onRemoveUiAssetExtractionReference}
|
||||
onSubmit={onSubmitUiAssetExtraction}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
/>
|
||||
|
||||
<ImageCanvasUiAssetExtractionOverlayView
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user