修复持久化画布生成结果重复落层

为图标规范、视频和音频补齐项目快照缺失时的提前返回
恢复生成对话框状态并避免前端重复创建本地图层
补充 canvasCompletion 回归测试和图片画布技术文档
This commit is contained in:
2026-08-05 17:05:21 +08:00
parent baaa587f2f
commit d4a4d0ba3a
3 changed files with 199 additions and 0 deletions
@@ -28,6 +28,7 @@
- 手动去背景与角色动作透明化失败时,前端只消费后端按任务类型返回的稳定业务文案;不得从队列 `lastErrorMessage`、HTTP error details 或图层状态恢复和展示 BgFilter、分割模型、provider 等内部诊断。
- BgFilter 单次真实 provider attempt 不再使用独立固定 timeout,而由父子共同按 `attempt = N × est × 2` 运行时派生;一次逻辑调用的 `callBudgetMs = 2 × attempt + 1s`,从子 worker 取得 provider permit 后才开始计时。当前冻结 `N=16 / est=5000ms` 时为 `160s / 321s`。父侧继续管理父 job / request 总预算,为每个内部 RPC 单独派生 `maxQueueWaitMs`;排队只消耗该字段,不侵蚀 `callBudgetMs`,flat 还需预留阿里云和本地键色 fallback 时间。角色动作不再按本次实际帧数增加 attempt,`32 / 40 / 48` 帧使用同一公式。角色动画继续用 `buffer_unordered(frame_count.max(1))` 同时提交单帧逻辑调用,由唯一子 worker 保证健康进程内实际在飞的 provider 请求不超过 `N`、admission 不超过默认保险丝 `Q=2048`;父流程仍按“对应绿幕源图上传 OSS 并释放原帧字节 → 以 object key 调内部 worker / 按 object key 降级 → 父侧完成透明帧处理并落 OSS”连续组成无序在途流水线,允许响应乱序,并在收口时 collect / drain 全部已提交 frame future、按 `frameIndex` 恢复顺序。任一帧最终失败时仍先排空全部已启动请求,再使整个动作任务失败退款,不发布缺帧动画;最终图片处理、OSS、画布写回和计费始终属于父流程。
- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS。透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,三类任务同时把 provider 原图作为第二个图层放在透明主结果右侧,图标和 UI 的实际拆分素材从 provider 原图右侧开始放置。透明背景处理最终失败时,只把已保存的原图作为唯一主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因。它与 `sliceWarning` 只在 `postprocess-failed-source-preserved` 这一条上互斥(透明背景最终失败不会进入拆分);风格归一化和像素规整产生的通用 `warning` 可与 `sliceWarning` 并存,此时 inline 与队列两条链路都必须按“通用在前、拆分在后”拼成同一条提示展示,不得只取其一。既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把归一后的字符串交给 BFF `warning` 由前端直接展示。无项目上下文时不创建项目资源或画布图层。
- 单产物画布生成同样以后端项目布局为唯一真相:普通图片、图标规范、视频和音频在项目画布生成器中提交 `canvasCompletion` 后,前端只应用即时响应携带的项目快照,或在队列完成后重新加载项目。即时响应没有携带项目快照时,前端把对应生成器恢复为可继续操作状态,但不得再用响应媒体本地补建结果图层;上述分支只有在没有项目上下文的临时画布生成中才允许走本地落图回退。
- 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits``sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。完整图标图集 `icon-spritesheet` 支持快速编辑,拆分后的单个 `icon` 不提供该入口,前后端必须使用同一素材类型规则。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并在错误红框中显示具体错误文案。
- 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。
- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或手动去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。
@@ -2107,6 +2107,157 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('does not add a local icon spec layer when project completion returns without a backend snapshot', async () => {
const onAppendCanvasLayers = vi.fn();
generateEditorIconSpecMock.mockResolvedValueOnce(
createGenerated({
imageSrc: 'data:image/png;base64,icon-spec',
width: 2048,
height: 1152,
project: null,
}),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-icon-spec"
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-icon-spec-persisted',
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType: 'icon',
specValues: {
playSetting: '背包整理玩法',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView: '',
customPrompt: '',
},
placeholder: {
x: 120,
y: 140,
width: 640,
height: 360,
originalWidth: 2048,
originalHeight: 1152,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorIconSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-icon-spec',
canvasCompletion: expect.objectContaining({
dialogId: 'dialog-icon-spec-persisted',
}),
}),
);
expect(screen.getByTestId('dialog').textContent).toBe(
'spec:idle:open:-:placeholder:-',
);
});
expect(onAppendCanvasLayers).not.toHaveBeenCalled();
});
it('does not add a local video layer when project completion returns without a backend snapshot', async () => {
const onAppendCanvasLayers = vi.fn();
generateEditorVideoMock.mockResolvedValueOnce(
createVideoGenerated({ project: null }),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-video"
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-video-persisted',
mode: 'video',
prompt: '让云朵飘动',
status: 'idle',
composerOpen: true,
placeholder: {
x: 160,
y: 120,
width: 640,
height: 360,
originalWidth: 1280,
originalHeight: 720,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorVideoMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-video',
canvasCompletion: expect.objectContaining({
dialogId: 'dialog-video-persisted',
}),
}),
);
expect(screen.getByTestId('dialog').textContent).toBe(
'video:idle:open:-:placeholder:-',
);
});
expect(onAppendCanvasLayers).not.toHaveBeenCalled();
});
it('does not add a local audio layer when project completion returns without a backend snapshot', async () => {
const onAppendCanvasLayers = vi.fn();
generateEditorSoundEffectMock.mockResolvedValueOnce(
createAudioGenerated({ project: null }),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-audio"
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-audio-persisted',
mode: 'audio-sound-effect',
prompt: '金币掉落叮当声',
status: 'idle',
composerOpen: true,
placeholder: {
x: 200,
y: 160,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorSoundEffectMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-audio',
canvasCompletion: expect.objectContaining({
dialogId: 'dialog-audio-persisted',
}),
}),
);
expect(screen.getByTestId('dialog').textContent).toBe(
'audio-sound-effect:idle:open:-:placeholder:-',
);
});
expect(onAppendCanvasLayers).not.toHaveBeenCalled();
});
it('submits publication detail materials as one billable editor image and appends the result', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
@@ -1786,6 +1786,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
layers,
nextGeneratedIndex: layerCounterRef.current + 1,
});
// TODO legacy code
if (submissionPlan.kind === 'edit') {
const referenceImageSrc = await resolveEditorGenerationMediaReference(
submissionPlan.sourceLayer,
@@ -1968,6 +1969,22 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
return;
}
// TODO refactor here
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,
@@ -2023,6 +2040,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
return;
}
if (canvasDialog && projectId) {
updateCanvasGenerationDialogById(
canvasDialog.id,
(currentDialog) =>
currentDialog.generatedLayerId
? currentDialog
: {
...currentDialog,
status: 'idle',
composerOpen: true,
errorMessage: undefined,
},
);
return;
}
addVideoResultLayer(
generated,
submissionPlan.result.title,
@@ -2087,6 +2119,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
return;
}
if (canvasDialog && projectId) {
updateCanvasGenerationDialogById(
canvasDialog.id,
(currentDialog) =>
currentDialog.generatedLayerId
? currentDialog
: {
...currentDialog,
status: 'idle',
composerOpen: true,
errorMessage: undefined,
},
);
return;
}
addAudioResultLayer(
generated,
submissionPlan.result.title,