补齐场景素材快速编辑白名单
同步前端与后端场景快速编辑准入 锁定用户素材标签覆盖的双端白名单 补充新增素材类型的快速编辑检查点 更新场景与画布契约文档
This commit is contained in:
@@ -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 或素材 ID;objectKey、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',
|
||||
|
||||
Reference in New Issue
Block a user