快速编辑直接覆盖原图

将图片快速编辑提交切到 /api/editor/images/edits,并用结果回写源图层

保留重绘作为右侧新结果图层,角色重绘继续走 character 生成契约

更新快速编辑文档和定向回归测试
This commit is contained in:
2026-06-27 19:47:53 +08:00
parent a6671e7da7
commit bb51c7e0d2
18 changed files with 336 additions and 298 deletions
File diff suppressed because one or more lines are too long
@@ -40,7 +40,7 @@
- 生成视频:`你希望生成什么视频?`
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
10. 快速编辑最多允许额外绑定 8 张参考图;原图始终自动作为最后一张隐式参考图提交,不占用额外参考图额度,也不在参考图条里固定展示 `图x`
10. 快速编辑最多允许额外绑定 8 张参考图;原图始终作为 `/api/editor/images/edits``sourceImageSrc` 直接提交,不占用额外参考图额度,也不在参考图条里固定展示 `图x`
11. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`
## 参数交互
@@ -175,10 +175,10 @@
- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
- 生成规范类图片底部展示禁用态参数按钮 `16:9·2K``gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。
- 快速编辑底部展示当前选择的比例 / 尺寸和模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮,并允许展开修改;额外参考图最多 8 张,原图作为隐式最后参考图参与提交但不在参考图条里固定显示 `图x`
- 快速编辑底部展示当前选择的比例 / 尺寸和模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮,并允许展开修改;额外参考图最多 8 张,原图作为 `sourceImageSrc` 直接编辑,不在参考图条里固定显示 `图x`
- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`
- 快速编辑提交前必须把提示词里对原图的 `原图``当前图片``当前图``图1` 引用改写成最后一张原图编号,例如额外 1 张参考图时改为 `图2`
- 快速编辑提交给后端的参考图顺序必须是额外参考图在前、原图在最后;若用户已完成红色圈选,最后一张隐式原图必须替换为绘制红框和序号的标注图。
- 快速编辑提交前保留提示词里对原图的 `原图``当前图片``当前图``图1` 引用,不再改写成 `图N`
- 快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`;额外参考图只进入输入快照和前端上下文,不作为图片编辑请求的 `referenceImageSrcs`
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
- 快速编辑生成中的占位图同样只支持键盘 `Delete` / `Backspace` 删除,不新增 UI 删除按钮;删除后异步结果不得再落回画布。
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
@@ -140,14 +140,14 @@ 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 ? '改造提示词' : '生成提示词');
promptLabel ?? (isQuickEdit ? '快速编辑提示词' : '生成提示词');
const resolvedPromptPlaceholder =
promptPlaceholder ??
(isQuickEdit ? '想怎么改造这张图?' : '今天想生成什么画面?');
(isQuickEdit ? '想怎么编辑这张图?' : '今天想生成什么画面?');
const resolvedOptionLabelPrefix =
optionLabelPrefix ?? (isQuickEdit ? '改造图片' : '生成图片');
optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
const resolvedReferenceButtonLabel = referenceButtonLabel;
const resolvedReferenceButtonAriaLabel =
referenceButtonAriaLabel ?? `添加${resolvedReferenceButtonLabel}`;
@@ -969,9 +969,9 @@ describe('ImageCanvasEditorView generation integration', () => {
clientY: 120,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
expect(screen.getByRole('dialog', { name: '改造图片' })).toBeTruthy();
expect(screen.getByRole('dialog', { name: '快速编辑图片' })).toBeTruthy();
expect(
screen.queryByRole('button', { name: '关闭改造图片' }),
screen.queryByRole('button', { name: '关闭快速编辑图片' }),
).toBeNull();
fireEvent.pointerDown(viewport, {
@@ -981,7 +981,7 @@ describe('ImageCanvasEditorView generation integration', () => {
clientY: 180,
});
expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull();
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
fireEvent.pointerDown(sourceLayer, {
button: 0,
@@ -3202,7 +3202,7 @@ describe('ImageCanvasEditorView generation integration', () => {
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
generateEditorImageMock.mockImplementationOnce(async (input) =>
editEditorImageMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse(input, {
imageSrc: 'data:image/png;base64,cXVpY2stZWRpdA==',
width: 1536,
@@ -3231,7 +3231,7 @@ describe('ImageCanvasEditorView generation integration', () => {
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickPanel = screen.getByRole('dialog', { name: '改造图片' });
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
expect(quickPanel.className).toContain(
'image-canvas-editor__quick-edit-panel',
);
@@ -3273,7 +3273,7 @@ describe('ImageCanvasEditorView generation integration', () => {
);
const updatedQuickPanel = screen.getByRole('dialog', {
name: '改造图片',
name: '快速编辑图片',
});
expect(within(updatedQuickPanel).getByText('星空参考')).toBeTruthy();
expect(within(updatedQuickPanel).queryByLabelText('原图 图2')).toBeNull();
@@ -3289,18 +3289,12 @@ describe('ImageCanvasEditorView generation integration', () => {
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '参考图2增加萤火虫',
prompt: '参考图增加萤火虫',
sourceImageSrc: 'data:image/png;base64,c291cmNl',
size: '1024x683',
aspectRatio: '3:2',
imageSize: '1K',
kind: 'quick-edit',
model: 'gpt-image-2',
referenceImageSrcs: [
'data:image/png;base64,ZXh0cmEtcmVm',
'data:image/png;base64,c291cmNl',
],
projectId: 'editor-project-quick-edit',
assetKind: 'spec',
assetFolderId: 'project',
@@ -3310,13 +3304,14 @@ describe('ImageCanvasEditorView generation integration', () => {
fields: expect.arrayContaining([
expect.objectContaining({
title: '快速编辑提示词',
value: '参考图2增加萤火虫',
value: '参考图增加萤火虫',
}),
]),
}),
}),
);
});
expect(generateEditorImageMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByAltText('画布图片:魔法森林 快速编辑')).toBeTruthy();
});
@@ -3373,7 +3368,7 @@ describe('ImageCanvasEditorView generation integration', () => {
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
generateEditorImageMock.mockImplementationOnce(async (input) =>
editEditorImageMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse(input, {
imageSrc: 'data:image/png;base64,Y29udGV4dC1xdWljaw==',
width: 1024,
@@ -3400,7 +3395,7 @@ describe('ImageCanvasEditorView generation integration', () => {
).toBeTruthy();
fireEvent.click(within(menu).getByRole('menuitem', { name: '快速编辑' }));
const panel = screen.getByRole('dialog', { name: '改造图片' });
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
expect(within(panel).queryByText('右键图片')).toBeNull();
fireEvent.change(within(panel).getByLabelText('快速编辑提示词'), {
target: { value: '换成夜晚' },
@@ -3408,15 +3403,12 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '换成夜晚',
referenceImageSrcs: ['data:image/png;base64,Y29udGV4dA=='],
sourceImageSrc: 'data:image/png;base64,Y29udGV4dA==',
size: '1024x1024',
aspectRatio: '1:1',
imageSize: '1K',
model: 'gpt-image-2',
kind: 'quick-edit',
projectId: 'editor-project-context-quick-edit',
assetFolderId: 'project',
assetLabel: '右键图片 快速编辑',
@@ -3424,6 +3416,7 @@ describe('ImageCanvasEditorView generation integration', () => {
}),
);
});
expect(generateEditorImageMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByAltText('画布图片:右键图片 快速编辑')).toBeTruthy();
});
@@ -3462,7 +3455,7 @@ describe('ImageCanvasEditorView generation integration', () => {
},
}),
);
generateEditorImageMock.mockImplementationOnce(async (input) =>
editEditorImageMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse(input, {
imageSrc: 'data:image/png;base64,cHVibGljLXF1aWNr',
width: 640,
@@ -3491,18 +3484,17 @@ describe('ImageCanvasEditorView generation integration', () => {
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickPanel = screen.getByRole('dialog', { name: '改造图片' });
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
target: { value: '改成陶泥风格' },
});
fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '改成陶泥风格',
kind: 'quick-edit',
referenceImageSrcs: ['data:image/webp;base64,aGVsbG8='],
sourceImageSrc: 'data:image/webp;base64,aGVsbG8=',
projectId: 'editor-project-public-quick-edit',
assetFolderId: 'project',
assetLabel: '公开拼图素材 快速编辑',
@@ -3510,6 +3502,7 @@ describe('ImageCanvasEditorView generation integration', () => {
}),
);
});
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(globalThis.fetch).toHaveBeenCalledWith(
'/creation-type-references/puzzle.webp',
expect.objectContaining({
@@ -3532,7 +3525,7 @@ describe('ImageCanvasEditorView generation integration', () => {
taskId: 'editor-real-task-2',
}),
);
generateEditorImageMock.mockImplementationOnce(async (input) =>
editEditorImageMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse(input, {
imageSrc: 'data:image/png;base64,ZWRhd24tcmVkcmF3',
width: 1024,
@@ -3605,15 +3598,12 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.click(within(editDialog).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenLastCalledWith(
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '把画面改成黄昏光线',
sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
size: '1024x1024',
aspectRatio: '1:1',
imageSize: '1K',
kind: 'quick-edit',
model: 'gpt-image-2',
referenceImageSrcs: ['data:image/png;base64,ZmFrZS1pbWFnZQ=='],
}),
);
});
@@ -3621,17 +3611,17 @@ describe('ImageCanvasEditorView generation integration', () => {
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull();
});
expect(screen.getByAltText(/画布图片:生成图片 .* 快速编辑/)).toBeTruthy();
expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy();
fireEvent.click(
screen.getAllByRole('button', {
name: / .* /u,
name: / .* /u,
})[0]!,
);
const editedMetadataDialog = screen.getByRole('dialog', {
name: '图片信息',
});
expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull();
expect(within(editedMetadataDialog).getByText('快速编辑提示词')).toBeTruthy();
expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy();
expect(
within(editedMetadataDialog).getByText('把画面改成黄昏光线'),
).toBeTruthy();
@@ -3742,13 +3732,13 @@ describe('ImageCanvasEditorView generation integration', () => {
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickPanel = screen.getByRole('dialog', { name: '改造图片' });
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
target: { value: '加一层暖光' },
});
fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull();
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
expect(screen.getByAltText('画布图片:快速编辑源图')).toBeTruthy();
expect(sourceLayer.className).not.toContain(
'image-canvas-editor__layer--generating',
@@ -130,7 +130,7 @@ describe('ImageCanvasGenerationComposerView', () => {
imageSize: '1K',
});
const panel = screen.getByRole('dialog', { name: '改造图片' });
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
expect(panel.className).toContain(
'image-canvas-editor__generation-composer',
);
@@ -140,7 +140,7 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(panel.className).not.toContain(
'image-canvas-editor__quick-edit-panel',
);
expect(within(panel).getByRole('textbox', { name: '改造提示词' }))
expect(within(panel).getByRole('textbox', { name: '快速编辑提示词' }))
.toBeTruthy();
expect(
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
@@ -36,6 +36,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
cost?: number;
submitLabel?: string;
submitAriaLabel?: string;
submitButtonClassName?: string;
lockedModel?: string;
renderEditorPortal?: (node: ReactNode) => ReactNode;
buildPortalMenuStyle?: (
@@ -126,6 +127,7 @@ export function ImageCanvasGenerationImageOptionsView({
cost,
submitLabel = '生成',
submitAriaLabel = '生成',
submitButtonClassName = 'image-canvas-editor__generation-submit',
lockedModel,
renderEditorPortal = (node) => node,
buildPortalMenuStyle = () => ({}),
@@ -374,7 +376,7 @@ export function ImageCanvasGenerationImageOptionsView({
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
className={submitButtonClassName}
disabled={isGenerating}
aria-label={submitAriaLabel}
>
@@ -5,6 +5,7 @@ import type {
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
applyImageEditResultToSourceLayer,
createAudioResultLayer,
createCharacterAnimationResultLayer,
createGeneratedResultLayer,
@@ -282,6 +283,46 @@ describe('ImageCanvasGenerationLayerModel', () => {
});
});
it('applies quick edits to the source layer in place', () => {
const sourceLayer = createSourceLayer({
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
});
const layer = applyImageEditResultToSourceLayer({
generated: createGenerated({
imageSrc: 'data:image/png;base64,edited',
width: 1536,
height: 1024,
resource: {
resourceId: 'resource-edited',
objectKey: 'generated/edited.png',
assetObjectId: 'asset-object-edited',
width: 1536,
height: 1024,
sourceType: 'generated',
generationInputs: createGenerationInputs(),
},
}),
sourceLayer,
generationInputs: createGenerationInputs(),
});
expect(layer).toMatchObject({
id: 'layer-source',
title: '源图',
src: 'data:image/png;base64,edited',
width: 1536,
height: 1024,
originalWidth: 1536,
originalHeight: 1024,
resourceId: 'resource-edited',
sourceResourceId: 'resource-source',
objectKey: 'generated/edited.png',
});
});
it('creates wrapped icon layers with icon metadata', () => {
const layers = createIconSpritesheetResultLayers({
generated: {
@@ -238,6 +238,43 @@ export function createQuickEditResultLayer({
);
}
export function applyImageEditResultToSourceLayer({
generated,
sourceLayer,
generationInputs,
}: {
generated: EditorImageGenerationResult;
sourceLayer: CanvasLayer;
generationInputs: CanvasGenerationInputs;
}): CanvasLayer {
const originalWidth =
generated.width || sourceLayer.originalWidth || sourceLayer.width;
const originalHeight =
generated.height || sourceLayer.originalHeight || sourceLayer.height;
const { width, height } = resolveLayerResolutionSize(
originalWidth,
originalHeight,
{
width: sourceLayer.width,
height: sourceLayer.height,
},
);
return applyGeneratedMetadata(
{
...sourceLayer,
src: generated.imageSrc,
width,
height,
originalWidth,
originalHeight,
sourceType: generated.sourceType,
sourceResourceId: sourceLayer.resourceId,
},
generated,
generationInputs,
);
}
export function createIconSpritesheetResultLayers({
generated,
iconResults,
@@ -41,7 +41,6 @@ import {
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
IMAGE_MODEL_GPT_IMAGE_2,
normalizeQuickEditPromptSourceReference,
resolveCharacterAnimationSourceImageSrc,
resolveImageGenerationErrorMessage,
VIDEO_MODEL_KLING_3,
@@ -299,24 +298,6 @@ describe('ImageCanvasGenerationModel', () => {
});
});
it('rewrites quick edit source image references to the final image index', () => {
expect(normalizeQuickEditPromptSourceReference('把图1改成夜晚', 3)).toBe(
'把图3改成夜晚',
);
expect(normalizeQuickEditPromptSourceReference('参考原图增加暖光', 2)).toBe(
'参考图2增加暖光',
);
expect(normalizeQuickEditPromptSourceReference('保留图1构图', 1)).toBe(
'保留图1构图',
);
expect(
normalizeQuickEditPromptSourceReference('保持当前图片构图', 1),
).toBe('保持图1构图');
expect(
normalizeQuickEditPromptSourceReference('不要影响图10内容', 2),
).toBe('不要影响图10内容');
});
it('keeps generated prompts and quick edit options stable', () => {
const prompt = buildSpecPrompt('ui', {
playSetting: '消除玩法',
@@ -944,17 +944,6 @@ export function appendLimitedQuickEditReferences(
].slice(0, QUICK_EDIT_REFERENCE_LIMIT);
}
export function normalizeQuickEditPromptSourceReference(
prompt: string,
sourceReferenceIndex: number,
) {
const normalizedIndex = Math.max(1, sourceReferenceIndex);
const sourceReferenceLabel = `${normalizedIndex}`;
return prompt
.replace(/||/gu, sourceReferenceLabel)
.replace(/\s*1(?!\d)/gu, sourceReferenceLabel);
}
export function createGenerationInputField(
title: string,
value: string | null | undefined,
@@ -1205,7 +1194,6 @@ export function buildQuickEditGenerationInputs(
return {
fields: createGenerationInputField(title, prompt),
references: [
// 中文注释:原图作为隐式最后参考图参与生成,但信息面板只展示用户额外选择的参考图。
...limitedReferences.flatMap((reference, index) =>
createGenerationInputReference(`参考图 ${index + 1}`, reference),
),
@@ -118,7 +118,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('builds quick-edit canvas generator plans with the source layer as the final reference', () => {
it('builds quick-edit canvas generator plans as direct source edits', () => {
const sourceLayer = createLayer({
objectKey: 'generated-character-drafts/editor/source.png',
assetKind: 'character',
@@ -147,25 +147,17 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
expect(plan).toMatchObject({
kind: 'quick-edit',
normalizedPrompt: '把图2改成雨天',
normalizedPrompt: '把当前图改成雨天',
sourceLayer,
input: {
prompt: '把图2改成雨天',
editInput: {
size: '1024x768',
kind: 'character',
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '4:3',
imageSize: '1K',
referenceImageSrcs: [
'data:image/png;base64,style',
'generated-character-drafts/editor/source.png',
],
},
result: {
title: '源图 快速编辑',
assetKind: 'character',
generationInputs: {
fields: [{ title: '快速编辑提示词', value: '把图2改成雨天' }],
fields: [{ title: '快速编辑提示词', value: '把当前图改成雨天' }],
references: [],
},
},
@@ -2,6 +2,7 @@ import type {
EditorBackgroundMusicGenerationInput,
EditorCharacterAnimationGenerationInput,
EditorIconSpritesheetGenerationInput,
EditorImageEditInput,
EditorImageGenerationInput,
EditorSoundEffectGenerationInput,
EditorVideoGenerationInput,
@@ -42,7 +43,6 @@ import {
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
normalizeEditorImageModel,
normalizeQuickEditPromptSourceReference,
resolveCharacterAnimationSourceImageSrc,
resolveEditorImageGenerationPixelSize,
SEEDANCE_VIDEO_REFERENCE_LIMITS,
@@ -166,7 +166,7 @@ export type ImageGenerationSubmissionPlan =
kind: 'quick-edit';
normalizedPrompt: string;
sourceLayer: CanvasLayer;
input: EditorImageGenerationInput;
editInput: Pick<EditorImageEditInput, 'size' | 'model'>;
result: {
title: string;
assetKind?: CanvasLayer['assetKind'];
@@ -257,12 +257,8 @@ export function buildImageGenerationSubmissionPlan({
throw new Error('未找到要改造的图片');
}
const quickEditReferences = dialog.generationReferences ?? [];
const sourceReferenceIndex = quickEditReferences.length + 1;
const basePrompt = dialog.prompt.trim() || '快速编辑图片';
const normalizedQuickEditPrompt = normalizeQuickEditPromptSourceReference(
basePrompt,
sourceReferenceIndex,
);
const normalizedQuickEditPrompt = basePrompt;
const imageModel = normalizeEditorImageModel(dialog.imageModel);
const aspectRatio =
dialog.aspectRatio ??
@@ -285,19 +281,9 @@ export function buildImageGenerationSubmissionPlan({
kind: 'quick-edit',
normalizedPrompt: normalizedQuickEditPrompt,
sourceLayer,
input: {
prompt: normalizedQuickEditPrompt,
editInput: {
size: `${outputSize.width}x${outputSize.height}`,
kind: sourceLayer.assetKind === 'character' ? 'character' : 'quick-edit',
model: imageModel,
aspectRatio,
imageSize,
referenceImageSrcs: [
...quickEditReferences.map((reference) =>
resolveImageReferenceSubmissionSource(reference),
),
sourceLayer.objectKey?.trim() || sourceLayer.src,
],
},
result: {
title: `${sourceLayer.title} 快速编辑`,
@@ -8,6 +8,7 @@ import type {
CanvasLayer,
CharacterReferenceImage,
QuickEditPanelState,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
@@ -36,7 +37,7 @@ function QuickEditPanelHarness({
}: {
initialPanel: QuickEditPanelState;
onSubmit?: () => void;
onRequestUpload?: (target: 'quick-edit-reference') => void;
onRequestUpload?: (target: UploadTarget) => void;
onRememberImageModel?: (model: string) => void;
}) {
const [panel, setPanel] = useState<QuickEditPanelState | null>(initialPanel);
@@ -49,7 +49,7 @@ function createQuickEditDialog(
panel: QuickEditPanelState,
): GenerateDialogState {
return {
mode: panel.mode ?? 'quick-edit',
mode: 'quick-edit',
prompt: panel.prompt,
status: panel.status,
sourceLayerId: panel.sourceLayerId,
@@ -136,13 +136,13 @@ export function ImageCanvasQuickEditPanelView({
onToggleReferenceMenu={() => setIsReferenceMenuOpen?.((open) => !open)}
onRememberImageModel={onRememberImageModel}
onSubmit={onSubmit}
dialogLabel={isRedraw ? '重绘图片' : '改造图片'}
dialogLabel={isRedraw ? '重绘图片' : '快速编辑图片'}
includeDimensions={true}
includeModel={true}
includeReferences={!isRedraw}
promptLabel={isRedraw ? '重绘提示词' : '快速编辑提示词'}
promptPlaceholder={
isRedraw ? '把画面重绘成什么样?' : '想怎么改造这张图?'
isRedraw ? '把画面重绘成什么样?' : '想怎么编辑这张图?'
}
optionLabelPrefix={isRedraw ? '重绘' : '快速编辑'}
dimensionRatioAriaLabelPrefix="比例"
@@ -154,35 +154,6 @@ function createQueueState(
};
}
function parseLayerRect(text: string, layerId: string) {
const entry = text
.split('|')
.find((currentEntry) => currentEntry.startsWith(`${layerId}:`));
if (!entry) {
throw new Error(`missing layer rect for ${layerId}`);
}
const [, x, y, width, height] = entry.split(':');
return {
x: Number(x),
y: Number(y),
width: Number(width),
height: Number(height),
};
}
function rectOverlapsExpanded(
rect: { x: number; y: number; width: number; height: number },
blocker: { x: number; y: number; width: number; height: number },
gap: number,
) {
return (
rect.x < blocker.x + blocker.width + gap &&
rect.x + rect.width > blocker.x - gap &&
rect.y < blocker.y + blocker.height + gap &&
rect.y + rect.height > blocker.y - gap
);
}
function SubmissionWorkflowHarness({
initialDialog = null,
initialQuickEditPanel = null,
@@ -259,6 +230,12 @@ function SubmissionWorkflowHarness({
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
updateSourceLayer: (sourceLayerId, updater) =>
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === sourceLayerId ? updater(layer) : layer,
),
),
selectSingleLayer: setSelectedLayerId,
fitLayers: fitLayersMockRef.current,
setActiveTool,
@@ -450,8 +427,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('submits quick edits, clears the panel, and fits the source plus result', async () => {
generateEditorImageMock.mockResolvedValueOnce(
it('submits quick edits and updates the source layer directly', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图' }),
);
render(<SubmissionWorkflowHarness />);
@@ -465,35 +442,31 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
'quick-edit:generating:closed:-:placeholder:-',
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '快速修图',
sourceImageSrc: 'data:image/png;base64,source.png',
size: '1024x768',
aspectRatio: '4:3',
imageSize: '1K',
kind: 'quick-edit',
model: 'gemini-3.1-flash-image-preview',
referenceImageSrcs: ['data:image/png;base64,source.png'],
}),
);
});
expect(generateEditorImageMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-quick-edit-1:源图 快速编辑:resource-source:-',
'layer-source:源图:resource-source:-:-:data:image/png;base64,generated',
);
});
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
expect(screen.getByTestId('selected').textContent).toBe(
'layer-quick-edit-1',
);
expect(screen.getByTestId('fit-count').textContent).toBe('1');
expect(screen.getByTestId('selected').textContent).toBe('-');
expect(screen.getByTestId('fit-count').textContent).toBe('0');
});
it('submits quick-edit canvas generators with the source image reference', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '把图1改成雨天' }),
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '把当前图改成雨天' }),
);
render(
<SubmissionWorkflowHarness
@@ -523,22 +496,21 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '把图1改成雨天',
kind: 'quick-edit',
prompt: '把当前图改成雨天',
sourceImageSrc: 'data:image/png;base64,source.png',
size: '1024x768',
model: 'gpt-image-2',
aspectRatio: '4:3',
imageSize: '1K',
referenceImageSrcs: ['https://assets.example.test/source.png'],
sourceResourceId: 'resource-source',
assetLabel: '源图 快速编辑',
}),
);
});
expect(generateEditorImageMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-quick-edit-1:源图 快速编辑:resource-source',
'layer-source:源图:resource-source',
);
});
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
@@ -546,7 +518,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
it('refreshes the wallet balance after inline image generation succeeds', async () => {
const refreshWalletBalance = vi.fn();
generateEditorImageMock.mockResolvedValueOnce(
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图' }),
);
render(
@@ -560,14 +532,14 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledTimes(1);
expect(editEditorImageMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
});
});
it('places quick edit result with the new image placement logic away from existing assets', async () => {
it('keeps quick edit results on the source layer when nearby assets exist', async () => {
const blockingLayer = createLayer({
id: 'layer-blocker',
resourceId: 'resource-blocker',
@@ -580,7 +552,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
originalHeight: 768,
zIndex: 3,
});
generateEditorImageMock.mockResolvedValueOnce(
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图', width: 1024, height: 768 }),
);
render(
@@ -595,17 +567,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-quick-edit-1:源图 快速编辑:resource-source:-',
'layer-source:源图:resource-source:-:-:data:image/png;base64,generated',
);
});
const layerRects = screen.getByTestId('layer-rects').textContent ?? '';
const resultRect = parseLayerRect(layerRects, 'layer-quick-edit-1');
expect(rectOverlapsExpanded(resultRect, blockingLayer, 32)).toBe(false);
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-quick-edit-1',
);
});
it('submits quick edits with the numbered red selection image as the final source reference', async () => {
generateEditorImageMock.mockResolvedValueOnce(
editEditorImageMock.mockResolvedValueOnce(
createGenerated({
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
}),
@@ -671,20 +642,18 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrcs: [
'data:image/png;base64,style.png',
'data:image/png;base64,marked-source',
],
sourceImageSrc: 'data:image/png;base64,marked-source',
}),
);
});
expect(generateEditorImageMock).not.toHaveBeenCalled();
});
it('keeps failed quick edit panels visible with the normalized prompt', async () => {
const refreshWalletBalance = vi.fn();
generateEditorImageMock.mockRejectedValueOnce(new Error('修图失败'));
editEditorImageMock.mockRejectedValueOnce(new Error('修图失败'));
render(
<SubmissionWorkflowHarness
onWalletBalanceMayHaveChanged={refreshWalletBalance}
@@ -757,9 +726,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('submits video quick edits through the video API with the source video as the hidden last reference', async () => {
it('submits video quick edits through the video API with the source video', async () => {
generateEditorVideoMock.mockResolvedValueOnce(
createVideoGenerated({ prompt: '把图2改成雨天' }),
createVideoGenerated({ prompt: '把当前图改成雨天' }),
);
render(
<SubmissionWorkflowHarness
@@ -800,7 +769,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await waitFor(() => {
expect(generateEditorVideoMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '把图2改成雨天',
prompt: '把当前图改成雨天',
model: 'seedance2.0-fast',
aspectRatio: '16:9',
durationSeconds: 4,
@@ -884,7 +853,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect.objectContaining({
sourceLayerId: 'layer-source',
sourceImageSrc: 'generated-character-drafts/editor/actions/frame.png',
promptText: '让图1跳得更高',
promptText: '让当前图跳得更高',
resolution: '480p',
ratio: 'same',
frameCount: 32,
@@ -41,6 +41,7 @@ import type {
} from './ImageCanvasEditorTypes';
import { createQuickEditGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
import {
applyImageEditResultToSourceLayer,
createAudioResultLayer,
createCharacterAnimationResultLayer,
createGeneratedResultLayer,
@@ -64,7 +65,6 @@ import {
inferEditorImageSizeLabel,
isCanvasGenerationDialog,
normalizeEditorImageModel,
normalizeQuickEditPromptSourceReference,
resolveCharacterAnimationSourceImageSrc,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
@@ -125,6 +125,11 @@ type GenerationSubmissionWorkflowOptions = {
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
updateSourceLayer: (
sourceLayerId: string,
updater: (layer: CanvasLayer) => CanvasLayer,
options?: { fit?: boolean; persist?: boolean },
) => void;
selectSingleLayer: (layerId: string | null) => void;
fitLayers: (targetLayers?: CanvasLayer[]) => void;
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
@@ -306,6 +311,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
updateSourceLayer,
selectSingleLayer,
fitLayers,
setActiveTool,
@@ -469,6 +475,39 @@ export function useImageCanvasGenerationSubmissionWorkflow({
],
);
const applyQuickEditResultToSourceLayer = useCallback(
(
generated: Parameters<typeof applyImageEditResultToSourceLayer>[0]['generated'],
sourceLayer: CanvasLayer,
generationInputs: CanvasGenerationInputs,
dialogId?: string,
) => {
updateSourceLayer(
sourceLayer.id,
(layer) =>
applyImageEditResultToSourceLayer({
generated,
sourceLayer: layer,
generationInputs,
}),
{ fit: false, persist: false },
);
setQuickEditPanel(null);
setActiveTool('select');
setActiveSidebarPanel('layers');
if (dialogId) {
updateCanvasGenerationDialogById(dialogId, () => null);
}
},
[
setActiveSidebarPanel,
setActiveTool,
setQuickEditPanel,
updateCanvasGenerationDialogById,
updateSourceLayer,
],
);
const addIconSpritesheetResultLayers = useCallback(
(
generated: Parameters<
@@ -912,7 +951,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const panelMode = quickEditPanel.mode ?? 'quick-edit';
const quickEditReferences =
panelMode === 'redraw' ? [] : (quickEditPanel.quickEditReferences ?? []);
const sourceReferenceIndex = quickEditReferences.length + 1;
const quickEditAspectRatio =
quickEditPanel.aspectRatio ??
inferEditorImageAspectRatio(
@@ -937,10 +975,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const normalizedPrompt =
panelMode === 'redraw'
? basePrompt
: normalizeQuickEditPromptSourceReference(
basePrompt,
sourceReferenceIndex,
);
: basePrompt;
let quickEditDialogId: string | undefined;
if (panelMode === 'quick-edit') {
const quickEditDraft = createQuickEditGenerationDialogDraft({
@@ -985,13 +1020,17 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
try {
const extraReferenceImageSrcs = await Promise.all(
quickEditReferences.map((reference) =>
resolveEditorImageReferenceDataUrl(
reference.objectKey?.trim() || reference.src,
),
),
);
const needsExtraReferenceImageSrcs =
panelMode === 'redraw' || isVideoQuickEditSource(quickEditSourceLayer);
const extraReferenceImageSrcs = needsExtraReferenceImageSrcs
? await Promise.all(
quickEditReferences.map((reference) =>
resolveEditorImageReferenceDataUrl(
reference.objectKey?.trim() || reference.src,
),
),
)
: [];
const quickEditFrame = quickEditDialogId
? getGeneratingDialogPlaceholder({
id: quickEditDialogId,
@@ -1174,40 +1213,53 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const isCharacterRedraw =
panelMode === 'redraw' &&
quickEditSourceLayer.assetKind === 'character';
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorImage({
prompt: normalizedPrompt,
size: quickEditSize,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
kind: isCharacterRedraw ? 'character' : 'quick-edit',
model: normalizeEditorImageModel(quickEditPanel.model),
referenceImageSrcs: [
...extraReferenceImageSrcs,
sourceReferenceImageSrc,
],
projectId,
assetKind: quickEditSourceLayer.assetKind,
generationInputs,
assetFolderId,
assetLabel: `${quickEditSourceLayer.title} ${
panelMode === 'redraw' ? '重绘' : '快速编辑'
}`,
sourceResourceId: quickEditSourceLayer.resourceId,
...(projectId && quickEditCanvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: quickEditDialogId,
title: `${quickEditSourceLayer.title} ${
panelMode === 'redraw' ? '重绘' : '快速编辑'
}`,
placeholder: quickEditCanvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
const generated =
panelMode === 'quick-edit'
? await runEditorGenerationWithWalletRefresh(
editEditorImage({
prompt: normalizedPrompt,
sourceImageSrc: sourceReferenceImageSrc,
size: quickEditSize,
model: normalizeEditorImageModel(quickEditPanel.model),
projectId,
assetKind: quickEditSourceLayer.assetKind,
generationInputs,
assetFolderId,
assetLabel: `${quickEditSourceLayer.title} 快速编辑`,
sourceResourceId: quickEditSourceLayer.resourceId,
}),
onWalletBalanceMayHaveChanged,
)
: await runEditorGenerationWithWalletRefresh(
generateEditorImage({
prompt: normalizedPrompt,
size: quickEditSize,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
kind: isCharacterRedraw ? 'character' : 'quick-edit',
model: normalizeEditorImageModel(quickEditPanel.model),
referenceImageSrcs: [
...extraReferenceImageSrcs,
sourceReferenceImageSrc,
],
projectId,
assetKind: quickEditSourceLayer.assetKind,
generationInputs,
assetFolderId,
assetLabel: `${quickEditSourceLayer.title} 重绘`,
sourceResourceId: quickEditSourceLayer.resourceId,
...(projectId && quickEditCanvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: quickEditDialogId,
title: `${quickEditSourceLayer.title} 重绘`,
placeholder: quickEditCanvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
if (
await applyQueuedEditorGenerationProject(
generated,
@@ -1222,18 +1274,27 @@ export function useImageCanvasGenerationSubmissionWorkflow({
applyProjectSnapshot(generated.project);
return;
}
addQuickEditResultLayer(
generated,
quickEditSourceLayer,
generationInputs,
panelMode,
quickEditDialogId
? {
frame: quickEditFrame,
dialogId: quickEditDialogId,
}
: {},
);
if (panelMode === 'quick-edit') {
applyQuickEditResultToSourceLayer(
generated,
quickEditSourceLayer,
generationInputs,
quickEditDialogId,
);
} else {
addQuickEditResultLayer(
generated,
quickEditSourceLayer,
generationInputs,
panelMode,
quickEditDialogId
? {
frame: quickEditFrame,
dialogId: quickEditDialogId,
}
: {},
);
}
} catch (error) {
if (quickEditDialogId) {
updateCanvasGenerationDialogById(
@@ -1372,28 +1433,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generationInputs: submissionPlan.generationInputs,
});
} else if (submissionPlan.kind === 'quick-edit') {
const imageGenerationInput =
await normalizeImageGenerationReferenceImages(submissionPlan.input);
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
submissionPlan.sourceLayer.objectKey?.trim() ||
submissionPlan.sourceLayer.src,
);
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorImage({
...imageGenerationInput,
editEditorImage({
prompt: submissionPlan.normalizedPrompt,
sourceImageSrc: referenceImageSrc,
...submissionPlan.editInput,
projectId,
assetKind: submissionPlan.result.assetKind,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
sourceResourceId: submissionPlan.sourceLayer.resourceId,
...(projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: submissionPlan.result.title,
placeholder: canvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
@@ -1407,9 +1461,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
) {
return;
}
if (submissionPlan.rememberImageModel) {
rememberImageModel(submissionPlan.rememberImageModel);
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
@@ -1417,15 +1468,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
return;
}
addQuickEditResultLayer(
applyQuickEditResultToSourceLayer(
generated,
submissionPlan.sourceLayer,
submissionPlan.result.generationInputs,
'quick-edit',
{
frame: canvasCompletionPlaceholder,
dialogId: canvasDialog?.id,
},
canvasDialog?.id,
);
} else if (submissionPlan.kind === 'video') {
const canvasCompletionPlaceholder =
@@ -175,7 +175,7 @@ function GenerationWorkflowHarness({
{layers
.map(
(layer) =>
`${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}`,
`${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.src}:${layer.taskId ?? '-'}`,
)
.join('|')}
</span>
@@ -1500,8 +1500,8 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
});
it('submits quick edits beside the source and fits source plus result', async () => {
generateEditorImageMock.mockResolvedValueOnce(
it('submits quick edits and updates the source layer directly', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图' }),
);
render(<GenerationWorkflowHarness />);
@@ -1511,34 +1511,29 @@ describe('useImageCanvasGenerationWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith({
prompt: '快速修图',
size: '1024x768',
aspectRatio: '4:3',
imageSize: '1K',
kind: 'quick-edit',
model: 'gemini-3.1-flash-image-preview',
referenceImageSrcs: ['data:image/png;base64,source'],
projectId: undefined,
assetFolderId: undefined,
assetKind: undefined,
assetLabel: '源图 快速编辑',
sourceResourceId: 'resource-source',
generationInputs: {
fields: [{ title: '快速编辑提示词', value: '快速修图' }],
references: [],
},
});
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '快速修图',
sourceImageSrc: 'data:image/png;base64,source',
assetLabel: '源图 快速编辑',
sourceResourceId: 'resource-source',
generationInputs: {
fields: [{ title: '快速编辑提示词', value: '快速修图' }],
references: [],
},
}),
);
});
expect(generateEditorImageMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-quick-edit-1:源图 快速编辑:resource-source',
'layer-source:源图:resource-source:-:data:image/png;base64,generated:task-generated',
);
});
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('fit-count').textContent).toBe('1');
expect(screen.getByTestId('fit-count').textContent).toBe('0');
});
it('opens crop-expand in free ratio and completes as a new beside layer', async () => {
@@ -1043,7 +1043,11 @@ export function useImageCanvasGenerationWorkflow({
);
const updateSourceLayer = useCallback(
(sourceLayerId: string, updater: (layer: CanvasLayer) => CanvasLayer) => {
(
sourceLayerId: string,
updater: (layer: CanvasLayer) => CanvasLayer,
options: { fit?: boolean; persist?: boolean } = {},
) => {
const sourceLayer = layers.find((layer) => layer.id === sourceLayerId);
if (!sourceLayer) {
return;
@@ -1055,9 +1059,13 @@ export function useImageCanvasGenerationWorkflow({
layer.id === sourceLayerId ? updatedLayer : layer,
),
);
persistUpdatedLayerResource?.(updatedLayer);
if (options.persist !== false) {
persistUpdatedLayerResource?.(updatedLayer);
}
selectSingleLayer(sourceLayerId);
fitLayers([updatedLayer]);
if (options.fit !== false) {
fitLayers([updatedLayer]);
}
},
[
captureCanvasHistory,
@@ -1549,6 +1557,7 @@ export function useImageCanvasGenerationWorkflow({
hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
updateSourceLayer,
selectSingleLayer,
fitLayers,
setActiveTool,