快速编辑直接覆盖原图
将图片快速编辑提交切到 /api/editor/images/edits,并用结果回写源图层 保留重绘作为右侧新结果图层,角色重绘继续走 character 生成契约 更新快速编辑文档和定向回归测试
This commit is contained in:
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,
|
||||
|
||||
Reference in New Issue
Block a user