修复图片改造后二维清晰度降级问题 (#90)

前端按图层真实尺寸恢复图片改造比例与清晰度

后端在角色去背景后恢复生成原图像素尺寸

补充前后端二千像素尺寸回归测试

同步更新图片画布技术文档与项目决策记录

---------

Co-authored-by: MengHao <menghao@MengHaodeMacBook-Air.local>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/90
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 孟豪 <mh18530625731@163.com>
Co-committed-by: 孟豪 <mh18530625731@163.com>
This commit was merged in pull request #90.
This commit is contained in:
2026-07-18 16:50:17 +08:00
committed by 段舒康
parent a5ae283561
commit 47f3297e84
16 changed files with 583 additions and 190 deletions
@@ -1236,6 +1236,11 @@ describe('ImageCanvasEditorView generation integration', () => {
{
open: () => {
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
selectGenerationDimensions(
screen.getByRole('dialog', { name: '生成图片' }),
'1:1',
'2K',
);
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '生成中的普通图片' },
});
@@ -1243,6 +1248,7 @@ describe('ImageCanvasEditorView generation integration', () => {
},
dialogName: '生成图片',
frameLabel: '图像生成占位图',
expectedFrameSize: { width: 2048, height: 2048 },
},
{
open: () => {
@@ -1268,10 +1274,16 @@ describe('ImageCanvasEditorView generation integration', () => {
},
dialogName: '生成规范',
frameLabel: '规范生成占位图',
expectedFrameSize: { width: 2048, height: 1152 },
},
{
open: () => {
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
selectGenerationDimensions(
screen.getByRole('dialog', { name: '生成角色形象' }),
'2:3',
'2K',
);
fireEvent.change(screen.getByLabelText('角色设定'), {
target: { value: '生成中的角色形象' },
});
@@ -1279,6 +1291,7 @@ describe('ImageCanvasEditorView generation integration', () => {
},
dialogName: '生成角色形象',
frameLabel: '角色生成占位图',
expectedFrameSize: { width: 1365, height: 2048 },
},
] as const;
@@ -1298,6 +1311,12 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(frame.className).toContain(
'image-canvas-editor__generation-frame--generating',
);
expect((frame as HTMLElement).style.width).toBe(
`${testCase.expectedFrameSize.width}px`,
);
expect((frame as HTMLElement).style.height).toBe(
`${testCase.expectedFrameSize.height}px`,
);
expect(within(frame).getByRole('status').textContent).toContain('生成中');
unmount();
@@ -144,7 +144,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('creates character generation drafts with model dimensions and icon drafts with icon frame dimensions', () => {
it('creates character and icon generation drafts with the selected model dimensions', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
@@ -183,12 +183,12 @@ describe('ImageCanvasGenerationDialogModel', () => {
iconSpecReference: null,
iconDescriptions: [],
placeholder: {
x: 300,
y: 180,
width: 360,
height: 360,
originalWidth: 512,
originalHeight: 512,
x: -32,
y: -152,
width: 1024,
height: 1024,
originalWidth: 1024,
originalHeight: 1024,
},
});
});
@@ -233,10 +233,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
imageSize: '1K',
uiDesignSpecReference: null,
placeholder: {
x: -32,
y: 72,
width: 1024,
height: 576,
x: -64,
y: 56,
width: 1088,
height: 608,
},
});
});
@@ -538,6 +538,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
placeholder: {
x: 472,
y: 140,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
},
});
@@ -614,9 +618,58 @@ describe('ImageCanvasGenerationDialogModel', () => {
imageModel: 'gpt-image-2',
aspectRatio: '2:3',
imageSize: '2K',
placeholder: {
x: 472,
y: 140,
width: 1376,
height: 2048,
originalWidth: 1376,
originalHeight: 2048,
},
});
});
it.each([
['character', 'character'],
['icon', 'icon-spritesheet'],
['ui-design', 'ui-design'],
] as const)(
'restores %s remodel placeholders to the selected 2K dimensions',
(mode, assetKind) => {
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({ assetKind }),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
sourceDialog: {
id: `generation-dialog-${mode}`,
mode,
prompt: '来源生成器',
status: 'idle',
generatedLayerId: 'layer-source',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '2:3',
imageSize: '2K',
},
mode: 'redraw',
}),
).toMatchObject({
mode,
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '2:3',
imageSize: '2K',
placeholder: {
x: 472,
y: 140,
width: 1376,
height: 2048,
originalWidth: 1376,
originalHeight: 2048,
},
});
},
);
it('restores a plain generated layer from its own metadata', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
@@ -672,6 +725,33 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('preserves 2K dimensions when reopening a persisted character layer', () => {
expect(
createLayerGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'generated',
assetKind: 'character',
model: IMAGE_MODEL_NANOBANANA2,
originalWidth: 2048,
originalHeight: 2048,
generationInputs: {
fields: [{ title: '角色设定', value: '香水师角色' }],
references: [],
},
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
}),
).toMatchObject({
mode: 'character',
prompt: '香水师角色',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '2K',
generatedLayerId: 'layer-source',
});
});
it('restores video and audio generators from media layer metadata', () => {
const context = {
canvasSize: { width: 960, height: 720 },
@@ -31,8 +31,6 @@ import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
ICON_DESCRIPTION_LIMIT,
ICON_FRAME_DISPLAY_SIZE,
ICON_FRAME_ORIGINAL_SIZE,
IMAGE_MODEL_GPT_IMAGE_2,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
@@ -40,6 +38,7 @@ import {
PUBLICATION_FRAME_ORIGINAL_SIZE,
resolveEditorImageGenerationPixelSize,
resolveEditorVideoGenerationPixelSize,
resizeGenerationPlaceholderToImageSelection,
SPEC_FRAME_ORIGINAL_SIZE,
} from './ImageCanvasGenerationModel';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
@@ -298,6 +297,11 @@ export function createIconGenerationDialogDraft({
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const normalizedImageModel = normalizeEditorImageModel(imageModel);
const dimensionDefaults = resolveImageDimensionDefaults(normalizedImageModel);
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: normalizedImageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
});
return {
mode: 'icon',
prompt: '',
@@ -310,12 +314,12 @@ export function createIconGenerationDialogDraft({
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
x: worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2,
width: ICON_FRAME_DISPLAY_SIZE.width,
height: ICON_FRAME_DISPLAY_SIZE.height,
originalWidth: ICON_FRAME_ORIGINAL_SIZE.width,
originalHeight: ICON_FRAME_ORIGINAL_SIZE.height,
x: worldCenter.x - placeholderSize.width / 2,
y: worldCenter.y - placeholderSize.height / 2,
width: placeholderSize.width,
height: placeholderSize.height,
originalWidth: placeholderSize.width,
originalHeight: placeholderSize.height,
},
};
}
@@ -706,8 +710,7 @@ export function createLayerGenerationDialogDraft({
return {
...draft,
prompt: draft.prompt || sourceLayer.prompt?.trim() || '',
imageModel:
sourceDialog?.imageModel ?? sourceLayer.model ?? draft.imageModel,
imageModel: sourceDialog?.imageModel ?? draft.imageModel,
status: 'idle',
composerOpen: true,
generatedLayerId: sourceLayer.id,
@@ -755,24 +758,47 @@ function restoreSharedImageOptions(
sourceLayer: CanvasLayer,
sourceDialog?: CanvasGenerationDialogState | null,
): Omit<CanvasGenerationDialogState, 'id'> {
return {
const requestedModel = normalizeEditorImageModel(
sourceDialog?.imageModel ?? sourceLayer.model ?? draft.imageModel,
);
const imageModel = EDITOR_IMAGE_MODEL_OPTIONS.some(
(option) => option.value === requestedModel,
)
? requestedModel
: DEFAULT_IMAGE_MODEL;
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
imageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL];
const supportedAspectRatios =
dimensionOptions.aspectRatios as readonly string[];
const supportedImageSizes = dimensionOptions.imageSizes as readonly string[];
const inferredAspectRatio = inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const inferredImageSize = inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const preferredAspectRatio = sourceDialog?.aspectRatio ?? inferredAspectRatio;
const preferredImageSize = sourceDialog?.imageSize ?? inferredImageSize;
return resizeGenerationPlaceholderToImageSelection({
...draft,
imageModel: sourceDialog?.imageModel ?? draft.imageModel,
aspectRatio:
sourceDialog?.aspectRatio ??
draft.aspectRatio ??
inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
),
imageSize:
sourceDialog?.imageSize ??
draft.imageSize ??
inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
),
};
imageModel,
aspectRatio: supportedAspectRatios.includes(preferredAspectRatio)
? preferredAspectRatio
: draft.aspectRatio && supportedAspectRatios.includes(draft.aspectRatio)
? draft.aspectRatio
: (dimensionOptions.aspectRatios[0] ?? '1:1'),
imageSize: supportedImageSizes.includes(preferredImageSize)
? preferredImageSize
: draft.imageSize && supportedImageSizes.includes(draft.imageSize)
? draft.imageSize
: (dimensionOptions.imageSizes.find((size) => size === '1K') ??
dimensionOptions.imageSizes[0] ??
'1K'),
});
}
function resolveSpecTypeFromSourceLayer(
@@ -42,8 +42,10 @@ import {
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
IMAGE_MODEL_GPT_IMAGE_2,
IMAGE_MODEL_NANOBANANA2,
isQuickEditUnsupportedAssetKind,
resolveCharacterAnimationSourceImageSrc,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
VIDEO_MODEL_KLING_3,
VIDEO_MODEL_KLING_3_OMNI,
@@ -101,6 +103,34 @@ describe('ImageCanvasGenerationModel', () => {
).toBeTruthy();
});
it.each([
[IMAGE_MODEL_NANOBANANA2, '1K', '1:1', 1024, 1024],
[IMAGE_MODEL_NANOBANANA2, '2K', '2:3', 1365, 2048],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '1:1', 1024, 1024],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '4:3', 1024, 768],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '3:2', 1024, 688],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '2:3', 688, 1024],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '9:16', 608, 1088],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '16:9', 1088, 608],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '1:1', 2048, 2048],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '4:3', 2048, 1536],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '3:2', 2048, 1376],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '2:3', 1376, 2048],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '9:16', 1152, 2048],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '16:9', 2048, 1152],
] as const)(
'%s 的 %s %s 占位尺寸与 provider 直接请求尺寸一致',
(model, imageSize, aspectRatio, width, height) => {
expect(
resolveEditorImageGenerationPixelSize({
model,
aspectRatio,
imageSize,
}),
).toEqual({ width, height });
},
);
it('所有生成按钮价格都从模型定价配置推导', () => {
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
@@ -173,7 +173,38 @@ function resolveImageFrameSizeFromRatio({
};
}
function alignImageDimensionUp(value: number, alignment = 16) {
return Math.ceil(value / alignment) * alignment;
}
function resolveGptImage2FrameSize({
aspectRatio,
imageSize,
}: {
aspectRatio: string | null | undefined;
imageSize: string | null | undefined;
}): EditorImageFrameSize {
const requested = resolveImageFrameSizeFromRatio({ aspectRatio, imageSize });
let width = requested.width;
let height = requested.height;
const pixels = width * height;
// 中文注释:gpt-image-2 要求总像素不少于 655360,且宽高均为 16 的倍数。
// 这里只把用户选择的 K 档和比例转换为 provider 可直接生成的真实尺寸,不做生成后放大。
if (pixels < 655_360) {
const scale = Math.sqrt(655_360 / pixels);
width *= scale;
height *= scale;
}
return {
width: alignImageDimensionUp(width),
height: alignImageDimensionUp(height),
};
}
export function resolveEditorImageGenerationPixelSize({
model,
aspectRatio,
imageSize,
}: {
@@ -181,6 +212,9 @@ export function resolveEditorImageGenerationPixelSize({
aspectRatio: string | null | undefined;
imageSize: string | null | undefined;
}): EditorImageFrameSize {
if (model?.trim() === IMAGE_MODEL_GPT_IMAGE_2) {
return resolveGptImage2FrameSize({ aspectRatio, imageSize });
}
return resolveImageFrameSizeFromRatio({ aspectRatio, imageSize });
}
@@ -225,9 +259,9 @@ export function inferEditorImageSizeLabel(width: number, height: number) {
return '1K';
}
export function resizeGenerationPlaceholderToImageSelection(
dialog: GenerateDialogState,
): GenerateDialogState {
export function resizeGenerationPlaceholderToImageSelection<
T extends GenerateDialogState,
>(dialog: T): T {
if (!dialog.placeholder) {
return dialog;
}
@@ -249,7 +283,7 @@ export function resizeGenerationPlaceholderToImageSelection(
originalWidth: size.width,
originalHeight: size.height,
},
};
} as T;
}
function resolveEvenVideoWidth(
@@ -1364,6 +1364,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
render(
<SubmissionWorkflowHarness
projectId="project-edit-size"
initialDialog={{
mode: 'edit',
prompt: '',
@@ -1384,6 +1385,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect.objectContaining({
prompt: '修改当前图片',
sourceImageSrc: 'resource-source',
canvasCompletion: expect.objectContaining({
placeholder: {
x: 472,
y: 140,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
},
}),
}),
);
});
@@ -2500,6 +2511,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
render(
<SubmissionWorkflowHarness
projectId="project-ui-extraction-size"
initialLayers={[
createLayer({
assetKind: 'ui-design',
@@ -2541,6 +2553,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
segModel: 'birefnet',
aspectRatio: '1:1',
imageSize: '2K',
canvasCompletion: expect.objectContaining({
placeholder: {
x: 472,
y: 140,
width: 2048,
height: 2048,
originalWidth: 2048,
originalHeight: 2048,
},
}),
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
@@ -61,8 +61,6 @@ import {
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
DEFAULT_IMAGE_MODEL,
DEFAULT_VIDEO_MODEL,
ICON_FRAME_DISPLAY_SIZE,
ICON_FRAME_ORIGINAL_SIZE,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
isCanvasGenerationDialog,
@@ -1085,13 +1083,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const extractionPlan = resolveUiAssetExtractionGenerationPlan(
options.marks?.length ?? 0,
);
const extractionSize = resolveEditorImageGenerationPixelSize({
model: options.model ?? DEFAULT_IMAGE_MODEL,
aspectRatio: extractionPlan.aspectRatio,
imageSize: extractionPlan.imageSize,
});
const extractionFrame = {
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
width: ICON_FRAME_DISPLAY_SIZE.width,
height: ICON_FRAME_DISPLAY_SIZE.height,
originalWidth: ICON_FRAME_ORIGINAL_SIZE.width,
originalHeight: ICON_FRAME_ORIGINAL_SIZE.height,
width: extractionSize.width,
height: extractionSize.height,
originalWidth: extractionSize.width,
originalHeight: extractionSize.height,
};
const generated = await runEditorGenerationWithWalletRefresh(
extractEditorUiDesignAssets({
@@ -1778,10 +1781,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
buildRightSideCanvasCompletionPlaceholder(
submissionPlan.sourceLayer,
{
width: 1024,
height: 1024,
originalWidth: 1024,
originalHeight: 1024,
width: submissionPlan.sourceLayer.originalWidth,
height: submissionPlan.sourceLayer.originalHeight,
originalWidth: submissionPlan.sourceLayer.originalWidth,
originalHeight: submissionPlan.sourceLayer.originalHeight,
},
);
const generated = await runEditorGenerationWithWalletRefresh(