Merge remote-tracking branch 'origin/dev-jenken' into dev-jenken
This commit is contained in:
@@ -66,29 +66,6 @@ function dispatchPointerEvent(
|
||||
fireEvent(target, event);
|
||||
}
|
||||
|
||||
function mockClipboard() {
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(
|
||||
navigator,
|
||||
'clipboard',
|
||||
);
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
return {
|
||||
writeText,
|
||||
restore: () => {
|
||||
if (originalClipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', originalClipboard);
|
||||
} else {
|
||||
delete (navigator as unknown as { clipboard?: Clipboard }).clipboard;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ImageCanvasEditorView', () => {
|
||||
beforeEach(() => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
|
||||
@@ -557,14 +534,14 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(within(batchToolbar).getByText(/已选 2/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows image size on hover and placeholder toolbar after selecting a layer', () => {
|
||||
it('shows image resolution on hover and placeholder toolbar after selecting a layer', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const canvasImage = screen.getByAltText('画布图片:拼图素材');
|
||||
fireEvent.mouseEnter(canvasImage.closest('button')!);
|
||||
|
||||
const sizeBadge = screen.getByText('420 x 420 px');
|
||||
const sizeBadge = screen.getByText('640 x 640 px');
|
||||
expect(sizeBadge.className).toContain('rounded-full');
|
||||
expect(sizeBadge.className).toContain('image-canvas-editor__size-badge');
|
||||
|
||||
@@ -612,10 +589,14 @@ describe('ImageCanvasEditorView', () => {
|
||||
const infoPanel = screen.getByRole('dialog', { name: '拼图素材图片信息' });
|
||||
expect(within(infoPanel).getByText('图片类型')).toBeTruthy();
|
||||
expect(within(infoPanel).getByText('上传图片')).toBeTruthy();
|
||||
expect(within(infoPanel).getByText('Prompt')).toBeTruthy();
|
||||
expect(within(infoPanel).getByText('生成输入')).toBeTruthy();
|
||||
expect(
|
||||
infoPanel.querySelector('.image-canvas-editor__metadata-inputs')
|
||||
?.textContent,
|
||||
).toBe('-');
|
||||
expect(within(infoPanel).queryByText('Prompt')).toBeNull();
|
||||
expect(within(infoPanel).getByText('Model')).toBeTruthy();
|
||||
expect(within(infoPanel).getByText('Size')).toBeTruthy();
|
||||
expect(within(infoPanel).getByText('420 x 420 px')).toBeTruthy();
|
||||
expect(within(infoPanel).queryByText('Size')).toBeNull();
|
||||
expect(within(infoPanel).getByText('Resolution')).toBeTruthy();
|
||||
expect(within(infoPanel).getByText('640 x 640 px')).toBeTruthy();
|
||||
expect(
|
||||
@@ -624,6 +605,53 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();
|
||||
});
|
||||
|
||||
it('hydrates canvas images from Resolution instead of saved Size', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-resolution',
|
||||
title: '原分辨率画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-resolution',
|
||||
resourceId: 'resource-resolution',
|
||||
title: '旧布局图片',
|
||||
src: 'data:image/png;base64,cmVzb2x1dGlvbg==',
|
||||
x: 120,
|
||||
y: 140,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 1536,
|
||||
originalHeight: 1024,
|
||||
zIndex: 2,
|
||||
sourceType: 'generated',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'resolution-task-1',
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-16T00:00:00.000Z',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const canvasImage = await screen.findByAltText('画布图片:旧布局图片');
|
||||
const canvasLayer = canvasImage.closest('button') as HTMLElement;
|
||||
expect(Number.parseFloat(canvasLayer.style.width)).toBe(1536);
|
||||
expect(Number.parseFloat(canvasLayer.style.height)).toBe(1024);
|
||||
|
||||
fireEvent.mouseEnter(canvasLayer);
|
||||
expect(screen.getByText('1536 x 1024 px')).toBeTruthy();
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: '查看旧布局图片图片信息' })[0]!,
|
||||
);
|
||||
const infoPanel = screen.getByRole('dialog', {
|
||||
name: '旧布局图片图片信息',
|
||||
});
|
||||
expect(within(infoPanel).queryByText('Size')).toBeNull();
|
||||
expect(within(infoPanel).getByText('Resolution')).toBeTruthy();
|
||||
expect(within(infoPanel).getByText('1536 x 1024 px')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deletes the selected layer from the floating toolbar', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -837,9 +865,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' }));
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /当前缩放比例 \d+%/u })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' }));
|
||||
@@ -1206,6 +1232,18 @@ describe('ImageCanvasEditorView', () => {
|
||||
name: /查看生成图片 .*图片信息/,
|
||||
});
|
||||
expect(metadataButtons[0]).toBeTruthy();
|
||||
fireEvent.click(metadataButtons[0]!);
|
||||
|
||||
const infoPanel = screen.getByRole('dialog', {
|
||||
name: /生成图片 .*图片信息/,
|
||||
});
|
||||
expect(within(infoPanel).queryByText('Prompt')).toBeNull();
|
||||
expect(
|
||||
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
|
||||
).toBeNull();
|
||||
expect(within(infoPanel).getByText('生成输入')).toBeTruthy();
|
||||
expect(within(infoPanel).getByText('生成提示词')).toBeTruthy();
|
||||
expect(within(infoPanel).getByText('一张明亮的拼图主视觉')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drags the generation placeholder and places the generated layer there', async () => {
|
||||
@@ -1252,6 +1290,13 @@ describe('ImageCanvasEditorView', () => {
|
||||
.top,
|
||||
);
|
||||
expect(draggedComposerTop).toBeGreaterThan(initialComposerTop);
|
||||
const draggedFrame = screen.getByLabelText('图像生成占位图') as HTMLElement;
|
||||
const draggedFrameCenterX =
|
||||
Number.parseFloat(draggedFrame.style.left) +
|
||||
Number.parseFloat(draggedFrame.style.width) / 2;
|
||||
const draggedFrameCenterY =
|
||||
Number.parseFloat(draggedFrame.style.top) +
|
||||
Number.parseFloat(draggedFrame.style.height) / 2;
|
||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||
target: { value: '拖拽后的生成图' },
|
||||
});
|
||||
@@ -1275,11 +1320,13 @@ describe('ImageCanvasEditorView', () => {
|
||||
);
|
||||
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||||
expect(
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.left),
|
||||
).toBeGreaterThan(300);
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.left) +
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2,
|
||||
).toBeCloseTo(draggedFrameCenterX, 1);
|
||||
expect(
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.top),
|
||||
).toBeGreaterThan(180);
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.top) +
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2,
|
||||
).toBeCloseTo(draggedFrameCenterY, 1);
|
||||
});
|
||||
|
||||
it('keeps the generation placeholder draggable while the image is generating', async () => {
|
||||
@@ -1353,11 +1400,21 @@ describe('ImageCanvasEditorView', () => {
|
||||
.getByAltText(/画布图片:生成图片/)
|
||||
.closest('button')!;
|
||||
expect(
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.left),
|
||||
).toBeGreaterThan(initialLeft);
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.left) +
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2,
|
||||
).toBeCloseTo(
|
||||
Number.parseFloat((draggedFrame as HTMLElement).style.left) +
|
||||
Number.parseFloat((draggedFrame as HTMLElement).style.width) / 2,
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.top),
|
||||
).toBeGreaterThan(initialTop);
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.top) +
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2,
|
||||
).toBeCloseTo(
|
||||
Number.parseFloat((draggedFrame as HTMLElement).style.top) +
|
||||
Number.parseFloat((draggedFrame as HTMLElement).style.height) / 2,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the generation composer when selecting another image but keeps the placeholder', () => {
|
||||
@@ -1766,6 +1823,116 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults character and icon generation to nanobanana2 model options', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
await screen.findByAltText('画布图片:拼图素材');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||||
const characterPanel = screen.getByRole('dialog', {
|
||||
name: '生成角色形象',
|
||||
});
|
||||
expect(within(characterPanel).getByText('尺寸比例')).toBeTruthy();
|
||||
expect(within(characterPanel).getByText('大小尺寸')).toBeTruthy();
|
||||
expect(
|
||||
(within(characterPanel).getByLabelText('角色模型') as HTMLSelectElement)
|
||||
.selectedOptions[0]?.textContent,
|
||||
).toBe('nanobanana2');
|
||||
expect(
|
||||
(
|
||||
within(characterPanel).getByLabelText(
|
||||
'角色尺寸比例',
|
||||
) as HTMLSelectElement
|
||||
).value,
|
||||
).toBe('1:1');
|
||||
expect(
|
||||
(
|
||||
within(characterPanel).getByLabelText(
|
||||
'角色大小尺寸',
|
||||
) as HTMLSelectElement
|
||||
).value,
|
||||
).toBe('1K');
|
||||
expect(
|
||||
Array.from(
|
||||
(
|
||||
within(characterPanel).getByLabelText(
|
||||
'角色尺寸比例',
|
||||
) as HTMLSelectElement
|
||||
).options,
|
||||
).map((option) => option.value),
|
||||
).toContain('1:8');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
expect(
|
||||
(within(iconPanel).getByLabelText('图标模型') as HTMLSelectElement)
|
||||
.selectedOptions[0]?.textContent,
|
||||
).toBe('nanobanana2');
|
||||
expect(
|
||||
(within(iconPanel).getByLabelText('图标大小尺寸') as HTMLSelectElement)
|
||||
.value,
|
||||
).toBe('1K');
|
||||
});
|
||||
|
||||
it('remembers the edited image model and submits character dimension options', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,character-model-options',
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
sourceType: 'generated',
|
||||
prompt: '高个子游侠',
|
||||
actualPrompt: '高个子游侠',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'character-model-options-1',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
await screen.findByAltText('画布图片:拼图素材');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||||
const characterPanel = screen.getByRole('dialog', {
|
||||
name: '生成角色形象',
|
||||
});
|
||||
fireEvent.change(within(characterPanel).getByLabelText('角色模型'), {
|
||||
target: { value: 'gpt-image-2' },
|
||||
});
|
||||
expect(
|
||||
Array.from(
|
||||
(
|
||||
within(characterPanel).getByLabelText(
|
||||
'角色尺寸比例',
|
||||
) as HTMLSelectElement
|
||||
).options,
|
||||
).map((option) => option.value),
|
||||
).not.toContain('1:8');
|
||||
fireEvent.change(within(characterPanel).getByLabelText('角色尺寸比例'), {
|
||||
target: { value: '2:3' },
|
||||
});
|
||||
fireEvent.change(within(characterPanel).getByLabelText('角色大小尺寸'), {
|
||||
target: { value: '1K' },
|
||||
});
|
||||
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
|
||||
target: { value: '高个子游侠' },
|
||||
});
|
||||
fireEvent.click(within(characterPanel).getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'character',
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '1K',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
expect(
|
||||
(within(iconPanel).getByLabelText('图标模型') as HTMLSelectElement).value,
|
||||
).toBe('gpt-image-2');
|
||||
});
|
||||
|
||||
it('keeps the bottom AI toolbar visible while generation panels are open', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -1895,12 +2062,16 @@ describe('ImageCanvasEditorView', () => {
|
||||
const generatedLayer = screen
|
||||
.getByAltText(/画布图片:生成图片/)
|
||||
.closest('button') as HTMLElement;
|
||||
const expectedLayerLeft =
|
||||
movedLeft + Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 - 512;
|
||||
const expectedLayerTop =
|
||||
movedTop + Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 - 512;
|
||||
expect(Number.parseFloat(generatedLayer.style.left)).toBeCloseTo(
|
||||
movedLeft,
|
||||
expectedLayerLeft,
|
||||
1,
|
||||
);
|
||||
expect(Number.parseFloat(generatedLayer.style.top)).toBeCloseTo(
|
||||
movedTop,
|
||||
expectedLayerTop,
|
||||
1,
|
||||
);
|
||||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||||
@@ -2222,6 +2393,26 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(screen.getByAltText(/画布图片:角色形象/u)).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText('角色')).toBeTruthy();
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', {
|
||||
name: /查看角色形象 .*图片信息/u,
|
||||
})[0]!,
|
||||
);
|
||||
const characterInfoPanel = screen.getByRole('dialog', {
|
||||
name: /角色形象 .*图片信息/u,
|
||||
});
|
||||
expect(within(characterInfoPanel).queryByText('Prompt')).toBeNull();
|
||||
expect(within(characterInfoPanel).getByText('生成输入')).toBeTruthy();
|
||||
expect(within(characterInfoPanel).getByText('角色设定')).toBeTruthy();
|
||||
expect(
|
||||
within(characterInfoPanel).getByText(
|
||||
'银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(within(characterInfoPanel).getByText('角色形象规范')).toBeTruthy();
|
||||
expect(within(characterInfoPanel).getByText('拼图素材')).toBeTruthy();
|
||||
expect(within(characterInfoPanel).getByText('常规参考图 1')).toBeTruthy();
|
||||
expect(within(characterInfoPanel).getByText('常规参考.png')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
@@ -2427,6 +2618,20 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull();
|
||||
expect(screen.getAllByText('图标')).toHaveLength(2);
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: '查看返回按钮图片信息' })[0]!,
|
||||
);
|
||||
const iconInfoPanel = screen.getByRole('dialog', {
|
||||
name: '返回按钮图片信息',
|
||||
});
|
||||
expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull();
|
||||
expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy();
|
||||
expect(within(iconInfoPanel).getByText('素材描述 1')).toBeTruthy();
|
||||
expect(within(iconInfoPanel).getByText('素材描述 2')).toBeTruthy();
|
||||
expect(within(iconInfoPanel).getByText('返回按钮')).toBeTruthy();
|
||||
expect(within(iconInfoPanel).getByText('设置按钮')).toBeTruthy();
|
||||
expect(within(iconInfoPanel).getByText('图标素材规范')).toBeTruthy();
|
||||
expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-icons',
|
||||
@@ -2487,6 +2692,8 @@ describe('ImageCanvasEditorView', () => {
|
||||
originalHeight: 1024,
|
||||
zIndex: 2,
|
||||
sourceType: 'generated',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/character-images/source/image.png',
|
||||
assetKind: 'character',
|
||||
},
|
||||
{
|
||||
@@ -2603,7 +2810,8 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceLayerId: 'layer-character',
|
||||
sourceImageSrc: 'data:image/png;base64,character',
|
||||
sourceImageSrc:
|
||||
'generated-character-drafts/editor/character-images/source/image.png',
|
||||
sourceWidth: 1024,
|
||||
sourceHeight: 1024,
|
||||
resolution: '720p',
|
||||
@@ -2717,10 +2925,10 @@ describe('ImageCanvasEditorView', () => {
|
||||
const generatedLayer = screen
|
||||
.getByAltText('画布图片:魔法森林 快速编辑')
|
||||
.closest('button') as HTMLElement;
|
||||
expect(Number.parseFloat(generatedLayer.style.left)).toBe(472);
|
||||
expect(Number.parseFloat(generatedLayer.style.left)).toBe(1688);
|
||||
expect(Number.parseFloat(generatedLayer.style.top)).toBe(140);
|
||||
expect(Number.parseFloat(generatedLayer.style.width)).toBe(320);
|
||||
expect(Number.parseFloat(generatedLayer.style.height)).toBe(240);
|
||||
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1536);
|
||||
expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-quick-edit',
|
||||
@@ -2729,11 +2937,11 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect.objectContaining({
|
||||
title: '魔法森林 快速编辑',
|
||||
assetKind: 'spec',
|
||||
width: 320,
|
||||
height: 240,
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
originalWidth: 1536,
|
||||
originalHeight: 1024,
|
||||
x: 472,
|
||||
x: 1688,
|
||||
y: 140,
|
||||
}),
|
||||
]),
|
||||
@@ -3029,8 +3237,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('opens generated image info from the corner button, copies Prompt and creates a real right-side edit result', async () => {
|
||||
const clipboard = mockClipboard();
|
||||
it('opens generated image info from the corner button and creates a real right-side edit result', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||||
width: 1024,
|
||||
@@ -3064,13 +3271,17 @@ describe('ImageCanvasEditorView', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||||
});
|
||||
const generatedLayer = screen
|
||||
.getByAltText(/画布图片:生成图片/)
|
||||
.closest('button') as HTMLElement;
|
||||
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024);
|
||||
expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
|
||||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||||
|
||||
const metadataCornerButton = screen.getAllByRole('button', {
|
||||
name: /查看生成图片 .*图片信息/,
|
||||
})[0];
|
||||
if (!metadataCornerButton) {
|
||||
clipboard.restore();
|
||||
throw new Error('metadata corner button should exist');
|
||||
}
|
||||
expect(metadataCornerButton.className).toContain('bg-black/55');
|
||||
@@ -3085,21 +3296,18 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(metadataDialog).toBeTruthy();
|
||||
expect(within(metadataDialog).getByText('图片类型')).toBeTruthy();
|
||||
expect(within(metadataDialog).getByText('生成图片')).toBeTruthy();
|
||||
expect(within(metadataDialog).getByText('Prompt')).toBeTruthy();
|
||||
expect(within(metadataDialog).queryByText('Prompt')).toBeNull();
|
||||
expect(
|
||||
within(metadataDialog).queryByRole('button', { name: '复制Prompt' }),
|
||||
).toBeNull();
|
||||
expect(within(metadataDialog).getByText('生成输入')).toBeTruthy();
|
||||
expect(within(metadataDialog).getByText('生成提示词')).toBeTruthy();
|
||||
expect(within(metadataDialog).getByText('一张可修改的生成图')).toBeTruthy();
|
||||
expect(within(metadataDialog).getByText('Model')).toBeTruthy();
|
||||
expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy();
|
||||
expect(within(metadataDialog).getByText('Size')).toBeTruthy();
|
||||
expect(within(metadataDialog).getByText('420 x 420 px')).toBeTruthy();
|
||||
expect(within(metadataDialog).queryByText('Size')).toBeNull();
|
||||
expect(within(metadataDialog).getByText('Resolution')).toBeTruthy();
|
||||
expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(metadataDialog).getByRole('button', { name: '复制Prompt' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(clipboard.writeText).toHaveBeenCalledWith('一张可修改的生成图');
|
||||
});
|
||||
clipboard.restore();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改图片' }));
|
||||
const editDialog = screen.getByRole('dialog', { name: '修改图片' });
|
||||
@@ -3126,9 +3334,22 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
|
||||
});
|
||||
expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy();
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', {
|
||||
name: /查看生成图片 .* 修改结果图片信息/u,
|
||||
})[0]!,
|
||||
);
|
||||
const editedMetadataDialog = screen.getByRole('dialog', {
|
||||
name: /生成图片 .* 修改结果图片信息/u,
|
||||
});
|
||||
expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull();
|
||||
expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy();
|
||||
expect(within(editedMetadataDialog).getByText('把画面改成黄昏光线')).toBeTruthy();
|
||||
expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
||||
within(editedMetadataDialog).getByText(/^生成图片 \d+$/u),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /当前缩放比例 \d+%/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides the edit image panel after generation starts while keeping the source preview visible', async () => {
|
||||
|
||||
@@ -111,6 +111,22 @@ type EditorAsset = {
|
||||
assetObjectId?: string;
|
||||
};
|
||||
|
||||
type CanvasGenerationInputField = {
|
||||
title: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type CanvasGenerationInputReference = {
|
||||
title: string;
|
||||
label: string;
|
||||
src: string;
|
||||
};
|
||||
|
||||
type CanvasGenerationInputs = {
|
||||
fields: CanvasGenerationInputField[];
|
||||
references: CanvasGenerationInputReference[];
|
||||
};
|
||||
|
||||
type CanvasLayer = {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
@@ -134,6 +150,7 @@ type CanvasLayer = {
|
||||
sourceResourceId?: string | null;
|
||||
groupId?: string | null;
|
||||
assetKind?: 'spec' | 'character' | 'icon' | 'icon-spec' | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
};
|
||||
|
||||
type CanvasViewport = {
|
||||
@@ -388,8 +405,8 @@ const INITIAL_LAYERS: CanvasLayer[] = [
|
||||
src: '/creation-type-references/puzzle.webp',
|
||||
x: 470,
|
||||
y: 300,
|
||||
width: 420,
|
||||
height: 420,
|
||||
width: 640,
|
||||
height: 640,
|
||||
originalWidth: 640,
|
||||
originalHeight: 640,
|
||||
zIndex: 1,
|
||||
@@ -402,8 +419,8 @@ const INITIAL_LAYERS: CanvasLayer[] = [
|
||||
src: '/creation-type-references/big-fish.webp',
|
||||
x: 930,
|
||||
y: 360,
|
||||
width: 420,
|
||||
height: 236,
|
||||
width: 720,
|
||||
height: 405,
|
||||
originalWidth: 720,
|
||||
originalHeight: 405,
|
||||
zIndex: 2,
|
||||
@@ -550,6 +567,18 @@ function formatImageSizeValue(width: number, height: number) {
|
||||
return `${safeWidth}x${safeHeight}`;
|
||||
}
|
||||
|
||||
function resolveLayerResolutionSize(
|
||||
originalWidth: number,
|
||||
originalHeight: number,
|
||||
fallback: { width: number; height: number },
|
||||
) {
|
||||
// 中文注释:画布不再维护独立展示 Size,图片显示尺寸统一跟随图片原始 Resolution。
|
||||
return {
|
||||
width: Math.max(1, Math.round(originalWidth || fallback.width || 1)),
|
||||
height: Math.max(1, Math.round(originalHeight || fallback.height || 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildQuickEditSizeOptions(currentSize: string) {
|
||||
return Array.from(new Set([currentSize, ...QUICK_EDIT_SIZE_PRESETS]));
|
||||
}
|
||||
@@ -571,10 +600,11 @@ function createLayerFromAsset(
|
||||
viewport: CanvasViewport,
|
||||
screenCenter: { x: number; y: number },
|
||||
): CanvasLayer {
|
||||
const longestSide = Math.max(asset.width, asset.height);
|
||||
const sizeRatio = longestSide > 0 ? 360 / longestSide : 1;
|
||||
const width = Math.round(asset.width * sizeRatio);
|
||||
const height = Math.round(asset.height * sizeRatio);
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
asset.width,
|
||||
asset.height,
|
||||
{ width: 360, height: 360 },
|
||||
);
|
||||
const worldCenterX = (screenCenter.x - viewport.x) / viewport.scale;
|
||||
const worldCenterY = (screenCenter.y - viewport.y) / viewport.scale;
|
||||
const offset = index * 34;
|
||||
@@ -625,6 +655,7 @@ function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
groupId: layer.groupId,
|
||||
assetKind: layer.assetKind,
|
||||
generationInputs: layer.generationInputs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -650,10 +681,18 @@ function hydrateLayer(
|
||||
src,
|
||||
x: numberFromSnapshot(snapshot.x, 0),
|
||||
y: numberFromSnapshot(snapshot.y, 0),
|
||||
width: numberFromSnapshot(snapshot.width, 320),
|
||||
height: numberFromSnapshot(snapshot.height, 320),
|
||||
originalWidth: numberFromSnapshot(snapshot.originalWidth, 320),
|
||||
originalHeight: numberFromSnapshot(snapshot.originalHeight, 320),
|
||||
...(() => {
|
||||
const originalWidth = numberFromSnapshot(snapshot.originalWidth, 320);
|
||||
const originalHeight = numberFromSnapshot(snapshot.originalHeight, 320);
|
||||
return {
|
||||
...resolveLayerResolutionSize(originalWidth, originalHeight, {
|
||||
width: numberFromSnapshot(snapshot.width, 320),
|
||||
height: numberFromSnapshot(snapshot.height, 320),
|
||||
}),
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
};
|
||||
})(),
|
||||
zIndex: numberFromSnapshot(snapshot.zIndex, 1),
|
||||
sourceType: isCanvasSourceType(snapshot.sourceType)
|
||||
? snapshot.sourceType
|
||||
@@ -668,6 +707,7 @@ function hydrateLayer(
|
||||
sourceResourceId: stringOrNull(snapshot.sourceResourceId),
|
||||
groupId: stringOrNull(snapshot.groupId),
|
||||
assetKind: canvasAssetKindOrNull(snapshot.assetKind),
|
||||
generationInputs: generationInputsOrNull(snapshot.generationInputs),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -724,6 +764,45 @@ function stringOrNull(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function generationInputsOrNull(value: unknown): CanvasGenerationInputs | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const snapshot = value as {
|
||||
fields?: unknown;
|
||||
references?: unknown;
|
||||
};
|
||||
const fields = Array.isArray(snapshot.fields)
|
||||
? snapshot.fields.flatMap((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const item = field as { title?: unknown; value?: unknown };
|
||||
const title = stringOrNull(item.title);
|
||||
const fieldValue = stringOrNull(item.value);
|
||||
return title && fieldValue ? [{ title, value: fieldValue }] : [];
|
||||
})
|
||||
: [];
|
||||
const references = Array.isArray(snapshot.references)
|
||||
? snapshot.references.flatMap((reference) => {
|
||||
if (!reference || typeof reference !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const item = reference as {
|
||||
title?: unknown;
|
||||
label?: unknown;
|
||||
src?: unknown;
|
||||
};
|
||||
const title = stringOrNull(item.title);
|
||||
const label = stringOrNull(item.label);
|
||||
const src = stringOrNull(item.src);
|
||||
return title && label && src ? [{ title, label, src }] : [];
|
||||
})
|
||||
: [];
|
||||
|
||||
return fields.length || references.length ? { fields, references } : null;
|
||||
}
|
||||
|
||||
function canvasAssetKindOrNull(value: unknown): CanvasLayer['assetKind'] {
|
||||
return value === 'spec' ||
|
||||
value === 'character' ||
|
||||
@@ -828,6 +907,11 @@ function calculateCharacterAnimationPrice(
|
||||
return (resolution === '720p' ? 20 : 10) * durationSeconds;
|
||||
}
|
||||
|
||||
function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) {
|
||||
// 中文注释:角色图已持久化到 OSS 时优先传 objectKey,避免把大 Data URL 塞进 JSON 请求体触发 body limit。
|
||||
return layer.objectKey?.trim() || layer.src;
|
||||
}
|
||||
|
||||
function createCanvasLayerReference(
|
||||
layer: CanvasLayer,
|
||||
): CharacterReferenceImage {
|
||||
@@ -838,6 +922,110 @@ function createCanvasLayerReference(
|
||||
};
|
||||
}
|
||||
|
||||
function createGenerationInputField(
|
||||
title: string,
|
||||
value: string | null | undefined,
|
||||
): CanvasGenerationInputField[] {
|
||||
const normalizedValue = value?.trim();
|
||||
return normalizedValue ? [{ title, value: normalizedValue }] : [];
|
||||
}
|
||||
|
||||
function buildImageGenerationInputs(prompt: string): CanvasGenerationInputs {
|
||||
return {
|
||||
fields: createGenerationInputField('生成提示词', prompt),
|
||||
references: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildSpecGenerationInputs(
|
||||
specType: SpecGenerationType,
|
||||
values: SpecFormValues,
|
||||
): CanvasGenerationInputs {
|
||||
if (specType === 'custom') {
|
||||
return {
|
||||
fields: createGenerationInputField('自定义规范提示词', values.customPrompt),
|
||||
references: [],
|
||||
};
|
||||
}
|
||||
|
||||
const baseFields = [
|
||||
...createGenerationInputField('玩法设定', values.playSetting),
|
||||
...createGenerationInputField('美术风格', values.artStyle),
|
||||
];
|
||||
if (specType === 'character') {
|
||||
baseFields.push(
|
||||
...createGenerationInputField('头身比', values.bodyRatio),
|
||||
...createGenerationInputField('角色视角', values.characterView),
|
||||
);
|
||||
}
|
||||
return {
|
||||
fields: baseFields,
|
||||
references: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildCharacterGenerationInputs(
|
||||
prompt: string,
|
||||
specReference: CharacterReferenceImage | null | undefined,
|
||||
references: CharacterReferenceImage[] | undefined,
|
||||
): CanvasGenerationInputs {
|
||||
return {
|
||||
fields: createGenerationInputField('角色设定', prompt),
|
||||
references: [
|
||||
...(specReference
|
||||
? [
|
||||
{
|
||||
title: '角色形象规范',
|
||||
label: specReference.label,
|
||||
src: specReference.src,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(references ?? []).map((reference, index) => ({
|
||||
title: `常规参考图 ${index + 1}`,
|
||||
label: reference.label,
|
||||
src: reference.src,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildIconGenerationInputs(
|
||||
iconDescriptions: string[],
|
||||
specReference: CharacterReferenceImage,
|
||||
): CanvasGenerationInputs {
|
||||
return {
|
||||
fields: iconDescriptions.map((description, index) => ({
|
||||
title: `素材描述 ${index + 1}`,
|
||||
value: description,
|
||||
})),
|
||||
references: [
|
||||
{
|
||||
title: '图标素材规范',
|
||||
label: specReference.label,
|
||||
src: specReference.src,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildEditGenerationInputs(
|
||||
title: '修改要求' | '快速编辑提示词',
|
||||
prompt: string,
|
||||
sourceLayer: CanvasLayer,
|
||||
): CanvasGenerationInputs {
|
||||
return {
|
||||
fields: createGenerationInputField(title, prompt),
|
||||
references: [
|
||||
{
|
||||
title: '参考图',
|
||||
label: sourceLayer.title,
|
||||
src: sourceLayer.src,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildPortalMenuStyle(
|
||||
anchor: HTMLElement | null,
|
||||
placement: 'above' | 'below',
|
||||
@@ -2421,10 +2609,11 @@ export function ImageCanvasEditorView() {
|
||||
uploadedImage.onload = () => {
|
||||
const originalWidth = uploadedImage.naturalWidth || fallbackWidth;
|
||||
const originalHeight = uploadedImage.naturalHeight || fallbackHeight;
|
||||
const longestSide = Math.max(originalWidth, originalHeight);
|
||||
const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1;
|
||||
const width = Math.round(originalWidth * sizeRatio);
|
||||
const height = Math.round(originalHeight * sizeRatio);
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
{ width: fallbackWidth, height: fallbackHeight },
|
||||
);
|
||||
if (options.addToCanvas) {
|
||||
setLayers((currentLayers) =>
|
||||
currentLayers.map((layer) =>
|
||||
@@ -2685,19 +2874,28 @@ export function ImageCanvasEditorView() {
|
||||
assetKind?: CanvasLayer['assetKind'];
|
||||
title?: string;
|
||||
dialogId?: string;
|
||||
generationInputs?: CanvasGenerationInputs;
|
||||
} = {},
|
||||
) => {
|
||||
layerCounterRef.current += 1;
|
||||
const generatedIndex = layerCounterRef.current;
|
||||
const originalWidth = generated.width || 1024;
|
||||
const originalHeight = generated.height || 1024;
|
||||
const longestSide = Math.max(originalWidth, originalHeight);
|
||||
const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1;
|
||||
const width = options.frame?.width ?? Math.round(originalWidth * sizeRatio);
|
||||
const height =
|
||||
options.frame?.height ?? Math.round(originalHeight * sizeRatio);
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
{ width: 1024, height: 1024 },
|
||||
);
|
||||
const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale;
|
||||
const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale;
|
||||
const frameX =
|
||||
options.frame && options.frame.width > 0
|
||||
? options.frame.x + options.frame.width / 2 - width / 2
|
||||
: undefined;
|
||||
const frameY =
|
||||
options.frame && options.frame.height > 0
|
||||
? options.frame.y + options.frame.height / 2 - height / 2
|
||||
: undefined;
|
||||
const nextLayer: CanvasLayer = {
|
||||
id: options.sourceLayer
|
||||
? `layer-edit-${generatedIndex}`
|
||||
@@ -2711,10 +2909,10 @@ export function ImageCanvasEditorView() {
|
||||
src: generated.imageSrc,
|
||||
x: options.sourceLayer
|
||||
? options.sourceLayer.x + options.sourceLayer.width + 32
|
||||
: (options.frame?.x ?? worldCenterX - width / 2),
|
||||
: (frameX ?? worldCenterX - width / 2),
|
||||
y: options.sourceLayer
|
||||
? options.sourceLayer.y
|
||||
: (options.frame?.y ?? worldCenterY - height / 2),
|
||||
: (frameY ?? worldCenterY - height / 2),
|
||||
width,
|
||||
height,
|
||||
originalWidth,
|
||||
@@ -2730,6 +2928,7 @@ export function ImageCanvasEditorView() {
|
||||
objectKey: generated.objectKey,
|
||||
assetObjectId: generated.assetObjectId,
|
||||
sourceResourceId: options.sourceLayer?.resourceId,
|
||||
generationInputs: options.generationInputs,
|
||||
};
|
||||
|
||||
setLayers((currentLayers) => [...currentLayers, nextLayer]);
|
||||
@@ -2761,9 +2960,20 @@ export function ImageCanvasEditorView() {
|
||||
const addQuickEditResultLayer = (
|
||||
generated: EditorImageGenerationResult,
|
||||
sourceLayer: CanvasLayer,
|
||||
generationInputs: CanvasGenerationInputs,
|
||||
) => {
|
||||
layerCounterRef.current += 1;
|
||||
const generatedIndex = layerCounterRef.current;
|
||||
const originalWidth = generated.width || sourceLayer.originalWidth || 1024;
|
||||
const originalHeight = generated.height || sourceLayer.originalHeight || 1024;
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
{
|
||||
width: sourceLayer.width,
|
||||
height: sourceLayer.height,
|
||||
},
|
||||
);
|
||||
const nextLayer: CanvasLayer = {
|
||||
id: `layer-quick-edit-${generatedIndex}`,
|
||||
resourceId: `local-resource-quick-edit-${generatedIndex}`,
|
||||
@@ -2771,10 +2981,10 @@ export function ImageCanvasEditorView() {
|
||||
src: generated.imageSrc,
|
||||
x: sourceLayer.x + sourceLayer.width + 32,
|
||||
y: sourceLayer.y,
|
||||
width: sourceLayer.width,
|
||||
height: sourceLayer.height,
|
||||
originalWidth: sourceLayer.originalWidth,
|
||||
originalHeight: sourceLayer.originalHeight,
|
||||
width,
|
||||
height,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
zIndex: generatedIndex + 10,
|
||||
sourceType: generated.sourceType,
|
||||
prompt: generated.prompt,
|
||||
@@ -2787,6 +2997,7 @@ export function ImageCanvasEditorView() {
|
||||
sourceResourceId: sourceLayer.resourceId,
|
||||
groupId: sourceLayer.groupId,
|
||||
assetKind: sourceLayer.assetKind,
|
||||
generationInputs,
|
||||
};
|
||||
|
||||
setLayers((currentLayers) => [...currentLayers, nextLayer]);
|
||||
@@ -2801,6 +3012,7 @@ export function ImageCanvasEditorView() {
|
||||
const addIconSpritesheetResultLayers = (
|
||||
generated: EditorIconSpritesheetGenerationResult,
|
||||
iconResults: EditorIconSpritesheetIconResult[],
|
||||
generationInputs: CanvasGenerationInputs,
|
||||
frame?: GenerateDialogState['placeholder'],
|
||||
dialogId?: string,
|
||||
) => {
|
||||
@@ -2822,10 +3034,11 @@ export function ImageCanvasEditorView() {
|
||||
iconResults.forEach((icon) => {
|
||||
const originalWidth = icon.width || 128;
|
||||
const originalHeight = icon.height || 128;
|
||||
const longestSide = Math.max(originalWidth, originalHeight);
|
||||
const sizeRatio = longestSide > 0 ? Math.min(1, 128 / longestSide) : 1;
|
||||
const width = Math.round(originalWidth * sizeRatio);
|
||||
const height = Math.round(originalHeight * sizeRatio);
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
{ width: 128, height: 128 },
|
||||
);
|
||||
if (cursorX > startX && cursorX + width - startX > maxRowWidth) {
|
||||
cursorX = startX;
|
||||
cursorY += rowHeight + spacing;
|
||||
@@ -2853,6 +3066,7 @@ export function ImageCanvasEditorView() {
|
||||
provider: generated.provider,
|
||||
taskId: generated.taskId,
|
||||
assetKind: 'icon',
|
||||
generationInputs,
|
||||
});
|
||||
|
||||
cursorX += width + spacing;
|
||||
@@ -2964,6 +3178,7 @@ export function ImageCanvasEditorView() {
|
||||
addIconSpritesheetResultLayers(
|
||||
generated,
|
||||
generated.iconImageSrcs,
|
||||
buildIconGenerationInputs(iconDescriptions, dialog.iconSpecReference),
|
||||
getGeneratingDialogPlaceholder(dialog),
|
||||
canvasDialog.id,
|
||||
);
|
||||
@@ -3001,7 +3216,15 @@ export function ImageCanvasEditorView() {
|
||||
model: quickEditPanel.model,
|
||||
referenceImageSrcs: [referenceImageSrc],
|
||||
});
|
||||
addQuickEditResultLayer(generated, quickEditSourceLayer);
|
||||
addQuickEditResultLayer(
|
||||
generated,
|
||||
quickEditSourceLayer,
|
||||
buildEditGenerationInputs(
|
||||
'快速编辑提示词',
|
||||
normalizedPrompt,
|
||||
quickEditSourceLayer,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
setQuickEditPanel({
|
||||
...quickEditPanel,
|
||||
@@ -3048,7 +3271,14 @@ export function ImageCanvasEditorView() {
|
||||
prompt: normalizedPrompt,
|
||||
sourceImageSrc: referenceImageSrc,
|
||||
});
|
||||
addGeneratedResultLayer(generated, { sourceLayer });
|
||||
addGeneratedResultLayer(generated, {
|
||||
sourceLayer,
|
||||
generationInputs: buildEditGenerationInputs(
|
||||
'修改要求',
|
||||
normalizedPrompt,
|
||||
sourceLayer,
|
||||
),
|
||||
});
|
||||
} else if (dialog.mode === 'spec') {
|
||||
const specType = dialog.specType ?? 'custom';
|
||||
const specValues =
|
||||
@@ -3065,6 +3295,7 @@ export function ImageCanvasEditorView() {
|
||||
assetKind: specType === 'icon' ? 'icon-spec' : 'spec',
|
||||
title: `${SPEC_TYPE_LABEL[specType]} ${layerCounterRef.current + 1}`,
|
||||
dialogId: canvasDialog?.id,
|
||||
generationInputs: buildSpecGenerationInputs(specType, specValues),
|
||||
});
|
||||
} else if (dialog.mode === 'character') {
|
||||
const referenceImageSrcs = [
|
||||
@@ -3083,6 +3314,11 @@ export function ImageCanvasEditorView() {
|
||||
assetKind: 'character',
|
||||
title: `角色形象 ${layerCounterRef.current + 1}`,
|
||||
dialogId: canvasDialog?.id,
|
||||
generationInputs: buildCharacterGenerationInputs(
|
||||
normalizedPrompt,
|
||||
dialog.characterSpecReference,
|
||||
dialog.characterReferences,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
const generated = await generateEditorImage({
|
||||
@@ -3091,6 +3327,7 @@ export function ImageCanvasEditorView() {
|
||||
addGeneratedResultLayer(generated, {
|
||||
frame: getGeneratingDialogPlaceholder(dialog),
|
||||
dialogId: canvasDialog?.id,
|
||||
generationInputs: buildImageGenerationInputs(normalizedPrompt),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -3738,7 +3975,9 @@ export function ImageCanvasEditorView() {
|
||||
try {
|
||||
const result = await generateEditorCharacterAnimation({
|
||||
sourceLayerId: characterAnimationSourceLayer.id,
|
||||
sourceImageSrc: characterAnimationSourceLayer.src,
|
||||
sourceImageSrc: resolveCharacterAnimationSourceImageSrc(
|
||||
characterAnimationSourceLayer,
|
||||
),
|
||||
sourceWidth: characterAnimationSourceLayer.originalWidth,
|
||||
sourceHeight: characterAnimationSourceLayer.originalHeight,
|
||||
promptText,
|
||||
@@ -4348,8 +4587,8 @@ export function ImageCanvasEditorView() {
|
||||
size="xs"
|
||||
className="image-canvas-editor__size-badge"
|
||||
>
|
||||
{Math.round(layer.width)} x {Math.round(layer.height)}{' '}
|
||||
px
|
||||
{Math.round(layer.originalWidth)} x{' '}
|
||||
{Math.round(layer.originalHeight)} px
|
||||
</PlatformPillBadge>
|
||||
) : null}
|
||||
{layerGeneratingLabel ? (
|
||||
@@ -5896,24 +6135,42 @@ export function ImageCanvasEditorView() {
|
||||
<dl className="image-canvas-editor__metadata-grid">
|
||||
<dt>图片类型</dt>
|
||||
<dd>{formatLayerImageType(metadataLayer)}</dd>
|
||||
<dt>Prompt</dt>
|
||||
<dd className="image-canvas-editor__metadata-prompt">
|
||||
{metadataLayer.prompt ? (
|
||||
<dt>生成输入</dt>
|
||||
<dd className="image-canvas-editor__metadata-inputs">
|
||||
{metadataLayer.generationInputs?.fields.length ||
|
||||
metadataLayer.generationInputs?.references.length ? (
|
||||
<>
|
||||
<span>{metadataLayer.prompt}</span>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
className="image-canvas-editor__metadata-copy"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(
|
||||
metadataLayer.prompt ?? '',
|
||||
);
|
||||
}}
|
||||
>
|
||||
复制Prompt
|
||||
</PlatformActionButton>
|
||||
{metadataLayer.generationInputs.fields.map((field) => (
|
||||
<div
|
||||
key={`${field.title}-${field.value}`}
|
||||
className="image-canvas-editor__metadata-input-field"
|
||||
>
|
||||
<span className="image-canvas-editor__metadata-input-title">
|
||||
{field.title}
|
||||
</span>
|
||||
<span>{field.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{metadataLayer.generationInputs.references.length ? (
|
||||
<div className="image-canvas-editor__metadata-reference-list">
|
||||
{metadataLayer.generationInputs.references.map(
|
||||
(reference) => (
|
||||
<div
|
||||
key={`${reference.title}-${reference.label}-${reference.src}`}
|
||||
className="image-canvas-editor__metadata-reference-card"
|
||||
>
|
||||
<img src={reference.src} alt="" aria-hidden="true" />
|
||||
<span className="image-canvas-editor__metadata-reference-copy">
|
||||
<span className="image-canvas-editor__metadata-input-title">
|
||||
{reference.title}
|
||||
</span>
|
||||
<span>{reference.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
'-'
|
||||
@@ -5921,11 +6178,6 @@ export function ImageCanvasEditorView() {
|
||||
</dd>
|
||||
<dt>Model</dt>
|
||||
<dd>{metadataLayer.model ?? '-'}</dd>
|
||||
<dt>Size</dt>
|
||||
<dd>
|
||||
{Math.round(metadataLayer.width)} x{' '}
|
||||
{Math.round(metadataLayer.height)} px
|
||||
</dd>
|
||||
<dt>Resolution</dt>
|
||||
<dd>
|
||||
{metadataLayer.originalWidth} x {metadataLayer.originalHeight} px
|
||||
|
||||
@@ -5205,18 +5205,53 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-prompt {
|
||||
.image-canvas-editor__metadata-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-copy {
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
.image-canvas-editor__metadata-input-field {
|
||||
display: grid;
|
||||
gap: 0.16rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-input-title {
|
||||
color: #64748b;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-reference-list {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-reference-card {
|
||||
display: grid;
|
||||
grid-template-columns: 2.4rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
border: 1px solid rgba(148, 163, 184, 0.26);
|
||||
border-radius: 0.65rem;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
padding: 0.38rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-reference-card img {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 0.48rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-canvas-editor__metadata-reference-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
|
||||
@@ -567,6 +567,48 @@ describe('editorProjectClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes image model options to editor image generation', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
sourceType: 'generated',
|
||||
prompt: '角色设定',
|
||||
actualPrompt: '角色设定',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'vector-character-1',
|
||||
});
|
||||
|
||||
await generateEditorImage({
|
||||
prompt: '角色设定',
|
||||
kind: 'character',
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '1K',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/images/generations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: '角色设定',
|
||||
kind: 'character',
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '1K',
|
||||
}),
|
||||
}),
|
||||
'生成图片失败',
|
||||
expect.objectContaining({
|
||||
timeoutMs: 1_200_000,
|
||||
retry: { maxRetries: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates icon spritesheets through the dedicated backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
||||
@@ -614,6 +656,48 @@ describe('editorProjectClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes image model options to icon spritesheet generation', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
||||
spritesheetWidth: 1024,
|
||||
spritesheetHeight: 1024,
|
||||
iconImageSrcs: [],
|
||||
prompt: '图标素材 prompt',
|
||||
actualPrompt: '图标素材 prompt',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'icon-spritesheet-task-2',
|
||||
});
|
||||
|
||||
await generateEditorIconSpritesheet({
|
||||
referenceImageSrc: 'data:image/png;base64,spec',
|
||||
iconDescriptions: ['返回按钮'],
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '2K',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/icon-spritesheets/generations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
referenceImageSrc: 'data:image/png;base64,spec',
|
||||
iconDescriptions: ['返回按钮'],
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '2K',
|
||||
}),
|
||||
}),
|
||||
'生成图标素材失败',
|
||||
expect.objectContaining({
|
||||
timeoutMs: 1_200_000,
|
||||
retry: { maxRetries: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes spec generation size and kind to the backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,spec',
|
||||
|
||||
@@ -8,7 +8,7 @@ const EDITOR_ICON_SPRITESHEET_GENERATION_API =
|
||||
'/api/editor/icon-spritesheets/generations';
|
||||
const EDITOR_CHARACTER_ANIMATION_GENERATION_API =
|
||||
'/api/editor/character-animations/generations';
|
||||
const EDITOR_ICON_SPRITESHEET_MODEL = 'gemini-3.1-flash-image-preview';
|
||||
const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
|
||||
const DEFAULT_PROJECT_TITLE = '未命名画布';
|
||||
const EDITOR_PROJECT_REQUEST_OPTIONS = {
|
||||
clearAuthOnUnauthorized: false,
|
||||
@@ -89,6 +89,8 @@ export type EditorImageGenerationInput = {
|
||||
size?: string;
|
||||
kind?: 'spec' | 'character' | 'quick-edit';
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
referenceImageSrcs?: string[];
|
||||
};
|
||||
|
||||
@@ -96,6 +98,8 @@ export type EditorIconSpritesheetGenerationInput = {
|
||||
referenceImageSrc: string;
|
||||
iconDescriptions: string[];
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
};
|
||||
|
||||
export type EditorImageEditInput = {
|
||||
@@ -477,6 +481,8 @@ export async function generateEditorImage(input: EditorImageGenerationInput) {
|
||||
...(input.size ? { size: input.size } : {}),
|
||||
...(input.kind ? { kind: input.kind } : {}),
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}),
|
||||
...(input.imageSize ? { imageSize: input.imageSize } : {}),
|
||||
...(input.referenceImageSrcs?.length
|
||||
? { referenceImageSrcs: input.referenceImageSrcs }
|
||||
: {}),
|
||||
@@ -500,7 +506,9 @@ export async function generateEditorIconSpritesheet(
|
||||
jsonRequest('POST', {
|
||||
referenceImageSrc: input.referenceImageSrc,
|
||||
iconDescriptions: input.iconDescriptions,
|
||||
model: input.model?.trim() || EDITOR_ICON_SPRITESHEET_MODEL,
|
||||
model: input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||||
...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}),
|
||||
...(input.imageSize ? { imageSize: input.imageSize } : {}),
|
||||
}),
|
||||
'生成图标素材失败',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user