Merge branch 'codex/editor-asset-metadata-migration' into codex/editor-asset-library

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	server-rs/crates/api-server/src/editor_project.rs
#	src/components/image-editor/ImageCanvasEditorModel.ts
#	src/components/image-editor/ImageCanvasEditorView.test-utils.ts
#	src/components/image-editor/ImageCanvasEditorView.tsx
#	src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts
#	src/services/image-editor/editorProjectClient.test.ts
This commit is contained in:
2026-06-19 16:55:41 +08:00
31 changed files with 1586 additions and 224 deletions
@@ -24,6 +24,14 @@
- 验证方式:`cargo test -p api-server external_api --manifest-path server-rs/Cargo.toml``cargo test -p api-server external_editor_api --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema``npm run check:encoding``git diff --check`
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`
## 2026-06-19 图片画布素材生成元数据上移到资源和素材
- 背景:角色、图标、UI 设计图、视频和音频等生成结果会在图片信息页展示用户可见输入快照;此前这些 `assetKind/generationInputs` 主要保存在画布 layer JSON 中,素材进入账号级素材库后跨项目复用和刷新恢复都依赖画布布局,不符合素材库作为账号级事实源的边界。
- 决策:普通图层的新保存不再把 `assetKind/generationInputs` 写入 `editor_canvas.layers_json``editor_project_resource` 保存项目画布资源快照的 `asset_kind/generation_inputs_json``editor_asset` 保存账号级素材的同名元数据;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `projectId` / `assetFolderId` 时由后端创建新 resource / 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、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。
@@ -39,12 +39,12 @@
- 新增 `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;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 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`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;视频结果当前只保存为画布视频资源,不进入图片素材库。
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 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` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成,前端只把响应中的 resource / asset 快照用于落画布和更新素材栏,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
@@ -65,10 +65,10 @@
- `POST /api/editor/assets`:批量或单个创建账号级素材,支持按钮上传和拖拽上传后的 data URL / 后续 OSS 元数据。
- `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。
- `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model``aspectRatio``imageSize``referenceImageSrcs``nanobanana2` 参考图作为 `inline_data` 进入 `generateContent``gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`接口返回 data URL、尺寸、prompt、model、provider 和 taskId
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model``aspectRatio``imageSize``priceMudPoints``priceMudPoints` 必须来自编辑器生成计费配置的 `icon` 档位(首版 12 泥点),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize``0.5K``"512"``gpt-image-2``/v1/images/edits`后端把 UI 尺寸归一为文档支持的 `1024x1024``1024x1536``1536x1024``2048x2048``2048x1152``size` 字符串
- `POST /api/editor/ui-designs/assets/extractions`:以 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。前端必须把 spritesheet 原图与拆分素材都加入画布。
- `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model``aspectRatio``imageSize``referenceImageSrcs``nanobanana2` 参考图作为 `inline_data` 进入 `generateContent``gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`请求可携带 `projectId``assetFolderId``assetKind``generationInputs``sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model``aspectRatio``imageSize``priceMudPoints``projectId``assetFolderId``generationInputs``priceMudPoints` 必须来自编辑器生成计费配置的 `icon` 档位(首版 12 泥点),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize``0.5K``"512"``gpt-image-2``/v1/images/edits`后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照
- `POST /api/editor/ui-designs/assets/extractions`:以 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求可携带 `projectId``assetFolderId``generationInputs``spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。
- `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照
- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音和泥点价格生成视频。前端可选模型为 `seedance2.0-fast``seedance2.0``kling3.0``kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`
所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。
@@ -454,7 +454,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` 保存用户可见生成输入快照,供图片信息页刷新后恢复。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取
- 索引:`by_editor_project_resource_project_id``by_editor_project_resource_owner_user_id`
### `editor_asset_folder`
@@ -468,7 +468,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`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,前端只用该快照更新素材栏。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照
- 索引:`by_editor_asset_owner_user_id``by_editor_asset_folder_id`
### `inventory_slot`
File diff suppressed because it is too large Load Diff
@@ -283,6 +283,8 @@ pub async fn generate_external_editor_image(
model: Some(GPT_IMAGE_2_MODEL.to_string()),
provider: Some(EXTERNAL_EDITOR_IMAGE_PROVIDER.to_string()),
task_id: Some(generated.task_id.clone()),
asset_kind: Some(EXTERNAL_EDITOR_IMAGE_ASSET_KIND.to_string()),
generation_inputs_json: None,
now_micros,
})
.await
@@ -307,6 +309,8 @@ pub async fn generate_external_editor_image(
provider: Some(EXTERNAL_EDITOR_IMAGE_PROVIDER.to_string()),
task_id: Some(generated.task_id.clone()),
source_resource_id: None,
asset_kind: Some(EXTERNAL_EDITOR_IMAGE_ASSET_KIND.to_string()),
generation_inputs_json: None,
updated_at_micros: now_micros,
})
.await
@@ -47,6 +47,8 @@ pub struct EditorProjectResourceRecord {
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs: Option<serde_json::Value>,
pub created_at: String,
pub updated_at: String,
}
@@ -78,6 +80,8 @@ pub struct EditorAssetRecord {
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs: Option<serde_json::Value>,
pub created_at: String,
pub updated_at: String,
}
@@ -142,6 +146,8 @@ pub struct EditorProjectResourceCreateRecordInput {
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
pub updated_at_micros: i64,
}
@@ -187,6 +193,8 @@ pub struct EditorAssetCreateRecordInput {
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
pub now_micros: i64,
}
@@ -283,6 +291,8 @@ impl From<EditorProjectResourceCreateRecordInput>
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<EditorAssetCreateRecordInput> 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::<Result<Vec<_>, _>>()?,
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<EditorProjectResourceRecord, SpacetimeClientError> {
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<EditorAssetLibraryRecord, SpacetimeClientError> {
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::<Result<Vec<_>, _>>()?,
})
}
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<EditorAssetRecord, SpacetimeClientError> {
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<Option<serde_json::Value>, 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}"))
})
}
@@ -22,6 +22,8 @@ pub struct EditorAssetCreateInput {
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
pub now_micros: i64,
}
@@ -21,6 +21,8 @@ pub struct EditorAssetSnapshot {
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
@@ -24,6 +24,8 @@ pub struct EditorAsset {
pub task_id: Option<String>,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
}
impl __sdk::InModule for EditorAsset {
@@ -51,6 +53,8 @@ pub struct EditorAssetCols {
pub task_id: __sdk::__query_builder::Col<EditorAsset, Option<String>>,
pub created_at: __sdk::__query_builder::Col<EditorAsset, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<EditorAsset, __sdk::Timestamp>,
pub asset_kind: __sdk::__query_builder::Col<EditorAsset, Option<String>>,
pub generation_inputs_json: __sdk::__query_builder::Col<EditorAsset, Option<String>>,
}
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",
),
}
}
}
@@ -22,6 +22,8 @@ pub struct EditorProjectResourceCreateInput {
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
pub updated_at_micros: i64,
}
@@ -21,6 +21,8 @@ pub struct EditorProjectResourceSnapshot {
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
@@ -24,6 +24,8 @@ pub struct EditorProjectResource {
pub source_resource_id: Option<String>,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
}
impl __sdk::InModule for EditorProjectResource {
@@ -51,6 +53,8 @@ pub struct EditorProjectResourceCols {
pub source_resource_id: __sdk::__query_builder::Col<EditorProjectResource, Option<String>>,
pub created_at: __sdk::__query_builder::Col<EditorProjectResource, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<EditorProjectResource, __sdk::Timestamp>,
pub asset_kind: __sdk::__query_builder::Col<EditorProjectResource, Option<String>>,
pub generation_inputs_json: __sdk::__query_builder::Col<EditorProjectResource, Option<String>>,
}
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",
),
}
}
}
@@ -69,6 +69,10 @@ pub struct EditorProjectResource {
source_resource_id: Option<String>,
created_at: Timestamp,
updated_at: Timestamp,
#[default(None::<String>)]
asset_kind: Option<String>,
#[default(None::<String>)]
generation_inputs_json: Option<String>,
}
#[spacetimedb::table(
@@ -111,6 +115,10 @@ pub struct EditorAsset {
task_id: Option<String>,
created_at: Timestamp,
updated_at: Timestamp,
#[default(None::<String>)]
asset_kind: Option<String>,
#[default(None::<String>)]
generation_inputs_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
@@ -184,6 +192,8 @@ pub struct EditorProjectResourceCreateInput {
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
pub updated_at_micros: i64,
}
@@ -203,6 +213,8 @@ pub struct EditorProjectResourceSnapshot {
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
@@ -234,6 +246,8 @@ pub struct EditorAssetSnapshot {
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
@@ -292,6 +306,8 @@ pub struct EditorAssetCreateInput {
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub asset_kind: Option<String>,
pub generation_inputs_json: Option<String>,
pub now_micros: i64,
}
@@ -736,7 +752,7 @@ fn save_editor_project_layout(
) -> Result<EditorProjectSnapshot, String> {
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,123 @@ fn normalize_layout_json(value: String) -> Result<String, String> {
Ok(value)
}
fn migrate_canvas_layer_asset_metadata(
ctx: &ReducerContext,
project_id: &str,
owner_user_id: &str,
layers_json: String,
now: Timestamp,
) -> Result<String, String> {
let mut layout = serde_json::from_str::<JsonValue>(&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<String>,
generation_inputs_json: Option<String>,
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<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn serialize_non_empty_json_value(value: &JsonValue) -> Option<String> {
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<EditorProjectSnapshot>) -> EditorProjectProcedureResult {
EditorProjectProcedureResult {
ok: true,
File diff suppressed because it is too large Load Diff
@@ -164,10 +164,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({
@@ -178,6 +192,7 @@ describe('ImageCanvasEditorModel', () => {
objectKey: 'generated/object.png',
locked: true,
});
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
});
it('hydrates manually selected publication material tags from saved layout', () => {
@@ -144,7 +144,8 @@ export function createLayerFromAsset(
assetObjectId: asset.assetObjectId,
durationSeconds: asset.durationSeconds,
sourceAssetId: asset.id,
assetKind,
assetKind: asset.assetKind ?? assetKind,
generationInputs: asset.generationInputs,
} satisfies CanvasLayer;
}
@@ -175,8 +176,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,
@@ -364,13 +363,14 @@ export function hydrateCanvasGenerationDialog(
export function hydrateLayer(
snapshot: EditorProjectLayerSnapshot,
resourcesById: Map<string, { imageSrc: string }>,
resourcesById: Map<string, CanvasLayerResourceMetadata>,
): 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) {
@@ -413,16 +413,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),
@@ -467,6 +467,8 @@ 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,
};
}),
@@ -494,6 +496,13 @@ function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
: 'sound-effect';
}
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;
@@ -1,4 +1,5 @@
import type {
EditorAssetSnapshot,
EditorCharacterAnimationFrameCount,
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
@@ -41,6 +42,8 @@ export type EditorAsset = {
taskId?: string;
objectKey?: string;
assetObjectId?: string;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
uploadStatus?: 'uploading' | 'failed';
uploadProgress?: number;
@@ -86,6 +89,7 @@ export type CanvasLayer = {
assetObjectId?: string | null;
sourceResourceId?: string | null;
sourceAssetId?: string | null;
generatedAssetSnapshot?: EditorAssetSnapshot | null;
groupId?: string | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
@@ -266,6 +266,8 @@ export function setupImageCanvasEditorViewTestLifecycle({
width: input.width,
height: input.height,
sourceType: input.sourceType,
assetKind: input.assetKind,
generationInputs: input.generationInputs,
durationSeconds: input.durationSeconds,
}));
createEditorAssetFolderMock.mockResolvedValue({
@@ -11,10 +11,15 @@ import {
import {
createEditorAsset,
createEditorProjectResource,
type EditorAssetSnapshot,
} 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,
@@ -343,6 +348,8 @@ export function ImageCanvasEditorView() {
provider: layer.provider,
taskId: layer.taskId,
durationSeconds: layer.durationSeconds,
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
})
.then((asset) => {
setAssets((currentAssets) => [
@@ -368,6 +375,8 @@ export function ImageCanvasEditorView() {
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
durationSeconds: asset.durationSeconds ?? layer.durationSeconds,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
},
]);
setAssetFolders((currentFolders) =>
@@ -406,6 +415,55 @@ export function ImageCanvasEditorView() {
},
[activeUploadFolderId, openEditorLoginModal, setAssetFolders, setAssets],
);
const upsertGeneratedAsset = useCallback(
(asset: EditorAssetSnapshot) => {
setAssets((currentAssets) => [
...currentAssets.filter(
(currentAsset) => currentAsset.id !== asset.assetId,
),
{
id: asset.assetId,
label: asset.label,
src: asset.imageSrc,
width: asset.width,
height: asset.height,
folderId: asset.folderId,
sourceKind: 'uploaded',
sourceType: asset.sourceType,
persisted: true,
prompt: asset.prompt ?? undefined,
actualPrompt: asset.actualPrompt ?? undefined,
model: asset.model ?? undefined,
provider: asset.provider ?? undefined,
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) =>
currentFolders.some((folder) => folder.id === asset.folderId)
? currentFolders.map((folder) =>
folder.id === asset.folderId
? { ...folder, collapsed: false }
: folder,
)
: [
...currentFolders,
{
id: asset.folderId,
label: '项目素材',
collapsed: false,
systemDefault: true,
persisted: true,
},
],
);
},
[setAssetFolders, setAssets],
);
const projectPersistenceRefs = useMemo(
() => ({
layersRef,
@@ -548,6 +606,9 @@ export function ImageCanvasEditorView() {
requestUpload,
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
assetFolderId: activeUploadFolderId,
upsertGeneratedAsset,
});
const {
setQuickEditPanel,
@@ -98,6 +98,57 @@ describe('ImageCanvasGenerationLayerModel', () => {
expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词');
});
it('uses backend-persisted resource and asset snapshots for generated layers', () => {
const generationInputs = createGenerationInputs();
const layer = createGeneratedResultLayer({
generated: createGenerated({
resource: {
resourceId: 'resource-generated-bff',
projectId: 'editor-project-1',
imageSrc: 'data:image/png;base64,generated',
objectKey: 'generated/from-resource.png',
assetObjectId: 'asset-object-from-resource',
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'character',
generationInputs,
},
asset: {
assetId: 'asset-generated-bff',
folderId: 'user-1:asset-folder:project',
label: '角色形象',
imageSrc: 'data:image/png;base64,generated',
objectKey: 'generated/from-asset.png',
assetObjectId: 'asset-object-from-asset',
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'character',
generationInputs,
},
}),
generatedIndex: 9,
canvasSize: { width: 900, height: 640 },
viewport: { x: 0, y: 0, scale: 1 },
assetKind: 'spec',
generationInputs: {
fields: [{ title: '旧输入', value: '本地兜底' }],
references: [],
},
});
expect(layer).toMatchObject({
resourceId: 'resource-generated-bff',
sourceAssetId: 'asset-generated-bff',
objectKey: 'generated/from-resource.png',
assetObjectId: 'asset-object-from-resource',
assetKind: 'character',
});
expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词');
expect(layer.generatedAssetSnapshot?.assetId).toBe('asset-generated-bff');
});
it('creates edit result layers beside the source image', () => {
const sourceLayer = createSourceLayer();
const layer = createGeneratedResultLayer({
@@ -101,16 +101,24 @@ function applyGeneratedMetadata(
generated: EditorImageGenerationResult,
generationInputs: CanvasGenerationInputs | undefined,
): CanvasLayer {
const resource = generated.resource;
const asset = generated.asset;
return {
...layer,
resourceId: resource?.resourceId ?? layer.resourceId,
sourceAssetId: asset?.assetId ?? layer.sourceAssetId,
prompt: generated.prompt,
actualPrompt: generated.actualPrompt ?? generated.prompt,
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
objectKey: generated.objectKey,
assetObjectId: generated.assetObjectId,
generationInputs,
objectKey: resource?.objectKey ?? generated.objectKey,
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
assetKind:
(resource?.assetKind as CanvasLayer['assetKind'] | undefined) ??
layer.assetKind,
generationInputs: resource?.generationInputs ?? generationInputs,
generatedAssetSnapshot: asset,
};
}
@@ -232,6 +240,8 @@ export function createIconSpritesheetResultLayers({
frame,
spritesheetTitle,
}: IconSpritesheetResultLayerOptions): CanvasLayer[] {
const spritesheetResource = generated.spritesheetResource;
const spritesheetAsset = generated.spritesheetAsset;
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const startX =
frame?.x ?? worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2;
@@ -250,7 +260,9 @@ export function createIconSpritesheetResultLayers({
);
const spritesheetLayer: CanvasLayer = {
id: `layer-icon-spritesheet-${startIndex}`,
resourceId: `local-resource-icon-spritesheet-${startIndex}`,
resourceId:
spritesheetResource?.resourceId ??
`local-resource-icon-spritesheet-${startIndex}`,
title: spritesheetTitle ?? `图标素材图集 ${startIndex}`,
src: generated.spritesheetImageSrc,
x: startX,
@@ -267,7 +279,12 @@ export function createIconSpritesheetResultLayers({
provider: generated.provider,
taskId: generated.taskId,
assetKind: 'icon-spritesheet',
generationInputs,
sourceAssetId: spritesheetAsset?.assetId,
objectKey: spritesheetResource?.objectKey ?? undefined,
assetObjectId: spritesheetResource?.assetObjectId ?? undefined,
generationInputs:
spritesheetResource?.generationInputs ?? generationInputs,
generatedAssetSnapshot: spritesheetAsset,
};
const iconStartX = startX + spritesheetDisplaySize.width + 32;
let cursorX = iconStartX;
@@ -275,6 +292,8 @@ export function createIconSpritesheetResultLayers({
let rowHeight = 0;
const iconLayers = iconResults.map((icon, index) => {
const resource = icon.resource;
const asset = icon.asset;
const originalWidth = icon.width || 128;
const originalHeight = icon.height || 128;
const { width, height } = resolveLayerResolutionSize(
@@ -291,7 +310,7 @@ export function createIconSpritesheetResultLayers({
const generatedIndex = startIndex + index + 1;
const layer: CanvasLayer = {
id: `layer-icon-${generatedIndex}`,
resourceId: `local-resource-icon-${generatedIndex}`,
resourceId: resource?.resourceId ?? `local-resource-icon-${generatedIndex}`,
title: icon.name,
src: icon.imageSrc,
x: cursorX,
@@ -308,7 +327,11 @@ export function createIconSpritesheetResultLayers({
provider: generated.provider,
taskId: generated.taskId,
assetKind: 'icon',
generationInputs,
sourceAssetId: asset?.assetId,
objectKey: resource?.objectKey ?? undefined,
assetObjectId: resource?.assetObjectId ?? undefined,
generationInputs: resource?.generationInputs ?? generationInputs,
generatedAssetSnapshot: asset,
};
cursorX += width + spacing;
@@ -58,6 +58,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
imageSize: '1K',
},
result: {
title: '生成图片 3',
generationInputs: {
fields: [{ title: '生成提示词', value: '一张发光主视觉' }],
references: [],
@@ -453,6 +453,7 @@ export function buildImageGenerationSubmissionPlan({
: {}),
},
result: {
title: `生成图片 ${nextGeneratedIndex}`,
generationInputs: buildImageGenerationInputs(
normalizedPrompt,
dialog.generationReferences,
@@ -20,6 +20,7 @@ import {
generateEditorSoundEffect,
generateEditorVideo,
} from '../../services/image-editor/editorProjectClient';
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputs,
@@ -96,7 +97,9 @@ type GenerationSubmissionWorkflowOptions = {
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
rememberImageModel: (imageModel: string) => void;
persistGeneratedAsset?: (layer: CanvasLayer) => void;
projectId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
};
async function normalizePublicationReferenceImages(
@@ -142,14 +145,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setActiveTool,
setActiveSidebarPanel,
rememberImageModel,
persistGeneratedAsset,
projectId,
assetFolderId,
upsertGeneratedAsset,
}: GenerationSubmissionWorkflowOptions) {
const addGeneratedLayersToCanvas = useCallback(
(nextLayers: CanvasLayer[]) => {
appendCanvasLayersWithResources(nextLayers);
nextLayers.forEach((layer) => persistGeneratedAsset?.(layer));
nextLayers.forEach((layer) => {
const asset = layer.generatedAssetSnapshot;
if (asset) {
upsertGeneratedAsset?.(asset);
}
});
},
[appendCanvasLayersWithResources, persistGeneratedAsset],
[appendCanvasLayersWithResources, upsertGeneratedAsset],
);
const addGeneratedResultLayer = useCallback(
@@ -510,8 +520,14 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const sourceImageSrc = await resolveEditorImageReferenceDataUrl(
sourceLayer.objectKey?.trim() || sourceLayer.src,
);
const generationInputs =
buildUiDesignAssetExtractionGenerationInputs(sourceLayer);
const generated = await extractEditorUiDesignAssets({
sourceImageSrc,
projectId,
generationInputs,
assetFolderId,
spritesheetLabel: `${sourceLayer.title} 素材图集`,
});
const extractionFrame = {
x: sourceLayer.x + sourceLayer.width + 32,
@@ -524,7 +540,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const nextLayers = addIconSpritesheetResultLayers(
generated,
generated.iconImageSrcs,
buildUiDesignAssetExtractionGenerationInputs(sourceLayer),
generationInputs,
extractionFrame,
undefined,
{
@@ -542,7 +558,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
);
}
},
[addIconSpritesheetResultLayers, fitLayers],
[addIconSpritesheetResultLayers, fitLayers, projectId, assetFolderId],
);
const submitIconSpritesheetGeneration = useCallback(
@@ -583,9 +599,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
});
try {
const generated = await generateEditorIconSpritesheet(
submissionPlan.input,
);
const generated = await generateEditorIconSpritesheet({
...submissionPlan.input,
projectId,
generationInputs: submissionPlan.generationInputs,
assetFolderId,
});
rememberImageModel(submissionPlan.rememberImageModel);
addIconSpritesheetResultLayers(
generated,
@@ -608,6 +627,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
addIconSpritesheetResultLayers,
getGeneratingDialogPlaceholder,
rememberImageModel,
projectId,
assetFolderId,
updateCanvasGenerationDialogById,
],
);
@@ -663,19 +684,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const sourceReferenceImageSrc = await resolveEditorImageReferenceDataUrl(
quickEditSourceLayer.objectKey?.trim() || quickEditSourceLayer.src,
);
const generated = await generateEditorImage({
prompt: normalizedPrompt,
size: quickEditPanel.size,
kind: 'quick-edit',
model: normalizeEditorImageModel(quickEditPanel.model),
referenceImageSrcs: [
...extraReferenceImageSrcs,
sourceReferenceImageSrc,
],
});
addQuickEditResultLayer(
generated,
quickEditSourceLayer,
const generationInputs =
panelMode === 'redraw'
? buildEditGenerationInputs(
'重绘提示词',
@@ -687,7 +696,29 @@ export function useImageCanvasGenerationSubmissionWorkflow({
normalizedPrompt,
quickEditSourceLayer,
quickEditReferences,
),
);
const generated = await generateEditorImage({
prompt: normalizedPrompt,
size: quickEditPanel.size,
kind: 'quick-edit',
model: normalizeEditorImageModel(quickEditPanel.model),
referenceImageSrcs: [
...extraReferenceImageSrcs,
sourceReferenceImageSrc,
],
projectId,
assetKind: quickEditSourceLayer.assetKind,
generationInputs,
assetFolderId,
assetLabel: `${quickEditSourceLayer.title} ${
panelMode === 'redraw' ? '重绘' : '快速编辑'
}`,
sourceResourceId: quickEditSourceLayer.resourceId,
});
addQuickEditResultLayer(
generated,
quickEditSourceLayer,
generationInputs,
panelMode,
quickEditDialogId
? {
@@ -722,6 +753,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
addQuickEditResultLayer,
getGeneratingDialogPlaceholder,
openCanvasGenerationDialog,
projectId,
assetFolderId,
quickEditPanel,
quickEditSourceLayer,
setQuickEditPanel,
@@ -764,11 +797,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
});
if (submissionPlan.kind === 'edit') {
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
submissionPlan.sourceLayer.src,
submissionPlan.sourceLayer.objectKey?.trim() ||
submissionPlan.sourceLayer.src,
);
const generated = await editEditorImage({
prompt: submissionPlan.normalizedPrompt,
sourceImageSrc: referenceImageSrc,
projectId,
assetKind: submissionPlan.sourceLayer.assetKind,
generationInputs: submissionPlan.generationInputs,
assetFolderId,
assetLabel: `${submissionPlan.sourceLayer.title} 修改结果`,
sourceResourceId: submissionPlan.sourceLayer.resourceId,
});
addGeneratedResultLayer(generated, {
sourceLayer: submissionPlan.sourceLayer,
@@ -800,7 +840,14 @@ export function useImageCanvasGenerationSubmissionWorkflow({
submissionPlan.input.kind === 'publication-material'
? await normalizePublicationReferenceImages(submissionPlan.input)
: submissionPlan.input;
const generated = await generateEditorImage(imageGenerationInput);
const generated = await generateEditorImage({
...imageGenerationInput,
projectId,
assetKind: submissionPlan.result.assetKind,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
});
if (submissionPlan.rememberImageModel) {
rememberImageModel(submissionPlan.rememberImageModel);
}
@@ -840,6 +887,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
layerCounterRef,
layers,
rememberImageModel,
projectId,
assetFolderId,
setGenerateDialog,
updateCanvasGenerationDialogById,
],
@@ -15,6 +15,7 @@ import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationDialogState,
CanvasLayer,
@@ -87,6 +88,9 @@ type ImageCanvasGenerationSurfaceOptions = {
requestUpload: (target: UploadTarget) => void;
persistGeneratedAsset?: (layer: CanvasLayer) => void;
persistUpdatedLayerResource?: (layer: CanvasLayer) => void;
projectId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
};
function renderEditorPortal(node: ReactNode) {
@@ -160,6 +164,9 @@ export function useImageCanvasGenerationSurface({
requestUpload,
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
assetFolderId,
upsertGeneratedAsset,
}: ImageCanvasGenerationSurfaceOptions) {
const toolbarOptionCloseTimerRef = useRef<ReturnType<
typeof setTimeout
@@ -190,6 +197,9 @@ export function useImageCanvasGenerationSurface({
setImageContextMenu,
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
assetFolderId,
upsertGeneratedAsset,
});
const activeGenerationLayer = activeCanvasGenerationDialog?.generatedLayerId

Some files were not shown because too many files have changed in this diff Show More