补齐场景素材快速编辑白名单
Project CI / Repository checks (pull_request) Successful in 1m26s
Project CI / Frontend tests (pull_request) Successful in 3m0s
Project CI / Backend tests (pull_request) Successful in 4m8s
Project CI / Native shell tests (pull_request) Successful in 15m38s

同步前端与后端场景快速编辑准入
锁定用户素材标签覆盖的双端白名单
补充新增素材类型的快速编辑检查点
更新场景与画布契约文档
This commit is contained in:
2026-08-08 12:19:59 +08:00
parent 3c07a96a07
commit cd78077e95
9 changed files with 50 additions and 4 deletions
@@ -6524,7 +6524,7 @@
- local 状态:`local-*` 只是 ID 形状,不能直接解释为“素材仍在保存”。新上传 / 新生成素材是否 pending 取资源登记在途状态;严格满足兼容谓词的历史自包含本地角色动作序列是持久化终态,不得误报等待。若当前版本尚不能复制这类序列,以准确原因失败关闭;既非 pending 又不满足历史谓词的 unresolved local 图层也失败关闭,但不得承诺稍后一定自动恢复。layout PATCH pending 不参与资源登记判断,系统剪贴板图片导入不受影响。
- schema 与迁移:在现有 `EditorCanvasLayer` 结构体末尾追加 `#[default(None::<String>)] asset_kind_override: Option<String>`,不删除、改名、重排或改类型。legacy 图层类型与资源默认相同则迁移为 `None`,不同则迁移为 override;资源无默认值时只有全部引用图层显式同值才补资源默认,否则保留各自 override;自包含历史序列的显式类型迁入 override,不伪造资源。同步 `migration.rs`、表目录 / 数据契约、生成 bindings、HTTP DTO 与结构化 canonical hash,并运行 `npm run spacetime:generate``npm run check:spacetime-schema`
- 并发边界:未登记图层被禁止复制后,不再按临时资源 ID 合并项目资源创建请求,也不再用一次响应批量改写共享临时 ID。每个合法新增图层保留自己的响应快照与回调;layout PATCH 的串行 latest-wins 队列、共享资源的多布局引用和 session 资源快照按 `resourceId` 去重继续保留,它们与资源创建 single-flight 是不同机制。
- 媒体兼容边界:override 只允许在资源默认类型的同一媒体族内变化。动作、视频各自独立成族,`audio/sound-effect/background-music` 同属音频族,其余类型与空默认值同属图片族前端菜单禁用跨族标签且更新入口重复校验;后端对每个结构化图层按资源完整校验,跨族值清空后回退资源默认类型,不因该兼容错误拒绝整个保存。
- 媒体兼容边界:override 只允许在资源默认类型的同一媒体族内变化。动作、视频各自独立成族,`audio/sound-effect/background-music` 同属音频族,其余类型与空默认值同属图片族`scene` 明确属于图片族。前端可选标签与 SpacetimeDB `EDITOR_CANVAS_ASSET_KINDS` 必须同步包含现役用户可覆盖类型;前端菜单禁用跨族标签且更新入口重复校验;后端对每个结构化图层按资源完整校验,跨族值清空后回退资源默认类型,不因该兼容错误拒绝整个保存。
- 历史恢复:客户端读取到已持久化的跨族 override 时必须保留图层、回退资源默认类型、显示明确提示并自动提交清理后的布局;不得再用 `hydrateLayer() -> null -> filter(Boolean)` 静默隐藏持久层仍存在的图层。修复保存只替换命中图层的规范化布局项,其他尚不能 hydrate 的历史项原样保留,避免修复一个标签时顺带删除无关数据。
## 2026-08-03 Agent Runtime 原生工具合同本地失败关闭
@@ -6590,10 +6590,11 @@
## 2026-08-04 图片画布快速编辑改用统一正向白名单(2026-08-05 修订)
- 背景:画布生成结果统一显示快速编辑,但部分角色动作 / 序列帧和音频结果会进入不受支持的图片编辑链路;不同入口各自判断时也容易继续漂移。
- 决策:快速编辑只支持普通静态图片、角色图、规范图、完整图标图集、UI 设计图、宣发图和视频。单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持;新增媒体或素材类型默认不开放。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁统一调用同一个正向白名单;后端图片编辑 BFF 基于目标图层的有效素材类型与媒体类型执行同一正向门禁,视频快速编辑只走视频生成接口。
- 决策:快速编辑只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图、游戏场景图和视频。单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持;新增媒体或素材类型默认不开放。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁统一调用同一个正向白名单;后端图片编辑 BFF 基于目标图层的有效素材类型与媒体类型执行同一正向门禁,视频快速编辑只走视频生成接口。
- 边界:角色动作继续通过对应的动作生成链路处理,不再把当前帧当作可快速编辑图片。
- 2026-08-06 修订:画布 Agent 的 `edit_image` 只接受图片输入,新任务以 `assetKind=null` 表示普通静态图片,不再使用 synthetic `editor_agent_edit_image`。worker 仅按服务端生成的 `editor-agent:` dedupe namespace 识别并归一历史排队 payload;普通调用伪造旧值继续被拒绝。已持久化资源中的旧值只有在后端从真实目标图层 / 项目资源解析后才兼容为空类型,避免历史 Agent 结果失去快速编辑能力,同时不扩大请求白名单。
- 2026-08-07 修订:站内与 External v1 图片编辑请求统一只接受必填 `sourceReferenceId`,且该值必须是当前账号已登记的项目资源 ID 或素材 IDobjectKey、URL、Data URL、Blob URL 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段全部返回 400,不提供兼容别名。后端用共享窄查询分别按两张表主键定点解析,双表同 ID、未命中、跨账号、对象缺失或越权均失败关闭;权威类型完全来自业务记录,只允许普通静态图片、规范图、角色图、完整图标图集、图标规范、宣发图和 UI 设计图。请求带 `targetLayerId` 时必须同时带 `projectId`,来源与目标优先比较 `assetObjectId`,任一方缺失才比较 canonical `(bucket, objectKey)`,且来源默认类型必须与目标资源默认类型一致;最终类型取目标覆盖值或目标资源类型。HTTP 入队写入版本化服务端解析快照,worker 执行前按同一业务 ID 再次定点解析,身份或类型漂移即失败关闭。旧任务只把已有资源 ID 或旧来源字符串本身当业务 ID 迁移,绝不按 objectKey 反查。Canvas Agent 必须从 `ImageMetadata.reference_id` 取主来源;红框标注上传图只作为辅助 `referenceImageSrcs`,不能冒充被编辑资源。仅以素材 ID 编辑时,队列审计与 `generationInputs.references` 保留素材 ID,不伪造项目资源关系。
- 2026-08-08 修订:`scene` 是单张静态图片素材,加入前端快速编辑正向白名单和 api-server 权威来源白名单;编辑结果继续保留 `scene`。用户标签覆盖侧的前端菜单与 SpacetimeDB 结构化布局白名单也必须显式覆盖 `scene`。通用图片生成接口仍拒绝 `scene`,避免绕过结构化场景生成契约。
- 验证:模型测试覆盖允许与拒绝类型,工具栏和两类右键菜单测试覆盖单个拆分图标、角色动作及音频不展示,提交工作流测试覆盖单个拆分图标和角色动作绕过入口时仍拒绝;后端表驱动测试覆盖全部现役素材 / 媒体类型与未知类型,锁定图片编辑端点失败关闭。
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts``ImageCanvasSelectedLayerToolbarView.tsx``ImageCanvasContextMenusView.tsx``useImageCanvasGenerationWorkflow.ts``useImageCanvasGenerationSubmissionWorkflow.ts`
File diff suppressed because one or more lines are too long
@@ -89,6 +89,8 @@ canvasCompletion?
场景参考图沿用普通图片生成的客户端前置门禁,最多 5 张;超限时不得发送 HTTP 请求。场景生成 POST 使用生成专用零重试策略,避免 inline 响应丢失后重复调用 Provider。
生成完成后的 `scene` 是静态图片素材:它属于画布素材标签的图片媒体族,允许用户把图片图层覆盖标记为 `scene`,并支持既有图片快速编辑链路。前端标签菜单与 SpacetimeDB 结构化布局白名单、前端快速编辑入口与 api-server 图片编辑白名单必须分别成对同步。该编辑能力不改变通用图片生成接口对 `kind = scene` / `assetKind = scene` 的拒绝;新场景仍必须从本节的结构化专用接口生成。
后端对模型、比例和清晰度先沿用 `normalize_editor_generation_options` 标准化,再使用标准化比例决定画幅描述和入队价格。
## 5. Prompt
@@ -463,6 +463,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
| 后台契约 | `server-rs/crates/shared-contracts/src/admin.rs``api-server/src/admin.rs` | 后台素材查询或精选审核需要展示该类别时返回完整正式字段,不能只返回封面或首帧 |
| TypeScript 客户端类型 | `src/services/image-editor/editorProjectClient.ts``apps/admin-web/src/api/adminApiTypes.ts` | 接收后端 camelCase 字段,不定义第二套业务真相 |
| 画布 layer / layout 映射 | `src/components/image-editor/` | 新类别能进入画布时贯通资源加载、素材点击 / 拖放、保存恢复、复制、撤销、删除和导出;layout 只保留恢复副本 |
| 用户标签覆盖白名单 | `src/components/image-editor/ImageCanvasWorldView.tsx``server-rs/crates/spacetime-module/src/editor_project_storage.rs` | 显式决定用户能否把同媒体族图层覆盖为该类别;允许时同步前端 `CANVAS_ASSET_KIND_TAG_OPTIONS` 与后端 `EDITOR_CANVAS_ASSET_KINDS`,并分别覆盖菜单选择和结构化 `assetKindOverride` 解析测试;不允许时补拒绝测试,不能只改一端 |
| 快速编辑正向白名单 | `src/components/image-editor/ImageCanvasGenerationModel.ts``server-rs/crates/api-server/src/editor_project.rs` | 显式决定该类别是否支持快速编辑;允许时同步前端 `QUICK_EDIT_SUPPORTED_ASSET_KINDS` 与后端 `ensure_editor_image_edit_source_kind_allowed`,并覆盖入口可见性、提交门禁、权威来源类型和未知类型失败关闭;不允许时保持默认关闭并补反例,不能仅因属于图片媒体族自动放行 |
| 素材库与 renderer | `src/components/image-editor/` | 由 `assetKind` 派生图片、视频、音频或序列 renderer,并实现正确缩略图、预览和下载行为 |
| 后台媒体 renderer | `apps/admin-web/src/components/AdminEditorAssetMedia.tsx` | 后台需要预览时复用现有组件;列表只加载最小媒体,弹窗再按需加载完整媒体 |
| 精选 read model 与 renderer | `api-server``src/components/creation-home/` | 新类别允许公开时贯通正式字段、卡片、弹窗和损坏数据行为 |
@@ -472,6 +474,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
其它注意事项:
- `assetKind` 是数据库、Rust DTO 和对外 JSON 的唯一媒体类别真相;不要新增或返回并列的 `mediaType`。前端 renderer 可以保留内部派生类型,但不得回写后端。
- 每次新增 `assetKind` 都必须分别完成“用户标签覆盖”和“快速编辑”两项资格评估;两者互不推导。可被用户选择不等于可快速编辑,属于图片媒体族也不等于自动进入快速编辑正向白名单。
- 只是新增分类或 renderer 且现有媒体字段足够时,不改 schema。只有必须跨刷新、复用、审核或公开保留的数据才新增类别专属字段。
- legacy 数据必须先通过有界、可审计、带 dry-run/hash/apply 门禁的数据库迁移收口;迁移后的 api-server、mapper、主站、后台和画布只读取正式字段,不保留运行时 fallback。
@@ -3945,6 +3945,7 @@ pub(crate) fn ensure_editor_image_edit_source_kind_allowed(
| "icon-spec"
| "publication-material"
| "ui-design"
| "scene"
)
);
let media_type_allowed = matches!(media_type, None | Some("image"));
@@ -20275,6 +20276,7 @@ mod tests {
(Some("icon-spec"), Some("image"), true),
(Some("publication-material"), Some("image"), true),
(Some("ui-design"), Some("image"), true),
(Some("scene"), Some("image"), true),
(Some("icon"), Some("image"), false),
(Some("character-animation"), Some("image-sequence"), false),
(Some("sound-effect"), Some("audio"), false),
@@ -17080,6 +17080,15 @@ mod tests {
let rebuilt: JsonValue = serde_json::from_str(&rebuilt).expect("rebuilt layout json");
assert!(rebuilt[0].get("assetKindOverride").is_none());
let scene =
r#"[{"layerId":"layer-scene","resourceId":"resource-1","assetKindOverride":"scene"}]"#;
let parsed = parse_structured_canvas_layout(scene)
.expect("scene must remain a user-overridable canvas asset kind");
assert_eq!(
parsed.layers[0].asset_kind_override.as_deref(),
Some("scene")
);
for invalid in [json!(""), json!("unknown-kind"), json!(42), json!({})] {
let layout = json!([{
"layerId": "layer-1",
@@ -435,6 +435,12 @@ describe('ImageCanvasGenerationModel', () => {
assetKind: 'icon-spritesheet',
}),
).toBe(true);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
assetKind: 'scene',
}),
).toBe(true);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
@@ -686,6 +686,7 @@ const QUICK_EDIT_SUPPORTED_ASSET_KINDS = new Set<CanvasLayer['assetKind']>([
'icon-spec',
'publication-material',
'ui-design',
'scene',
'video',
]);
@@ -1108,6 +1108,28 @@ describe('ImageCanvasWorldView', () => {
}
});
it('offers scene as a user-selectable label for image layers', () => {
const imageLayer = createLayer({
resourceAssetKind: 'image',
assetKind: 'character',
});
const { props } = renderWorldView({ layers: [imageLayer] });
fireEvent.click(screen.getByRole('button', { name: '素材标签:角色' }));
const sceneItem = within(
screen.getByRole('menu', { name: '选择素材标签' }),
).getByRole('menuitem', { name: '场景' });
expect(sceneItem.hasAttribute('disabled')).toBe(false);
fireEvent.click(sceneItem);
expect(props.onUpdateLayerAssetKind).toHaveBeenCalledWith(
imageLayer.id,
'scene',
);
});
it('hides resolution and duration text for focused audio generation frames', () => {
const dialog = createGenerationDialog({
id: 'dialog-audio',