后端保存画布生成素材
生成接口接收项目与素材上下文并返回 resource/asset 快照 画布前端使用后端回包落层并避免重复创建资源和素材 补充生成元数据保存边界的测试与文档
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
## 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"` 保存在画布布局中。
|
||||
- 决策:普通图层的新保存不再把 `assetKind/generationInputs` 写入 `editor_canvas.layers_json`。`editor_project_resource` 保存项目画布资源快照的 `asset_kind/generation_inputs_json`,`editor_asset` 保存账号级素材的同名元数据;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `projectId` / `assetFolderId` 时由后端创建新 resource / asset 并把快照回传前端,前端只用回包更新画布图层和素材栏,不再把同一生成结果二次调用保存接口。前端加载时优先从 resource / asset 恢复素材类别和生成输入快照,旧 layout 中的同名字段只作为历史兼容兜底。生成器对象本身仍作为 `itemType="generation-dialog"` 保存在画布布局中。
|
||||
- 影响范围:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/mapper/editor_project.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`src/services/image-editor/editorProjectClient.ts`、图片画布 hydrate / serialize / project persistence / asset library 代码和后端数据契约文档。
|
||||
- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`,并按需补充 `cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
@@ -40,11 +40,11 @@
|
||||
- 新增 `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、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
|
||||
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId。
|
||||
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。
|
||||
- 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
|
||||
- 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。
|
||||
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
|
||||
- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;视频结果当前只保存为画布视频资源,不进入图片素材库。
|
||||
- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成,前端只把响应中的 resource / asset 快照用于落画布和更新素材栏,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。
|
||||
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
|
||||
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
|
||||
|
||||
@@ -65,10 +65,10 @@
|
||||
- `POST /api/editor/assets`:批量或单个创建账号级素材,支持按钮上传和拖拽上传后的 data URL / 后续 OSS 元数据。
|
||||
- `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。
|
||||
- `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;接口返回 data URL、尺寸、prompt、model、provider 和 taskId。
|
||||
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize` 和 `priceMudPoints`;`priceMudPoints` 必须来自编辑器生成计费配置的 `icon` 档位(首版 12 泥点),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`,后端把 UI 尺寸归一为文档支持的 `1024x1024`、`1024x1536`、`1536x1024`、`2048x2048`、`2048x1152` 等 `size` 字符串。
|
||||
- `POST /api/editor/ui-designs/assets/extractions`:以 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。前端必须把 spritesheet 原图与拆分素材都加入画布。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。
|
||||
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置的 `icon` 档位(首版 12 泥点),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。
|
||||
- `POST /api/editor/ui-designs/assets/extractions`:以 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
|
||||
- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。
|
||||
|
||||
所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。
|
||||
|
||||
@@ -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、源资源关系、`asset_kind` 和 `generation_inputs_json`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。
|
||||
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind` 和 `generation_inputs_json`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。
|
||||
- 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。
|
||||
|
||||
### `editor_asset_folder`
|
||||
@@ -461,7 +461,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`EditorAsset`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind` 和 `generation_inputs_json`。素材在同一账号的所有项目中可见;素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。
|
||||
- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind` 和 `generation_inputs_json`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,前端只用该快照更新素材栏。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。
|
||||
- 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。
|
||||
|
||||
### `inventory_slot`
|
||||
|
||||
@@ -61,6 +61,7 @@ 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_ASSET_DEFAULT_FOLDER_ID: &str = "project";
|
||||
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
|
||||
const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
|
||||
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
|
||||
@@ -169,6 +170,12 @@ pub struct EditorImageGenerationRequest {
|
||||
aspect_ratio: Option<String>,
|
||||
image_size: Option<String>,
|
||||
reference_image_srcs: Option<Vec<String>>,
|
||||
project_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
asset_folder_id: Option<String>,
|
||||
asset_label: Option<String>,
|
||||
source_resource_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -176,6 +183,12 @@ pub struct EditorImageGenerationRequest {
|
||||
pub struct EditorImageEditRequest {
|
||||
prompt: String,
|
||||
source_image_src: String,
|
||||
project_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
asset_folder_id: Option<String>,
|
||||
asset_label: Option<String>,
|
||||
source_resource_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -187,12 +200,19 @@ pub struct EditorIconSpritesheetGenerationRequest {
|
||||
aspect_ratio: Option<String>,
|
||||
image_size: Option<String>,
|
||||
price_mud_points: u32,
|
||||
project_id: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
asset_folder_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorUiDesignAssetExtractionRequest {
|
||||
source_image_src: String,
|
||||
project_id: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
asset_folder_id: Option<String>,
|
||||
spritesheet_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -249,6 +269,33 @@ pub struct EditorAssetResponse {
|
||||
asset: EditorAssetPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct EditorGeneratedAssetRecord {
|
||||
resource: Option<EditorProjectResourcePayload>,
|
||||
asset: Option<EditorAssetPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PersistEditorGeneratedAssetInput {
|
||||
project_id: Option<String>,
|
||||
owner_user_id: String,
|
||||
folder_id: Option<String>,
|
||||
label: String,
|
||||
image_src: String,
|
||||
object_key: Option<String>,
|
||||
asset_object_id: Option<String>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
prompt: String,
|
||||
actual_prompt: Option<String>,
|
||||
model: String,
|
||||
provider: String,
|
||||
task_id: String,
|
||||
source_resource_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorImageGenerationResponse {
|
||||
@@ -263,6 +310,8 @@ pub struct EditorImageGenerationResponse {
|
||||
model: String,
|
||||
provider: &'static str,
|
||||
task_id: String,
|
||||
resource: Option<EditorProjectResourcePayload>,
|
||||
asset: Option<EditorAssetPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -272,6 +321,8 @@ pub struct EditorIconSpritesheetIconResponse {
|
||||
image_src: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
resource: Option<EditorProjectResourcePayload>,
|
||||
asset: Option<EditorAssetPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -287,6 +338,8 @@ pub struct EditorIconSpritesheetGenerationResponse {
|
||||
provider: &'static str,
|
||||
task_id: String,
|
||||
price_mud_points: u32,
|
||||
spritesheet_resource: Option<EditorProjectResourcePayload>,
|
||||
spritesheet_asset: Option<EditorAssetPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -967,6 +1020,32 @@ pub async fn generate_editor_image(
|
||||
image.mime_type,
|
||||
BASE64_STANDARD.encode(image.bytes.as_slice())
|
||||
);
|
||||
let generated_asset = persist_editor_generated_asset(
|
||||
&state,
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: payload.project_id,
|
||||
owner_user_id: current_owner_user_id(&authenticated),
|
||||
folder_id: payload.asset_folder_id,
|
||||
label: normalize_optional_string(payload.asset_label)
|
||||
.unwrap_or_else(|| role_setting.clone()),
|
||||
image_src: image_src.clone(),
|
||||
object_key: persisted.as_ref().map(|asset| asset.object_key.clone()),
|
||||
asset_object_id: persisted
|
||||
.as_ref()
|
||||
.map(|asset| asset.asset_object_id.clone()),
|
||||
width,
|
||||
height,
|
||||
prompt: role_setting.clone(),
|
||||
actual_prompt: generated.actual_prompt.clone(),
|
||||
model: generation_options.model.to_string(),
|
||||
provider: "VectorEngine".to_string(),
|
||||
task_id: generated.task_id.clone(),
|
||||
source_resource_id: payload.source_resource_id,
|
||||
asset_kind: payload.asset_kind,
|
||||
generation_inputs: payload.generation_inputs,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -984,6 +1063,8 @@ pub async fn generate_editor_image(
|
||||
model: generation_options.model.to_string(),
|
||||
provider: "VectorEngine",
|
||||
task_id: generated.task_id,
|
||||
resource: generated_asset.resource,
|
||||
asset: generated_asset.asset,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -1155,6 +1236,30 @@ pub async fn edit_editor_image(
|
||||
image.mime_type,
|
||||
BASE64_STANDARD.encode(image.bytes.as_slice())
|
||||
);
|
||||
let generated_asset = persist_editor_generated_asset(
|
||||
&state,
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: payload.project_id,
|
||||
owner_user_id: current_owner_user_id(&authenticated),
|
||||
folder_id: payload.asset_folder_id,
|
||||
label: normalize_optional_string(payload.asset_label)
|
||||
.unwrap_or_else(|| "图片修改结果".to_string()),
|
||||
image_src: image_src.clone(),
|
||||
object_key: None,
|
||||
asset_object_id: None,
|
||||
width,
|
||||
height,
|
||||
prompt: prompt.clone(),
|
||||
actual_prompt: generated.actual_prompt.clone(),
|
||||
model: GPT_IMAGE_2_MODEL.to_string(),
|
||||
provider: "VectorEngine".to_string(),
|
||||
task_id: generated.task_id.clone(),
|
||||
source_resource_id: payload.source_resource_id,
|
||||
asset_kind: payload.asset_kind,
|
||||
generation_inputs: payload.generation_inputs,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -1170,6 +1275,8 @@ pub async fn edit_editor_image(
|
||||
model: GPT_IMAGE_2_MODEL.to_string(),
|
||||
provider: "VectorEngine",
|
||||
task_id: generated.task_id,
|
||||
resource: generated_asset.resource,
|
||||
asset: generated_asset.asset,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -1260,10 +1367,63 @@ pub async fn generate_editor_icon_spritesheet(
|
||||
})?;
|
||||
let spritesheet_image_src =
|
||||
data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice());
|
||||
let icon_image_srcs = icon_slices
|
||||
.into_iter()
|
||||
.map(editor_icon_response_from_slice)
|
||||
.collect();
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
let owner_user_id = current_owner_user_id(&authenticated);
|
||||
let spritesheet_record = persist_editor_generated_asset(
|
||||
&state,
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: payload.project_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
folder_id: payload.asset_folder_id.clone(),
|
||||
label: "图标素材图集".to_string(),
|
||||
image_src: spritesheet_image_src.clone(),
|
||||
object_key: None,
|
||||
asset_object_id: None,
|
||||
width: spritesheet_width,
|
||||
height: spritesheet_height,
|
||||
prompt: prompt.clone(),
|
||||
actual_prompt: generated.actual_prompt.clone(),
|
||||
model: generation_options.model.to_string(),
|
||||
provider: "VectorEngine".to_string(),
|
||||
task_id: generated.task_id.clone(),
|
||||
source_resource_id: None,
|
||||
asset_kind: Some("icon-spritesheet".to_string()),
|
||||
generation_inputs: generation_inputs.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let mut icon_image_srcs = Vec::new();
|
||||
for icon in icon_slices {
|
||||
let icon_response = editor_icon_response_from_slice(icon);
|
||||
let icon_record = persist_editor_generated_asset(
|
||||
&state,
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: payload.project_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
folder_id: payload.asset_folder_id.clone(),
|
||||
label: icon_response.name.clone(),
|
||||
image_src: icon_response.image_src.clone(),
|
||||
object_key: None,
|
||||
asset_object_id: None,
|
||||
width: icon_response.width,
|
||||
height: icon_response.height,
|
||||
prompt: prompt.clone(),
|
||||
actual_prompt: generated.actual_prompt.clone(),
|
||||
model: generation_options.model.to_string(),
|
||||
provider: "VectorEngine".to_string(),
|
||||
task_id: generated.task_id.clone(),
|
||||
source_resource_id: None,
|
||||
asset_kind: Some("icon".to_string()),
|
||||
generation_inputs: generation_inputs.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
icon_image_srcs.push(EditorIconSpritesheetIconResponse {
|
||||
resource: icon_record.resource,
|
||||
asset: icon_record.asset,
|
||||
..icon_response
|
||||
});
|
||||
}
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -1278,6 +1438,8 @@ pub async fn generate_editor_icon_spritesheet(
|
||||
provider: "VectorEngine",
|
||||
task_id: generated.task_id,
|
||||
price_mud_points: expected_price_mud_points,
|
||||
spritesheet_resource: spritesheet_record.resource,
|
||||
spritesheet_asset: spritesheet_record.asset,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -1337,10 +1499,65 @@ pub async fn extract_editor_ui_design_assets(
|
||||
})?;
|
||||
let spritesheet_image_src =
|
||||
data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice());
|
||||
let icon_image_srcs = icon_slices
|
||||
.into_iter()
|
||||
.map(editor_icon_response_from_slice)
|
||||
.collect();
|
||||
let prompt = EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT.to_string();
|
||||
let generation_inputs = payload.generation_inputs.clone();
|
||||
let owner_user_id = current_owner_user_id(&authenticated);
|
||||
let spritesheet_record = persist_editor_generated_asset(
|
||||
&state,
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: payload.project_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
folder_id: payload.asset_folder_id.clone(),
|
||||
label: normalize_optional_string(payload.spritesheet_label)
|
||||
.unwrap_or_else(|| "UI设计图素材图集".to_string()),
|
||||
image_src: spritesheet_image_src.clone(),
|
||||
object_key: None,
|
||||
asset_object_id: None,
|
||||
width: spritesheet_width,
|
||||
height: spritesheet_height,
|
||||
prompt: prompt.clone(),
|
||||
actual_prompt: generated.actual_prompt.clone(),
|
||||
model: GPT_IMAGE_2_MODEL.to_string(),
|
||||
provider: "VectorEngine".to_string(),
|
||||
task_id: generated.task_id.clone(),
|
||||
source_resource_id: None,
|
||||
asset_kind: Some("icon-spritesheet".to_string()),
|
||||
generation_inputs: generation_inputs.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let mut icon_image_srcs = Vec::new();
|
||||
for icon in icon_slices {
|
||||
let icon_response = editor_icon_response_from_slice(icon);
|
||||
let icon_record = persist_editor_generated_asset(
|
||||
&state,
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: payload.project_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
folder_id: payload.asset_folder_id.clone(),
|
||||
label: icon_response.name.clone(),
|
||||
image_src: icon_response.image_src.clone(),
|
||||
object_key: None,
|
||||
asset_object_id: None,
|
||||
width: icon_response.width,
|
||||
height: icon_response.height,
|
||||
prompt: prompt.clone(),
|
||||
actual_prompt: generated.actual_prompt.clone(),
|
||||
model: GPT_IMAGE_2_MODEL.to_string(),
|
||||
provider: "VectorEngine".to_string(),
|
||||
task_id: generated.task_id.clone(),
|
||||
source_resource_id: None,
|
||||
asset_kind: Some("icon".to_string()),
|
||||
generation_inputs: generation_inputs.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
icon_image_srcs.push(EditorIconSpritesheetIconResponse {
|
||||
resource: icon_record.resource,
|
||||
asset: icon_record.asset,
|
||||
..icon_response
|
||||
});
|
||||
}
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -1349,7 +1566,7 @@ pub async fn extract_editor_ui_design_assets(
|
||||
spritesheet_width,
|
||||
spritesheet_height,
|
||||
icon_image_srcs,
|
||||
prompt: EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT.to_string(),
|
||||
prompt,
|
||||
actual_prompt: generated.actual_prompt,
|
||||
model: GPT_IMAGE_2_MODEL.to_string(),
|
||||
provider: "VectorEngine",
|
||||
@@ -1358,6 +1575,8 @@ pub async fn extract_editor_ui_design_assets(
|
||||
Some("icon"),
|
||||
Some(GPT_IMAGE_2_MODEL),
|
||||
),
|
||||
spritesheet_resource: spritesheet_record.resource,
|
||||
spritesheet_asset: spritesheet_record.asset,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -1478,6 +1697,96 @@ fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPay
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_editor_generated_asset(
|
||||
state: &AppState,
|
||||
input: PersistEditorGeneratedAssetInput,
|
||||
) -> Result<EditorGeneratedAssetRecord, AppError> {
|
||||
let generation_inputs_json = serialize_editor_asset_metadata(input.generation_inputs.clone())?;
|
||||
let now_micros = current_utc_micros();
|
||||
let resource = if let Some(project_id) = normalize_optional_string(input.project_id.clone()) {
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
|
||||
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
|
||||
project_id,
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
asset_object_id: input.asset_object_id.clone(),
|
||||
image_src: input.image_src.clone(),
|
||||
object_key: input.object_key.clone(),
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
source_type: "generated".to_string(),
|
||||
prompt: Some(input.prompt.clone()),
|
||||
actual_prompt: input.actual_prompt.clone(),
|
||||
model: Some(input.model.clone()),
|
||||
provider: Some(input.provider.clone()),
|
||||
task_id: Some(input.task_id.clone()),
|
||||
source_resource_id: normalize_optional_string(input.source_resource_id.clone()),
|
||||
asset_kind: normalize_optional_string(input.asset_kind.clone()),
|
||||
generation_inputs_json: generation_inputs_json.clone(),
|
||||
updated_at_micros: now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
Some(editor_project_resource_payload_from_record(record))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let folder_id =
|
||||
normalize_generated_asset_folder_id(input.folder_id, input.owner_user_id.as_str());
|
||||
let asset = if let Some(folder_id) = folder_id {
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.create_editor_asset(EditorAssetCreateRecordInput {
|
||||
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
folder_id,
|
||||
label: input.label,
|
||||
asset_object_id: input.asset_object_id,
|
||||
image_src: input.image_src,
|
||||
object_key: input.object_key,
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
source_type: "generated".to_string(),
|
||||
prompt: Some(input.prompt),
|
||||
actual_prompt: input.actual_prompt,
|
||||
model: Some(input.model),
|
||||
provider: Some(input.provider),
|
||||
task_id: Some(input.task_id),
|
||||
asset_kind: normalize_optional_string(input.asset_kind),
|
||||
generation_inputs_json,
|
||||
now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
Some(editor_asset_payload_from_record(record))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(EditorGeneratedAssetRecord { resource, asset })
|
||||
}
|
||||
|
||||
fn normalize_generated_asset_folder_id(
|
||||
folder_id: Option<String>,
|
||||
owner_user_id: &str,
|
||||
) -> Option<String> {
|
||||
let normalized = normalize_optional_string(folder_id);
|
||||
match normalized.as_deref() {
|
||||
None => None,
|
||||
Some("project") => Some(default_editor_asset_folder_id(owner_user_id)),
|
||||
Some(value) if value.starts_with("folder-") => {
|
||||
Some(default_editor_asset_folder_id(owner_user_id))
|
||||
}
|
||||
Some(_) => normalized,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_editor_asset_folder_id(owner_user_id: &str) -> String {
|
||||
format!("{owner_user_id}:asset-folder:{EDITOR_ASSET_DEFAULT_FOLDER_ID}")
|
||||
}
|
||||
|
||||
impl EditorCanvasViewportPayload {
|
||||
fn into_record(self) -> EditorCanvasViewportRecord {
|
||||
EditorCanvasViewportRecord {
|
||||
@@ -1681,6 +1990,8 @@ fn editor_icon_response_from_slice(
|
||||
image_src: data_url_from_image_bytes("image/png", icon.bytes.as_slice()),
|
||||
width: icon.width,
|
||||
height: icon.height,
|
||||
resource: None,
|
||||
asset: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2128,6 +2439,8 @@ mod tests {
|
||||
image_src: "data:image/png;base64,back".to_string(),
|
||||
width: 128,
|
||||
height: 128,
|
||||
resource: None,
|
||||
asset: None,
|
||||
}],
|
||||
prompt: "图标 prompt".to_string(),
|
||||
actual_prompt: Some("图标 prompt".to_string()),
|
||||
@@ -2138,9 +2451,35 @@ mod tests {
|
||||
Some("icon"),
|
||||
None,
|
||||
),
|
||||
spritesheet_resource: None,
|
||||
spritesheet_asset: None,
|
||||
};
|
||||
let payload = serde_json::to_value(response).expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["priceMudPoints"], json!(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_asset_folder_id_normalizes_project_and_legacy_local_folder() {
|
||||
assert_eq!(
|
||||
normalize_generated_asset_folder_id(Some("project".to_string()), "user-1"),
|
||||
Some("user-1:asset-folder:project".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_generated_asset_folder_id(Some(" folder-temporary ".to_string()), "user-1"),
|
||||
Some("user-1:asset-folder:project".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_generated_asset_folder_id(Some("folder-real".to_string()), "user-1"),
|
||||
Some("user-1:asset-folder:project".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_generated_asset_folder_id(
|
||||
Some("user-1:asset-folder:project".to_string()),
|
||||
"user-1",
|
||||
),
|
||||
Some("user-1:asset-folder:project".to_string())
|
||||
);
|
||||
assert_eq!(normalize_generated_asset_folder_id(None, "user-1"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1424,11 +1424,7 @@ fn migrate_canvas_layer_asset_metadata(
|
||||
let Some(object) = item.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
if object
|
||||
.get("itemType")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some()
|
||||
{
|
||||
if object.get("itemType").and_then(JsonValue::as_str).is_some() {
|
||||
continue;
|
||||
}
|
||||
let resource_id = object
|
||||
@@ -1499,12 +1495,14 @@ fn migrate_project_resource_asset_metadata(
|
||||
.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
|
||||
});
|
||||
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> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorCharacterAnimationFrameCount,
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorCharacterAnimationRatio,
|
||||
@@ -88,6 +89,7 @@ export type CanvasLayer = {
|
||||
assetObjectId?: string | null;
|
||||
sourceResourceId?: string | null;
|
||||
sourceAssetId?: string | null;
|
||||
generatedAssetSnapshot?: EditorAssetSnapshot | null;
|
||||
groupId?: string | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
createEditorAsset,
|
||||
createEditorProjectResource,
|
||||
type EditorAssetSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
@@ -411,6 +412,55 @@ export function ImageCanvasEditorView() {
|
||||
},
|
||||
[activeUploadFolderId, openEditorLoginModal, setAssetFolders, setAssets],
|
||||
);
|
||||
const upsertGeneratedAsset = useCallback(
|
||||
(asset: EditorAssetSnapshot) => {
|
||||
setAssets((currentAssets) => [
|
||||
...currentAssets.filter(
|
||||
(currentAsset) => currentAsset.id !== asset.assetId,
|
||||
),
|
||||
{
|
||||
id: asset.assetId,
|
||||
label: asset.label,
|
||||
src: asset.imageSrc,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
folderId: asset.folderId,
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: asset.sourceType,
|
||||
persisted: true,
|
||||
prompt: asset.prompt ?? undefined,
|
||||
actualPrompt: asset.actualPrompt ?? undefined,
|
||||
model: asset.model ?? undefined,
|
||||
provider: asset.provider ?? undefined,
|
||||
taskId: asset.taskId ?? undefined,
|
||||
objectKey: asset.objectKey ?? undefined,
|
||||
assetObjectId: asset.assetObjectId ?? undefined,
|
||||
durationSeconds: asset.durationSeconds ?? undefined,
|
||||
assetKind: canvasAssetKindOrNull(asset.assetKind),
|
||||
generationInputs: generationInputsOrNull(asset.generationInputs),
|
||||
},
|
||||
]);
|
||||
setAssetFolders((currentFolders) =>
|
||||
currentFolders.some((folder) => folder.id === asset.folderId)
|
||||
? currentFolders.map((folder) =>
|
||||
folder.id === asset.folderId
|
||||
? { ...folder, collapsed: false }
|
||||
: folder,
|
||||
)
|
||||
: [
|
||||
...currentFolders,
|
||||
{
|
||||
id: asset.folderId,
|
||||
label: '项目素材',
|
||||
collapsed: false,
|
||||
systemDefault: true,
|
||||
persisted: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
[setAssetFolders, setAssets],
|
||||
);
|
||||
const projectPersistenceRefs = useMemo(
|
||||
() => ({
|
||||
layersRef,
|
||||
@@ -552,6 +602,9 @@ export function ImageCanvasEditorView() {
|
||||
requestUpload,
|
||||
persistGeneratedAsset,
|
||||
persistUpdatedLayerResource,
|
||||
projectId,
|
||||
assetFolderId: activeUploadFolderId,
|
||||
upsertGeneratedAsset,
|
||||
});
|
||||
const {
|
||||
setQuickEditPanel,
|
||||
|
||||
@@ -97,6 +97,57 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词');
|
||||
});
|
||||
|
||||
it('uses backend-persisted resource and asset snapshots for generated layers', () => {
|
||||
const generationInputs = createGenerationInputs();
|
||||
const layer = createGeneratedResultLayer({
|
||||
generated: createGenerated({
|
||||
resource: {
|
||||
resourceId: 'resource-generated-bff',
|
||||
projectId: 'editor-project-1',
|
||||
imageSrc: 'data:image/png;base64,generated',
|
||||
objectKey: 'generated/from-resource.png',
|
||||
assetObjectId: 'asset-object-from-resource',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character',
|
||||
generationInputs,
|
||||
},
|
||||
asset: {
|
||||
assetId: 'asset-generated-bff',
|
||||
folderId: 'user-1:asset-folder:project',
|
||||
label: '角色形象',
|
||||
imageSrc: 'data:image/png;base64,generated',
|
||||
objectKey: 'generated/from-asset.png',
|
||||
assetObjectId: 'asset-object-from-asset',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character',
|
||||
generationInputs,
|
||||
},
|
||||
}),
|
||||
generatedIndex: 9,
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
assetKind: 'spec',
|
||||
generationInputs: {
|
||||
fields: [{ title: '旧输入', value: '本地兜底' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(layer).toMatchObject({
|
||||
resourceId: 'resource-generated-bff',
|
||||
sourceAssetId: 'asset-generated-bff',
|
||||
objectKey: 'generated/from-resource.png',
|
||||
assetObjectId: 'asset-object-from-resource',
|
||||
assetKind: 'character',
|
||||
});
|
||||
expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词');
|
||||
expect(layer.generatedAssetSnapshot?.assetId).toBe('asset-generated-bff');
|
||||
});
|
||||
|
||||
it('creates edit result layers beside the source image', () => {
|
||||
const sourceLayer = createSourceLayer();
|
||||
const layer = createGeneratedResultLayer({
|
||||
|
||||
@@ -101,16 +101,24 @@ function applyGeneratedMetadata(
|
||||
generated: EditorImageGenerationResult,
|
||||
generationInputs: CanvasGenerationInputs | undefined,
|
||||
): CanvasLayer {
|
||||
const resource = generated.resource;
|
||||
const asset = generated.asset;
|
||||
return {
|
||||
...layer,
|
||||
resourceId: resource?.resourceId ?? layer.resourceId,
|
||||
sourceAssetId: asset?.assetId ?? layer.sourceAssetId,
|
||||
prompt: generated.prompt,
|
||||
actualPrompt: generated.actualPrompt ?? generated.prompt,
|
||||
model: generated.model,
|
||||
provider: generated.provider,
|
||||
taskId: generated.taskId,
|
||||
objectKey: generated.objectKey,
|
||||
assetObjectId: generated.assetObjectId,
|
||||
generationInputs,
|
||||
objectKey: resource?.objectKey ?? generated.objectKey,
|
||||
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
|
||||
assetKind:
|
||||
(resource?.assetKind as CanvasLayer['assetKind'] | undefined) ??
|
||||
layer.assetKind,
|
||||
generationInputs: resource?.generationInputs ?? generationInputs,
|
||||
generatedAssetSnapshot: asset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -232,6 +240,8 @@ export function createIconSpritesheetResultLayers({
|
||||
frame,
|
||||
spritesheetTitle,
|
||||
}: IconSpritesheetResultLayerOptions): CanvasLayer[] {
|
||||
const spritesheetResource = generated.spritesheetResource;
|
||||
const spritesheetAsset = generated.spritesheetAsset;
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
const startX =
|
||||
frame?.x ?? worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2;
|
||||
@@ -250,7 +260,9 @@ export function createIconSpritesheetResultLayers({
|
||||
);
|
||||
const spritesheetLayer: CanvasLayer = {
|
||||
id: `layer-icon-spritesheet-${startIndex}`,
|
||||
resourceId: `local-resource-icon-spritesheet-${startIndex}`,
|
||||
resourceId:
|
||||
spritesheetResource?.resourceId ??
|
||||
`local-resource-icon-spritesheet-${startIndex}`,
|
||||
title: spritesheetTitle ?? `图标素材图集 ${startIndex}`,
|
||||
src: generated.spritesheetImageSrc,
|
||||
x: startX,
|
||||
@@ -267,7 +279,12 @@ export function createIconSpritesheetResultLayers({
|
||||
provider: generated.provider,
|
||||
taskId: generated.taskId,
|
||||
assetKind: 'icon-spritesheet',
|
||||
generationInputs,
|
||||
sourceAssetId: spritesheetAsset?.assetId,
|
||||
objectKey: spritesheetResource?.objectKey ?? undefined,
|
||||
assetObjectId: spritesheetResource?.assetObjectId ?? undefined,
|
||||
generationInputs:
|
||||
spritesheetResource?.generationInputs ?? generationInputs,
|
||||
generatedAssetSnapshot: spritesheetAsset,
|
||||
};
|
||||
const iconStartX = startX + spritesheetDisplaySize.width + 32;
|
||||
let cursorX = iconStartX;
|
||||
@@ -275,6 +292,8 @@ export function createIconSpritesheetResultLayers({
|
||||
let rowHeight = 0;
|
||||
|
||||
const iconLayers = iconResults.map((icon, index) => {
|
||||
const resource = icon.resource;
|
||||
const asset = icon.asset;
|
||||
const originalWidth = icon.width || 128;
|
||||
const originalHeight = icon.height || 128;
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
@@ -291,7 +310,7 @@ export function createIconSpritesheetResultLayers({
|
||||
const generatedIndex = startIndex + index + 1;
|
||||
const layer: CanvasLayer = {
|
||||
id: `layer-icon-${generatedIndex}`,
|
||||
resourceId: `local-resource-icon-${generatedIndex}`,
|
||||
resourceId: resource?.resourceId ?? `local-resource-icon-${generatedIndex}`,
|
||||
title: icon.name,
|
||||
src: icon.imageSrc,
|
||||
x: cursorX,
|
||||
@@ -308,7 +327,11 @@ export function createIconSpritesheetResultLayers({
|
||||
provider: generated.provider,
|
||||
taskId: generated.taskId,
|
||||
assetKind: 'icon',
|
||||
generationInputs,
|
||||
sourceAssetId: asset?.assetId,
|
||||
objectKey: resource?.objectKey ?? undefined,
|
||||
assetObjectId: resource?.assetObjectId ?? undefined,
|
||||
generationInputs: resource?.generationInputs ?? generationInputs,
|
||||
generatedAssetSnapshot: asset,
|
||||
};
|
||||
|
||||
cursorX += width + spacing;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
generateEditorSoundEffect,
|
||||
generateEditorVideo,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasGenerationInputs,
|
||||
@@ -96,7 +97,9 @@ type GenerationSubmissionWorkflowOptions = {
|
||||
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
|
||||
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
|
||||
rememberImageModel: (imageModel: string) => void;
|
||||
persistGeneratedAsset?: (layer: CanvasLayer) => void;
|
||||
projectId?: string | null;
|
||||
assetFolderId?: string | null;
|
||||
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
|
||||
};
|
||||
|
||||
async function normalizePublicationReferenceImages(
|
||||
@@ -142,18 +145,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
setActiveTool,
|
||||
setActiveSidebarPanel,
|
||||
rememberImageModel,
|
||||
persistGeneratedAsset,
|
||||
projectId,
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
}: GenerationSubmissionWorkflowOptions) {
|
||||
const addGeneratedLayersToCanvas = useCallback(
|
||||
(nextLayers: CanvasLayer[]) => {
|
||||
appendCanvasLayersWithResources(nextLayers);
|
||||
nextLayers
|
||||
.filter(
|
||||
(layer) => layer.mediaType !== 'video' && layer.mediaType !== 'audio',
|
||||
)
|
||||
.forEach((layer) => persistGeneratedAsset?.(layer));
|
||||
nextLayers.forEach((layer) => {
|
||||
const asset = layer.generatedAssetSnapshot;
|
||||
if (asset) {
|
||||
upsertGeneratedAsset?.(asset);
|
||||
}
|
||||
});
|
||||
},
|
||||
[appendCanvasLayersWithResources, persistGeneratedAsset],
|
||||
[appendCanvasLayersWithResources, upsertGeneratedAsset],
|
||||
);
|
||||
|
||||
const addGeneratedResultLayer = useCallback(
|
||||
@@ -514,8 +520,14 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
const sourceImageSrc = await resolveEditorImageReferenceDataUrl(
|
||||
sourceLayer.objectKey?.trim() || sourceLayer.src,
|
||||
);
|
||||
const generationInputs =
|
||||
buildUiDesignAssetExtractionGenerationInputs(sourceLayer);
|
||||
const generated = await extractEditorUiDesignAssets({
|
||||
sourceImageSrc,
|
||||
projectId,
|
||||
generationInputs,
|
||||
assetFolderId,
|
||||
spritesheetLabel: `${sourceLayer.title} 素材图集`,
|
||||
});
|
||||
const extractionFrame = {
|
||||
x: sourceLayer.x + sourceLayer.width + 32,
|
||||
@@ -528,7 +540,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
const nextLayers = addIconSpritesheetResultLayers(
|
||||
generated,
|
||||
generated.iconImageSrcs,
|
||||
buildUiDesignAssetExtractionGenerationInputs(sourceLayer),
|
||||
generationInputs,
|
||||
extractionFrame,
|
||||
undefined,
|
||||
{
|
||||
@@ -546,7 +558,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
);
|
||||
}
|
||||
},
|
||||
[addIconSpritesheetResultLayers, fitLayers],
|
||||
[addIconSpritesheetResultLayers, fitLayers, projectId, assetFolderId],
|
||||
);
|
||||
|
||||
const submitIconSpritesheetGeneration = useCallback(
|
||||
@@ -587,9 +599,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
});
|
||||
|
||||
try {
|
||||
const generated = await generateEditorIconSpritesheet(
|
||||
submissionPlan.input,
|
||||
);
|
||||
const generated = await generateEditorIconSpritesheet({
|
||||
...submissionPlan.input,
|
||||
projectId,
|
||||
generationInputs: submissionPlan.generationInputs,
|
||||
assetFolderId,
|
||||
});
|
||||
rememberImageModel(submissionPlan.rememberImageModel);
|
||||
addIconSpritesheetResultLayers(
|
||||
generated,
|
||||
@@ -612,6 +627,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
addIconSpritesheetResultLayers,
|
||||
getGeneratingDialogPlaceholder,
|
||||
rememberImageModel,
|
||||
projectId,
|
||||
assetFolderId,
|
||||
updateCanvasGenerationDialogById,
|
||||
],
|
||||
);
|
||||
@@ -667,19 +684,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
const sourceReferenceImageSrc = await resolveEditorImageReferenceDataUrl(
|
||||
quickEditSourceLayer.objectKey?.trim() || quickEditSourceLayer.src,
|
||||
);
|
||||
const generated = await generateEditorImage({
|
||||
prompt: normalizedPrompt,
|
||||
size: quickEditPanel.size,
|
||||
kind: 'quick-edit',
|
||||
model: normalizeEditorImageModel(quickEditPanel.model),
|
||||
referenceImageSrcs: [
|
||||
...extraReferenceImageSrcs,
|
||||
sourceReferenceImageSrc,
|
||||
],
|
||||
});
|
||||
addQuickEditResultLayer(
|
||||
generated,
|
||||
quickEditSourceLayer,
|
||||
const generationInputs =
|
||||
panelMode === 'redraw'
|
||||
? buildEditGenerationInputs(
|
||||
'重绘提示词',
|
||||
@@ -691,7 +696,29 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
normalizedPrompt,
|
||||
quickEditSourceLayer,
|
||||
quickEditReferences,
|
||||
),
|
||||
);
|
||||
const generated = await generateEditorImage({
|
||||
prompt: normalizedPrompt,
|
||||
size: quickEditPanel.size,
|
||||
kind: 'quick-edit',
|
||||
model: normalizeEditorImageModel(quickEditPanel.model),
|
||||
referenceImageSrcs: [
|
||||
...extraReferenceImageSrcs,
|
||||
sourceReferenceImageSrc,
|
||||
],
|
||||
projectId,
|
||||
assetKind: quickEditSourceLayer.assetKind,
|
||||
generationInputs,
|
||||
assetFolderId,
|
||||
assetLabel: `${quickEditSourceLayer.title} ${
|
||||
panelMode === 'redraw' ? '重绘' : '快速编辑'
|
||||
}`,
|
||||
sourceResourceId: quickEditSourceLayer.resourceId,
|
||||
});
|
||||
addQuickEditResultLayer(
|
||||
generated,
|
||||
quickEditSourceLayer,
|
||||
generationInputs,
|
||||
panelMode,
|
||||
quickEditDialogId
|
||||
? {
|
||||
@@ -726,6 +753,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
addQuickEditResultLayer,
|
||||
getGeneratingDialogPlaceholder,
|
||||
openCanvasGenerationDialog,
|
||||
projectId,
|
||||
assetFolderId,
|
||||
quickEditPanel,
|
||||
quickEditSourceLayer,
|
||||
setQuickEditPanel,
|
||||
@@ -768,11 +797,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
});
|
||||
if (submissionPlan.kind === 'edit') {
|
||||
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
|
||||
submissionPlan.sourceLayer.src,
|
||||
submissionPlan.sourceLayer.objectKey?.trim() ||
|
||||
submissionPlan.sourceLayer.src,
|
||||
);
|
||||
const generated = await editEditorImage({
|
||||
prompt: submissionPlan.normalizedPrompt,
|
||||
sourceImageSrc: referenceImageSrc,
|
||||
projectId,
|
||||
assetKind: submissionPlan.sourceLayer.assetKind,
|
||||
generationInputs: submissionPlan.generationInputs,
|
||||
assetFolderId,
|
||||
assetLabel: `${submissionPlan.sourceLayer.title} 修改结果`,
|
||||
sourceResourceId: submissionPlan.sourceLayer.resourceId,
|
||||
});
|
||||
addGeneratedResultLayer(generated, {
|
||||
sourceLayer: submissionPlan.sourceLayer,
|
||||
@@ -804,7 +840,14 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
submissionPlan.input.kind === 'publication-material'
|
||||
? await normalizePublicationReferenceImages(submissionPlan.input)
|
||||
: submissionPlan.input;
|
||||
const generated = await generateEditorImage(imageGenerationInput);
|
||||
const generated = await generateEditorImage({
|
||||
...imageGenerationInput,
|
||||
projectId,
|
||||
assetKind: submissionPlan.result.assetKind,
|
||||
generationInputs: submissionPlan.result.generationInputs,
|
||||
assetFolderId,
|
||||
assetLabel: submissionPlan.result.title,
|
||||
});
|
||||
if (submissionPlan.rememberImageModel) {
|
||||
rememberImageModel(submissionPlan.rememberImageModel);
|
||||
}
|
||||
@@ -844,6 +887,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
layerCounterRef,
|
||||
layers,
|
||||
rememberImageModel,
|
||||
projectId,
|
||||
assetFolderId,
|
||||
setGenerateDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
],
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
@@ -86,6 +87,9 @@ type ImageCanvasGenerationSurfaceOptions = {
|
||||
requestUpload: (target: UploadTarget) => void;
|
||||
persistGeneratedAsset?: (layer: CanvasLayer) => void;
|
||||
persistUpdatedLayerResource?: (layer: CanvasLayer) => void;
|
||||
projectId?: string | null;
|
||||
assetFolderId?: string | null;
|
||||
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
|
||||
};
|
||||
|
||||
function renderEditorPortal(node: ReactNode) {
|
||||
@@ -158,6 +162,9 @@ export function useImageCanvasGenerationSurface({
|
||||
requestUpload,
|
||||
persistGeneratedAsset,
|
||||
persistUpdatedLayerResource,
|
||||
projectId,
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
}: ImageCanvasGenerationSurfaceOptions) {
|
||||
const toolbarOptionCloseTimerRef = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
@@ -187,6 +194,9 @@ export function useImageCanvasGenerationSurface({
|
||||
setImageContextMenu,
|
||||
persistGeneratedAsset,
|
||||
persistUpdatedLayerResource,
|
||||
projectId,
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
});
|
||||
|
||||
const activeGenerationLayer = activeCanvasGenerationDialog?.generatedLayerId
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
@@ -182,6 +183,9 @@ type GenerationWorkflowOptions = {
|
||||
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||
persistGeneratedAsset?: (layer: CanvasLayer) => void;
|
||||
persistUpdatedLayerResource?: (layer: CanvasLayer) => void;
|
||||
projectId?: string | null;
|
||||
assetFolderId?: string | null;
|
||||
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
|
||||
};
|
||||
|
||||
export function useImageCanvasGenerationWorkflow({
|
||||
@@ -209,6 +213,9 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setImageContextMenu,
|
||||
persistGeneratedAsset,
|
||||
persistUpdatedLayerResource,
|
||||
projectId,
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
}: GenerationWorkflowOptions) {
|
||||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
||||
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
|
||||
@@ -951,7 +958,9 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setActiveTool,
|
||||
setActiveSidebarPanel,
|
||||
rememberImageModel: setLastImageModel,
|
||||
persistGeneratedAsset,
|
||||
projectId,
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
});
|
||||
|
||||
const setEffectiveCharacterAnimationPanel: Dispatch<
|
||||
|
||||
@@ -146,6 +146,20 @@ function ProjectPersistenceHarness({
|
||||
>
|
||||
append
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
persistence.appendCanvasLayersWithResources([
|
||||
createLayer('layer-server-resource', {
|
||||
resourceId: 'resource-from-generation-bff',
|
||||
title: '后端已保存图片',
|
||||
sourceType: 'generated',
|
||||
}),
|
||||
]);
|
||||
}}
|
||||
>
|
||||
append server resource
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -270,6 +284,34 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not create a project resource again for backend-persisted generated layers', async () => {
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'append server resource' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('layers').textContent).toBe(
|
||||
'layer-server-resource:resource-from-generation-bff:-:-',
|
||||
);
|
||||
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
layers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-server-resource',
|
||||
resourceId: 'resource-from-generation-bff',
|
||||
sourceType: 'generated',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists generated layer metadata on the project resource and restores it from resources', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
|
||||
@@ -171,7 +171,9 @@ export function useImageCanvasProjectPersistence({
|
||||
refs.layersRef.current = snapshotLayers;
|
||||
setLayers(snapshotLayers);
|
||||
nextLayers.forEach((layer) =>
|
||||
createProjectResourceForLayer(layer, { snapshotLayers }),
|
||||
layer.resourceId.startsWith('local-resource-')
|
||||
? createProjectResourceForLayer(layer, { snapshotLayers })
|
||||
: undefined,
|
||||
);
|
||||
},
|
||||
[createProjectResourceForLayer, refs, setLayers],
|
||||
|
||||
@@ -571,6 +571,15 @@ describe('editorProjectClient', () => {
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '1K',
|
||||
projectId: 'editor-project-1',
|
||||
assetKind: 'character',
|
||||
assetFolderId: 'project',
|
||||
assetLabel: '角色形象',
|
||||
sourceResourceId: 'resource-spec-1',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '角色设定' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
@@ -584,6 +593,15 @@ describe('editorProjectClient', () => {
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '1K',
|
||||
projectId: 'editor-project-1',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '角色设定' }],
|
||||
references: [],
|
||||
},
|
||||
assetFolderId: 'project',
|
||||
assetLabel: '角色形象',
|
||||
sourceResourceId: 'resource-spec-1',
|
||||
}),
|
||||
}),
|
||||
'生成图片失败',
|
||||
@@ -663,6 +681,12 @@ describe('editorProjectClient', () => {
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '2K',
|
||||
priceMudPoints: 12,
|
||||
projectId: 'editor-project-1',
|
||||
assetFolderId: 'project',
|
||||
generationInputs: {
|
||||
fields: [{ title: '素材描述', value: '返回按钮' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
@@ -677,6 +701,12 @@ describe('editorProjectClient', () => {
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '2K',
|
||||
priceMudPoints: 12,
|
||||
projectId: 'editor-project-1',
|
||||
generationInputs: {
|
||||
fields: [{ title: '素材描述', value: '返回按钮' }],
|
||||
references: [],
|
||||
},
|
||||
assetFolderId: 'project',
|
||||
}),
|
||||
}),
|
||||
'生成图标素材失败',
|
||||
@@ -710,6 +740,13 @@ describe('editorProjectClient', () => {
|
||||
|
||||
const result = await extractEditorUiDesignAssets({
|
||||
sourceImageSrc: 'data:image/png;base64,ui-design',
|
||||
projectId: 'editor-project-1',
|
||||
assetFolderId: 'project',
|
||||
spritesheetLabel: '战斗UI设计图 素材图集',
|
||||
generationInputs: {
|
||||
fields: [{ title: '来源', value: '战斗UI设计图' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.taskId).toBe('ui-asset-extraction-1');
|
||||
@@ -720,6 +757,13 @@ describe('editorProjectClient', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceImageSrc: 'data:image/png;base64,ui-design',
|
||||
projectId: 'editor-project-1',
|
||||
generationInputs: {
|
||||
fields: [{ title: '来源', value: '战斗UI设计图' }],
|
||||
references: [],
|
||||
},
|
||||
assetFolderId: 'project',
|
||||
spritesheetLabel: '战斗UI设计图 素材图集',
|
||||
}),
|
||||
}),
|
||||
'提取UI设计图素材失败',
|
||||
@@ -1128,6 +1172,15 @@ describe('editorProjectClient', () => {
|
||||
sourceImageSrc: 'data:image/png;base64,source',
|
||||
size: '1024x1024',
|
||||
model: 'gpt-image-2',
|
||||
projectId: 'editor-project-1',
|
||||
assetKind: 'character',
|
||||
assetFolderId: 'project',
|
||||
assetLabel: '角色形象 修改结果',
|
||||
sourceResourceId: 'resource-character-1',
|
||||
generationInputs: {
|
||||
fields: [{ title: '修改提示词', value: '把画面改成黄昏光线' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.taskId).toBe('vector-edit-1');
|
||||
@@ -1141,6 +1194,15 @@ describe('editorProjectClient', () => {
|
||||
sourceImageSrc: 'data:image/png;base64,source',
|
||||
size: '1024x1024',
|
||||
model: 'gpt-image-2',
|
||||
projectId: 'editor-project-1',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '修改提示词', value: '把画面改成黄昏光线' }],
|
||||
references: [],
|
||||
},
|
||||
assetFolderId: 'project',
|
||||
assetLabel: '角色形象 修改结果',
|
||||
sourceResourceId: 'resource-character-1',
|
||||
}),
|
||||
}),
|
||||
'修改图片失败',
|
||||
|
||||
@@ -122,6 +122,12 @@ export type EditorImageGenerationInput = {
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
referenceImageSrcs?: string[];
|
||||
projectId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: EditorAssetGenerationInputs | null;
|
||||
assetFolderId?: string | null;
|
||||
assetLabel?: string | null;
|
||||
sourceResourceId?: string | null;
|
||||
};
|
||||
|
||||
export type EditorIconSpritesheetGenerationInput = {
|
||||
@@ -131,10 +137,17 @@ export type EditorIconSpritesheetGenerationInput = {
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
priceMudPoints: number;
|
||||
projectId?: string | null;
|
||||
generationInputs?: EditorAssetGenerationInputs | null;
|
||||
assetFolderId?: string | null;
|
||||
};
|
||||
|
||||
export type EditorUiDesignAssetExtractionInput = {
|
||||
sourceImageSrc: string;
|
||||
projectId?: string | null;
|
||||
generationInputs?: EditorAssetGenerationInputs | null;
|
||||
assetFolderId?: string | null;
|
||||
spritesheetLabel?: string | null;
|
||||
};
|
||||
|
||||
export type EditorImageEditInput = {
|
||||
@@ -142,6 +155,12 @@ export type EditorImageEditInput = {
|
||||
sourceImageSrc: string;
|
||||
size?: string;
|
||||
model?: string;
|
||||
projectId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: EditorAssetGenerationInputs | null;
|
||||
assetFolderId?: string | null;
|
||||
assetLabel?: string | null;
|
||||
sourceResourceId?: string | null;
|
||||
};
|
||||
|
||||
export type EditorImageGenerationResult = {
|
||||
@@ -156,6 +175,8 @@ export type EditorImageGenerationResult = {
|
||||
model: string;
|
||||
provider: string;
|
||||
taskId: string;
|
||||
resource?: EditorProjectResourceSnapshot | null;
|
||||
asset?: EditorAssetSnapshot | null;
|
||||
};
|
||||
|
||||
export type EditorIconSpritesheetIconResult = {
|
||||
@@ -163,6 +184,8 @@ export type EditorIconSpritesheetIconResult = {
|
||||
imageSrc: string;
|
||||
width: number;
|
||||
height: number;
|
||||
resource?: EditorProjectResourceSnapshot | null;
|
||||
asset?: EditorAssetSnapshot | null;
|
||||
};
|
||||
|
||||
export type EditorIconSpritesheetGenerationResult = {
|
||||
@@ -176,6 +199,8 @@ export type EditorIconSpritesheetGenerationResult = {
|
||||
provider: string;
|
||||
taskId: string;
|
||||
priceMudPoints: number;
|
||||
spritesheetResource?: EditorProjectResourceSnapshot | null;
|
||||
spritesheetAsset?: EditorAssetSnapshot | null;
|
||||
};
|
||||
|
||||
export type EditorCharacterAnimationResolution = '480p' | '720p';
|
||||
@@ -578,6 +603,16 @@ export async function generateEditorImage(input: EditorImageGenerationInput) {
|
||||
...(input.referenceImageSrcs?.length
|
||||
? { referenceImageSrcs: input.referenceImageSrcs }
|
||||
: {}),
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(input.assetKind ? { assetKind: input.assetKind } : {}),
|
||||
...(input.generationInputs
|
||||
? { generationInputs: input.generationInputs }
|
||||
: {}),
|
||||
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
|
||||
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
|
||||
...(input.sourceResourceId
|
||||
? { sourceResourceId: input.sourceResourceId }
|
||||
: {}),
|
||||
}),
|
||||
'生成图片失败',
|
||||
{
|
||||
@@ -601,6 +636,11 @@ export async function generateEditorIconSpritesheet(
|
||||
...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}),
|
||||
...(input.imageSize ? { imageSize: input.imageSize } : {}),
|
||||
priceMudPoints: input.priceMudPoints,
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(input.generationInputs
|
||||
? { generationInputs: input.generationInputs }
|
||||
: {}),
|
||||
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
|
||||
}),
|
||||
'生成图标素材失败',
|
||||
{
|
||||
@@ -619,6 +659,14 @@ export async function extractEditorUiDesignAssets(
|
||||
EDITOR_UI_DESIGN_ASSET_EXTRACTION_API,
|
||||
jsonRequest('POST', {
|
||||
sourceImageSrc: input.sourceImageSrc,
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(input.generationInputs
|
||||
? { generationInputs: input.generationInputs }
|
||||
: {}),
|
||||
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
|
||||
...(input.spritesheetLabel
|
||||
? { spritesheetLabel: input.spritesheetLabel }
|
||||
: {}),
|
||||
}),
|
||||
'提取UI设计图素材失败',
|
||||
{
|
||||
@@ -638,6 +686,16 @@ export async function editEditorImage(input: EditorImageEditInput) {
|
||||
sourceImageSrc: input.sourceImageSrc,
|
||||
...(input.size ? { size: input.size } : {}),
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(input.assetKind ? { assetKind: input.assetKind } : {}),
|
||||
...(input.generationInputs
|
||||
? { generationInputs: input.generationInputs }
|
||||
: {}),
|
||||
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
|
||||
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
|
||||
...(input.sourceResourceId
|
||||
? { sourceResourceId: input.sourceResourceId }
|
||||
: {}),
|
||||
}),
|
||||
'修改图片失败',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user