From 76445da9b4d970072653cbff47ad0e4eb60d4bb0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 15:11:32 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E7=94=9F=E6=88=90=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展 editor_project_resource 和 editor_asset 保存素材类别与生成输入快照 调整前端资源与素材读写链路并停止把素材元数据写入新 layout 在服务端保存 layout 时回填并清洗旧图层元数据 更新图片画布契约文档、共享决策和相关测试期望 --- .../shared-memory/decision-log.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 6 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- .../crates/api-server/src/editor_project.rs | 45 +++++ .../src/mapper/editor_project.rs | 64 +++++-- .../editor_asset_create_input_type.rs | 2 + .../editor_asset_snapshot_type.rs | 2 + .../src/module_bindings/editor_asset_type.rs | 9 + ...itor_project_resource_create_input_type.rs | 2 + .../editor_project_resource_snapshot_type.rs | 2 + .../editor_project_resource_type.rs | 9 + .../src/editor_project_storage.rs | 161 +++++++++++++++++- ...CanvasEditorGenerationIntegration.test.tsx | 65 ++++--- .../ImageCanvasEditorModel.test.ts | 17 +- .../image-editor/ImageCanvasEditorModel.ts | 33 ++-- .../image-editor/ImageCanvasEditorTypes.ts | 2 + .../ImageCanvasEditorView.test-utils.ts | 3 + .../image-editor/ImageCanvasEditorView.tsx | 12 +- .../useImageCanvasProjectPersistence.test.tsx | 114 ++++++++++++- .../useImageCanvasProjectPersistence.ts | 7 +- .../image-editor/editorProjectClient.test.ts | 20 +++ .../image-editor/editorProjectClient.ts | 24 +++ 22 files changed, 556 insertions(+), 55 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 13ecbf4ac..4714b7c32 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-19 图片画布素材生成元数据上移到资源和素材 + +- 背景:角色、图标、UI 设计图、视频和音频等生成结果会在图片信息页展示用户可见输入快照;此前这些 `assetKind/generationInputs` 主要保存在画布 layer JSON 中,素材进入账号级素材库后跨项目复用和刷新恢复都依赖画布布局,不符合素材库作为账号级事实源的边界。 +- 决策:普通图层的新保存不再把 `assetKind/generationInputs` 写入 `editor_canvas.layers_json`。`editor_project_resource` 保存项目画布资源快照的 `asset_kind/generation_inputs_json`,`editor_asset` 保存账号级素材的同名元数据;前端加载时优先从 resource / asset 恢复素材类别和生成输入快照,旧 layout 中的同名字段只作为历史兼容兜底。生成器对象本身仍作为 `itemType="generation-dialog"` 保存在画布布局中。 +- 影响范围:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/mapper/editor_project.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`src/services/image-editor/editorProjectClient.ts`、图片画布 hydrate / serialize / project persistence / asset library 代码和后端数据契约文档。 +- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`,并按需补充 `cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-06-19 图片画布生成按钮价格统一绑定模型定价配置 - 背景:图片画布的生成图片、生成视频、生成规范、生成角色、生成素材、生成 UI、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 41f06309a..53a5da454 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -39,11 +39,11 @@ - 新增 `editor_project` 表保存图片画布工程:`projectId`、`ownerUserId`、标题、创建时间和更新时间;历史 layout 字段暂保留为兼容列,不再作为权威画布数据。 - 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。 - 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。 -- 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、创建时间和更新时间。素材只跟账号走,不跟 project 走。 -- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId。 +- 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。 +- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId。 - 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。 - 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。 -- 资源表只保存资源元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;视频结果当前只保存为画布视频资源,不进入图片素材库。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 6d6df13ec..995561ec6 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -447,7 +447,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorProjectResource` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task 和源资源关系;账号级素材删除不级联删除该表,避免历史画布丢图。 +- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind` 和 `generation_inputs_json`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 - 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。 ### `editor_asset_folder` @@ -461,7 +461,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、OSS 引用、尺寸、来源类型和生成元数据;素材在同一账号的所有项目中可见。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId。 +- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind` 和 `generation_inputs_json`。素材在同一账号的所有项目中可见;素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 - 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。 ### `inventory_slot` diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index def2bea84..03bc01132 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -59,6 +59,7 @@ const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-"; const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-"; const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-"; const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024; +const EDITOR_ASSET_METADATA_MAX_BYTES: usize = 64 * 1024; const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布"; const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024"; const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; @@ -113,6 +114,8 @@ pub struct EditorProjectResourceCreateRequest { provider: Option, task_id: Option, source_resource_id: Option, + asset_kind: Option, + generation_inputs: Option, } #[derive(Debug, Deserialize)] @@ -145,6 +148,8 @@ pub struct EditorAssetCreateRequest { model: Option, provider: Option, task_id: Option, + asset_kind: Option, + generation_inputs: Option, } #[derive(Debug, Deserialize)] @@ -325,6 +330,8 @@ pub struct EditorProjectResourcePayload { provider: Option, task_id: Option, source_resource_id: Option, + asset_kind: Option, + generation_inputs: Option, created_at: String, updated_at: String, } @@ -365,6 +372,8 @@ pub struct EditorAssetPayload { model: Option, provider: Option, task_id: Option, + asset_kind: Option, + generation_inputs: Option, created_at: String, updated_at: String, } @@ -545,6 +554,8 @@ pub async fn create_editor_project_resource( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { + let generation_inputs_json = + serialize_editor_asset_metadata(payload.generation_inputs.clone())?; let resource = state .spacetime_client() .create_editor_project_resource(EditorProjectResourceCreateRecordInput { @@ -563,6 +574,8 @@ pub async fn create_editor_project_resource( provider: normalize_optional_string(payload.provider), task_id: normalize_optional_string(payload.task_id), source_resource_id: normalize_optional_string(payload.source_resource_id), + asset_kind: normalize_optional_string(payload.asset_kind), + generation_inputs_json, updated_at_micros: current_utc_micros(), }) .await @@ -678,6 +691,8 @@ pub async fn create_editor_asset( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { + let generation_inputs_json = + serialize_editor_asset_metadata(payload.generation_inputs.clone())?; let asset = state .spacetime_client() .create_editor_asset(EditorAssetCreateRecordInput { @@ -696,6 +711,8 @@ pub async fn create_editor_asset( model: normalize_optional_string(payload.model), provider: normalize_optional_string(payload.provider), task_id: normalize_optional_string(payload.task_id), + asset_kind: normalize_optional_string(payload.asset_kind), + generation_inputs_json, now_micros: current_utc_micros(), }) .await @@ -1400,6 +1417,8 @@ fn editor_project_resource_payload_from_record( provider: record.provider, task_id: record.task_id, source_resource_id: record.source_resource_id, + asset_kind: record.asset_kind, + generation_inputs: record.generation_inputs, created_at: record.created_at, updated_at: record.updated_at, } @@ -1452,6 +1471,8 @@ fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPay model: record.model, provider: record.provider, task_id: record.task_id, + asset_kind: record.asset_kind, + generation_inputs: record.generation_inputs, created_at: record.created_at, updated_at: record.updated_at, } @@ -1489,6 +1510,30 @@ fn serialize_editor_layers(layers: Value) -> Result { Ok(payload) } +fn serialize_editor_asset_metadata(metadata: Option) -> Result, AppError> { + let Some(metadata) = metadata else { + return Ok(None); + }; + if metadata.is_null() { + return Ok(None); + } + let payload = serde_json::to_string(&metadata).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project", + "message": format!("资产元数据无法序列化:{error}"), + })) + })?; + if payload.len() > EDITOR_ASSET_METADATA_MAX_BYTES { + return Err( + AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({ + "provider": "editor-project", + "message": "资产元数据过大", + })), + ); + } + Ok(Some(payload)) +} + fn normalize_optional_string(value: Option) -> Option { value .map(|item| item.trim().to_string()) diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index 6f977bf42..b53925b53 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -47,6 +47,8 @@ pub struct EditorProjectResourceRecord { pub provider: Option, pub task_id: Option, pub source_resource_id: Option, + pub asset_kind: Option, + pub generation_inputs: Option, pub created_at: String, pub updated_at: String, } @@ -78,6 +80,8 @@ pub struct EditorAssetRecord { pub model: Option, pub provider: Option, pub task_id: Option, + pub asset_kind: Option, + pub generation_inputs: Option, pub created_at: String, pub updated_at: String, } @@ -142,6 +146,8 @@ pub struct EditorProjectResourceCreateRecordInput { pub provider: Option, pub task_id: Option, pub source_resource_id: Option, + pub asset_kind: Option, + pub generation_inputs_json: Option, pub updated_at_micros: i64, } @@ -187,6 +193,8 @@ pub struct EditorAssetCreateRecordInput { pub model: Option, pub provider: Option, pub task_id: Option, + pub asset_kind: Option, + pub generation_inputs_json: Option, pub now_micros: i64, } @@ -283,6 +291,8 @@ impl From provider: input.provider, task_id: input.task_id, source_resource_id: input.source_resource_id, + asset_kind: input.asset_kind, + generation_inputs_json: input.generation_inputs_json, updated_at_micros: input.updated_at_micros, } } @@ -346,6 +356,8 @@ impl From for crate::module_bindings::EditorAssetC model: input.model, provider: input.provider, task_id: input.task_id, + asset_kind: input.asset_kind, + generation_inputs_json: input.generation_inputs_json, now_micros: input.now_micros, } } @@ -426,6 +438,7 @@ pub(crate) fn map_editor_project_resource_procedure_result( result .resource .map(map_editor_project_resource_snapshot) + .transpose()? .ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布资源快照")) } @@ -439,6 +452,7 @@ pub(crate) fn map_editor_asset_library_procedure_result( result .library .map(map_editor_asset_library_snapshot) + .transpose()? .ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布素材库快照")) } @@ -465,6 +479,7 @@ pub(crate) fn map_editor_asset_folder_library_procedure_result( result .library .map(map_editor_asset_library_snapshot) + .transpose()? .ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布素材库快照")) } @@ -478,6 +493,7 @@ pub(crate) fn map_editor_asset_procedure_result( result .asset .map(map_editor_asset_snapshot) + .transpose()? .ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布素材快照")) } @@ -516,7 +532,7 @@ fn map_editor_project_snapshot( .resources .into_iter() .map(map_editor_project_resource_snapshot) - .collect(), + .collect::, _>>()?, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), }) @@ -524,8 +540,12 @@ fn map_editor_project_snapshot( fn map_editor_project_resource_snapshot( snapshot: EditorProjectResourceSnapshot, -) -> EditorProjectResourceRecord { - EditorProjectResourceRecord { +) -> Result { + let generation_inputs = parse_optional_generation_inputs_json( + snapshot.generation_inputs_json.as_deref(), + "图片画布资源生成输入 JSON", + )?; + Ok(EditorProjectResourceRecord { resource_id: snapshot.resource_id, project_id: snapshot.project_id, asset_object_id: snapshot.asset_object_id, @@ -540,15 +560,17 @@ fn map_editor_project_resource_snapshot( provider: snapshot.provider, task_id: snapshot.task_id, source_resource_id: snapshot.source_resource_id, + asset_kind: snapshot.asset_kind, + generation_inputs, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } + }) } fn map_editor_asset_library_snapshot( snapshot: EditorAssetLibrarySnapshot, -) -> EditorAssetLibraryRecord { - EditorAssetLibraryRecord { +) -> Result { + Ok(EditorAssetLibraryRecord { folders: snapshot .folders .into_iter() @@ -558,8 +580,8 @@ fn map_editor_asset_library_snapshot( .assets .into_iter() .map(map_editor_asset_snapshot) - .collect(), - } + .collect::, _>>()?, + }) } fn map_editor_asset_folder_snapshot( @@ -576,8 +598,14 @@ fn map_editor_asset_folder_snapshot( } } -fn map_editor_asset_snapshot(snapshot: EditorAssetSnapshot) -> EditorAssetRecord { - EditorAssetRecord { +fn map_editor_asset_snapshot( + snapshot: EditorAssetSnapshot, +) -> Result { + let generation_inputs = parse_optional_generation_inputs_json( + snapshot.generation_inputs_json.as_deref(), + "图片画布素材生成输入 JSON", + )?; + Ok(EditorAssetRecord { asset_id: snapshot.asset_id, folder_id: snapshot.folder_id, label: snapshot.label, @@ -592,7 +620,21 @@ fn map_editor_asset_snapshot(snapshot: EditorAssetSnapshot) -> EditorAssetRecord model: snapshot.model, provider: snapshot.provider, task_id: snapshot.task_id, + asset_kind: snapshot.asset_kind, + generation_inputs, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } + }) +} + +fn parse_optional_generation_inputs_json( + raw_json: Option<&str>, + label: &str, +) -> Result, SpacetimeClientError> { + let Some(raw_json) = raw_json.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + serde_json::from_str(raw_json).map(Some).map_err(|error| { + SpacetimeClientError::validation_failed(format!("{label} 无法解析:{error}")) + }) } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs index 5c5df7547..f37faf5d1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_create_input_type.rs @@ -22,6 +22,8 @@ pub struct EditorAssetCreateInput { pub model: Option, pub provider: Option, pub task_id: Option, + pub asset_kind: Option, + pub generation_inputs_json: Option, pub now_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs index 2bcb7cedc..85cb1de4a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_snapshot_type.rs @@ -21,6 +21,8 @@ pub struct EditorAssetSnapshot { pub model: Option, pub provider: Option, pub task_id: Option, + pub asset_kind: Option, + pub generation_inputs_json: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs index f824530ec..04e959907 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs @@ -24,6 +24,8 @@ pub struct EditorAsset { pub task_id: Option, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, + pub asset_kind: Option, + pub generation_inputs_json: Option, } impl __sdk::InModule for EditorAsset { @@ -51,6 +53,8 @@ pub struct EditorAssetCols { pub task_id: __sdk::__query_builder::Col>, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub asset_kind: __sdk::__query_builder::Col>, + pub generation_inputs_json: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for EditorAsset { @@ -74,6 +78,11 @@ impl __sdk::__query_builder::HasCols for EditorAsset { task_id: __sdk::__query_builder::Col::new(table_name, "task_id"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + asset_kind: __sdk::__query_builder::Col::new(table_name, "asset_kind"), + generation_inputs_json: __sdk::__query_builder::Col::new( + table_name, + "generation_inputs_json", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_create_input_type.rs index f0576ddbb..cac8c3376 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_create_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_create_input_type.rs @@ -22,6 +22,8 @@ pub struct EditorProjectResourceCreateInput { pub provider: Option, pub task_id: Option, pub source_resource_id: Option, + pub asset_kind: Option, + pub generation_inputs_json: Option, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs index 5f812b246..085ff2b4c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs @@ -21,6 +21,8 @@ pub struct EditorProjectResourceSnapshot { pub provider: Option, pub task_id: Option, pub source_resource_id: Option, + pub asset_kind: Option, + pub generation_inputs_json: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs index d635e0dcd..a47526ce4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs @@ -24,6 +24,8 @@ pub struct EditorProjectResource { pub source_resource_id: Option, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, + pub asset_kind: Option, + pub generation_inputs_json: Option, } impl __sdk::InModule for EditorProjectResource { @@ -51,6 +53,8 @@ pub struct EditorProjectResourceCols { pub source_resource_id: __sdk::__query_builder::Col>, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub asset_kind: __sdk::__query_builder::Col>, + pub generation_inputs_json: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for EditorProjectResource { @@ -74,6 +78,11 @@ impl __sdk::__query_builder::HasCols for EditorProjectResource { source_resource_id: __sdk::__query_builder::Col::new(table_name, "source_resource_id"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + asset_kind: __sdk::__query_builder::Col::new(table_name, "asset_kind"), + generation_inputs_json: __sdk::__query_builder::Col::new( + table_name, + "generation_inputs_json", + ), } } } diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index aacc3e55b..043d2ff17 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -69,6 +69,10 @@ pub struct EditorProjectResource { source_resource_id: Option, created_at: Timestamp, updated_at: Timestamp, + #[default(None::)] + asset_kind: Option, + #[default(None::)] + generation_inputs_json: Option, } #[spacetimedb::table( @@ -111,6 +115,10 @@ pub struct EditorAsset { task_id: Option, created_at: Timestamp, updated_at: Timestamp, + #[default(None::)] + asset_kind: Option, + #[default(None::)] + generation_inputs_json: Option, } #[derive(Clone, Debug, PartialEq, SpacetimeType)] @@ -184,6 +192,8 @@ pub struct EditorProjectResourceCreateInput { pub provider: Option, pub task_id: Option, pub source_resource_id: Option, + pub asset_kind: Option, + pub generation_inputs_json: Option, pub updated_at_micros: i64, } @@ -203,6 +213,8 @@ pub struct EditorProjectResourceSnapshot { pub provider: Option, pub task_id: Option, pub source_resource_id: Option, + pub asset_kind: Option, + pub generation_inputs_json: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } @@ -234,6 +246,8 @@ pub struct EditorAssetSnapshot { pub model: Option, pub provider: Option, pub task_id: Option, + pub asset_kind: Option, + pub generation_inputs_json: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } @@ -292,6 +306,8 @@ pub struct EditorAssetCreateInput { pub model: Option, pub provider: Option, pub task_id: Option, + pub asset_kind: Option, + pub generation_inputs_json: Option, pub now_micros: i64, } @@ -736,7 +752,7 @@ fn save_editor_project_layout( ) -> Result { let project_id = normalize_required(&input.project_id, "editor_project.project_id")?; let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?; - let layers_json = normalize_layout_json(input.layers_json)?; + let input_layers_json = normalize_layout_json(input.layers_json)?; let project = require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?; let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let canvas = ensure_default_canvas( @@ -746,6 +762,20 @@ fn save_editor_project_layout( Some(&project), now, )?; + let _ = migrate_canvas_layer_asset_metadata( + ctx, + project.project_id.as_str(), + owner_user_id.as_str(), + canvas.layers_json.clone(), + now, + )?; + let layers_json = migrate_canvas_layer_asset_metadata( + ctx, + project.project_id.as_str(), + owner_user_id.as_str(), + input_layers_json, + now, + )?; ctx.db.editor_canvas().canvas_id().delete(&canvas.canvas_id); ctx.db.editor_canvas().insert(EditorCanvas { @@ -828,6 +858,8 @@ fn create_editor_project_resource( source_resource_id: normalize_optional(input.source_resource_id), created_at: now, updated_at: now, + asset_kind: normalize_optional(input.asset_kind), + generation_inputs_json: normalize_optional(input.generation_inputs_json), }); ctx.db @@ -986,6 +1018,8 @@ fn create_editor_asset( task_id: normalize_optional(input.task_id), created_at: now, updated_at: now, + asset_kind: normalize_optional(input.asset_kind), + generation_inputs_json: normalize_optional(input.generation_inputs_json), }); ctx.db .editor_asset() @@ -1031,6 +1065,8 @@ fn update_editor_asset( task_id: asset.task_id, created_at: asset.created_at, updated_at: now, + asset_kind: asset.asset_kind, + generation_inputs_json: asset.generation_inputs_json, }); ctx.db .editor_asset() @@ -1302,6 +1338,8 @@ fn asset_snapshot_from_row(row: EditorAsset) -> EditorAssetSnapshot { model: row.model, provider: row.provider, task_id: row.task_id, + asset_kind: row.asset_kind, + generation_inputs_json: row.generation_inputs_json, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } @@ -1323,6 +1361,8 @@ fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResour provider: row.provider, task_id: row.task_id, source_resource_id: row.source_resource_id, + asset_kind: row.asset_kind, + generation_inputs_json: row.generation_inputs_json, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } @@ -1367,6 +1407,125 @@ fn normalize_layout_json(value: String) -> Result { Ok(value) } +fn migrate_canvas_layer_asset_metadata( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + layers_json: String, + now: Timestamp, +) -> Result { + let mut layout = serde_json::from_str::(&layers_json) + .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + let Some(items) = layout.as_array_mut() else { + return Ok(layers_json); + }; + let mut changed = false; + for item in items { + let Some(object) = item.as_object_mut() else { + continue; + }; + if object + .get("itemType") + .and_then(JsonValue::as_str) + .is_some() + { + continue; + } + let resource_id = object + .get("resourceId") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let asset_kind = object + .remove("assetKind") + .and_then(|value| normalize_legacy_layout_string(value.as_str())); + let generation_inputs_json = object + .remove("generationInputs") + .and_then(|value| serialize_non_empty_json_value(&value)); + changed |= asset_kind.is_some() || generation_inputs_json.is_some(); + let Some(resource_id) = resource_id else { + continue; + }; + if asset_kind.is_some() || generation_inputs_json.is_some() { + migrate_project_resource_asset_metadata( + ctx, + project_id, + owner_user_id, + resource_id, + asset_kind, + generation_inputs_json, + now, + ); + } + } + if !changed { + return Ok(layers_json); + } + serde_json::to_string(&layout).map_err(|_| "图片画布图层布局无法序列化".to_string()) +} + +fn migrate_project_resource_asset_metadata( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + resource_id: String, + asset_kind: Option, + generation_inputs_json: Option, + now: Timestamp, +) { + let Some(resource) = ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource_id) + .filter(|resource| { + resource.project_id == project_id && resource.owner_user_id == owner_user_id + }) + else { + return; + }; + let next_asset_kind = resource.asset_kind.clone().or(asset_kind); + let next_generation_inputs_json = resource + .generation_inputs_json + .clone() + .or(generation_inputs_json); + if next_asset_kind == resource.asset_kind + && next_generation_inputs_json == resource.generation_inputs_json + { + return; + } + ctx.db + .editor_project_resource() + .resource_id() + .delete(&resource.resource_id); + ctx.db.editor_project_resource().insert(EditorProjectResource { + updated_at: now, + asset_kind: next_asset_kind, + generation_inputs_json: next_generation_inputs_json, + ..resource + }); +} + +fn normalize_legacy_layout_string(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn serialize_non_empty_json_value(value: &JsonValue) -> Option { + if value.is_null() { + return None; + } + if value.as_object().is_some_and(JsonMap::is_empty) + || value.as_array().is_some_and(Vec::is_empty) + { + return None; + } + serde_json::to_string(value).ok() +} + fn editor_project_ok(project: Option) -> EditorProjectProcedureResult { EditorProjectProcedureResult { ok: true, diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 34d19c373..a9c2f38d8 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -905,6 +905,15 @@ describe('ImageCanvasEditorView generation integration', () => { sourceType: 'generated', width: 2048, height: 1152, + assetKind: 'spec', + generationInputs: expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + title: '玩法设定', + value: '平台跳跃玩法', + }), + ]), + }), }), ); }); @@ -915,7 +924,6 @@ describe('ImageCanvasEditorView generation integration', () => { layers: expect.arrayContaining([ expect.objectContaining({ title: expect.stringMatching(/角色规范/u), - assetKind: 'spec', }), ]), }), @@ -1791,7 +1799,6 @@ describe('ImageCanvasEditorView generation integration', () => { layers: expect.arrayContaining([ expect.objectContaining({ title: expect.stringMatching(/角色形象/u), - assetKind: 'character', objectKey: 'generated-character-drafts/editor/character-images/editor-character-1/image.png', assetObjectId: 'asset-object-editor-character-1', @@ -1807,6 +1814,16 @@ describe('ImageCanvasEditorView generation integration', () => { objectKey: 'generated-character-drafts/editor/character-images/editor-character-1/image.png', assetObjectId: 'asset-object-editor-character-1', + assetKind: 'character', + generationInputs: expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + title: '角色设定', + value: + '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', + }), + ]), + }), }), ); }); @@ -1990,22 +2007,27 @@ describe('ImageCanvasEditorView generation integration', () => { expect(within(iconInfoPanel).getByText('图标规范')).toBeTruthy(); expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy(); await waitFor(() => { - expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'editor-project-icons', expect.objectContaining({ - layers: expect.arrayContaining([ - expect.objectContaining({ - title: '返回按钮', - assetKind: 'icon', - }), - expect.objectContaining({ - title: '设置按钮', - assetKind: 'icon', - }), - ]), + assetKind: 'icon', + generationInputs: expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + title: '素材描述', + value: '返回按钮\n设置按钮', + }), + ]), + }), }), ); }); + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( + 'editor-project-icons', + expect.objectContaining({ + assetKind: 'icon', + }), + ); }); it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => { @@ -2064,15 +2086,18 @@ describe('ImageCanvasEditorView generation integration', () => { }), ); await waitFor(() => { - expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ - layers: expect.arrayContaining([ - expect.objectContaining({ - title: expect.stringMatching(/图标规范/u), - assetKind: 'icon-spec', - }), - ]), + assetKind: 'icon-spec', + generationInputs: expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + title: '玩法设定', + value: '背包整理玩法', + }), + ]), + }), }), ); }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 0bcb5f4c8..21e04449f 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -112,10 +112,24 @@ describe('ImageCanvasEditorModel', () => { const snapshot = serializeLayer(layer); expect(snapshot).not.toHaveProperty('src'); + expect(snapshot).not.toHaveProperty('assetKind'); + expect(snapshot).not.toHaveProperty('generationInputs'); const hydrated = hydrateLayer( snapshot, - new Map([['resource-generated', { imageSrc: '/read/generated.png' }]]), + new Map([ + [ + 'resource-generated', + { + imageSrc: '/read/generated.png', + assetKind: 'character', + generationInputs: { + fields: [{ title: '角色设定', value: '骑士' }], + references: [], + }, + }, + ], + ]), ); expect(hydrated).toMatchObject({ @@ -126,6 +140,7 @@ describe('ImageCanvasEditorModel', () => { objectKey: 'generated/object.png', locked: true, }); + expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士'); }); it('hydrates audio media type and sound effect asset kind from saved layout', () => { diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 3c55776fd..a5783509a 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -137,6 +137,8 @@ export function createLayerFromAsset( assetObjectId: asset.assetObjectId, durationSeconds: asset.durationSeconds, sourceAssetId: asset.id, + assetKind: asset.assetKind, + generationInputs: asset.generationInputs, } satisfies CanvasLayer; } @@ -167,8 +169,6 @@ export function serializeLayer( sourceResourceId: layer.sourceResourceId, sourceAssetId: layer.sourceAssetId, groupId: layer.groupId, - assetKind: layer.assetKind, - generationInputs: layer.generationInputs, hidden: layer.hidden, locked: layer.locked, flipX: layer.flipX, @@ -356,13 +356,14 @@ export function hydrateCanvasGenerationDialog( export function hydrateLayer( snapshot: EditorProjectLayerSnapshot, - resourcesById: Map, + resourcesById: Map, ): CanvasLayer | null { const resourceId = typeof snapshot.resourceId === 'string' ? snapshot.resourceId : ''; const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : ''; + const resource = resourcesById.get(resourceId); const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : ''; - const src = snapshotSrc || resourcesById.get(resourceId)?.imageSrc || ''; + const src = snapshotSrc || resource?.imageSrc || ''; const title = typeof snapshot.title === 'string' ? snapshot.title : '画布图片'; if (!resourceId || !layerId || !src) { @@ -405,16 +406,16 @@ export function hydrateLayer( assetObjectId: stringOrNull(snapshot.assetObjectId), durationSeconds: audioDurationOrNull(snapshot.durationSeconds) ?? - audioDurationOrNull( - resourcesById.get(resourceId) as - | ({ imageSrc: string } & { durationSeconds?: unknown }) - | undefined, - ), + audioDurationOrNull(resource?.durationSeconds), sourceResourceId: stringOrNull(snapshot.sourceResourceId), sourceAssetId: stringOrNull(snapshot.sourceAssetId), groupId: stringOrNull(snapshot.groupId), - assetKind: canvasAssetKindOrNull(snapshot.assetKind), - generationInputs: generationInputsOrNull(snapshot.generationInputs), + assetKind: + canvasAssetKindOrNull(resource?.assetKind) ?? + canvasAssetKindOrNull(snapshot.assetKind), + generationInputs: + generationInputsOrNull(resource?.generationInputs) ?? + generationInputsOrNull(snapshot.generationInputs), hidden: booleanFromSnapshot(snapshot.hidden), locked: booleanFromSnapshot(snapshot.locked), flipX: booleanFromSnapshot(snapshot.flipX), @@ -453,11 +454,20 @@ export function mapAssetLibrarySnapshot( taskId: asset.taskId ?? undefined, objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, + assetKind: canvasAssetKindOrNull(asset.assetKind), + generationInputs: generationInputsOrNull(asset.generationInputs), durationSeconds: asset.durationSeconds ?? undefined, })), }; } +export type CanvasLayerResourceMetadata = { + imageSrc: string; + durationSeconds?: number | null; + assetKind?: string | null; + generationInputs?: unknown; +}; + export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) { const mapped = mapAssetLibrarySnapshot(library); let hasDefaultFolder = false; @@ -666,6 +676,7 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null { value === 'icon' || value === 'icon-spritesheet' || value === 'icon-spec' || + value === 'publication-material' || value === 'ui-design' || value === 'video' || value === 'sound-effect' || diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index a49bff48d..1b9fd84d8 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -41,6 +41,8 @@ export type EditorAsset = { taskId?: string; objectKey?: string; assetObjectId?: string; + assetKind?: CanvasAssetKind | null; + generationInputs?: CanvasGenerationInputs | null; durationSeconds?: number; uploadStatus?: 'uploading' | 'failed'; uploadProgress?: number; diff --git a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts index 1b1831242..082838f72 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts +++ b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts @@ -264,6 +264,9 @@ export function setupImageCanvasEditorViewTestLifecycle({ width: input.width, height: input.height, sourceType: input.sourceType, + assetKind: input.assetKind, + generationInputs: input.generationInputs, + durationSeconds: input.durationSeconds, })); createEditorAssetFolderMock.mockResolvedValue({ folderId: 'folder-role-persisted', diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index f44139371..94ce98191 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -14,7 +14,11 @@ import { } from '../../services/image-editor/editorProjectClient'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; -import { resolveContextMenuPosition } from './ImageCanvasEditorModel'; +import { + canvasAssetKindOrNull, + generationInputsOrNull, + resolveContextMenuPosition, +} from './ImageCanvasEditorModel'; import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; import type { AssetPointerDragState, @@ -340,6 +344,9 @@ export function ImageCanvasEditorView() { model: layer.model, provider: layer.provider, taskId: layer.taskId, + durationSeconds: layer.durationSeconds, + assetKind: layer.assetKind, + generationInputs: layer.generationInputs, }) .then((asset) => { setAssets((currentAssets) => [ @@ -363,6 +370,9 @@ export function ImageCanvasEditorView() { taskId: asset.taskId ?? undefined, objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, + durationSeconds: asset.durationSeconds ?? undefined, + assetKind: canvasAssetKindOrNull(asset.assetKind), + generationInputs: generationInputsOrNull(asset.generationInputs), }, ]); setAssetFolders((currentFolders) => diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index b916c92be..74946aacb 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -29,7 +29,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }; }); -function createLayer(id: string): CanvasLayer { +function createLayer( + id: string, + overrides: Partial = {}, +): CanvasLayer { return { id, resourceId: `local-${id}`, @@ -44,6 +47,7 @@ function createLayer(id: string): CanvasLayer { zIndex: 3, sourceType: 'uploaded', sourceAssetId: 'asset-a', + ...overrides, }; } @@ -118,7 +122,14 @@ function ProjectPersistenceHarness({ {projectTitle} {projectRenameValue} - {layers.map((layer) => `${layer.id}:${layer.resourceId}`).join(',')} + {layers + .map( + (layer) => + `${layer.id}:${layer.resourceId}:${layer.assetKind ?? '-'}:${ + layer.generationInputs?.fields[0]?.value ?? '-' + }`, + ) + .join(',')} {selectedLayerRef.current ?? '-'} {layerCounterRef.current} @@ -135,6 +146,24 @@ function ProjectPersistenceHarness({ > append + +