迁移画布素材生成元数据

扩展 editor_project_resource 和 editor_asset 保存素材类别与生成输入快照

调整前端资源与素材读写链路并停止把素材元数据写入新 layout

在服务端保存 layout 时回填并清洗旧图层元数据

更新图片画布契约文档、共享决策和相关测试期望
This commit is contained in:
2026-06-19 15:11:32 +08:00
parent fdcccec385
commit 76445da9b4
22 changed files with 556 additions and 55 deletions
@@ -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}"))
})
}
@@ -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,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 = {