迁移画布素材生成元数据
扩展 editor_project_resource 和 editor_asset 保存素材类别与生成输入快照 调整前端资源与素材读写链路并停止把素材元数据写入新 layout 在服务端保存 layout 时回填并清洗旧图层元数据 更新图片画布契约文档、共享决策和相关测试期望
This commit is contained in:
@@ -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、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。
|
||||
|
||||
@@ -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 直接作为生图弹窗主文案。
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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<String>,
|
||||
task_id: Option<String>,
|
||||
source_resource_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -145,6 +148,8 @@ pub struct EditorAssetCreateRequest {
|
||||
model: Option<String>,
|
||||
provider: Option<String>,
|
||||
task_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -325,6 +330,8 @@ pub struct EditorProjectResourcePayload {
|
||||
provider: Option<String>,
|
||||
task_id: Option<String>,
|
||||
source_resource_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
@@ -365,6 +372,8 @@ pub struct EditorAssetPayload {
|
||||
model: Option<String>,
|
||||
provider: Option<String>,
|
||||
task_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
@@ -545,6 +554,8 @@ pub async fn create_editor_project_resource(
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorProjectResourceCreateRequest>,
|
||||
) -> Result<Json<Value>, 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<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorAssetCreateRequest>,
|
||||
) -> Result<Json<Value>, 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<String, AppError> {
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn serialize_editor_asset_metadata(metadata: Option<Value>) -> Result<Option<String>, 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<String>) -> Option<String> {
|
||||
value
|
||||
.map(|item| item.trim().to_string())
|
||||
|
||||
@@ -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}"))
|
||||
})
|
||||
}
|
||||
|
||||
+2
@@ -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",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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,125 @@ 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,
|
||||
|
||||
@@ -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: '背包整理玩法',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<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) {
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -29,7 +29,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function createLayer(id: string): CanvasLayer {
|
||||
function createLayer(
|
||||
id: string,
|
||||
overrides: Partial<CanvasLayer> = {},
|
||||
): 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({
|
||||
<span data-testid="project-title">{projectTitle}</span>
|
||||
<span data-testid="project-rename">{projectRenameValue}</span>
|
||||
<span data-testid="layers">
|
||||
{layers.map((layer) => `${layer.id}:${layer.resourceId}`).join(',')}
|
||||
{layers
|
||||
.map(
|
||||
(layer) =>
|
||||
`${layer.id}:${layer.resourceId}:${layer.assetKind ?? '-'}:${
|
||||
layer.generationInputs?.fields[0]?.value ?? '-'
|
||||
}`,
|
||||
)
|
||||
.join(',')}
|
||||
</span>
|
||||
<span data-testid="selected">{selectedLayerRef.current ?? '-'}</span>
|
||||
<span data-testid="counter">{layerCounterRef.current}</span>
|
||||
@@ -135,6 +146,24 @@ function ProjectPersistenceHarness({
|
||||
>
|
||||
append
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
persistence.appendCanvasLayersWithResources([
|
||||
createLayer('layer-character', {
|
||||
title: '角色形象',
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '银发游侠' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}}
|
||||
>
|
||||
append character
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -218,11 +247,11 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('layers').textContent).toBe(
|
||||
'layer-a:local-layer-a',
|
||||
'layer-a:local-layer-a:-:-',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toBe(
|
||||
'layer-a:resource-added-asset-a',
|
||||
'layer-a:resource-added-asset-a:-:-',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
@@ -241,6 +270,83 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('persists generated layer metadata on the project resource and restores it from resources', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '已有角色项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-character',
|
||||
resourceId: 'resource-character',
|
||||
title: '角色形象',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 3,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-character',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/generated/character.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '银发游侠' }],
|
||||
references: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
createEditorProjectResourceMock.mockResolvedValueOnce({
|
||||
resourceId: 'resource-character-new',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: 'data:image/png;base64,YQ==',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '银发游侠' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toBe(
|
||||
'layer-character:resource-character:character:银发游侠',
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'append character' }).click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '银发游侠' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('saves generation dialogs in the project layout and restores them on load', async () => {
|
||||
const savedDialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-1',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
saveEditorProjectLayout,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import {
|
||||
type CanvasLayerResourceMetadata,
|
||||
hydrateLayer,
|
||||
serializeCanvasLayout,
|
||||
splitCanvasLayoutItems,
|
||||
@@ -108,6 +109,8 @@ export function useImageCanvasProjectPersistence({
|
||||
taskId: layer.taskId,
|
||||
durationSeconds: layer.durationSeconds,
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
assetKind: layer.assetKind,
|
||||
generationInputs: layer.generationInputs,
|
||||
})
|
||||
.then((resource) => {
|
||||
options.onCreated?.(resource.resourceId);
|
||||
@@ -205,12 +208,14 @@ export function useImageCanvasProjectPersistence({
|
||||
createProjectResourceForLayer(layer, options);
|
||||
});
|
||||
setViewport(project.viewport);
|
||||
const resourcesById = new Map(
|
||||
const resourcesById = new Map<string, CanvasLayerResourceMetadata>(
|
||||
project.resources.map((resource) => [
|
||||
resource.resourceId,
|
||||
{
|
||||
imageSrc: resource.imageSrc,
|
||||
durationSeconds: resource.durationSeconds,
|
||||
assetKind: resource.assetKind,
|
||||
generationInputs: resource.generationInputs,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -279,6 +279,11 @@ describe('editorProjectClient', () => {
|
||||
prompt: 'dragon knight',
|
||||
model: 'gpt-image-2',
|
||||
sourceResourceId: 'resource-source',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: 'dragon knight' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
@@ -295,6 +300,11 @@ describe('editorProjectClient', () => {
|
||||
prompt: 'dragon knight',
|
||||
model: 'gpt-image-2',
|
||||
sourceResourceId: 'resource-source',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: 'dragon knight' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'创建图片画布资源失败',
|
||||
@@ -425,6 +435,11 @@ describe('editorProjectClient', () => {
|
||||
width: 640,
|
||||
height: 480,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon',
|
||||
generationInputs: {
|
||||
fields: [{ title: '素材描述', value: '返回按钮' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
await updateEditorAsset('asset-1', {
|
||||
label: '角色主视觉.png',
|
||||
@@ -444,6 +459,11 @@ describe('editorProjectClient', () => {
|
||||
width: 640,
|
||||
height: 480,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon',
|
||||
generationInputs: {
|
||||
fields: [{ title: '素材描述', value: '返回按钮' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'创建图片画布素材失败',
|
||||
|
||||
@@ -29,6 +29,22 @@ export type EditorProjectLayerSnapshot = Record<string, unknown> & {
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
export type EditorAssetGenerationInputField = {
|
||||
title: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type EditorAssetGenerationInputReference = {
|
||||
title: string;
|
||||
label: string;
|
||||
src: string;
|
||||
};
|
||||
|
||||
export type EditorAssetGenerationInputs = {
|
||||
fields: EditorAssetGenerationInputField[];
|
||||
references: EditorAssetGenerationInputReference[];
|
||||
};
|
||||
|
||||
export type EditorProjectResourceSourceType =
|
||||
| 'uploaded'
|
||||
| 'generated'
|
||||
@@ -50,6 +66,8 @@ export type EditorProjectResourceSnapshot = {
|
||||
taskId?: string | null;
|
||||
durationSeconds?: number | null;
|
||||
sourceResourceId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: EditorAssetGenerationInputs | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
@@ -80,6 +98,8 @@ export type EditorAssetSnapshot = {
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
durationSeconds?: number | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: EditorAssetGenerationInputs | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
@@ -311,6 +331,8 @@ export type EditorProjectResourceCreateInput = {
|
||||
taskId?: string | null;
|
||||
durationSeconds?: number | null;
|
||||
sourceResourceId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: EditorAssetGenerationInputs | null;
|
||||
};
|
||||
|
||||
export type EditorAssetCreateInput = {
|
||||
@@ -328,6 +350,8 @@ export type EditorAssetCreateInput = {
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
durationSeconds?: number | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: EditorAssetGenerationInputs | null;
|
||||
};
|
||||
|
||||
export type EditorAssetUpdateInput = {
|
||||
|
||||
Reference in New Issue
Block a user