修复游戏场景生成复审问题
补齐触屏画风预览点击开关 转发场景生成告警并保留权威项目写回 限制场景参考图并禁用自动重试 补充场景生成回归测试与技术说明
This commit is contained in:
@@ -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
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user