修复游戏场景生成复审问题

补齐触屏画风预览点击开关

转发场景生成告警并保留权威项目写回

限制场景参考图并禁用自动重试

补充场景生成回归测试与技术说明
This commit is contained in:
2026-08-06 10:26:28 +08:00
parent 6753e7fb79
commit d07cdce7bc
8 changed files with 192 additions and 16 deletions
@@ -56,7 +56,7 @@
- FileReader Data URL 正式请求。
- 复制出的整套画布和缩减版类型。
四张固定图片只用于前端预览,不进入 `generationReferences`,不随生成请求提交。保留原分辨率,只做轻量无损压缩;画风菜单打开时不同时挂载全部预览图,只在用户悬停键盘聚焦对应预览入口时按需挂载当前图片。
四张固定图片只用于前端预览,不进入 `generationReferences`,不随生成请求提交。保留原分辨率,只做轻量无损压缩;画风菜单打开时不同时挂载全部预览图,只在用户悬停键盘聚焦或点击对应预览入口时按需挂载当前图片。点击同一入口可关闭预览,保证无 hover 的触屏设备可用。
## 4. API 契约
@@ -85,7 +85,9 @@ canvasCompletion?
请求不接受前端组装后的完整 `prompt`。本期不修改 `/api/external/v1`
`generationInputs` 只承载结构化输入快照。后端不信任或透传前端提交的 `fields`,而是根据标准化后的场景请求重新构建画面内容、视觉风格、比例、清晰度和模型字段;前端提交的参考图元数据只使用既有项目资源或账号素材的行引用,不保存媒体 URL。完整 Provider Prompt 仍只能由后端生成。
完整 Provider Prompt 仍只能由后端生成。
场景参考图沿用普通图片生成的客户端前置门禁,最多 5 张;超限时不得发送 HTTP 请求。场景生成 POST 使用生成专用零重试策略,避免 inline 响应丢失后重复调用 Provider。
后端对模型、比例和清晰度先沿用 `normalize_editor_generation_options` 标准化,再使用标准化比例决定画幅描述和入队价格。
@@ -127,6 +129,7 @@ prompt = 后端完整 Prompt
- `scene` 明确按普通图片模型档位计价。
- Provider 失败、执行取消和 lease 耗尽沿用现有扣退费语义。
- Provider 成功后的持久化或画布写回失败语义不在本期调整。
- inline 响应中的通用生成告警必须在队列终态处理前转发;有项目画布占位但响应缺少权威 `project` 快照时,不得追加本地图层或把占位标记为已完成。
## 7. 数据落点
@@ -339,6 +339,41 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(within(panel).queryByText('像素艺术')).toBeNull();
});
it('触屏点击可以打开并关闭当前画风预览', () => {
renderComposer({
mode: 'scene',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
sceneStylePreset: 'anime',
sceneCustomStyle: '',
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
});
fireEvent.click(screen.getByRole('button', { name: '画风预设 日系动画' }));
const previewButton = screen.getByRole('button', {
name: '预览日系动画画风',
});
fireEvent.pointerDown(previewButton, { pointerType: 'touch' });
fireEvent.click(previewButton);
fireEvent.blur(previewButton);
expect(previewButton.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByAltText('日系动画固定画风预览')).toBeTruthy();
fireEvent.pointerDown(previewButton, { pointerType: 'touch' });
fireEvent.click(previewButton);
expect(previewButton.getAttribute('aria-expanded')).toBe('false');
expect(screen.queryByAltText('日系动画固定画风预览')).toBeNull();
fireEvent.focus(previewButton);
expect(previewButton.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByAltText('日系动画固定画风预览')).toBeTruthy();
});
it('让场景自定义画风使用可自增长的多行字段', () => {
renderComposer({
mode: 'scene',
@@ -53,16 +53,19 @@ export function ImageCanvasSceneStyleControl({
useState<EditorSceneStylePreset | null>(null);
const [focusedPreview, setFocusedPreview] =
useState<EditorSceneStylePreset | null>(null);
const [clickedPreview, setClickedPreview] =
useState<EditorSceneStylePreset | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const dismiss = useCallback(() => {
setIsOpen(false);
setHoveredPreview(null);
setFocusedPreview(null);
setClickedPreview(null);
}, []);
const selectedValue =
dialog.sceneStylePreset ?? DEFAULT_EDITOR_SCENE_STYLE_PRESET;
const selectedLabel = getEditorSceneStylePresetLabel(selectedValue);
const activePreview = hoveredPreview ?? focusedPreview;
const activePreview = hoveredPreview ?? focusedPreview ?? clickedPreview;
useImageCanvasFloatingOptionDismiss({
isOpen,
@@ -174,8 +177,17 @@ export function ImageCanvasSceneStyleControl({
className="image-canvas-editor__scene-style-preview-button"
aria-label={`预览${option.label}画风`}
aria-expanded={previewActive}
onPointerDown={(event) => event.preventDefault()}
onClick={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setClickedPreview((currentPreview) => {
if (currentPreview === option.value) {
setHoveredPreview(null);
setFocusedPreview(null);
return null;
}
return option.value;
});
}}
>
<Eye className="h-3.5 w-3.5" aria-hidden="true" />
<span></span>
@@ -1686,6 +1686,96 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
});
it('forwards inline scene generation warnings before adding the result', async () => {
generateEditorSceneMock.mockResolvedValueOnce(
createGenerated({
width: 1024,
height: 576,
warning: {
code: 'delivery-size-normalization-failed',
reason: '尺寸恢复失败,已保留生成服务返回的实际尺寸。',
},
}),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-scene-warning',
mode: 'scene',
prompt: '雨夜海边车站',
status: 'idle',
composerOpen: true,
sceneStylePreset: 'anime',
sceneCustomStyle: '',
generationReferences: [],
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(screen.getByTestId('generation-warning').textContent).toBe(
'尺寸恢复失败,已保留生成服务返回的实际尺寸。',
);
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:游戏场景 1:-:scene',
);
});
it('does not add a local scene layer when the backend omits its project snapshot', async () => {
generateEditorSceneMock.mockResolvedValueOnce(
createGenerated({
width: 1024,
height: 576,
project: null,
}),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialDialog={{
id: 'dialog-scene-authority',
mode: 'scene',
prompt: '雨夜海边车站',
status: 'idle',
composerOpen: true,
sceneStylePreset: 'anime',
sceneCustomStyle: '',
generationReferences: [],
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
placeholder: {
x: 100,
y: 100,
width: 1024,
height: 576,
originalWidth: 1024,
originalHeight: 576,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toBe(
'scene:idle:open:-:placeholder:-',
);
});
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-generated-1',
);
});
it('uses the latest moved canvas placeholder for local-only generation fallback', async () => {
const callOrder: string[] = [];
const captureCanvasHistory = vi.fn((action: CanvasHistoryAction) => {
@@ -2079,6 +2079,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}),
onWalletBalanceMayHaveChanged,
);
notifyEditorGenerationWarning(
generated.warning?.reason,
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
@@ -2101,6 +2105,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
return;
}
if (canvasDialog && projectId) {
updateCanvasGenerationDialogById(
canvasDialog.id,
(currentDialog) =>
currentDialog.generatedLayerId
? currentDialog
: {
...currentDialog,
status: 'idle',
composerOpen: true,
errorMessage: undefined,
},
);
return;
}
addGeneratedResultLayer(generated, {
frame: canvasCompletionPlaceholder,
assetKind: submissionPlan.result.assetKind,
+1 -8
View File
@@ -17000,7 +17000,7 @@ button {
right: calc(100% + 0.62rem);
top: 50%;
z-index: 80;
display: none;
display: block;
width: 19rem;
overflow: hidden;
border: 1px solid rgba(15, 23, 42, 0.1);
@@ -17011,13 +17011,6 @@ button {
transform: translateY(-50%);
}
.image-canvas-editor__scene-style-preview-wrap:hover
.image-canvas-editor__scene-style-preview-card,
.image-canvas-editor__scene-style-preview-wrap:focus-within
.image-canvas-editor__scene-style-preview-card {
display: block;
}
.image-canvas-editor__scene-style-preview-card img {
display: block;
width: 100%;
@@ -852,7 +852,7 @@ describe('editorProjectClient', () => {
);
});
it('generates editor scenes through the structured backend endpoint', async () => {
it('generates editor scenes through the structured endpoint without automatic retries', async () => {
requestJsonMock.mockResolvedValueOnce({
queueState: { jobId: 'scene-job-1', status: 'pending' },
});
@@ -882,11 +882,30 @@ describe('editorProjectClient', () => {
'生成游戏场景失败',
expect.objectContaining({
timeoutMs: 1_200_000,
retry: editorRetryOptionsExpectation,
retry: {
maxRetries: 0,
retryUnsafeMethods: false,
},
}),
);
});
it('rejects scene reference overflow without sending an HTTP request', async () => {
await expect(
generateEditorScene({
sceneContent: '雨夜中的欧洲小镇街道',
stylePreset: 'anime',
referenceImageSrcs: Array.from(
{ length: 6 },
(_, index) =>
`/generated-images/editor/scene-reference-${index + 1}.png`,
),
}),
).rejects.toThrow('场景参考图最多允许 5 张');
expect(requestJsonMock).not.toHaveBeenCalled();
});
it('rejects inline media before submitting editor generation requests', async () => {
await expect(
generateEditorImage({
@@ -999,6 +999,11 @@ export type EditorSceneGenerationInput = EditorSceneGenerationRequest;
export async function generateEditorScene(input: EditorSceneGenerationInput) {
assertStableEditorMediaReferences(input.referenceImageSrcs, '场景参考图');
assertEditorReferenceLimit(
input.referenceImageSrcs,
EDITOR_IMAGE_REFERENCE_LIMIT,
'场景参考图',
);
return requestJson<EditorImageGenerationResponse>(
EDITOR_SCENE_GENERATION_API,
jsonRequest('POST', {
@@ -1024,7 +1029,7 @@ export async function generateEditorScene(input: EditorSceneGenerationInput) {
'生成游戏场景失败',
{
timeoutMs: 1_200_000,
retry: EDITOR_REQUEST_RETRY_OPTIONS,
retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS,
},
);
}