处理生成输入复用

新增不可用引用检测与警告
拒绝缺失必要来源的参数复用
优化引用匹配逻辑
同步测试与文档
This commit is contained in:
2026-08-04 14:11:37 +08:00
parent 5fd26a11ee
commit d0fedd1d99
8 changed files with 188 additions and 18 deletions
@@ -1024,7 +1024,7 @@
## 2026-06-21 图片画布参考图元数据只保存项目内行引用
- 背景:参考图如果把 Data URL、signed URL 或 `objectKey` 写入 `generationInputs` 或生成器布局快照,会撑大资源 / 素材 / 画布 JSON,也无法稳定索引到项目内用户可见行数据。
- 决策:`generationInputs.references` 只保存稳定行引用,不保存媒体本身。2026-08-03 起 V2 新写入结构为 `{ id, title, label?, refType, refId }`其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId``refType="asset"` 指向 `editor_asset.assetId`,媒体类型从对应行的查询结果获得。生成器 `itemType="generation-dialog"` 布局快照中的参考图也只保存 `resourceId/sourceAssetId` 和展示 label,不保存图片 Data URL、signed URL 或 `objectKey`;提交生成请求前的内存态可以临时持有 `src/objectKey`,刷新恢复时从 `editor_project_resource` / `editor_asset` 行补回请求所需图片源。不兼容旧 `src` 型参考图元数据。
- 决策:`generationInputs.references` 只保存稳定行指针,不保存媒体本身。2026-08-03 起 V2 新写入结构为 `{ id, title, label?, refType, refId }``refType="project-resource"` `refType="asset"` 只用于匹配当前画布中已 hydrate 图层的 `resourceId/sourceAssetId`,媒体类型取匹配图层的运行时数据,不新增 owner-only 工程资源 / 素材库 resolver。生成器 `itemType="generation-dialog"` 布局快照中的参考图也只保存 `resourceId/sourceAssetId` 和展示 label,不保存图片 Data URL、signed URL 或 `objectKey`;提交生成请求前的内存态可以临时持有 `src/objectKey`。面板直接上传引用不是画布图层,不承诺刷新或复用恢复;已移出画布的引用同样不恢复。不兼容旧 `src` 型参考图元数据。
- 影响范围:图片画布生成输入快照、生成器布局保存 / 恢复、参考图上传工作流、元数据弹窗和图片画布技术文档。
- 验证方式:运行图片画布生成模型、生成提交、上传工作流、项目持久化、元数据弹窗相关前端测试,执行 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -5912,7 +5912,7 @@
## 2026-08-03 图片画布生成产物统一“改造”契约
- 背景:画布生成结果统一显示“改造”,但部分动画 / 音频结果无法恢复面板;另一些非生成型派生结果继承最近生成输入,产生错误可执行动作。中文标题和素材类别被同时当成显示文案、参数键和路由键,改名后容易漂移。
- 决策:用户动作 `改造` 的语义是恢复原生成输入、编辑并生成新产物。沿用 `generation_inputs_json`V2 以稳定 `action``fields[].id``references[].id/refType/refId` 作为唯一执行契约,`title` / `label` 只用于展示,字段值保留基础类型。引用的媒体类型从其资源行查回,不重复写入快照。提交前参数只归一一次,请求与持久快照共用同一归一值。
- 决策:用户动作 `改造` 的语义是恢复原生成输入、编辑并生成新产物。沿用 `generation_inputs_json`V2 以稳定 `action``fields[].id``references[].id/refType/refId` 作为唯一执行契约,`title` / `label` 只用于展示,字段值保留基础类型。引用只匹配当前已 hydrate 的画布图层,媒体类型取匹配图层的运行时数据,不重复写入快照,也不新增 owner-only 工程资源 / 素材库 resolver。面板直接上传引用和已移出画布的引用不恢复:可重新选择的槽位留空并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时拒绝改造。有效 V2 不因引用缺失降级到 legacy adapter。提交前参数只归一一次,请求与持久快照共用同一归一值。
- 兼容:恢复优先级为有效 V2 → 完整历史生成对话框 → legacy adapter。legacy 允许使用 `assetKind/mediaType`、历史标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 和当前默认值,但必须显示恢复告警;不回填存量数据,不做 SpacetimeDB schema 迁移。
- 边界:独立裁扩、手动去背景和手动图集拆分结果不继承生成输入,不显示 `改造`;原生成任务内自动后处理产物可保留原输入。Owner 读取保留 V2 执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,不沿用 owner 可执行配方 DTO。
- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作、生成型图片编辑和 UI 素材提取;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`
File diff suppressed because one or more lines are too long
@@ -620,7 +620,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind``generation_inputs_json` 和历史 `public_showcase_enabled``asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation``陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存稳定 `resource_id` 布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot``source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 layout 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。结构化 canvas 的几何、层级、分组和资源引用以 `editor_canvas_layer` 为权威,生成器对象以 `editor_canvas_generation_dialog` 为权威;legacy canvas 才在 2 MiB 上限内从 `editor_canvas.layers_json` 兼容读取。新写入不再把素材生成输入快照作为图层布局真相保存。历史普通图层缺资源只能由 migration operator 调用 `repair_editor_canvas_resources_and_return` 定向修复:procedure 每次只处理一个尚无迁移记录的 legacy canvas,校验 owner/project、revision、canvas/project 两份 raw layout SHA-256、精确 layer/resource/sourceResourceId、同工程替换资源与 private asset_object 谱系;图片只替换引用,音频只恢复经核验的 `420x120` 项目资源行。运维入口 `npm run spacetime:editor-canvas-resources:repair` 默认 dry-runapply 必须绑定 plan SHA-256 并在成功后自动复核 already-repaired,禁止手工 SQL 绕过事务 guard。
- `generation_inputs_json` 包络契约:`fields` / `references` 是图片信息读取的用户可见生成输入快照;顶层允许保存后端内部结果扩展。现有 `screenColorHex` 保存实际背景色,角色、图标图集和 UI 图集抠图派生资产使用 `mattingProvider` / `mattingModel` 保存实际成功的处理后端与模型。BgFilter 保存本次 `seg_model`,阿里云通用抠图保存 `Aliyun Matting / segment-common-image`,本地键色保存 `Genarrative Local / screen-color-keying`。同源画布 BFF 的角色、图标和 UI 请求由前端自动提交 `screenColor=auto` 与默认 `segModel=birefnet`,其中 `segModel` 是不可由用户选择的请求控制字段,不进入 `generationInputs``background_mode``cross_check` 只属于 api-server 到 worker 的内部 RPC。External OpenAPI 不开放 `segModel`。上述内部结果字段不写入 `fields`,普通用户(包括素材 owner)与匿名公开读取均不得取得;普通用户响应还必须省略素材顶层 `provider` 和内部处理 `model`,但保留正常用户可见 `model` 与其他合法的顶层功能字段。后台管理和服务端审计可读取原始值。过滤只作用于普通用户 / 公开响应边界,不修改素材或精选快照,因此历史数据无需迁移。
- `generation_inputs_json` V2 可执行改造契约沿用现有 JSON 列,无 SpacetimeDB schema 迁移或存量回填:顶层 `version=2` 和稳定 `action` 确定生成器,`fields[].id` 确定参数,`references[].id/refType/refId` 确定引用参数与资源`fields[].value` 保持 `string | number | boolean` 类型,`title` / `label` 只作展示。引用媒体类型从 `refType/refId` 对应的工程资源或账号素材查询结果获得,不在快照内重复保存。Owner resource / asset payload 在普通用户元数据清理后保留这些执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,避免公开接口沿用 owner 可执行配方 DTO。独立裁扩、手动去背景和手动图集拆分是确定性派生操作,新结果 `generation_inputs_json = null`;原生成任务内的自动透明化 / 拆分后处理可保留同任务的原生成输入。
- `generation_inputs_json` V2 可执行改造契约沿用现有 JSON 列,无 SpacetimeDB schema 迁移或存量回填:顶层 `version=2` 和稳定 `action` 确定生成器,`fields[].id` 确定参数,`references[].id/refType/refId` 确定引用参数与稳定指针`fields[].value` 保持 `string | number | boolean` 类型,`title` / `label` 只作展示。前端改造只在当前画布图层中匹配引用并取得运行时媒体类型,不新增 owner-only 工程资源 / 素材库 resolver;面板直接上传引用和已移出画布的引用均不恢复。可重新选择的引用由前端留空槽位、提示并交给提交门禁校验;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时拒绝改造。有效 V2 的引用缺失不得触发 legacy adapter。Owner resource / asset payload 在普通用户元数据清理后保留这些执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,避免公开接口沿用 owner 可执行配方 DTO。独立裁扩、手动去背景和手动图集拆分是确定性派生操作,新结果 `generation_inputs_json = null`;原生成任务内的自动透明化 / 拆分后处理可保留同任务的原生成输入。
- 普通用户生成结果契约:图片、图标图集、视频、音频和角色动画的完成响应与新建画布图层均不返回或写入生成 provider;项目资源、素材、精选和 Agent 紧凑结果使用同一读取边界。真实 provider 只保留在持久化、tracking / tracing 和后台管理原始审计中。该规则针对生成供应商元数据,不改变直传票据等必须由客户端执行的存储协议字段。
- 普通用户错误契约:手动去背景和角色动作透明化的原始服务端错误可能包含 BgFilter、分割模型或 provider 细节;Owner HTTP 响应与外部任务状态必须按 job kind 返回稳定业务文案,原始错误只保留在任务记录、tracing 与后台审计。
- 索引:`by_editor_project_resource_project_id``by_editor_project_resource_owner_user_id`
@@ -113,7 +113,7 @@
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。
- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。
- 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。独立非生成型裁扩、手动去背景和手动图集拆分结果不继承 `generationInputs`,不显示该动作;原生成任务内自动后处理的同源产物可保留原生成输入。
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时拒绝改造。有效 V2 不得因引用缺失降级到 legacy。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。独立非生成型裁扩、手动去背景和手动图集拆分结果不继承 `generationInputs`,不显示该动作;原生成任务内自动后处理的同源产物可保留原生成输入。
- 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。
## 画布保存
@@ -30,6 +30,7 @@ import {
createSpecDialogDraft,
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
getUnavailableNormalizedGenerationInputReferences,
hideGeneratedLayerComposerAfterBlur,
updateCharacterAnimationDurationPanel,
updateIconDescriptionsTextInDialog,
@@ -926,6 +927,46 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('treats references outside the current canvas as unavailable', () => {
const availableReference = createLayer({
id: 'layer-available-reference',
resourceId: 'resource-available-reference',
});
const sourceLayer = createLayer({
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.generate',
fields: [{ id: 'prompt', title: '生成提示词', value: '场景图' }],
references: [
{
id: 'reference',
title: '画布参考图',
refType: 'project-resource',
refId: 'resource-available-reference',
},
{
id: 'reference',
title: '面板上传参考图',
refType: 'project-resource',
refId: 'resource-panel-upload',
},
],
},
});
expect(
getUnavailableNormalizedGenerationInputReferences(sourceLayer, [
availableReference,
]),
).toEqual([
expect.objectContaining({
title: '面板上传参考图',
refId: 'resource-panel-upload',
}),
]);
});
it('keeps valid V2 fields ahead of conflicting legacy dialog and resource fallbacks', () => {
expect(
createLayerGenerationDialogDraft({
@@ -1,6 +1,7 @@
import { formatImageSizeValue } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputReference,
CanvasLayer,
CanvasViewport,
CharacterAnimationPanelState,
@@ -781,19 +782,41 @@ function getNormalizedNumber(
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function findAvailableGenerationReferenceLayer(
reference: CanvasGenerationInputReference,
availableLayers: CanvasLayer[] | undefined,
) {
return (availableLayers ?? []).find((candidate) =>
reference.refType === 'project-resource'
? candidate.resourceId === reference.refId
: candidate.sourceAssetId === reference.refId,
);
}
export function getUnavailableNormalizedGenerationInputReferences(
sourceLayer: CanvasLayer,
availableLayers: CanvasLayer[] | undefined,
) {
if (!isNormalizedCanvasGenerationInputs(sourceLayer.generationInputs)) {
return [];
}
return sourceLayer.generationInputs.references.filter(
(reference) =>
!findAvailableGenerationReferenceLayer(reference, availableLayers),
);
}
function resolveNormalizedReferences(
sourceLayer: CanvasLayer,
id: string,
availableLayers: CanvasLayer[] | undefined,
) {
const layers = availableLayers ?? [];
return (sourceLayer.generationInputs?.references ?? [])
.filter((reference) => reference.id === id)
.flatMap((reference) => {
const layer = layers.find((candidate) =>
reference.refType === 'project-resource'
? candidate.resourceId === reference.refId
: candidate.sourceAssetId === reference.refId,
const layer = findAvailableGenerationReferenceLayer(
reference,
availableLayers,
);
return layer ? [createCanvasLayerReference(layer)] : [];
});
@@ -1377,6 +1400,9 @@ export function createSameSourceGenerationDialogDraft({
if (normalizedDraft) {
return normalizedDraft;
}
if (isNormalizedCanvasGenerationInputs(sourceLayer.generationInputs)) {
return null;
}
const sourceMode = resolveGeneratedSourceDialogMode({
sourceLayer,
sourceDialog,
@@ -2270,6 +2270,77 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('opens V2 panels without references that are not on the current canvas', () => {
render(
<GenerationWorkflowHarness
initialLayers={[
createLayer({
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.generate',
fields: [
{ id: 'prompt', title: '生成提示词', value: '森林场景' },
],
references: [
{
id: 'reference',
title: '面板上传参考图',
refType: 'project-resource',
refId: 'resource-panel-upload',
},
],
},
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:idle:open:-:placeholder',
);
expect(screen.getByTestId('generation-references').textContent).toBe('');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。',
);
});
it('does not legacy-fallback when a V2 required source is unavailable', () => {
render(
<GenerationWorkflowHarness
initialLayers={[
createLayer({
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.edit',
fields: [
{ id: 'prompt', title: '修改要求', value: '改成夜景' },
],
references: [
{
id: 'source',
title: '原图',
refType: 'project-resource',
refId: 'resource-missing-source',
},
],
},
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'原必需参考素材不在当前画布或来自面板上传,无法改造。',
);
});
it('keeps character animation asset names synchronized with the dialog', () => {
render(<GenerationWorkflowHarness />);
@@ -64,6 +64,7 @@ import {
createUiDesignGenerationDialogDraft,
createVideoGenerationDialogDraft,
createVideoRedrawGenerationDialogDraft,
getUnavailableNormalizedGenerationInputReferences,
hideGeneratedLayerComposerAfterBlur,
isCharacterSpecReferenceLayer,
isIconSpecReferenceLayer,
@@ -1278,11 +1279,23 @@ export function useImageCanvasGenerationWorkflow({
setCharacterAnimationPanel(null);
setUiAssetExtractionState(null);
setQuickEditSelectionState(null);
const normalizedGenerationInputs = isNormalizedCanvasGenerationInputs(
sourceLayer.generationInputs,
)
? sourceLayer.generationInputs
: null;
const isNormalizedGenerationInputs = normalizedGenerationInputs !== null;
const unavailableReferences = isNormalizedGenerationInputs
? getUnavailableNormalizedGenerationInputReferences(
sourceLayer,
layers,
)
: [];
if (
isNormalizedCanvasGenerationInputs(sourceLayer.generationInputs) &&
sourceLayer.generationInputs.action === 'ui-design.extract-assets'
isNormalizedGenerationInputs &&
normalizedGenerationInputs.action === 'ui-design.extract-assets'
) {
const sourceReference = sourceLayer.generationInputs.references.find(
const sourceReference = normalizedGenerationInputs.references.find(
(reference) => reference.id === 'source',
);
const extractionSource = sourceReference
@@ -1293,13 +1306,15 @@ export function useImageCanvasGenerationWorkflow({
)
: null;
if (!extractionSource) {
showGenerationWarning('原 UI 设计图已不存在,无法改造。');
showGenerationWarning(
'原 UI 设计图不在当前画布或来自面板上传,无法改造。',
);
return;
}
const modelField = sourceLayer.generationInputs.fields.find(
const modelField = normalizedGenerationInputs.fields.find(
(field) => field.id === 'model',
);
const extractionReferences = sourceLayer.generationInputs.references
const extractionReferences = normalizedGenerationInputs.references
.filter((reference) => reference.id === 'reference')
.flatMap((reference) => {
const layer = layers.find((candidate) =>
@@ -1322,6 +1337,11 @@ export function useImageCanvasGenerationWorkflow({
setQuickEditPanel(null);
selectSingleLayer(extractionSource.id);
setActiveTool('select');
if (unavailableReferences.length) {
showGenerationWarning(
'部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。',
);
}
return;
}
const sourceDialog = findSourceGenerationDialog(
@@ -1346,13 +1366,25 @@ export function useImageCanvasGenerationWorkflow({
setQuickEditPanel(null);
selectSingleLayer(null);
setActiveTool(getCanvasToolForGenerationMode(sameSourceDraft.mode));
if (!isNormalizedCanvasGenerationInputs(sourceLayer.generationInputs)) {
if (isNormalizedGenerationInputs && unavailableReferences.length) {
showGenerationWarning(
'部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。',
);
} else if (!isNormalizedGenerationInputs) {
showGenerationWarning(
'已按旧版数据恢复,部分参数可能使用当前默认值。',
);
}
return;
}
if (isNormalizedGenerationInputs) {
showGenerationWarning(
unavailableReferences.length
? '原必需参考素材不在当前画布或来自面板上传,无法改造。'
: '该生成配方当前无法恢复,无法改造。',
);
return;
}
if (sourceLayer.mediaType === 'audio') {
const audioDraft = createAudioRedrawGenerationDialogDraft(sourceLayer);
if (!audioDraft) {