Image editor: hide raw Prompt, use Resolution

Remove backend-assembled raw Prompt and copy action from image info; render a lightweight generationInputs snapshot (user panel inputs + reference thumbnails) stored on canvas layers and shown in the image info dialog. Unify canvas display and info to use originalWidth/originalHeight (Resolution) instead of saved Size and hydrate legacy layout width/height only as fallback. Add model/aspectRatio/imageSize options for character/icon generation (frontend state, tests, and client payloads). Increase Axum JSON body limit for character animation endpoint to 12MB for compatibility and prefer submitting persisted objectKey over large Data URLs. Update tests, docs, and related server/frontend code to reflect these behaviors and validations.
This commit is contained in:
2026-06-16 17:06:21 +08:00
parent 7eeff10c67
commit 3a3cc89280
14 changed files with 1041 additions and 135 deletions

View File

@@ -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 />);
@@ -795,9 +823,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%' }));
@@ -1117,6 +1143,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 () => {
@@ -1163,6 +1201,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: '拖拽后的生成图' },
});
@@ -1186,11 +1231,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 () => {
@@ -1264,11 +1311,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', () => {
@@ -1677,6 +1734,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 />);
@@ -1806,12 +1973,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();
@@ -2133,6 +2304,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',
@@ -2338,6 +2529,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',
@@ -2398,6 +2603,8 @@ describe('ImageCanvasEditorView', () => {
originalHeight: 1024,
zIndex: 2,
sourceType: 'generated',
objectKey:
'generated-character-drafts/editor/character-images/source/image.png',
assetKind: 'character',
},
{
@@ -2514,7 +2721,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',
@@ -2628,10 +2836,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',
@@ -2640,11 +2848,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,
}),
]),
@@ -2940,8 +3148,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,
@@ -2975,13 +3182,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');
@@ -2996,21 +3207,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: '修改图片' });
@@ -3037,9 +3245,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 () => {

View File

@@ -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 = {
@@ -383,8 +400,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,
@@ -397,8 +414,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,
@@ -544,6 +561,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]));
}
@@ -565,10 +594,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;
@@ -620,6 +650,7 @@ function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
sourceResourceId: layer.sourceResourceId,
groupId: layer.groupId,
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
};
}
@@ -643,10 +674,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
@@ -661,6 +700,7 @@ function hydrateLayer(
sourceResourceId: stringOrNull(snapshot.sourceResourceId),
groupId: stringOrNull(snapshot.groupId),
assetKind: canvasAssetKindOrNull(snapshot.assetKind),
generationInputs: generationInputsOrNull(snapshot.generationInputs),
};
}
@@ -717,6 +757,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' ||
@@ -821,6 +900,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 {
@@ -831,6 +915,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',
@@ -2408,10 +2596,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) =>
@@ -2672,19 +2861,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}`
@@ -2698,10 +2896,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,
@@ -2717,6 +2915,7 @@ export function ImageCanvasEditorView() {
objectKey: generated.objectKey,
assetObjectId: generated.assetObjectId,
sourceResourceId: options.sourceLayer?.resourceId,
generationInputs: options.generationInputs,
};
setLayers((currentLayers) => [...currentLayers, nextLayer]);
@@ -2748,9 +2947,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}`,
@@ -2758,10 +2968,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,
@@ -2774,6 +2984,7 @@ export function ImageCanvasEditorView() {
sourceResourceId: sourceLayer.resourceId,
groupId: sourceLayer.groupId,
assetKind: sourceLayer.assetKind,
generationInputs,
};
setLayers((currentLayers) => [...currentLayers, nextLayer]);
@@ -2788,6 +2999,7 @@ export function ImageCanvasEditorView() {
const addIconSpritesheetResultLayers = (
generated: EditorIconSpritesheetGenerationResult,
iconResults: EditorIconSpritesheetIconResult[],
generationInputs: CanvasGenerationInputs,
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
) => {
@@ -2809,10 +3021,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;
@@ -2840,6 +3053,7 @@ export function ImageCanvasEditorView() {
provider: generated.provider,
taskId: generated.taskId,
assetKind: 'icon',
generationInputs,
});
cursorX += width + spacing;
@@ -2951,6 +3165,7 @@ export function ImageCanvasEditorView() {
addIconSpritesheetResultLayers(
generated,
generated.iconImageSrcs,
buildIconGenerationInputs(iconDescriptions, dialog.iconSpecReference),
getGeneratingDialogPlaceholder(dialog),
canvasDialog.id,
);
@@ -2988,7 +3203,15 @@ export function ImageCanvasEditorView() {
model: quickEditPanel.model,
referenceImageSrcs: [referenceImageSrc],
});
addQuickEditResultLayer(generated, quickEditSourceLayer);
addQuickEditResultLayer(
generated,
quickEditSourceLayer,
buildEditGenerationInputs(
'快速编辑提示词',
normalizedPrompt,
quickEditSourceLayer,
),
);
} catch (error) {
setQuickEditPanel({
...quickEditPanel,
@@ -3035,7 +3258,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 =
@@ -3052,6 +3282,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 = [
@@ -3070,6 +3301,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({
@@ -3078,6 +3314,7 @@ export function ImageCanvasEditorView() {
addGeneratedResultLayer(generated, {
frame: getGeneratingDialogPlaceholder(dialog),
dialogId: canvasDialog?.id,
generationInputs: buildImageGenerationInputs(normalizedPrompt),
});
}
} catch (error) {
@@ -3688,7 +3925,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,
@@ -4298,8 +4537,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 ? (
@@ -5846,24 +6085,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}
</>
) : (
'-'
@@ -5871,11 +6128,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