优化画布快速编辑参数与替换规则

快速编辑继承并支持模型、比例和尺寸参数,旧模型回落当前默认模型
仅禁止图标与图标图集快速编辑,图标规范继续按普通图片处理
按用户选定分辨率替换当前图层并保持图层中心位置
补齐后端真实图层校验、移动端避让、定向测试与项目文档
This commit is contained in:
2026-07-13 18:48:29 +08:00
parent 2ac57463c0
commit 3cbc33bd69
25 changed files with 646 additions and 223 deletions
@@ -183,7 +183,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
expect(submitButton.textContent).toBe('生成5泥点');
});
it('keeps quick edit to one prompt box and model selection', () => {
it('keeps quick edit to one prompt box with image parameters', () => {
render(
<BasicGenerationHarness
initialDialog={createDialog({
@@ -213,10 +213,10 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
).toBeNull();
expect(within(panel).queryByText('旧参考图')).toBeNull();
expect(
within(panel).queryByRole('button', {
name: /快速编辑图片尺寸/u,
within(panel).getByRole('button', {
name: '快速编辑图片尺寸 1:1·1K',
}),
).toBeNull();
).toBeTruthy();
expect(
within(panel).getByRole('button', {
name: '快速编辑图片模型 gpt-image-2',
@@ -234,22 +234,16 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
name: '生成图片尺寸 16:9·1K',
}),
);
expect(
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
).toBeTruthy();
expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy();
fireEvent.click(screen.getByRole('textbox', { name: '生成提示词' }));
expect(
screen.queryByRole('menu', { name: '生成图片尺寸选项' }),
).toBeNull();
expect(screen.queryByRole('menu', { name: '生成图片尺寸选项' })).toBeNull();
});
it('does not render a standalone close button', () => {
render(<BasicGenerationHarness />);
expect(
screen.queryByRole('button', { name: '关闭生成图片' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '关闭生成图片' })).toBeNull();
});
});
@@ -140,19 +140,21 @@ export function ImageCanvasBasicGenerationComposerView({
const references = dialog.generationReferences ?? [];
const isQuickEdit = dialog.mode === 'quick-edit';
const resolvedDialogLabel =
dialogLabel ?? optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
dialogLabel ??
optionLabelPrefix ??
(isQuickEdit ? '快速编辑图片' : '生成图片');
const resolvedPromptLabel =
promptLabel ?? (isQuickEdit ? '快速编辑提示词' : '生成提示词');
const resolvedPromptPlaceholder =
promptPlaceholder ??
(isQuickEdit ? '写下每个编号要怎么改' : '今天想生成什么画面?');
(isQuickEdit ? '你希望素材如何修改?' : '今天想生成什么画面?');
const resolvedOptionLabelPrefix =
optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
const resolvedReferenceButtonLabel = referenceButtonLabel;
const resolvedReferenceButtonAriaLabel =
referenceButtonAriaLabel ?? `添加${resolvedReferenceButtonLabel}`;
const shouldIncludeReferences = isQuickEdit ? false : includeReferences;
const shouldIncludeDimensions = isQuickEdit ? false : includeDimensions;
const shouldIncludeDimensions = includeDimensions;
const resolvedSubmitLabel =
isQuickEdit && submitLabel === '生成' ? '修改' : submitLabel;
const resolvedSubmitAriaLabel =
@@ -265,4 +265,30 @@ describe('ImageCanvasContextMenusView', () => {
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
expect(props.onDeleteLayerById).toHaveBeenCalledWith(layer.id);
});
it.each(['icon', 'icon-spritesheet'] as const)(
'hides quick edit from the standalone menu for %s assets',
(assetKind) => {
const layer = createLayer({ assetKind });
renderContextMenus({
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
imageContextMenuLayer: layer,
});
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
expect(
screen.getByRole('menuitem', { name: '查看图片信息' }),
).toBeTruthy();
},
);
it('keeps quick edit in the standalone menu for icon specs', () => {
const layer = createLayer({ assetKind: 'icon-spec' });
renderContextMenus({
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
imageContextMenuLayer: layer,
});
expect(screen.getByRole('menuitem', { name: '快速编辑' })).toBeTruthy();
});
});
@@ -18,6 +18,7 @@ import type {
CanvasViewport,
ImageContextMenuState,
} from './ImageCanvasEditorTypes';
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
type ImageCanvasContextMenusViewProps = {
viewport: CanvasViewport;
@@ -477,17 +478,19 @@ export function ImageCanvasContextMenusView({
<hr />
{imageContextMenuLayer ? (
<>
<button
type="button"
role="menuitem"
onClick={() => {
onOpenQuickEditPanel(imageContextMenuLayer);
onCloseContextMenu();
onCloseImageContextMenu();
}}
>
快速编辑
</button>
{!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? (
<button
type="button"
role="menuitem"
onClick={() => {
onOpenQuickEditPanel(imageContextMenuLayer);
onCloseContextMenu();
onCloseImageContextMenu();
}}
>
快速编辑
</button>
) : null}
<button
type="button"
role="menuitem"
@@ -539,15 +542,17 @@ export function ImageCanvasContextMenusView({
onPointerDown={(event) => event.stopPropagation()}
>
<PlatformFloatingMenu label="图片功能面板" placement="bottom-start">
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
onOpenQuickEditPanel(imageContextMenuLayer);
onCloseImageContextMenu();
}}
>
快速编辑
</PlatformFloatingMenuItem>
{!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? (
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
onOpenQuickEditPanel(imageContextMenuLayer);
onCloseImageContextMenu();
}}
>
快速编辑
</PlatformFloatingMenuItem>
) : null}
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
@@ -1712,6 +1712,23 @@ describe('ImageCanvasEditorView', () => {
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
});
it('closes the Agent conversation before opening quick edit', async () => {
enableEditorAgentSidebarForTest();
render(<ImageCanvasEditorView />);
expect(await screen.findByLabelText('发送给画布 Agent')).toBeTruthy();
const sourceLayer = (
await screen.findByAltText('画布图片:拼图素材')
).closest('button')!;
fireEvent.click(sourceLayer);
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
expect(
await screen.findByRole('dialog', { name: '快速编辑图片' }),
).toBeTruthy();
expect(screen.queryByLabelText('发送给画布 Agent')).toBeNull();
});
it('closes the Agent conversation panel when runtime config disables it after focus', async () => {
loadFrontendRuntimeConfigMock
.mockResolvedValueOnce({
@@ -1276,6 +1276,16 @@ export function ImageCanvasEditorView({
}
generationSurface.toggleTaskSidebar();
}, [effectiveIsAgentConversationOpen, generationSurface]);
const openQuickEditPanelWithAvailableCanvas = useCallback(
(layer: CanvasLayer) => {
if (generationSurface.isTaskSidebarOpen) {
generationSurface.toggleTaskSidebar();
}
setIsAgentConversationOpen(false);
generationSurface.openQuickEditPanel(layer);
},
[generationSurface],
);
const toggleCanvasSidebarPanel = useCallback(
(panel: SidebarPanel) => {
toggleSidebarPanel(panel);
@@ -1327,7 +1337,6 @@ export function ImageCanvasEditorView({
setIsPickingUiDesignSpecFromCanvas,
openCharacterAnimationPanel,
openRedrawPanel,
openQuickEditPanel,
openCropExpandPanel,
removeSelectedLayerBackground,
extractUiDesignAssets,
@@ -2136,7 +2145,7 @@ export function ImageCanvasEditorView({
onToggleTaskSidebar: toggleTaskSidebar,
onToggleAgentConversation: toggleAgentConversation,
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
onOpenQuickEditPanel: openQuickEditPanel,
onOpenQuickEditPanel: openQuickEditPanelWithAvailableCanvas,
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
@@ -117,7 +117,7 @@ function renderComposer(
}
describe('ImageCanvasGenerationComposerView', () => {
it('让快速编辑只保留提示词和模型选择', () => {
it('让快速编辑显示提示词、尺寸和模型选择', () => {
renderComposer({
mode: 'quick-edit',
prompt: '',
@@ -146,10 +146,10 @@ describe('ImageCanvasGenerationComposerView', () => {
within(panel).queryByRole('button', { name: '添加参考图' }),
).toBeNull();
expect(
within(panel).queryByRole('button', {
name: /快速编辑图片尺寸/u,
within(panel).getByRole('button', {
name: '快速编辑图片尺寸 1:1·1K',
}),
).toBeNull();
).toBeTruthy();
expect(
within(panel).getByRole('button', {
name: '快速编辑图片模型 gpt-image-2',
@@ -38,6 +38,7 @@ import {
} from './ImageCanvasGenerationDialogModel';
import {
IMAGE_MODEL_GPT_IMAGE_2,
IMAGE_MODEL_NANOBANANA2,
} from './ImageCanvasGenerationModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
@@ -195,28 +196,29 @@ describe('ImageCanvasGenerationDialogModel', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(createVideoGenerationDialogDraft({ canvasSize, viewport }))
.toMatchObject({
mode: 'video',
status: 'idle',
composerOpen: true,
generationReferences: [],
videoModel: 'seedance2.0-fast',
videoAspectRatio: '16:9',
videoResolution: '480p',
videoDurationSeconds: 4,
videoMode: 'std',
videoSound: 'on',
videoWebSearchEnabled: true,
placeholder: {
x: 53,
y: 120,
width: 854,
height: 480,
originalWidth: 854,
originalHeight: 480,
},
});
expect(
createVideoGenerationDialogDraft({ canvasSize, viewport }),
).toMatchObject({
mode: 'video',
status: 'idle',
composerOpen: true,
generationReferences: [],
videoModel: 'seedance2.0-fast',
videoAspectRatio: '16:9',
videoResolution: '480p',
videoDurationSeconds: 4,
videoMode: 'std',
videoSound: 'on',
videoWebSearchEnabled: true,
placeholder: {
x: 53,
y: 120,
width: 854,
height: 480,
originalWidth: 854,
originalHeight: 480,
},
});
expect(
createUiDesignGenerationDialogDraft({
canvasSize,
@@ -242,37 +244,39 @@ describe('ImageCanvasGenerationDialogModel', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(createSoundEffectGenerationDialogDraft({ canvasSize, viewport }))
.toMatchObject({
mode: 'audio-sound-effect',
prompt: '',
status: 'idle',
composerOpen: true,
soundModel: 'audio1.0',
soundDurationSeconds: 5,
placeholder: {
x: 270,
y: 300,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
});
expect(createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport }))
.toMatchObject({
mode: 'audio-background-music',
prompt: '',
status: 'idle',
composerOpen: true,
makeInstrumental: true,
placeholder: {
x: 270,
y: 300,
width: 420,
height: 120,
},
});
expect(
createSoundEffectGenerationDialogDraft({ canvasSize, viewport }),
).toMatchObject({
mode: 'audio-sound-effect',
prompt: '',
status: 'idle',
composerOpen: true,
soundModel: 'audio1.0',
soundDurationSeconds: 5,
placeholder: {
x: 270,
y: 300,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
});
expect(
createBackgroundMusicGenerationDialogDraft({ canvasSize, viewport }),
).toMatchObject({
mode: 'audio-background-music',
prompt: '',
status: 'idle',
composerOpen: true,
makeInstrumental: true,
placeholder: {
x: 270,
y: 300,
width: 420,
height: 120,
},
});
});
it('creates audio remodel drafts from generated audio layers without references', () => {
@@ -496,17 +500,17 @@ describe('ImageCanvasGenerationDialogModel', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
const characterDraft = createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
assetKind: 'character',
generationInputs: {
fields: [
{ title: '角色设定', value: '红发骑士' },
{ title: '抠图背景色', value: '暖浅桃色 #FFD6C2' },
{ title: '抠图模型', value: '动漫风格 anime-seg' },
],
references: [],
},
}),
sourceLayer: createLayer({
assetKind: 'character',
generationInputs: {
fields: [
{ title: '角色设定', value: '红发骑士' },
{ title: '抠图背景色', value: '暖浅桃色 #FFD6C2' },
{ title: '抠图模型', value: '动漫风格 anime-seg' },
],
references: [],
},
}),
canvasSize,
viewport,
mode: 'redraw',
@@ -653,21 +657,38 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('locks quick edit drafts to gpt-image-2 despite legacy source models', () => {
it('inherits and normalizes quick edit model parameters from the source', () => {
expect(
createQuickEditPanelDraft(
createLayer({
model: 'nano-banana',
}),
).model,
).toBe(IMAGE_MODEL_GPT_IMAGE_2);
),
).toMatchObject({
model: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '4:3',
imageSize: '1K',
});
expect(
createQuickEditPanelDraft(createLayer({ model: 'nanobanana2' }), {
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '2K',
}),
).toMatchObject({
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '2K',
});
expect(
createQuickEditPanelDraft(
createLayer({
model: 'nanobanana2',
}),
).model,
).toBe(IMAGE_MODEL_GPT_IMAGE_2);
createLayer({ originalWidth: 512, originalHeight: 512 }),
{ imageModel: IMAGE_MODEL_GPT_IMAGE_2, imageSize: '0.5K' },
).imageSize,
).toBe('1K');
expect(
createQuickEditPanelDraft(createLayer({ model: 'legacy-image-model' })),
).toMatchObject({ model: IMAGE_MODEL_NANOBANANA2 });
});
it('creates character animation generation dialog drafts for character layers', () => {
@@ -805,7 +826,8 @@ describe('ImageCanvasGenerationDialogModel', () => {
title: '动作参考',
src: 'https://signed.example.com/reference.mp4',
mediaType: 'video',
objectKey: 'generated-character-drafts/editor/seedance-references/video/reference.mp4',
objectKey:
'generated-character-drafts/editor/seedance-references/video/reference.mp4',
assetObjectId: 'asset-object-video',
});
expect(
@@ -864,7 +886,9 @@ describe('ImageCanvasGenerationDialogModel', () => {
status: 'idle',
generationReferences: [],
};
expect(appendGenerationReference(imageDialog, videoLayer)).toBe(imageDialog);
expect(appendGenerationReference(imageDialog, videoLayer)).toBe(
imageDialog,
);
const specDialog: GenerateDialogState = {
mode: 'spec',
prompt: '',
@@ -978,7 +1002,9 @@ describe('ImageCanvasGenerationDialogModel', () => {
errorMessage: '描述错误',
iconDescriptions: ['旧描述'],
};
expect(updateIconDescriptionsTextInDialog(iconDialog, '剑\n盾、药水')).toEqual(
expect(
updateIconDescriptionsTextInDialog(iconDialog, '剑\n盾、药水'),
).toEqual(
expect.objectContaining({
status: 'idle',
errorMessage: undefined,
@@ -29,6 +29,7 @@ import {
DEFAULT_VIDEO_SOUND,
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
ICON_DESCRIPTION_LIMIT,
ICON_FRAME_DISPLAY_SIZE,
ICON_FRAME_ORIGINAL_SIZE,
@@ -690,7 +691,8 @@ export function createLayerGenerationDialogDraft({
return {
...draft,
prompt: draft.prompt || sourceLayer.prompt?.trim() || '',
imageModel: sourceDialog?.imageModel ?? sourceLayer.model ?? draft.imageModel,
imageModel:
sourceDialog?.imageModel ?? sourceLayer.model ?? draft.imageModel,
status: 'idle',
composerOpen: true,
generatedLayerId: sourceLayer.id,
@@ -1176,19 +1178,41 @@ export function createQuickEditPanelDraft(
imageSize?: string;
} = {},
): QuickEditPanelState {
const aspectRatio =
options.aspectRatio ??
inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const imageSize =
options.imageSize ??
inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const model = IMAGE_MODEL_GPT_IMAGE_2;
const normalizedModel = normalizeEditorImageModel(
options.imageModel ?? sourceLayer.model,
);
const model = EDITOR_IMAGE_MODEL_OPTIONS.some(
(option) => option.value === normalizedModel,
)
? normalizedModel
: DEFAULT_IMAGE_MODEL;
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
model as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
const supportedAspectRatios =
dimensionOptions.aspectRatios as readonly string[];
const supportedImageSizes = dimensionOptions.imageSizes as readonly string[];
const inferredAspectRatio = inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const inferredImageSize = inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const aspectRatio = supportedAspectRatios.includes(options.aspectRatio ?? '')
? options.aspectRatio
: supportedAspectRatios.includes(inferredAspectRatio)
? inferredAspectRatio
: (dimensionOptions.aspectRatios[0] ?? '1:1');
const imageSize = supportedImageSizes.includes(options.imageSize ?? '')
? options.imageSize
: supportedImageSizes.includes(inferredImageSize)
? inferredImageSize
: (dimensionOptions.imageSizes.find((size) => size === '1K') ??
dimensionOptions.imageSizes[0] ??
'1K');
return {
mode: 'quick-edit',
sourceLayerId: sourceLayer.id,
@@ -313,14 +313,22 @@ describe('ImageCanvasGenerationLayerModel', () => {
id: 'layer-source',
title: '源图',
src: 'data:image/png;base64,edited',
width: 320,
height: 240,
originalWidth: 1537,
originalHeight: 1025,
x: -488,
y: -252,
width: 1536,
height: 1024,
originalWidth: 1536,
originalHeight: 1024,
resourceId: 'resource-edited',
sourceResourceId: 'resource-source',
objectKey: 'generated/edited.png',
});
expect(layer.x + layer.width / 2).toBe(
sourceLayer.x + sourceLayer.width / 2,
);
expect(layer.y + layer.height / 2).toBe(
sourceLayer.y + sourceLayer.height / 2,
);
});
it('creates wrapped icon layers with icon metadata', () => {
@@ -192,7 +192,10 @@ export function createQuickEditResultLayer({
frame,
}: QuickEditResultLayerOptions): CanvasLayer {
const originalWidth =
frame?.originalWidth || generated.width || sourceLayer.originalWidth || 1024;
frame?.originalWidth ||
generated.width ||
sourceLayer.originalWidth ||
1024;
const originalHeight =
frame?.originalHeight ||
generated.height ||
@@ -247,14 +250,29 @@ export function applyImageEditResultToSourceLayer({
sourceLayer: CanvasLayer;
generationInputs: CanvasGenerationInputs;
}): CanvasLayer {
const originalWidth = sourceLayer.originalWidth || sourceLayer.width;
const originalHeight = sourceLayer.originalHeight || sourceLayer.height;
const width = sourceLayer.width;
const height = sourceLayer.height;
const originalWidth =
generated.resource?.width ||
generated.width ||
sourceLayer.originalWidth ||
1;
const originalHeight =
generated.resource?.height ||
generated.height ||
sourceLayer.originalHeight ||
1;
const { width, height } = resolveLayerResolutionSize(
originalWidth,
originalHeight,
sourceLayer,
);
const centerX = sourceLayer.x + sourceLayer.width / 2;
const centerY = sourceLayer.y + sourceLayer.height / 2;
return applyGeneratedMetadata(
{
...sourceLayer,
src: generated.imageSrc,
x: centerX - width / 2,
y: centerY - height / 2,
width,
height,
originalWidth,
@@ -280,10 +298,8 @@ export function createIconSpritesheetResultLayers({
const spritesheetResource = generated.spritesheetResource;
const spritesheetAsset = generated.spritesheetAsset;
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const startX =
frame?.x ?? worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2;
const startY =
frame?.y ?? worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2;
const startX = frame?.x ?? worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2;
const startY = frame?.y ?? worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2;
const spacing = 24;
const maxRowWidth = 560;
const spritesheetOriginalWidth =
@@ -319,8 +335,7 @@ export function createIconSpritesheetResultLayers({
sourceAssetId: spritesheetAsset?.assetId,
objectKey: spritesheetResource?.objectKey ?? undefined,
assetObjectId: spritesheetResource?.assetObjectId ?? undefined,
generationInputs:
spritesheetResource?.generationInputs ?? generationInputs,
generationInputs: spritesheetResource?.generationInputs ?? generationInputs,
generatedAssetSnapshot: spritesheetAsset,
};
const iconStartX = startX + spritesheetDisplaySize.width + 32;
@@ -348,7 +363,8 @@ export function createIconSpritesheetResultLayers({
const generatedIndex = startIndex + index + 1;
const layer: CanvasLayer = {
id: `layer-icon-${generatedIndex}`,
resourceId: resource?.resourceId ?? `local-resource-icon-${generatedIndex}`,
resourceId:
resource?.resourceId ?? `local-resource-icon-${generatedIndex}`,
title: icon.name,
src: icon.imageSrc,
x: cursorX,
@@ -412,11 +428,14 @@ export function createVideoResultLayer({
return {
id: `layer-video-${generatedIndex}`,
resourceId: resource?.resourceId ?? `local-resource-video-${generatedIndex}`,
resourceId:
resource?.resourceId ?? `local-resource-video-${generatedIndex}`,
title,
src: generated.videoSrc,
mediaType: 'video',
assetKind: (resource?.assetKind as CanvasLayer['assetKind'] | undefined) ?? assetKind,
assetKind:
(resource?.assetKind as CanvasLayer['assetKind'] | undefined) ??
assetKind,
x: frameX ?? worldCenter.x - width / 2,
y: frameY ?? worldCenter.y - height / 2,
width,
@@ -432,7 +451,8 @@ export function createVideoResultLayer({
taskId: generated.taskId,
objectKey: resource?.objectKey ?? generated.objectKey,
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
thumbnailSrc: generated.thumbnailSrc ?? generated.asset?.thumbnailSrc ?? undefined,
thumbnailSrc:
generated.thumbnailSrc ?? generated.asset?.thumbnailSrc ?? undefined,
sourceResourceId: sourceLayer?.resourceId,
sourceAssetId: asset?.assetId,
groupId: sourceLayer?.groupId,
@@ -250,9 +250,7 @@ describe('ImageCanvasGenerationModel', () => {
],
),
).toEqual({
fields: [
{ title: '角色设定', value: '主角骑士' },
],
fields: [{ title: '角色设定', value: '主角骑士' }],
references: [
{
title: '角色规范',
@@ -381,9 +379,17 @@ describe('ImageCanvasGenerationModel', () => {
buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }),
).toBe('自定义');
expect(buildQuickEditModelOptions('nano-banana')).toEqual([
{
label: 'nanobanana2',
value: 'gemini-3.1-flash-image-preview',
},
{ label: 'gpt-image-2', value: 'gpt-image-2' },
]);
expect(buildQuickEditModelOptions('nanobanana2')).toEqual([
{
label: 'nanobanana2',
value: 'gemini-3.1-flash-image-preview',
},
{ label: 'gpt-image-2', value: 'gpt-image-2' },
]);
});
@@ -111,9 +111,7 @@ export const EDITOR_IMAGE_MODEL_OPTIONS = [
{ label: 'nanobanana2', value: IMAGE_MODEL_NANOBANANA2 },
{ label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 },
] as const;
export const QUICK_EDIT_MODEL_OPTIONS = [
{ label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 },
] as const;
export const QUICK_EDIT_MODEL_OPTIONS = EDITOR_IMAGE_MODEL_OPTIONS;
export const EDITOR_IMAGE_DIMENSION_OPTIONS = {
[IMAGE_MODEL_NANOBANANA2]: {
aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'],
@@ -557,6 +555,10 @@ export function buildQuickEditModelOptions(currentModel: string) {
return options;
}
export function isQuickEditUnsupportedAssetKind(layer: CanvasLayer) {
return layer.assetKind === 'icon' || layer.assetKind === 'icon-spritesheet';
}
export function buildCharacterSpecPrompt(values: SpecFormValues) {
return [
'生成2D 角色美术视觉规范设定图,纯白底板,整齐排布全身标准立绘;固定统一头身比例、勾线粗细恒定;展示待机行走攻击基础动作帧样例,重心对齐不变位,服饰配饰分层结构示意,搭配专属角色色卡标注色号,无多余杂物,精准尺寸标注,高清矢量规范稿',
@@ -103,22 +103,22 @@ describe('ImageCanvasQuickEditPanelView', () => {
target: { value: '新提示' },
});
expect(
screen.getByPlaceholderText('写下每个编号要怎么改'),
).toBeTruthy();
expect(screen.getByPlaceholderText('你希望素材如何修改?')).toBeTruthy();
expect(screen.getByLabelText('当前提示词').textContent).toBe('新提示');
expect(screen.getByLabelText('当前尺寸').textContent).toBe('1024x1024');
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
expect(screen.getByLabelText('当前模型').textContent).toBe(
'gemini-3.1-flash-image-preview',
);
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
expect(
screen.queryByRole('button', { name: '快速编辑尺寸 1:1·1K' }),
).toBeNull();
screen.getByRole('button', { name: '快速编辑尺寸 1:1·1K' }),
).toBeTruthy();
fireEvent.click(
screen.getByRole('button', { name: '快速编辑模型 gpt-image-2' }),
screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }),
);
expect(screen.queryByRole('button', { name: 'nanobanana2' })).toBeNull();
expect(screen.getByRole('button', { name: 'nanobanana2' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2');
@@ -8,10 +8,7 @@ import {
} from 'react';
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
import {
IMAGE_MODEL_GPT_IMAGE_2,
QUICK_EDIT_REFERENCE_LIMIT,
} from './ImageCanvasGenerationModel';
import { QUICK_EDIT_REFERENCE_LIMIT } from './ImageCanvasGenerationModel';
import type {
CanvasLayer,
GenerateDialogState,
@@ -58,7 +55,7 @@ function createQuickEditDialog(
status: panel.status,
sourceLayerId: panel.sourceLayerId,
generationReferences: panel.quickEditReferences,
imageModel: isQuickEdit ? IMAGE_MODEL_GPT_IMAGE_2 : panel.model,
imageModel: panel.model,
aspectRatio: panel.aspectRatio,
imageSize: panel.imageSize,
};
@@ -70,7 +67,6 @@ function syncPanelFromDialogUpdate(
) {
const normalizedPanel = resetFailedPanelStatus(panel);
const normalizedDialog = resetFailedPanelStatus(dialog);
const isQuickEdit = (normalizedPanel.mode ?? 'quick-edit') === 'quick-edit';
return {
...normalizedPanel,
size: panel.size,
@@ -78,9 +74,7 @@ function syncPanelFromDialogUpdate(
aspectRatio:
normalizedDialog.aspectRatio ?? normalizedPanel.aspectRatio ?? '1:1',
imageSize: normalizedDialog.imageSize ?? normalizedPanel.imageSize ?? '1K',
model: isQuickEdit
? IMAGE_MODEL_GPT_IMAGE_2
: (normalizedDialog.imageModel ?? normalizedPanel.model),
model: normalizedDialog.imageModel ?? normalizedPanel.model,
quickEditReferences: normalizedDialog.generationReferences,
status: normalizedDialog.status,
errorMessage: normalizedDialog.errorMessage,
@@ -102,9 +96,8 @@ export function ImageCanvasQuickEditPanelView({
onRememberImageModel = () => {},
onSubmit,
}: ImageCanvasQuickEditPanelViewProps) {
void sourceLayer;
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;
@@ -144,12 +137,12 @@ export function ImageCanvasQuickEditPanelView({
onRememberImageModel={onRememberImageModel}
onSubmit={onSubmit}
dialogLabel={isRedraw ? '重绘图片' : '快速编辑图片'}
includeDimensions={isRedraw}
includeDimensions={isRedraw || isImageQuickEdit}
includeModel={true}
includeReferences={false}
promptLabel={isRedraw ? '重绘提示词' : '快速编辑提示词'}
promptPlaceholder={
isRedraw ? '把画面重绘成什么样?' : '写下每个编号要怎么改'
isRedraw ? '把画面重绘成什么样?' : '你希望素材如何修改?'
}
optionLabelPrefix={isRedraw ? '重绘' : '快速编辑'}
dimensionRatioAriaLabelPrefix="比例"
@@ -31,7 +31,9 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
}
function renderSelectedToolbar(
overrides: Partial<Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0]> = {},
overrides: Partial<
Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0]
> = {},
) {
const props: Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0] = {
selectedLayer: createLayer(),
@@ -73,7 +75,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
fireEvent.click(within(toolbar).getByRole('button', { name: '改造' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' }));
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(props.selectedLayer);
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(
props.selectedLayer,
);
expect(props.onOpenCropExpandPanel).toHaveBeenCalledWith(
props.selectedLayer,
);
@@ -137,7 +141,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
'改造',
'下载按钮',
]);
expect(within(toolbar).queryByRole('button', { name: '快速编辑' })).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '快速编辑' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '裁扩按钮' }),
).toBeNull();
@@ -173,9 +179,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
within(videoToolbar).queryByRole('button', { name: '去除背景按钮' }),
).toBeNull();
expect(
within(videoToolbar).getAllByRole('button').map((button) =>
button.getAttribute('aria-label'),
),
within(videoToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['快速编辑', '改造', '下载按钮']);
cleanup();
@@ -196,9 +202,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
within(actionToolbar).queryByRole('button', { name: '去除背景按钮' }),
).toBeNull();
expect(
within(actionToolbar).getAllByRole('button').map((button) =>
button.getAttribute('aria-label'),
),
within(actionToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['快速编辑', '改造', '下载按钮']);
});
@@ -211,7 +217,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
const animationButton = screen.getByRole('button', { name: '生成动画' });
expect(animationButton.textContent).toContain('生成动画');
expect(animationButton.querySelector('.lucide-person-standing')).toBeTruthy();
expect(
animationButton.querySelector('.lucide-person-standing'),
).toBeTruthy();
fireEvent.click(animationButton);
@@ -222,6 +230,25 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
});
it.each(['icon', 'icon-spritesheet'] as const)(
'hides quick edit for %s assets',
(assetKind) => {
renderSelectedToolbar({
selectedLayer: createLayer({ assetKind }),
});
expect(screen.queryByRole('button', { name: '快速编辑' })).toBeNull();
},
);
it('keeps quick edit available for icon specs', () => {
renderSelectedToolbar({
selectedLayer: createLayer({ assetKind: 'icon-spec' }),
});
expect(screen.getByRole('button', { name: '快速编辑' })).toBeTruthy();
});
it('renders nothing without a selected layer or toolbar position', () => {
const { rerender } = render(
<ImageCanvasSelectedLayerToolbarView
@@ -12,6 +12,7 @@ import type { CSSProperties } from 'react';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
type ImageCanvasSelectedLayerToolbarViewProps = {
selectedLayer: CanvasLayer | null;
@@ -81,19 +82,23 @@ export function ImageCanvasSelectedLayerToolbarView({
aria-label="图片工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label="快速编辑"
title="快速编辑"
icon={<Sparkles className="h-4 w-4" />}
onClick={() => onOpenQuickEditPanel(selectedLayer)}
>
<span>快速编辑</span>
</PlatformIconButton>
<span
aria-hidden="true"
className="image-canvas-editor__floating-toolbar-divider"
/>
{!isQuickEditUnsupportedAssetKind(selectedLayer) ? (
<>
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label="快速编辑"
title="快速编辑"
icon={<Sparkles className="h-4 w-4" />}
onClick={() => onOpenQuickEditPanel(selectedLayer)}
>
<span>快速编辑</span>
</PlatformIconButton>
<span
aria-hidden="true"
className="image-canvas-editor__floating-toolbar-divider"
/>
</>
) : null}
{canRasterEdit ? (
<>
<EditorIconButton
@@ -556,8 +556,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect.objectContaining({
prompt: '快速修图',
sourceImageSrc: 'resource-source',
size: '1537x1025',
model: 'gpt-image-2',
size: '2048x1365',
model: 'gemini-3.1-flash-image-preview',
}),
);
});
@@ -578,6 +578,31 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('fit-count').textContent).toBe('0');
});
it('rejects direct quick edit submission for icon assets', async () => {
render(
<SubmissionWorkflowHarness
initialLayers={[createLayer({ assetKind: 'icon' })]}
initialQuickEditPanel={{
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '修改图标',
size: '1024x1024',
aspectRatio: '1:1',
imageSize: '1K',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
expect(editEditorImageMock).not.toHaveBeenCalled();
expect(screen.getByTestId('quick-edit').textContent).toContain(
'failed:修改图标:图标类素材不支持快速编辑',
);
});
it('clears quick edit panel after a queued edit reloads the project snapshot', async () => {
const applyProjectSnapshot = vi.fn();
const refreshTaskList = vi.fn();
@@ -67,6 +67,7 @@ import {
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
isCanvasGenerationDialog,
isQuickEditUnsupportedAssetKind,
normalizeEditorImageModel,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
@@ -1251,6 +1252,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({
return;
}
if (isQuickEditUnsupportedAssetKind(quickEditSourceLayer)) {
setQuickEditPanel({
...quickEditPanel,
status: 'failed',
errorMessage: '图标类素材不支持快速编辑',
});
return;
}
const panelMode = quickEditPanel.mode ?? 'quick-edit';
const quickEditReferences =
panelMode === 'redraw' || panelMode === 'quick-edit'
@@ -1268,11 +1278,20 @@ export function useImageCanvasGenerationSubmissionWorkflow({
quickEditSourceLayer.originalWidth,
quickEditSourceLayer.originalHeight,
);
const quickEditOutputSize = {
width: quickEditSourceLayer.originalWidth,
height: quickEditSourceLayer.originalHeight,
};
const quickEditSize = `${quickEditSourceLayer.originalWidth}x${quickEditSourceLayer.originalHeight}`;
const normalizedQuickEditModel = normalizeEditorImageModel(
quickEditPanel.model,
);
const quickEditOutputSize = isVideoQuickEditSource(quickEditSourceLayer)
? {
width: quickEditSourceLayer.originalWidth,
height: quickEditSourceLayer.originalHeight,
}
: resolveEditorImageGenerationPixelSize({
model: normalizedQuickEditModel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
});
const quickEditSize = `${quickEditOutputSize.width}x${quickEditOutputSize.height}`;
const basePrompt =
quickEditPanel.prompt.trim() ||
(panelMode === 'redraw' ? '重绘图片' : '快速编辑图片');
@@ -1484,7 +1503,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
prompt: normalizedPrompt,
sourceImageSrc: sourceReferenceImageSrc,
size: quickEditSize,
model: IMAGE_MODEL_GPT_IMAGE_2,
model: normalizedQuickEditModel,
...(extraReferenceImageSrcs.length
? { referenceImageSrcs: extraReferenceImageSrcs }
: {}),
@@ -1505,7 +1524,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
kind: isCharacterRedraw ? 'character' : 'quick-edit',
model: normalizeEditorImageModel(quickEditPanel.model),
model: normalizedQuickEditModel,
referenceImageSrcs: [
...extraReferenceImageSrcs,
sourceReferenceImageSrc,
@@ -915,7 +915,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('opens quick edit for plain generated images without inheriting hidden source dimensions', () => {
it('opens quick edit for plain generated images with source model parameters', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(
@@ -929,7 +929,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
'layer-source:idle:-',
);
expect(screen.getByTestId('quick-edit-image-options').textContent).toBe(
'quick-edit:gpt-image-2:4:3:1K',
'quick-edit:gpt-image-2:2:3:2K',
);
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
'layer-source:none:0',
@@ -1074,7 +1074,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('keeps source image model and infers dimensions from the current image when opening quick edit', () => {
it('prefers the source generation parameter snapshot when opening quick edit', () => {
render(
<GenerationWorkflowHarness
initialLayers={[
@@ -1094,7 +1094,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('quick-edit-image-options').textContent).toBe(
'quick-edit:gpt-image-2:3:2:2K',
'quick-edit:gpt-image-2:2:3:2K',
);
});
@@ -79,6 +79,7 @@ import {
ICON_DESCRIPTION_LIMIT,
IMAGE_MODEL_GPT_IMAGE_2,
isCanvasGenerationDialog,
isQuickEditUnsupportedAssetKind,
normalizeEditorImageModel,
resizeGenerationPlaceholderToImageSelection,
resolveEditorImageGenerationPixelSize,
@@ -1119,6 +1120,8 @@ export function useImageCanvasGenerationWorkflow({
);
return createQuickEditPanelDraft(sourceLayer, {
imageModel: sourceDialog?.imageModel,
aspectRatio: sourceDialog?.aspectRatio,
imageSize: sourceDialog?.imageSize,
});
},
[canvasGenerationDialogs],
@@ -1126,6 +1129,9 @@ export function useImageCanvasGenerationWorkflow({
const openQuickEditPanel = useCallback(
(sourceLayer: CanvasLayer) => {
if (isQuickEditUnsupportedAssetKind(sourceLayer)) {
return;
}
setImageContextMenu(null);
setMetadataLayer(null);
setUiAssetExtractionState(null);
+15 -1
View File
@@ -8621,6 +8621,13 @@ button.image-canvas-editor__reference-chip:disabled {
.image-canvas-editor__quick-edit-footer
.image-canvas-editor__option-popover-anchor--model {
grid-column: 3;
width: 9.75rem;
}
.image-canvas-editor__quick-edit-footer
.image-canvas-editor__option-cluster--model {
width: 100%;
max-width: none;
}
.image-canvas-editor__quick-edit-footer
@@ -9376,7 +9383,7 @@ button.image-canvas-editor__reference-chip:disabled {
.image-canvas-editor__quick-edit-panel.image-canvas-editor__generation-composer {
width: auto;
bottom: 3.85rem;
bottom: calc(9rem + env(safe-area-inset-bottom, 0px));
}
.image-canvas-editor__generation-composer {
@@ -9398,6 +9405,13 @@ button.image-canvas-editor__reference-chip:disabled {
align-items: stretch;
}
.image-canvas-editor__option-popover-anchor--dimensions,
.image-canvas-editor__option-popover-anchor--model {
grid-column: auto;
width: 100%;
justify-self: stretch;
}
.image-canvas-editor__option-cluster--dimensions,
.image-canvas-editor__option-cluster--model,
.image-canvas-editor__readonly-generation-option,