修复图片交付尺寸与提示重触发
Project CI / Repository checks (push) Successful in 47s
Project CI / Frontend tests (push) Successful in 1m39s
Project CI / Native shell tests (push) Successful in 2m15s
Project CI / Backend tests (push) Successful in 2m56s

统一图片模型的业务交付像素并保留 GPT provider 合法请求尺寸
禁止欠交付图片被放大并返回尺寸降级提示
重置相同 warning 的三秒自动关闭计时
补充前后端回归测试与编辑器契约文档
This commit is contained in:
2026-07-27 21:16:01 +08:00
parent 25ee537e39
commit a5906ac26e
11 changed files with 429 additions and 134 deletions
@@ -233,10 +233,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
imageSize: '1K',
uiDesignSpecReference: null,
placeholder: {
x: -64,
y: 56,
width: 1088,
height: 608,
x: -32,
y: 72,
width: 1024,
height: 576,
},
});
});
@@ -622,9 +622,9 @@ describe('ImageCanvasGenerationDialogModel', () => {
placeholder: {
x: 472,
y: 140,
width: 1376,
width: 1365,
height: 2048,
originalWidth: 1376,
originalWidth: 1365,
originalHeight: 2048,
},
});
@@ -662,9 +662,9 @@ describe('ImageCanvasGenerationDialogModel', () => {
placeholder: {
x: 472,
y: 140,
width: 1376,
width: 1365,
height: 2048,
originalWidth: 1376,
originalWidth: 1365,
originalHeight: 2048,
},
});
@@ -108,18 +108,18 @@ describe('ImageCanvasGenerationModel', () => {
[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, '1K', '3:2', 1024, 683],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '2:3', 683, 1024],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '9:16', 576, 1024],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '16:9', 1024, 576],
[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', '3:2', 2048, 1365],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '2:3', 1365, 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 直接请求尺寸一致',
'%s 的 %s %s 占位尺寸与统一业务交付尺寸一致',
(model, imageSize, aspectRatio, width, height) => {
expect(
resolveEditorImageGenerationPixelSize({
@@ -173,38 +173,8 @@ 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,
model: _model,
aspectRatio,
imageSize,
}: {
@@ -212,9 +182,6 @@ 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 });
}
@@ -671,6 +671,42 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('shows dimension restore warnings after non-character quick edits', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({
prompt: '改成雨天',
width: 1024,
height: 576,
warning: {
code: 'dimension-restore-fallback',
reason: 'provider 回图小于目标尺寸,已保留实际回图。',
},
}),
);
render(
<SubmissionWorkflowHarness
initialQuickEditPanel={{
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '改成雨天',
size: '2048x1152',
aspectRatio: '16:9',
imageSize: '2K',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(screen.getByTestId('generation-warning').textContent).toBe(
'provider 回图小于目标尺寸,已保留实际回图。',
);
});
});
it('rejects direct quick edit submission for icon assets', async () => {
render(
<SubmissionWorkflowHarness
@@ -1643,15 +1643,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}),
onWalletBalanceMayHaveChanged,
);
const characterWarningCallback = isCharacterRedraw
? onGenerationWarning
: undefined;
if (isCharacterRedraw) {
notifyEditorGenerationWarning(
generated.warning?.reason,
characterWarningCallback,
);
}
notifyEditorGenerationWarning(
generated.warning?.reason,
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
@@ -1659,7 +1654,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
characterWarningCallback,
onGenerationWarning,
)
) {
if (panelMode === 'quick-edit') {
@@ -2066,16 +2061,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}),
onWalletBalanceMayHaveChanged,
);
const characterWarningCallback =
imageGenerationInput.kind === 'character'
? onGenerationWarning
: undefined;
if (imageGenerationInput.kind === 'character') {
notifyEditorGenerationWarning(
generated.warning?.reason,
characterWarningCallback,
);
}
notifyEditorGenerationWarning(
generated.warning?.reason,
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
@@ -2083,7 +2072,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
characterWarningCallback,
onGenerationWarning,
)
) {
return;
@@ -349,6 +349,7 @@ export function useImageCanvasGenerationSurface({
<>
{generationWorkflow.generationWarning ? (
<PlatformRuntimeStatusToast
key={generationWorkflow.generationWarningVersion}
tone="warning"
surface="solid"
size="sm"
@@ -1447,6 +1447,37 @@ describe('useImageCanvasGenerationWorkflow', () => {
}
});
it('restarts the dismissal timer when the same warning is shown again', () => {
vi.useFakeTimers();
try {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' }));
act(() => {
vi.advanceTimersByTime(2_999);
});
fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' }));
act(() => {
vi.advanceTimersByTime(1);
});
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'选择的图片不是角色规范图,请选择生成规范里的角色规范。',
);
act(() => {
vi.advanceTimersByTime(2_999);
});
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
} finally {
vi.useRealTimers();
}
});
it('restores the last picked character spec from local cache after remounting the editor', () => {
const firstRender = render(<GenerationWorkflowHarness />);
@@ -720,6 +720,11 @@ export function useImageCanvasGenerationWorkflow({
const [generationWarning, setGenerationWarning] = useState<string | null>(
null,
);
const [generationWarningVersion, setGenerationWarningVersion] = useState(0);
const showGenerationWarning = useCallback((warning: string) => {
setGenerationWarning(warning);
setGenerationWarningVersion((version) => version + 1);
}, []);
useEffect(() => {
if (!generationWarning) {
@@ -733,7 +738,7 @@ export function useImageCanvasGenerationWorkflow({
return () => {
window.clearTimeout(timer);
};
}, [generationWarning]);
}, [generationWarning, generationWarningVersion]);
const [cropExpandPanel, setCropExpandPanel] =
useState<CropExpandPanelState | null>(null);
const [uiAssetExtractionState, setUiAssetExtractionState] =
@@ -1335,7 +1340,7 @@ export function useImageCanvasGenerationWorkflow({
return;
}
if (sourceLayer.assetKind === 'character-animation') {
setGenerationWarning('未找到角色动作关联的原角色图层,无法改造。');
showGenerationWarning('未找到角色动作关联的原角色图层,无法改造。');
return;
}
const redrawPanel = createRedrawPanelDraft(sourceLayer, {
@@ -1375,6 +1380,7 @@ export function useImageCanvasGenerationWorkflow({
setActiveTool,
setImageContextMenu,
setMetadataLayer,
showGenerationWarning,
canvasGenerationDialogs,
canvasSize,
layers,
@@ -1703,7 +1709,7 @@ export function useImageCanvasGenerationWorkflow({
}),
refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
setGenerationWarning,
showGenerationWarning,
backgroundRemovalDialogId,
applyBackgroundRemovalProjectSnapshot
? (project) =>
@@ -1736,6 +1742,7 @@ export function useImageCanvasGenerationWorkflow({
refreshTaskListForQueuedGeneration,
setImageContextMenu,
setMetadataLayer,
showGenerationWarning,
updateCanvasGenerationDialogById,
],
);
@@ -1819,7 +1826,7 @@ export function useImageCanvasGenerationWorkflow({
const pickCharacterSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (!isCharacterSpecReferenceLayer(layer)) {
setGenerationWarning(INVALID_CHARACTER_SPEC_WARNING);
showGenerationWarning(INVALID_CHARACTER_SPEC_WARNING);
return;
}
setGenerationWarning(null);
@@ -1838,7 +1845,7 @@ export function useImageCanvasGenerationWorkflow({
setIsCharacterSpecMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
[setGenerateDialog, setImageContextMenu, showGenerationWarning],
);
const pickGenerationReferenceFromLayer = useCallback(
@@ -1879,7 +1886,7 @@ export function useImageCanvasGenerationWorkflow({
const pickIconSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (!isIconSpecReferenceLayer(layer)) {
setGenerationWarning(INVALID_ICON_SPEC_WARNING);
showGenerationWarning(INVALID_ICON_SPEC_WARNING);
return;
}
setGenerationWarning(null);
@@ -1895,13 +1902,18 @@ export function useImageCanvasGenerationWorkflow({
setIsIconSpecMenuOpen(false);
setImageContextMenu(null);
},
[generateDialog, setGenerateDialog, setImageContextMenu],
[
generateDialog,
setGenerateDialog,
setImageContextMenu,
showGenerationWarning,
],
);
const pickUiDesignSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (!isIconSpecReferenceLayer(layer)) {
setGenerationWarning(INVALID_ICON_SPEC_WARNING);
showGenerationWarning(INVALID_ICON_SPEC_WARNING);
return;
}
setGenerationWarning(null);
@@ -1920,7 +1932,12 @@ export function useImageCanvasGenerationWorkflow({
setIsUiDesignSpecMenuOpen(false);
setImageContextMenu(null);
},
[generateDialog, setGenerateDialog, setImageContextMenu],
[
generateDialog,
setGenerateDialog,
setImageContextMenu,
showGenerationWarning,
],
);
const pickPublicationReferenceFromLayer = useCallback(
@@ -1986,7 +2003,7 @@ export function useImageCanvasGenerationWorkflow({
applyProjectSnapshot,
onQueuedGenerationTask: refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
onGenerationWarning: setGenerationWarning,
onGenerationWarning: showGenerationWarning,
});
const setEffectiveCharacterAnimationPanel: Dispatch<
@@ -2554,7 +2571,8 @@ export function useImageCanvasGenerationWorkflow({
isPickingPublicationReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
generationWarning,
showGenerationWarning: setGenerationWarning,
generationWarningVersion,
showGenerationWarning,
clearGenerationWarning: () => setGenerationWarning(null),
openGenerateDialog,
openSpecDialog,
@@ -2649,6 +2667,7 @@ export function useImageCanvasGenerationWorkflow({
isSpecMenuOpen,
isPickingPublicationReferenceFromCanvas,
generationWarning,
generationWarningVersion,
openBackgroundMusicGenerationDialog,
openCharacterAnimationPanel,
openCharacterGenerationDialog,
@@ -2686,6 +2705,7 @@ export function useImageCanvasGenerationWorkflow({
updateIconDescriptionsText,
updateSpecFormValue,
rememberImageModel,
showGenerationWarning,
],
);
}