From f5ede50ba92e59d24b518e75cdb2422e20585cd5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 23 Jun 2026 17:20:10 +0800 Subject: [PATCH] =?UTF-8?q?=E7=98=A6=E8=BA=AB=E7=94=BB=E6=9D=BF=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E4=BF=9D=E5=AD=98=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改造画板项目保存响应为轻量 ack 统一生成上传媒体以 objectKey 持久化 补齐素材库缩略图换签与本地轻量缓存 刷新生成后泥点余额并补充回归测试 --- docs/project-memory/shared-memory/pitfalls.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 13 +- .../crates/api-server/src/editor_project.rs | 1130 +++++++++++++++-- .../api-server/src/external_editor_api.rs | 16 +- .../spacetime-client/src/editor_project.rs | 73 ++ server-rs/crates/spacetime-client/src/lib.rs | 9 +- .../crates/spacetime-client/src/mapper.rs | 16 +- .../src/mapper/editor_project.rs | 76 ++ .../spacetime-client/src/module_bindings.rs | 14 + .../editor_asset_media_repair_input_type.rs | 20 + .../editor_project_layout_save_ack_type.rs | 17 + ...oject_layout_save_procedure_result_type.rs | 19 + ...roject_resource_media_repair_input_type.rs | 20 + ...editor_asset_media_and_return_procedure.rs | 59 + ...ect_resource_media_and_return_procedure.rs | 62 + ...ave_editor_project_layout_ack_procedure.rs | 59 + .../src/editor_project_storage.rs | 226 +++- .../common/PlatformMediaFrame.test.tsx | 42 + src/components/common/PlatformMediaFrame.tsx | 9 +- .../ImageCanvasAssetRowView.test.tsx | 43 + .../image-editor/ImageCanvasAssetRowView.tsx | 1 + ...mageCanvasEditorAssetsIntegration.test.tsx | 127 +- .../ImageCanvasEditorModel.test.ts | 4 + .../image-editor/ImageCanvasEditorModel.ts | 77 +- .../ImageCanvasEditorPrimitives.tsx | 3 + .../ImageCanvasEditorView.test-utils.ts | 5 + .../image-editor/ImageCanvasEditorView.tsx | 21 +- .../ImageCanvasGenerationSubmissionModel.ts | 37 +- .../ImageCanvasLayerPanelView.tsx | 1 + .../ImageCanvasUploadModel.test.ts | 5 + .../image-editor/ImageCanvasUploadModel.ts | 6 + ...anvasGenerationSubmissionWorkflow.test.tsx | 238 +++- ...ImageCanvasGenerationSubmissionWorkflow.ts | 467 ++++--- .../useImageCanvasGenerationSurface.tsx | 3 + .../useImageCanvasGenerationWorkflow.ts | 3 + .../useImageCanvasProjectPersistence.test.tsx | 163 ++- .../useImageCanvasProjectPersistence.ts | 185 ++- .../useImageCanvasUploadWorkflow.test.tsx | 37 +- .../useImageCanvasUploadWorkflow.ts | 60 +- src/services/assetReadUrlService.test.ts | 50 + src/services/assetReadUrlService.ts | 100 +- .../editorMediaAssetUploadClient.test.ts | 65 + .../editorMediaAssetUploadClient.ts | 20 +- .../image-editor/editorProjectClient.test.ts | 30 +- .../image-editor/editorProjectClient.ts | 13 +- 45 files changed, 3150 insertions(+), 502 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_asset_media_repair_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_ack_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_media_repair_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/repair_editor_asset_media_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/repair_editor_project_resource_media_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_ack_procedure.rs diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index be82d798d..6a5e3f533 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -143,6 +143,14 @@ - 验证:`cargo test -p api-server editor_canvas_generation_completion --manifest-path server-rs/Cargo.toml` 覆盖后端完成态写 layout;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand` 覆盖前端提交 `canvasCompletion`、应用后端快照、项目加载不推断完成态和 `objectKey` 资源创建不提交大 URL。 - 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 +## 图片编辑器项目和素材 payload 不能持久化内联媒体 + +- 现象:`/api/editor/projects*`、素材库、项目资源或 layout payload 里出现数 MB 的 `data:image/*`、`data:video/*`、`data:audio/*`,刷新恢复变慢,发布入口可能 OOM / 413,素材库缩略图还可能只显示文件名。 +- 原因:生成、规范图、角色图、图标 / UI spritesheet、音视频或动画帧如果直接把 Data URL / signed URL 写入 `editor_project_resource`、`editor_asset` 或 `editor_canvas.layers_json`,就把媒体本体塞进了项目快照;signed URL 还会过期,素材库也无法稳定换签。 +- 处理:登录态媒体必须先上传 OSS / asset object,持久化只写 `imageSrc: "/"`、`objectKey`、`assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage` 传 `objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `objectKey` 时读出归一成 `/`,没有 `objectKey` 的旧 Data URL 必须走修复上传后回写轻量引用。刷新恢复可先用 session 轻量缓存显示,但缓存不得含内联媒体,也不能在后端快照回来前自动保存。生成扣费、失败退款或 queue 终态后,右上角泥点余额通过 `/profile/dashboard` 回读,不做本地乐观扣减。 +- 验证:Network 中 `/api/editor/projects*`、`PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/common/PlatformMediaFrame.tsx`、`src/services/assetReadUrlService.ts`。 + ## 图片画布发布入口 429 先查自动保存 PATCH 并发 - 现象:发布域名访问画板时出现短时间密集 `429`,Nginx access log 中 `PATCH /api/editor/projects/`、生成接口和资料接口混杂,429 行常见 `request_time=0.000`、`upstream_status=-`,error log 写 `limiting connections by zone "genarrative_api_conn"`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index c4384e66b..23ae3d6e9 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -35,7 +35,7 @@ - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。 - 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。 -- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。 +- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。`PATCH /api/editor/projects/{projectId}` 只返回 `{ projectId, canvasId, updatedAt }` 轻量 ack,不再返回完整 project,前端必须以后续显式读取或生成完成返回的后端快照作为项目真相。 - 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。 - 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。 @@ -46,11 +46,12 @@ - 新增 `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;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。 -- 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。 -- 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 +- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。 +- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 -- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。 +- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。视频结果当前只保存为画布视频资源,不进入图片素材库。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 +- 工程刷新恢复可先应用 session 级轻量项目快照缓存,让画布和素材 chrome 尽快显示;缓存快照必须排除 `data:*` / `blob:` 内联媒体,且在后端项目快照返回前不得触发自动保存。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 ## 后端接口 @@ -59,7 +60,7 @@ - `GET /api/editor/projects`:读取当前用户所有图片画布工程,按更新时间倒序返回。 - `POST /api/editor/projects`:创建图片画布工程。 - `GET /api/editor/projects/{projectId}`:读取指定工程及资源列表。 -- `PATCH /api/editor/projects/{projectId}`:保存 viewport 与图层布局快照。 +- `PATCH /api/editor/projects/{projectId}`:保存 viewport 与图层布局快照;响应只包含 `{ projectId, canvasId, updatedAt }` ack,不返回完整工程快照。 - `PATCH /api/editor/projects/{projectId}/metadata`:重命名指定工程。 - `DELETE /api/editor/projects/{projectId}`:删除指定工程,并级联删除默认画布和资源元数据。 - `POST /api/editor/projects/{projectId}/resources`:创建画布资源记录,接收上传资源或真实生成资源元数据。 @@ -67,7 +68,7 @@ - `POST /api/editor/assets/folders`:新建素材文件夹。 - `PATCH /api/editor/assets/folders/{folderId}`:重命名、折叠 / 展开素材文件夹。 - `DELETE /api/editor/assets/folders/{folderId}`:删除素材文件夹;系统默认文件夹不能删除,普通文件夹删除时素材移入默认文件夹。 -- `POST /api/editor/assets`:批量或单个创建账号级素材,支持按钮上传和拖拽上传后的 data URL / 后续 OSS 元数据。 +- `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/` 轻量路径,不允许把 Data URL / signed URL 写入素材库。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index a34393826..04cb30a1d 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -13,8 +13,7 @@ use module_assets::{ use platform_image::{ DownloadedImage, generated_asset_sheets::{ - GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetConnectedIcon, - apply_generated_asset_sheet_alpha_with_options, + GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_alpha_with_options, slice_generated_icon_spritesheet_all_by_connected_components, slice_generated_icon_spritesheet_by_connected_components, }, @@ -30,11 +29,13 @@ use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, - EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetRecord, - EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, - EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, + EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, + EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, + EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, + EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, - EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError, + EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, + EditorProjectResourceRecord, SpacetimeClientError, }; use crate::{ @@ -50,7 +51,7 @@ use crate::{ generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, - normalize_generated_image_asset_mime, + decode_generated_image_asset_data_url, normalize_generated_image_asset_mime, }, http_error::AppError, openai_image_generation::{ @@ -83,7 +84,17 @@ const EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT: &str = const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; +const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image"; +const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image"; +const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image"; +const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image"; +const EDITOR_IMAGE_EDIT_ASSET_KIND: &str = "editor_image_edit"; +const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet"; +const EDITOR_ICON_IMAGE_ASSET_KIND: &str = "editor_icon_image"; +const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet"; +const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset"; const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material"; +const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -255,6 +266,14 @@ pub struct EditorProjectResponse { project: EditorProjectPayload, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorProjectLayoutSaveResponse { + project_id: String, + canvas_id: String, + updated_at: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectRecentResponse { @@ -335,6 +354,13 @@ struct PersistEditorGeneratedAssetInput { generation_inputs: Option, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct EditorGeneratedImageStorageProfile { + asset_kind: &'static str, + path_kind: &'static str, + slot: &'static str, +} + pub(crate) struct EditorCanvasGeneratedLayerInput { pub(crate) layer_id: String, pub(crate) resource_id: String, @@ -509,8 +535,13 @@ pub async fn load_recent_editor_project( .spacetime_client() .get_recent_editor_project(owner_user_id) .await - .map_err(map_editor_project_error)? - .map(editor_project_payload_from_record); + .map_err(map_editor_project_error)?; + let project = match project { + Some(project) => Some(editor_project_payload_from_record( + repair_editor_project_record_inline_media(&state, project).await, + )), + None => None, + }; Ok(json_success_body( Some(&request_context), @@ -538,14 +569,17 @@ pub async fn list_editor_projects( Extension(authenticated): Extension, ) -> Result, AppError> { let owner_user_id = authenticated.claims().user_id().to_string(); - let projects = state + let project_records = state .spacetime_client() .list_editor_projects(owner_user_id) .await - .map_err(map_editor_project_error)? - .into_iter() - .map(editor_project_payload_from_record) - .collect(); + .map_err(map_editor_project_error)?; + let mut projects = Vec::with_capacity(project_records.len()); + for project in project_records { + projects.push(editor_project_payload_from_record( + repair_editor_project_record_inline_media(&state, project).await, + )); + } Ok(json_success_body( Some(&request_context), @@ -598,6 +632,7 @@ pub async fn get_editor_project( }) .await .map_err(map_editor_project_error)?; + let project = repair_editor_project_record_inline_media(&state, project).await; Ok(json_success_body( Some(&request_context), @@ -615,9 +650,9 @@ pub async fn save_editor_project_layout( Json(payload): Json, ) -> Result, AppError> { let layers_json = serialize_editor_layers(payload.layers)?; - let project = state + let ack = state .spacetime_client() - .save_editor_project_layout(EditorProjectLayoutSaveRecordInput { + .save_editor_project_layout_ack(EditorProjectLayoutSaveRecordInput { project_id, owner_user_id: authenticated.claims().user_id().to_string(), viewport: payload.viewport.into_record(), @@ -629,8 +664,10 @@ pub async fn save_editor_project_layout( Ok(json_success_body( Some(&request_context), - EditorProjectResponse { - project: editor_project_payload_from_record(project), + EditorProjectLayoutSaveResponse { + project_id: ack.project_id, + canvas_id: ack.canvas_id, + updated_at: ack.updated_at, }, )) } @@ -691,6 +728,8 @@ pub async fn create_editor_project_resource( ) -> Result, AppError> { let generation_inputs_json = serialize_editor_asset_metadata(payload.generation_inputs.clone())?; + let object_key = normalize_editor_object_key(payload.object_key); + let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; let resource = state .spacetime_client() .create_editor_project_resource(EditorProjectResourceCreateRecordInput { @@ -698,8 +737,8 @@ pub async fn create_editor_project_resource( project_id, owner_user_id: authenticated.claims().user_id().to_string(), asset_object_id: normalize_optional_string(payload.asset_object_id), - image_src: payload.image_src, - object_key: normalize_optional_string(payload.object_key), + image_src, + object_key, width: payload.width, height: payload.height, source_type: payload.source_type, @@ -729,11 +768,14 @@ pub async fn get_editor_asset_library( Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { + let owner_user_id = current_owner_user_id(&authenticated); let library = state .spacetime_client() - .get_editor_asset_library(current_owner_user_id(&authenticated), current_utc_micros()) + .get_editor_asset_library(owner_user_id.clone(), current_utc_micros()) .await .map_err(map_editor_project_error)?; + let library = + repair_editor_asset_library_inline_media(&state, owner_user_id.as_str(), library).await; Ok(json_success_body( Some(&request_context), @@ -828,6 +870,8 @@ pub async fn create_editor_asset( ) -> Result, AppError> { let generation_inputs_json = serialize_editor_asset_metadata(payload.generation_inputs.clone())?; + let object_key = normalize_editor_object_key(payload.object_key); + let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; let asset = state .spacetime_client() .create_editor_asset(EditorAssetCreateRecordInput { @@ -836,8 +880,8 @@ pub async fn create_editor_asset( folder_id: payload.folder_id, label: payload.label, asset_object_id: normalize_optional_string(payload.asset_object_id), - image_src: payload.image_src, - object_key: normalize_optional_string(payload.object_key), + image_src, + object_key, width: payload.width, height: payload.height, source_type: payload.source_type, @@ -1116,29 +1160,21 @@ pub(crate) async fn generate_editor_image_for_owner( let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); - let persisted = if is_character_generation { - Some( - persist_editor_generated_image( - state, - caller.owner_user_id.as_str(), - generated.task_id.as_str(), - &image, - submitted_prompt.as_str(), - generated.actual_prompt.as_deref(), - EDITOR_CHARACTER_IMAGE_ASSET_KIND, - "character-images", - EDITOR_CHARACTER_IMAGE_SLOT, - ) - .await?, - ) - } else { - None - }; - let image_src = format!( - "data:{};base64,{}", - image.mime_type, - BASE64_STANDARD.encode(image.bytes.as_slice()) + let storage_profile = editor_generated_image_storage_profile(normalized_kind); + let persisted = persist_editor_generated_image( + state, + caller.owner_user_id.as_str(), + generated.task_id.as_str(), + &image, + submitted_prompt.as_str(), + generated.actual_prompt.as_deref(), + storage_profile.asset_kind, + storage_profile.path_kind, + "image", + storage_profile.slot, ); + let persisted = persisted.await?; + let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); let generated_asset = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { @@ -1148,10 +1184,8 @@ pub(crate) async fn generate_editor_image_for_owner( 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()), + object_key: Some(persisted.object_key.clone()), + asset_object_id: Some(persisted.asset_object_id.clone()), width, height, prompt: role_setting.clone(), @@ -1178,10 +1212,8 @@ pub(crate) async fn generate_editor_image_for_owner( Some(&request_context), EditorImageGenerationResponse { image_src, - object_key: persisted.as_ref().map(|asset| asset.object_key.clone()), - asset_object_id: persisted - .as_ref() - .map(|asset| asset.asset_object_id.clone()), + object_key: Some(persisted.object_key), + asset_object_id: Some(persisted.asset_object_id), width, height, source_type: "generated", @@ -1200,11 +1232,48 @@ pub(crate) async fn generate_editor_image_for_owner( fn editor_image_generation_billing_asset_kind(normalized_kind: Option<&str>) -> &'static str { match normalized_kind { Some("character") => EDITOR_CHARACTER_IMAGE_ASSET_KIND, - Some("spec") => "editor_spec_image", - Some("quick-edit") => "editor_quick_edit_image", - Some("ui-design") => "editor_ui_design", + Some("spec") => EDITOR_SPEC_IMAGE_ASSET_KIND, + Some("quick-edit") => EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND, + Some("ui-design") => EDITOR_UI_DESIGN_IMAGE_ASSET_KIND, Some("publication-material") => EDITOR_PUBLICATION_MATERIAL_ASSET_KIND, - _ => "editor_image_generation", + _ => EDITOR_GENERATED_IMAGE_ASSET_KIND, + } +} + +fn editor_generated_image_storage_profile( + normalized_kind: Option<&str>, +) -> EditorGeneratedImageStorageProfile { + match normalized_kind { + Some("character") => EditorGeneratedImageStorageProfile { + asset_kind: EDITOR_CHARACTER_IMAGE_ASSET_KIND, + path_kind: "character-images", + slot: EDITOR_CHARACTER_IMAGE_SLOT, + }, + Some("spec") => EditorGeneratedImageStorageProfile { + asset_kind: EDITOR_SPEC_IMAGE_ASSET_KIND, + path_kind: "spec-images", + slot: "spec", + }, + Some("quick-edit") => EditorGeneratedImageStorageProfile { + asset_kind: EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND, + path_kind: "quick-edit-images", + slot: "quick-edit", + }, + Some("ui-design") => EditorGeneratedImageStorageProfile { + asset_kind: EDITOR_UI_DESIGN_IMAGE_ASSET_KIND, + path_kind: "ui-design-images", + slot: "ui-design", + }, + Some("publication-material") => EditorGeneratedImageStorageProfile { + asset_kind: EDITOR_PUBLICATION_MATERIAL_ASSET_KIND, + path_kind: "publication-materials", + slot: "publication-material", + }, + _ => EditorGeneratedImageStorageProfile { + asset_kind: EDITOR_GENERATED_IMAGE_ASSET_KIND, + path_kind: "generated-images", + slot: "image", + }, } } @@ -1446,12 +1515,21 @@ pub(crate) async fn edit_editor_image_for_owner( let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); - let image_src = format!( - "data:{};base64,{}", - image.mime_type, - BASE64_STANDARD.encode(image.bytes.as_slice()) - ); let owner_user_id = caller.owner_user_id.clone(); + let persisted = persist_editor_generated_image( + state, + owner_user_id.as_str(), + generated.task_id.as_str(), + &image, + prompt.as_str(), + generated.actual_prompt.as_deref(), + EDITOR_IMAGE_EDIT_ASSET_KIND, + "edit-images", + "image", + "edit", + ) + .await?; + let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); let generated_asset = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { @@ -1461,8 +1539,8 @@ pub(crate) async fn edit_editor_image_for_owner( label: normalize_optional_string(payload.asset_label) .unwrap_or_else(|| "图片修改结果".to_string()), image_src: image_src.clone(), - object_key: None, - asset_object_id: None, + object_key: Some(persisted.object_key.clone()), + asset_object_id: Some(persisted.asset_object_id.clone()), width, height, prompt: prompt.clone(), @@ -1489,8 +1567,8 @@ pub(crate) async fn edit_editor_image_for_owner( Some(&request_context), EditorImageGenerationResponse { image_src, - object_key: None, - asset_object_id: None, + object_key: Some(persisted.object_key), + asset_object_id: Some(persisted.asset_object_id), width, height, source_type: "generated", @@ -1636,10 +1714,23 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "message": error.to_string(), })) })?; - let spritesheet_image_src = - data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice()); let generation_inputs = payload.generation_inputs.clone(); let owner_user_id = caller.owner_user_id; + let spritesheet_persisted = persist_editor_generated_image( + state, + owner_user_id.as_str(), + generated.task_id.as_str(), + &image, + prompt.as_str(), + generated.actual_prompt.as_deref(), + EDITOR_ICON_SPRITESHEET_ASSET_KIND, + "icon-spritesheets", + "spritesheet", + "spritesheet", + ) + .await?; + let spritesheet_image_src = + editor_media_src_from_object_key(spritesheet_persisted.object_key.as_str()); let spritesheet_record = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { @@ -1648,8 +1739,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( folder_id: payload.asset_folder_id.clone(), label: "图标素材图集".to_string(), image_src: spritesheet_image_src.clone(), - object_key: None, - asset_object_id: None, + object_key: Some(spritesheet_persisted.object_key.clone()), + asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), width: spritesheet_width, height: spritesheet_height, prompt: prompt.clone(), @@ -1665,19 +1756,42 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .await?; let mut icon_image_srcs = Vec::new(); for icon in icon_slices { - let icon_response = editor_icon_response_from_slice(icon); + let icon_index = icon_image_srcs.len() + 1; + let icon_name = icon.name.clone(); + let icon_width = icon.width; + let icon_height = icon.height; + let icon_image = DownloadedOpenAiImage { + bytes: icon.bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let icon_file_stem = format!("icon-{icon_index:03}"); + let icon_persisted = persist_editor_generated_image( + state, + owner_user_id.as_str(), + generated.task_id.as_str(), + &icon_image, + prompt.as_str(), + generated.actual_prompt.as_deref(), + EDITOR_ICON_IMAGE_ASSET_KIND, + "icons", + icon_file_stem.as_str(), + "icon", + ) + .await?; + let icon_image_src = editor_media_src_from_object_key(icon_persisted.object_key.as_str()); 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, + label: icon_name.clone(), + image_src: icon_image_src.clone(), + object_key: Some(icon_persisted.object_key.clone()), + asset_object_id: Some(icon_persisted.asset_object_id.clone()), + width: icon_width, + height: icon_height, prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), @@ -1690,9 +1804,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( ) .await?; icon_image_srcs.push(EditorIconSpritesheetIconResponse { + name: icon_name, + image_src: icon_image_src, + width: icon_width, + height: icon_height, resource: icon_record.resource, asset: icon_record.asset, - ..icon_response }); } let (canvas_items, primary_layer_id) = @@ -1838,11 +1955,24 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "message": error.to_string(), })) })?; - let spritesheet_image_src = - data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice()); let prompt = EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT.to_string(); let generation_inputs = payload.generation_inputs.clone(); let owner_user_id = caller.owner_user_id; + let spritesheet_persisted = persist_editor_generated_image( + state, + owner_user_id.as_str(), + generated.task_id.as_str(), + &image, + prompt.as_str(), + generated.actual_prompt.as_deref(), + EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND, + "ui-design-spritesheets", + "spritesheet", + "spritesheet", + ) + .await?; + let spritesheet_image_src = + editor_media_src_from_object_key(spritesheet_persisted.object_key.as_str()); let spritesheet_record = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { @@ -1852,8 +1982,8 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( 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, + object_key: Some(spritesheet_persisted.object_key.clone()), + asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), width: spritesheet_width, height: spritesheet_height, prompt: prompt.clone(), @@ -1869,19 +1999,42 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( .await?; let mut icon_image_srcs = Vec::new(); for icon in icon_slices { - let icon_response = editor_icon_response_from_slice(icon); + let icon_index = icon_image_srcs.len() + 1; + let icon_name = icon.name.clone(); + let icon_width = icon.width; + let icon_height = icon.height; + let icon_image = DownloadedOpenAiImage { + bytes: icon.bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let icon_file_stem = format!("asset-{icon_index:03}"); + let icon_persisted = persist_editor_generated_image( + state, + owner_user_id.as_str(), + generated.task_id.as_str(), + &icon_image, + prompt.as_str(), + generated.actual_prompt.as_deref(), + EDITOR_UI_DESIGN_ASSET_IMAGE_KIND, + "ui-design-assets", + icon_file_stem.as_str(), + "asset", + ) + .await?; + let icon_image_src = editor_media_src_from_object_key(icon_persisted.object_key.as_str()); 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, + label: icon_name.clone(), + image_src: icon_image_src.clone(), + object_key: Some(icon_persisted.object_key.clone()), + asset_object_id: Some(icon_persisted.asset_object_id.clone()), + width: icon_width, + height: icon_height, prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: GPT_IMAGE_2_MODEL.to_string(), @@ -1894,9 +2047,12 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( ) .await?; icon_image_srcs.push(EditorIconSpritesheetIconResponse { + name: icon_name, + image_src: icon_image_src, + width: icon_width, + height: icon_height, resource: icon_record.resource, asset: icon_record.asset, - ..icon_response }); } let (canvas_items, primary_layer_id) = @@ -1939,10 +2095,233 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( )) } +async fn repair_editor_project_record_inline_media( + state: &AppState, + mut record: EditorProjectRecord, +) -> EditorProjectRecord { + let owner_user_id = record.owner_user_id.clone(); + let mut repaired_resources = Vec::with_capacity(record.resources.len()); + for resource in record.resources { + repaired_resources.push( + repair_editor_project_resource_inline_media(state, owner_user_id.as_str(), resource) + .await, + ); + } + record.resources = repaired_resources; + record +} + +async fn repair_editor_asset_library_inline_media( + state: &AppState, + owner_user_id: &str, + mut record: EditorAssetLibraryRecord, +) -> EditorAssetLibraryRecord { + let mut repaired_assets = Vec::with_capacity(record.assets.len()); + for asset in record.assets { + repaired_assets.push(repair_editor_asset_inline_media(state, owner_user_id, asset).await); + } + record.assets = repaired_assets; + record +} + +async fn repair_editor_project_resource_inline_media( + state: &AppState, + owner_user_id: &str, + resource: EditorProjectResourceRecord, +) -> EditorProjectResourceRecord { + if !should_repair_editor_inline_image( + resource.image_src.as_str(), + resource.object_key.as_deref(), + ) { + return resource; + } + + let repair = persist_editor_legacy_inline_image( + state, + owner_user_id, + resource.image_src.as_str(), + resource + .task_id + .as_deref() + .unwrap_or(resource.resource_id.as_str()), + resource + .prompt + .as_deref() + .or(resource.actual_prompt.as_deref()) + .unwrap_or("历史画布内联图片修复"), + resource.actual_prompt.as_deref(), + resource.asset_kind.as_deref(), + "legacy-resources", + resource.resource_id.as_str(), + ) + .await; + let persisted = match repair { + Ok(persisted) => persisted, + Err(error) => { + tracing::warn!( + resource_id = %resource.resource_id, + error = %error, + "repair legacy inline editor project resource media failed" + ); + return resource; + } + }; + let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); + let repaired = state + .spacetime_client() + .repair_editor_project_resource_media(EditorProjectResourceMediaRepairRecordInput { + resource_id: resource.resource_id.clone(), + owner_user_id: owner_user_id.to_string(), + asset_object_id: persisted.asset_object_id.clone(), + image_src: image_src.clone(), + object_key: persisted.object_key.clone(), + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error); + match repaired { + Ok(record) => record, + Err(error) => { + tracing::warn!( + resource_id = %resource.resource_id, + error = %error, + "persist legacy inline editor project resource repair failed" + ); + EditorProjectResourceRecord { + image_src, + object_key: Some(persisted.object_key), + asset_object_id: Some(persisted.asset_object_id), + ..resource + } + } + } +} + +async fn repair_editor_asset_inline_media( + state: &AppState, + owner_user_id: &str, + asset: EditorAssetRecord, +) -> EditorAssetRecord { + if !should_repair_editor_inline_image(asset.image_src.as_str(), asset.object_key.as_deref()) { + return asset; + } + + let repair = persist_editor_legacy_inline_image( + state, + owner_user_id, + asset.image_src.as_str(), + asset.task_id.as_deref().unwrap_or(asset.asset_id.as_str()), + asset.prompt.as_deref().unwrap_or(asset.label.as_str()), + asset.actual_prompt.as_deref(), + asset.asset_kind.as_deref(), + "legacy-assets", + asset.asset_id.as_str(), + ) + .await; + let persisted = match repair { + Ok(persisted) => persisted, + Err(error) => { + tracing::warn!( + asset_id = %asset.asset_id, + error = %error, + "repair legacy inline editor asset media failed" + ); + return asset; + } + }; + let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); + let repaired = state + .spacetime_client() + .repair_editor_asset_media(EditorAssetMediaRepairRecordInput { + asset_id: asset.asset_id.clone(), + owner_user_id: owner_user_id.to_string(), + asset_object_id: persisted.asset_object_id.clone(), + image_src: image_src.clone(), + object_key: persisted.object_key.clone(), + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error); + match repaired { + Ok(record) => record, + Err(error) => { + tracing::warn!( + asset_id = %asset.asset_id, + error = %error, + "persist legacy inline editor asset repair failed" + ); + EditorAssetRecord { + image_src, + object_key: Some(persisted.object_key), + asset_object_id: Some(persisted.asset_object_id), + ..asset + } + } + } +} + +async fn persist_editor_legacy_inline_image( + state: &AppState, + owner_user_id: &str, + image_src: &str, + task_id: &str, + prompt: &str, + actual_prompt: Option<&str>, + asset_kind: Option<&str>, + path_kind: &str, + file_stem: &str, +) -> Result { + let decoded = decode_generated_image_asset_data_url(image_src).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project", + "message": format!("历史画布内联图片无法解析:{error:?}"), + })) + })?; + let image = DownloadedOpenAiImage { + bytes: decoded.bytes, + mime_type: decoded.format.mime_type, + extension: decoded.format.extension, + }; + persist_editor_generated_image( + state, + owner_user_id, + task_id, + &image, + prompt, + actual_prompt, + asset_kind.unwrap_or(EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND), + path_kind, + file_stem, + "legacy-repair", + ) + .await +} + +fn should_repair_editor_inline_image(image_src: &str, object_key: Option<&str>) -> bool { + object_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + && is_editor_inline_image_src(image_src) +} + +fn is_editor_inline_image_src(value: &str) -> bool { + value + .trim_start() + .to_ascii_lowercase() + .starts_with("data:image/") +} + pub(crate) fn editor_project_payload_from_record( record: EditorProjectRecord, ) -> EditorProjectPayload { - let canvas = editor_canvas_payload_from_record(record.canvas); + let resources: Vec = record + .resources + .into_iter() + .map(editor_project_resource_payload_from_record) + .collect(); + let canvas = editor_canvas_payload_from_record(record.canvas, resources.as_slice()); + let layers = sanitize_editor_payload_media(record.layers, resources.as_slice()); EditorProjectPayload { project_id: record.project_id, title: record.title, @@ -1952,17 +2331,16 @@ pub(crate) fn editor_project_payload_from_record( y: record.viewport.y, scale: record.viewport.scale, }, - layers: record.layers, - resources: record - .resources - .into_iter() - .map(editor_project_resource_payload_from_record) - .collect(), + layers, + resources, updated_at: record.updated_at, } } -fn editor_canvas_payload_from_record(record: EditorCanvasRecord) -> EditorCanvasPayload { +fn editor_canvas_payload_from_record( + record: EditorCanvasRecord, + resources: &[EditorProjectResourcePayload], +) -> EditorCanvasPayload { EditorCanvasPayload { canvas_id: record.canvas_id, project_id: record.project_id, @@ -1972,7 +2350,7 @@ fn editor_canvas_payload_from_record(record: EditorCanvasRecord) -> EditorCanvas y: record.viewport.y, scale: record.viewport.scale, }, - layers: record.layers, + layers: sanitize_editor_payload_media(record.layers, resources), created_at: record.created_at, updated_at: record.updated_at, } @@ -1981,10 +2359,12 @@ fn editor_canvas_payload_from_record(record: EditorCanvasRecord) -> EditorCanvas pub(crate) fn editor_project_resource_payload_from_record( record: EditorProjectResourceRecord, ) -> EditorProjectResourcePayload { + let image_src = + normalize_editor_record_media_src(record.image_src, record.object_key.as_deref()); EditorProjectResourcePayload { resource_id: record.resource_id, project_id: record.project_id, - image_src: record.image_src, + image_src, object_key: record.object_key, asset_object_id: record.asset_object_id, width: record.width, @@ -1997,7 +2377,9 @@ pub(crate) fn editor_project_resource_payload_from_record( task_id: record.task_id, source_resource_id: record.source_resource_id, asset_kind: record.asset_kind, - generation_inputs: record.generation_inputs, + generation_inputs: record + .generation_inputs + .map(sanitize_editor_payload_inline_media), created_at: record.created_at, updated_at: record.updated_at, } @@ -2035,11 +2417,13 @@ pub(crate) fn editor_asset_folder_payload_from_record( } pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload { + let image_src = + normalize_editor_record_media_src(record.image_src, record.object_key.as_deref()); EditorAssetPayload { asset_id: record.asset_id, folder_id: record.folder_id, label: record.label, - image_src: record.image_src, + image_src, object_key: record.object_key, asset_object_id: record.asset_object_id, width: record.width, @@ -2051,17 +2435,139 @@ pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> Edi provider: record.provider, task_id: record.task_id, asset_kind: record.asset_kind, - generation_inputs: record.generation_inputs, + generation_inputs: record + .generation_inputs + .map(sanitize_editor_payload_inline_media), created_at: record.created_at, updated_at: record.updated_at, } } +#[derive(Clone, Debug)] +struct EditorPayloadMediaReference { + image_src: String, + object_key: Option, + asset_object_id: Option, +} + +fn sanitize_editor_payload_media( + mut value: Value, + resources: &[EditorProjectResourcePayload], +) -> Value { + let resource_media: BTreeMap = resources + .iter() + .filter(|resource| !resource.image_src.trim().is_empty()) + .map(|resource| { + ( + resource.resource_id.clone(), + EditorPayloadMediaReference { + image_src: resource.image_src.clone(), + object_key: resource.object_key.clone(), + asset_object_id: resource.asset_object_id.clone(), + }, + ) + }) + .collect(); + sanitize_editor_payload_media_value(&mut value, None, &resource_media); + value +} + +fn sanitize_editor_payload_inline_media(mut value: Value) -> Value { + sanitize_editor_payload_media_value(&mut value, None, &BTreeMap::new()); + value +} + +fn sanitize_editor_payload_media_value( + value: &mut Value, + inherited_media: Option<&EditorPayloadMediaReference>, + resource_media: &BTreeMap, +) { + match value { + Value::Object(object) => { + let current_media = object + .get("resourceId") + .and_then(Value::as_str) + .and_then(|resource_id| resource_media.get(resource_id)) + .cloned() + .or_else(|| inherited_media.cloned()); + if let Some(media) = current_media.as_ref() { + for field in ["src", "imageSrc", "thumbnailSrc", "previewVideoPath"] { + rewrite_or_remove_inline_media_field(object, field, media); + } + fill_missing_media_identity_field(object, "objectKey", media.object_key.as_ref()); + fill_missing_media_identity_field( + object, + "assetObjectId", + media.asset_object_id.as_ref(), + ); + } + for child in object.values_mut() { + sanitize_editor_payload_media_value(child, current_media.as_ref(), resource_media); + } + object.retain(|_, child| !child.is_null()); + } + Value::Array(items) => { + for item in items.iter_mut() { + sanitize_editor_payload_media_value(item, inherited_media, resource_media); + } + items.retain(|item| !item.is_null()); + } + Value::String(item) if is_forbidden_editor_persisted_media_src(item) => { + *value = Value::Null; + } + _ => {} + } +} + +fn rewrite_or_remove_inline_media_field( + object: &mut serde_json::Map, + field: &str, + media: &EditorPayloadMediaReference, +) { + let Some(Value::String(current)) = object.get(field) else { + return; + }; + if !is_forbidden_editor_persisted_media_src(current) { + return; + } + if media.image_src.trim().is_empty() { + object.remove(field); + } else { + object.insert(field.to_string(), Value::String(media.image_src.clone())); + } +} + +fn fill_missing_media_identity_field( + object: &mut serde_json::Map, + field: &str, + value: Option<&String>, +) { + let Some(value) = value + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return; + }; + let should_fill = object + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|current| !current.is_empty()) + .is_none(); + if should_fill { + object.insert(field.to_string(), Value::String(value.to_string())); + } +} + async fn persist_editor_generated_asset( state: &AppState, input: PersistEditorGeneratedAssetInput, ) -> Result { let generation_inputs_json = serialize_editor_asset_metadata(input.generation_inputs.clone())?; + let object_key = normalize_editor_object_key(input.object_key); + let asset_object_id = normalize_optional_string(input.asset_object_id); + let image_src = normalize_editor_persisted_media_src(input.image_src, object_key.as_deref())?; let now_micros = current_utc_micros(); let resource = if let Some(project_id) = normalize_optional_string(input.project_id.clone()) { let record = state @@ -2070,9 +2576,9 @@ async fn persist_editor_generated_asset( 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(), + asset_object_id: asset_object_id.clone(), + image_src: image_src.clone(), + object_key: object_key.clone(), width: input.width, height: input.height, source_type: "generated".to_string(), @@ -2103,9 +2609,9 @@ async fn persist_editor_generated_asset( 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, + asset_object_id, + image_src, + object_key, width: input.width, height: input.height, source_type: "generated".to_string(), @@ -2152,8 +2658,11 @@ pub(crate) async fn complete_editor_canvas_generation( }) .await .map_err(map_editor_project_error)?; + let project = repair_editor_project_record_inline_media(state, project).await; + let viewport = project.viewport.clone(); + let project_payload = editor_project_payload_from_record(project); let Some(placeholder) = - resolve_canvas_completion_placeholder(project.layers.clone(), completion)? + resolve_canvas_completion_placeholder(project_payload.layers.clone(), completion)? else { return Ok(None); }; @@ -2161,7 +2670,7 @@ pub(crate) async fn complete_editor_canvas_generation( let item = build_generated_canvas_layer_item(completion, &placeholder, resource, layer_id.as_str(), 0); let completion_result = apply_editor_canvas_generation_items( - project.layers, + project_payload.layers, completion, placeholder, vec![item], @@ -2175,7 +2684,7 @@ pub(crate) async fn complete_editor_canvas_generation( .save_editor_project_layout(EditorProjectLayoutSaveRecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), - viewport: project.viewport.clone(), + viewport, layers_json: serialize_editor_layers(completion_result.layers)?, updated_at_micros: current_utc_micros(), }) @@ -2209,15 +2718,18 @@ pub(crate) async fn complete_editor_canvas_generation_with_items( }) .await .map_err(map_editor_project_error)?; + let project = repair_editor_project_record_inline_media(state, project).await; + let viewport = project.viewport.clone(); + let project_payload = editor_project_payload_from_record(project); let Some(placeholder) = - resolve_canvas_completion_placeholder(project.layers.clone(), completion)? + resolve_canvas_completion_placeholder(project_payload.layers.clone(), completion)? else { return Ok(None); }; let items = translate_editor_canvas_generation_items(items, &completion.placeholder, &placeholder); let completion_result = apply_editor_canvas_generation_items( - project.layers, + project_payload.layers, completion, placeholder, items, @@ -2231,7 +2743,7 @@ pub(crate) async fn complete_editor_canvas_generation_with_items( .save_editor_project_layout(EditorProjectLayoutSaveRecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), - viewport: project.viewport.clone(), + viewport, layers_json: serialize_editor_layers(completion_result.layers)?, updated_at_micros: current_utc_micros(), }) @@ -2625,6 +3137,70 @@ fn default_editor_asset_folder_id(owner_user_id: &str) -> String { format!("{owner_user_id}:asset-folder:{EDITOR_ASSET_DEFAULT_FOLDER_ID}") } +fn normalize_editor_object_key(value: Option) -> Option { + normalize_optional_string(value) + .map(|item| item.trim_start_matches('/').to_string()) + .filter(|item| !item.is_empty()) +} + +fn editor_media_src_from_object_key(object_key: &str) -> String { + format!("/{}", object_key.trim().trim_start_matches('/')) +} + +fn normalize_editor_record_media_src(image_src: String, object_key: Option<&str>) -> String { + if let Some(media_src) = object_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(editor_media_src_from_object_key) + { + return media_src; + } + if is_forbidden_editor_persisted_media_src(image_src.as_str()) { + return String::new(); + } + image_src +} + +pub(crate) fn normalize_editor_persisted_media_src( + image_src: String, + object_key: Option<&str>, +) -> Result { + if let Some(object_key) = object_key.map(str::trim).filter(|value| !value.is_empty()) { + return Ok(editor_media_src_from_object_key(object_key)); + } + if is_forbidden_editor_persisted_media_src(image_src.as_str()) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project", + "message": "图片画布媒体必须先上传 OSS 并以 objectKey 引用,不能写入 Data URL。", + })), + ); + } + Ok(image_src) +} + +fn is_forbidden_editor_persisted_media_src(value: &str) -> bool { + let normalized = value.trim_start().to_ascii_lowercase(); + let value = normalized.as_str(); + value.starts_with("blob:") + || value.starts_with("data:image/") + || value.starts_with("data:video/") + || value.starts_with("data:audio/") +} + +fn value_contains_forbidden_editor_persisted_media(value: &Value) -> bool { + match value { + Value::String(item) => is_forbidden_editor_persisted_media_src(item), + Value::Array(items) => items + .iter() + .any(value_contains_forbidden_editor_persisted_media), + Value::Object(object) => object + .values() + .any(value_contains_forbidden_editor_persisted_media), + _ => false, + } +} + impl EditorCanvasViewportPayload { pub(crate) fn into_record(self) -> EditorCanvasViewportRecord { EditorCanvasViewportRecord { @@ -2640,6 +3216,14 @@ fn current_owner_user_id(authenticated: &AuthenticatedAccessToken) -> String { } pub(crate) fn serialize_editor_layers(layers: Value) -> Result { + if value_contains_forbidden_editor_persisted_media(&layers) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project", + "message": "图层布局不能包含内联媒体 Data URL。", + })), + ); + } let payload = serde_json::to_string(&layers).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", @@ -2666,6 +3250,14 @@ pub(crate) fn serialize_editor_asset_metadata( if metadata.is_null() { return Ok(None); } + if value_contains_forbidden_editor_persisted_media(&metadata) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project", + "message": "资产元数据不能包含内联媒体 Data URL。", + })), + ); + } let payload = serde_json::to_string(&metadata).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", @@ -2855,27 +3447,6 @@ fn editor_general_cutout_alpha_options() -> GeneratedAssetSheetAlphaOptions { } } -pub(crate) fn data_url_from_image_bytes(mime_type: &str, bytes: &[u8]) -> String { - format!( - "data:{};base64,{}", - mime_type, - BASE64_STANDARD.encode(bytes) - ) -} - -fn editor_icon_response_from_slice( - icon: GeneratedAssetSheetConnectedIcon, -) -> EditorIconSpritesheetIconResponse { - EditorIconSpritesheetIconResponse { - name: icon.name, - image_src: data_url_from_image_bytes("image/png", icon.bytes.as_slice()), - width: icon.width, - height: icon.height, - resource: None, - asset: None, - } -} - #[derive(Debug, Clone, PartialEq, Eq)] struct EditorGenerationOptions { model: &'static str, @@ -2899,6 +3470,7 @@ pub(crate) async fn persist_editor_generated_image( actual_prompt: Option<&str>, asset_kind: &str, path_kind: &str, + file_stem: &str, slot: &str, ) -> Result { let oss_client = state.oss_client().ok_or_else(|| { @@ -2915,7 +3487,7 @@ pub(crate) async fn persist_editor_generated_image( sanitize_editor_storage_segment(path_kind, "generated-images"), sanitize_editor_storage_segment(task_id, "task"), ], - file_stem: "image".to_string(), + file_stem: sanitize_editor_storage_segment(file_stem, "image"), image: GeneratedImageAssetDataUrl { format: normalize_generated_image_asset_mime(image.mime_type.as_str()), bytes: image.bytes.clone(), @@ -2938,7 +3510,7 @@ pub(crate) async fn persist_editor_generated_image( .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "generated-image-assets", - "message": format!("准备画板角色形象 OSS 上传请求失败:{error:?}"), + "message": format!("准备画板生成图片 OSS 上传请求失败:{error:?}"), })) })?; let persisted_mime_type = prepared.format.mime_type.clone(); @@ -3133,6 +3705,288 @@ mod tests { ) } + #[test] + fn editor_record_media_src_prefers_object_key_over_legacy_inline_media() { + assert_eq!( + normalize_editor_record_media_src( + "data:image/png;base64,too-large".to_string(), + Some("/generated-character-drafts/editor/character.png") + ), + "/generated-character-drafts/editor/character.png" + ); + } + + #[test] + fn editor_record_media_src_drops_legacy_inline_media_without_object_key() { + assert_eq!( + normalize_editor_record_media_src("data:image/png;base64,too-large".to_string(), None), + "" + ); + assert_eq!( + normalize_editor_record_media_src("blob:http://localhost/preview".to_string(), None), + "" + ); + } + + #[test] + fn editor_payload_media_sanitizer_replaces_inline_layer_src_from_resource() { + let resources = vec![EditorProjectResourcePayload { + resource_id: "resource-1".to_string(), + project_id: "project-1".to_string(), + image_src: "/generated-character-drafts/editor/spec.png".to_string(), + object_key: Some("generated-character-drafts/editor/spec.png".to_string()), + asset_object_id: Some("asset-object-1".to_string()), + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + source_resource_id: None, + asset_kind: Some("spec".to_string()), + generation_inputs: None, + created_at: "2026-06-23T00:00:00.000Z".to_string(), + updated_at: "2026-06-23T00:00:00.000Z".to_string(), + }]; + + let sanitized = sanitize_editor_payload_media( + json!([ + { + "layerId": "layer-1", + "resourceId": "resource-1", + "src": "data:image/png;base64,layer", + "thumbnailSrc": "data:image/png;base64,thumbnail", + "imageSequenceFrames": [ + { "frameIndex": 0, "imageSrc": "data:image/png;base64,frame" } + ], + "generationInputs": { + "references": [ + { + "resourceId": "resource-1", + "src": "data:image/png;base64,reference" + } + ] + } + } + ]), + resources.as_slice(), + ); + let payload = serde_json::to_string(&sanitized).expect("payload serializes"); + + assert!(!payload.contains("data:image")); + assert_eq!( + sanitized[0]["src"], + json!("/generated-character-drafts/editor/spec.png") + ); + assert_eq!( + sanitized[0]["thumbnailSrc"], + json!("/generated-character-drafts/editor/spec.png") + ); + assert_eq!( + sanitized[0]["imageSequenceFrames"][0]["imageSrc"], + json!("/generated-character-drafts/editor/spec.png") + ); + assert_eq!( + sanitized[0]["objectKey"], + json!("generated-character-drafts/editor/spec.png") + ); + assert_eq!( + sanitized[0]["generationInputs"]["references"][0]["src"], + json!("/generated-character-drafts/editor/spec.png") + ); + } + + #[test] + fn editor_canvas_generation_completion_saves_after_sanitizing_legacy_inline_layers() { + let existing_resources = vec![EditorProjectResourcePayload { + resource_id: "resource-spec".to_string(), + project_id: "project-1".to_string(), + image_src: "/generated-character-drafts/editor/spec.png".to_string(), + object_key: Some("generated-character-drafts/editor/spec.png".to_string()), + asset_object_id: Some("asset-object-spec".to_string()), + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + source_resource_id: None, + asset_kind: Some("spec".to_string()), + generation_inputs: None, + created_at: "2026-06-23T00:00:00.000Z".to_string(), + updated_at: "2026-06-23T00:00:00.000Z".to_string(), + }]; + let dirty_layers = json!([ + { + "layerId": "layer-spec", + "resourceId": "resource-spec", + "title": "规范图", + "src": "data:image/png;base64,old-spec", + "thumbnailSrc": "data:image/png;base64,old-thumbnail" + }, + { + "layerId": "generation-dialog:dialog-character", + "resourceId": "generation-dialog:dialog-character", + "itemType": "generation-dialog", + "dialog": { + "id": "dialog-character", + "mode": "character", + "status": "generating", + "placeholder": { + "x": 200, + "y": 160, + "width": 420, + "height": 560, + "originalWidth": 1024, + "originalHeight": 1536 + } + } + } + ]); + let layers = sanitize_editor_payload_media(dirty_layers, existing_resources.as_slice()); + assert!( + !serde_json::to_string(&layers) + .unwrap() + .contains("data:image") + ); + + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: Some("dialog-character".to_string()), + title: "角色形象 1".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 200.0, + y: 160.0, + width: 420.0, + height: 560.0, + original_width: 1024.0, + original_height: 1536.0, + }, + }; + let generated_resource = EditorProjectResourcePayload { + resource_id: "editor-resource-character".to_string(), + project_id: "editor-project-1".to_string(), + image_src: "/generated-character-drafts/editor/character.png".to_string(), + object_key: Some("generated-character-drafts/editor/character.png".to_string()), + asset_object_id: Some("asset-object-character".to_string()), + width: 1024, + height: 1536, + source_type: "generated".to_string(), + prompt: Some("银发游侠".to_string()), + actual_prompt: Some("实际提示词".to_string()), + model: Some("gpt-image-2".to_string()), + provider: Some("VectorEngine".to_string()), + task_id: Some("task-character".to_string()), + source_resource_id: None, + asset_kind: Some("character".to_string()), + generation_inputs: None, + created_at: "2026-06-23T00:00:00.000Z".to_string(), + updated_at: "2026-06-23T00:00:00.000Z".to_string(), + }; + + let completed = + apply_editor_canvas_generation_completion(layers, &completion, &generated_resource) + .expect("completion should apply to sanitized legacy layout"); + + assert!(completed.changed); + serialize_editor_layers(completed.layers).expect("sanitized completion should save"); + } + + #[test] + fn editor_persisted_media_src_rejects_inline_media_without_object_key() { + let rejected = normalize_editor_persisted_media_src( + "data:image/png;base64,too-large".to_string(), + None, + ) + .expect_err("inline image media should be rejected"); + assert!(rejected.body_text().contains("objectKey")); + + assert!( + normalize_editor_persisted_media_src("data:video/mp4;base64,AAAA".to_string(), None) + .is_err() + ); + assert!( + normalize_editor_persisted_media_src("data:audio/mp3;base64,AAAA".to_string(), None) + .is_err() + ); + assert!( + normalize_editor_persisted_media_src("blob:http://localhost/preview".to_string(), None) + .is_err() + ); + assert_eq!( + normalize_editor_persisted_media_src( + "data:image/png;base64,legacy".to_string(), + Some("generated-character-drafts/editor/spec.png") + ) + .expect("objectKey-backed legacy rows should normalize"), + "/generated-character-drafts/editor/spec.png" + ); + } + + #[test] + fn editor_layout_serialization_rejects_nested_inline_media() { + let error = serialize_editor_layers(json!([ + { + "layerId": "layer-animation", + "thumbnailSrc": "/generated-character-drafts/editor/frame-1.png", + "imageSequenceFrames": [ + { + "frameIndex": 1, + "imageSrc": "data:image/png;base64,frame" + } + ] + } + ])) + .expect_err("layout should reject nested inline image data"); + + assert!(error.body_text().contains("图层布局")); + assert!( + serialize_editor_layers(json!([ + { + "layerId": "layer-animation", + "thumbnailSrc": "/generated-character-drafts/editor/frame-1.png", + "imageSequenceFrames": [ + { + "frameIndex": 1, + "imageSrc": "/generated-character-drafts/editor/frame-1.png" + } + ], + "previewVideoPath": "/generated-character-drafts/editor/preview.mp4" + } + ])) + .is_ok() + ); + } + + #[test] + fn editor_asset_metadata_serialization_rejects_nested_inline_media() { + let error = serialize_editor_asset_metadata(Some(json!({ + "references": [ + { + "title": "参考图", + "src": "data:image/png;base64,reference" + } + ] + }))) + .expect_err("metadata should reject nested inline image data"); + + assert!(error.body_text().contains("资产元数据")); + assert!( + serialize_editor_asset_metadata(Some(json!({ + "references": [ + { + "title": "参考图", + "src": "/generated-character-drafts/editor/reference.png" + } + ] + }))) + .is_ok() + ); + } + #[test] fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() { assert_eq!(normalize_editor_image_generation_size(None), "1024x1024"); diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 6bb592e36..0b313d690 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -31,8 +31,8 @@ use crate::{ editor_asset_payload_from_record, editor_project_payload_from_record, editor_project_resource_payload_from_record, extract_editor_ui_design_assets_for_owner, generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, - map_editor_project_error, normalize_optional_string, serialize_editor_asset_metadata, - serialize_editor_layers, + map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, + serialize_editor_asset_metadata, serialize_editor_layers, }, external_api_auth::ExternalApiPrincipal, http_error::AppError, @@ -468,6 +468,8 @@ pub async fn create_external_editor_asset( require_scope(&principal, SCOPE_EDITOR_ASSET)?; let generation_inputs_json = serialize_editor_asset_metadata(payload.generation_inputs.clone())?; + let object_key = normalize_optional_string(payload.object_key); + let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; let asset = state .spacetime_client() .create_editor_asset(EditorAssetCreateRecordInput { @@ -476,8 +478,8 @@ pub async fn create_external_editor_asset( folder_id: payload.folder_id, label: payload.label, asset_object_id: normalize_optional_string(payload.asset_object_id), - image_src: payload.image_src, - object_key: normalize_optional_string(payload.object_key), + image_src, + object_key, width: payload.width, height: payload.height, source_type: payload.source_type, @@ -563,6 +565,8 @@ pub async fn create_external_editor_project_resource( require_scope(&principal, SCOPE_EDITOR_ASSET)?; let generation_inputs_json = serialize_editor_asset_metadata(payload.generation_inputs.clone())?; + let object_key = normalize_optional_string(payload.object_key); + let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; let resource = state .spacetime_client() .create_editor_project_resource(EditorProjectResourceCreateRecordInput { @@ -570,8 +574,8 @@ pub async fn create_external_editor_project_resource( project_id, owner_user_id: principal.owner_user_id().to_string(), asset_object_id: normalize_optional_string(payload.asset_object_id), - image_src: payload.image_src, - object_key: normalize_optional_string(payload.object_key), + image_src, + object_key, width: payload.width, height: payload.height, source_type: payload.source_type, diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index 89c009289..fba5f9c20 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -162,6 +162,29 @@ impl SpacetimeClient { .await } + pub async fn save_editor_project_layout_ack( + &self, + input: EditorProjectLayoutSaveRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "save_editor_project_layout_ack", + move |connection, sender| { + connection.procedures().save_editor_project_layout_ack_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_project_layout_save_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn create_editor_project_resource( &self, input: EditorProjectResourceCreateRecordInput, @@ -187,6 +210,31 @@ impl SpacetimeClient { .await } + pub async fn repair_editor_project_resource_media( + &self, + input: EditorProjectResourceMediaRepairRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "repair_editor_project_resource_media_and_return", + move |connection, sender| { + connection + .procedures() + .repair_editor_project_resource_media_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_project_resource_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn get_editor_asset_library( &self, owner_user_id: String, @@ -334,6 +382,31 @@ impl SpacetimeClient { .await } + pub async fn repair_editor_asset_media( + &self, + input: EditorAssetMediaRepairRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "repair_editor_asset_media_and_return", + move |connection, sender| { + connection + .procedures() + .repair_editor_asset_media_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_asset_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn delete_editor_asset( &self, input: EditorAssetDeleteRecordInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 08d1f0371..1e21cd6c9 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -33,10 +33,11 @@ pub use mapper::{ CustomWorldWorkSummaryRecord, EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, - EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord, - EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, - EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, - EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, + EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, + EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, + EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord, + EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, + EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, EditorProjectResourceRecord, ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput, ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index e2dc114d0..0a852bc9d 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -75,11 +75,13 @@ pub use self::common::{ pub use self::editor_project::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, - EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetRecord, - EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, - EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, + EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, + EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, + EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, + EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, - EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, + EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, + EditorProjectResourceRecord, }; pub use self::external_api_key::{ ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, @@ -204,9 +206,9 @@ pub(crate) use self::custom_world::{ pub(crate) use self::editor_project::{ map_editor_asset_folder_library_procedure_result, map_editor_asset_folder_procedure_result, map_editor_asset_library_procedure_result, map_editor_asset_procedure_result, - map_editor_project_delete_procedure_result, map_editor_project_list_procedure_result, - map_editor_project_optional_procedure_result, map_editor_project_required_procedure_result, - map_editor_project_resource_procedure_result, + map_editor_project_delete_procedure_result, map_editor_project_layout_save_procedure_result, + map_editor_project_list_procedure_result, map_editor_project_optional_procedure_result, + map_editor_project_required_procedure_result, map_editor_project_resource_procedure_result, }; pub(crate) use self::external_api_key::{ map_external_api_key_list_procedure_result, map_external_api_key_single_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index b53925b53..85c6fb64e 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -31,6 +31,14 @@ pub struct EditorCanvasRecord { pub updated_at: String, } +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct EditorProjectLayoutSaveAckRecord { + pub project_id: String, + pub canvas_id: String, + pub updated_at: String, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct EditorProjectResourceRecord { pub resource_id: String, @@ -151,6 +159,16 @@ pub struct EditorProjectResourceCreateRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorProjectResourceMediaRepairRecordInput { + pub resource_id: String, + pub owner_user_id: String, + pub asset_object_id: String, + pub image_src: String, + pub object_key: String, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct EditorAssetFolderCreateRecordInput { pub folder_id: String, @@ -207,6 +225,16 @@ pub struct EditorAssetUpdateRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorAssetMediaRepairRecordInput { + pub asset_id: String, + pub owner_user_id: String, + pub asset_object_id: String, + pub image_src: String, + pub object_key: String, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct EditorAssetDeleteRecordInput { pub asset_id: String, @@ -298,6 +326,21 @@ impl From } } +impl From + for crate::module_bindings::EditorProjectResourceMediaRepairInput +{ + fn from(input: EditorProjectResourceMediaRepairRecordInput) -> Self { + Self { + resource_id: input.resource_id, + owner_user_id: input.owner_user_id, + asset_object_id: input.asset_object_id, + image_src: input.image_src, + object_key: input.object_key, + updated_at_micros: input.updated_at_micros, + } + } +} + impl From for crate::module_bindings::EditorAssetFolderCreateInput { @@ -375,6 +418,21 @@ impl From for crate::module_bindings::EditorAssetU } } +impl From + for crate::module_bindings::EditorAssetMediaRepairInput +{ + fn from(input: EditorAssetMediaRepairRecordInput) -> Self { + Self { + asset_id: input.asset_id, + owner_user_id: input.owner_user_id, + asset_object_id: input.asset_object_id, + image_src: input.image_src, + object_key: input.object_key, + updated_at_micros: input.updated_at_micros, + } + } +} + impl From for crate::module_bindings::EditorAssetDeleteInput { fn from(input: EditorAssetDeleteRecordInput) -> Self { Self { @@ -401,6 +459,24 @@ pub(crate) fn map_editor_project_required_procedure_result( .ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布工程快照")) } +pub(crate) fn map_editor_project_layout_save_procedure_result( + result: EditorProjectLayoutSaveProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let ack = result + .ack + .ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布保存结果"))?; + Ok(EditorProjectLayoutSaveAckRecord { + project_id: ack.project_id, + canvas_id: ack.canvas_id, + updated_at: format_timestamp_micros(ack.updated_at_micros), + updated_at_micros: ack.updated_at_micros, + }) +} + pub(crate) fn map_editor_project_list_procedure_result( result: EditorProjectListProcedureResult, ) -> Result, SpacetimeClientError> { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 8d6885175..3c60f4497 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -366,6 +366,7 @@ pub mod editor_asset_folder_update_input_type; pub mod editor_asset_library_get_input_type; pub mod editor_asset_library_procedure_result_type; pub mod editor_asset_library_snapshot_type; +pub mod editor_asset_media_repair_input_type; pub mod editor_asset_procedure_result_type; pub mod editor_asset_snapshot_type; pub mod editor_asset_table; @@ -379,12 +380,15 @@ pub mod editor_project_delete_input_type; pub mod editor_project_delete_procedure_result_type; pub mod editor_project_get_input_type; pub mod editor_project_get_recent_input_type; +pub mod editor_project_layout_save_ack_type; pub mod editor_project_layout_save_input_type; +pub mod editor_project_layout_save_procedure_result_type; pub mod editor_project_list_input_type; pub mod editor_project_list_procedure_result_type; pub mod editor_project_procedure_result_type; pub mod editor_project_rename_input_type; pub mod editor_project_resource_create_input_type; +pub mod editor_project_resource_media_repair_input_type; pub mod editor_project_resource_procedure_result_type; pub mod editor_project_resource_snapshot_type; pub mod editor_project_resource_table; @@ -859,6 +863,8 @@ pub mod remix_custom_world_profile_procedure; pub mod remix_puzzle_work_procedure; pub mod rename_editor_project_and_return_procedure; pub mod renew_external_generation_job_lease_and_return_procedure; +pub mod repair_editor_asset_media_and_return_procedure; +pub mod repair_editor_project_resource_media_and_return_procedure; pub mod resolve_combat_action_and_return_procedure; pub mod resolve_combat_action_input_type; pub mod resolve_combat_action_procedure_result_type; @@ -997,6 +1003,7 @@ pub mod runtime_tracking_event_batch_procedure_result_type; pub mod runtime_tracking_event_input_type; pub mod runtime_tracking_event_procedure_result_type; pub mod runtime_tracking_scope_kind_type; +pub mod save_editor_project_layout_ack_procedure; pub mod save_editor_project_layout_and_return_procedure; pub mod save_puzzle_form_draft_procedure; pub mod save_puzzle_generated_images_procedure; @@ -1568,6 +1575,7 @@ pub use editor_asset_folder_update_input_type::EditorAssetFolderUpdateInput; pub use editor_asset_library_get_input_type::EditorAssetLibraryGetInput; pub use editor_asset_library_procedure_result_type::EditorAssetLibraryProcedureResult; pub use editor_asset_library_snapshot_type::EditorAssetLibrarySnapshot; +pub use editor_asset_media_repair_input_type::EditorAssetMediaRepairInput; pub use editor_asset_procedure_result_type::EditorAssetProcedureResult; pub use editor_asset_snapshot_type::EditorAssetSnapshot; pub use editor_asset_table::*; @@ -1581,12 +1589,15 @@ pub use editor_project_delete_input_type::EditorProjectDeleteInput; pub use editor_project_delete_procedure_result_type::EditorProjectDeleteProcedureResult; pub use editor_project_get_input_type::EditorProjectGetInput; pub use editor_project_get_recent_input_type::EditorProjectGetRecentInput; +pub use editor_project_layout_save_ack_type::EditorProjectLayoutSaveAck; pub use editor_project_layout_save_input_type::EditorProjectLayoutSaveInput; +pub use editor_project_layout_save_procedure_result_type::EditorProjectLayoutSaveProcedureResult; pub use editor_project_list_input_type::EditorProjectListInput; pub use editor_project_list_procedure_result_type::EditorProjectListProcedureResult; pub use editor_project_procedure_result_type::EditorProjectProcedureResult; pub use editor_project_rename_input_type::EditorProjectRenameInput; pub use editor_project_resource_create_input_type::EditorProjectResourceCreateInput; +pub use editor_project_resource_media_repair_input_type::EditorProjectResourceMediaRepairInput; pub use editor_project_resource_procedure_result_type::EditorProjectResourceProcedureResult; pub use editor_project_resource_snapshot_type::EditorProjectResourceSnapshot; pub use editor_project_resource_table::*; @@ -2061,6 +2072,8 @@ pub use remix_custom_world_profile_procedure::remix_custom_world_profile; pub use remix_puzzle_work_procedure::remix_puzzle_work; pub use rename_editor_project_and_return_procedure::rename_editor_project_and_return; pub use renew_external_generation_job_lease_and_return_procedure::renew_external_generation_job_lease_and_return; +pub use repair_editor_asset_media_and_return_procedure::repair_editor_asset_media_and_return; +pub use repair_editor_project_resource_media_and_return_procedure::repair_editor_project_resource_media_and_return; pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return; pub use resolve_combat_action_input_type::ResolveCombatActionInput; pub use resolve_combat_action_procedure_result_type::ResolveCombatActionProcedureResult; @@ -2199,6 +2212,7 @@ pub use runtime_tracking_event_batch_procedure_result_type::RuntimeTrackingEvent pub use runtime_tracking_event_input_type::RuntimeTrackingEventInput; pub use runtime_tracking_event_procedure_result_type::RuntimeTrackingEventProcedureResult; pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind; +pub use save_editor_project_layout_ack_procedure::save_editor_project_layout_ack; pub use save_editor_project_layout_and_return_procedure::save_editor_project_layout_and_return; pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft; pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_media_repair_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_media_repair_input_type.rs new file mode 100644 index 000000000..7bfd90fcc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_media_repair_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAssetMediaRepairInput { + pub asset_id: String, + pub owner_user_id: String, + pub asset_object_id: String, + pub image_src: String, + pub object_key: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorAssetMediaRepairInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_ack_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_ack_type.rs new file mode 100644 index 000000000..c415e0b75 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_ack_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectLayoutSaveAck { + pub project_id: String, + pub canvas_id: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorProjectLayoutSaveAck { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_procedure_result_type.rs new file mode 100644 index 000000000..ad6137d14 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_layout_save_ack_type::EditorProjectLayoutSaveAck; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectLayoutSaveProcedureResult { + pub ok: bool, + pub ack: Option, + pub error_message: Option, +} + +impl __sdk::InModule for EditorProjectLayoutSaveProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_media_repair_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_media_repair_input_type.rs new file mode 100644 index 000000000..b682cb5eb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_media_repair_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectResourceMediaRepairInput { + pub resource_id: String, + pub owner_user_id: String, + pub asset_object_id: String, + pub image_src: String, + pub object_key: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorProjectResourceMediaRepairInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_asset_media_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_asset_media_and_return_procedure.rs new file mode 100644 index 000000000..1b035028b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_asset_media_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_asset_media_repair_input_type::EditorAssetMediaRepairInput; +use super::editor_asset_procedure_result_type::EditorAssetProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RepairEditorAssetMediaAndReturnArgs { + pub input: EditorAssetMediaRepairInput, +} + +impl __sdk::InModule for RepairEditorAssetMediaAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `repair_editor_asset_media_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait repair_editor_asset_media_and_return { + fn repair_editor_asset_media_and_return(&self, input: EditorAssetMediaRepairInput) { + self.repair_editor_asset_media_and_return_then(input, |_, _| {}); + } + + fn repair_editor_asset_media_and_return_then( + &self, + input: EditorAssetMediaRepairInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl repair_editor_asset_media_and_return for super::RemoteProcedures { + fn repair_editor_asset_media_and_return_then( + &self, + input: EditorAssetMediaRepairInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAssetProcedureResult>( + "repair_editor_asset_media_and_return", + RepairEditorAssetMediaAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_project_resource_media_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_project_resource_media_and_return_procedure.rs new file mode 100644 index 000000000..7e521cd37 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_project_resource_media_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_resource_media_repair_input_type::EditorProjectResourceMediaRepairInput; +use super::editor_project_resource_procedure_result_type::EditorProjectResourceProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RepairEditorProjectResourceMediaAndReturnArgs { + pub input: EditorProjectResourceMediaRepairInput, +} + +impl __sdk::InModule for RepairEditorProjectResourceMediaAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `repair_editor_project_resource_media_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait repair_editor_project_resource_media_and_return { + fn repair_editor_project_resource_media_and_return( + &self, + input: EditorProjectResourceMediaRepairInput, + ) { + self.repair_editor_project_resource_media_and_return_then(input, |_, _| {}); + } + + fn repair_editor_project_resource_media_and_return_then( + &self, + input: EditorProjectResourceMediaRepairInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl repair_editor_project_resource_media_and_return for super::RemoteProcedures { + fn repair_editor_project_resource_media_and_return_then( + &self, + input: EditorProjectResourceMediaRepairInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectResourceProcedureResult>( + "repair_editor_project_resource_media_and_return", + RepairEditorProjectResourceMediaAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_ack_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_ack_procedure.rs new file mode 100644 index 000000000..a564b72be --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_ack_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_layout_save_input_type::EditorProjectLayoutSaveInput; +use super::editor_project_layout_save_procedure_result_type::EditorProjectLayoutSaveProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SaveEditorProjectLayoutAckArgs { + pub input: EditorProjectLayoutSaveInput, +} + +impl __sdk::InModule for SaveEditorProjectLayoutAckArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `save_editor_project_layout_ack`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait save_editor_project_layout_ack { + fn save_editor_project_layout_ack(&self, input: EditorProjectLayoutSaveInput) { + self.save_editor_project_layout_ack_then(input, |_, _| {}); + } + + fn save_editor_project_layout_ack_then( + &self, + input: EditorProjectLayoutSaveInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl save_editor_project_layout_ack for super::RemoteProcedures { + fn save_editor_project_layout_ack_then( + &self, + input: EditorProjectLayoutSaveInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectLayoutSaveProcedureResult>( + "save_editor_project_layout_ack", + SaveEditorProjectLayoutAckArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 0195c9da9..59cdd6b28 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -197,6 +197,16 @@ pub struct EditorProjectResourceCreateInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorProjectResourceMediaRepairInput { + pub resource_id: String, + pub owner_user_id: String, + pub asset_object_id: String, + pub image_src: String, + pub object_key: String, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorProjectResourceSnapshot { pub resource_id: String, @@ -320,6 +330,16 @@ pub struct EditorAssetUpdateInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorAssetMediaRepairInput { + pub asset_id: String, + pub owner_user_id: String, + pub asset_object_id: String, + pub image_src: String, + pub object_key: String, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorAssetDeleteInput { pub asset_id: String, @@ -355,6 +375,20 @@ pub struct EditorProjectProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorProjectLayoutSaveAck { + pub project_id: String, + pub canvas_id: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorProjectLayoutSaveProcedureResult { + pub ok: bool, + pub ack: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct EditorProjectListProcedureResult { pub ok: bool, @@ -461,6 +495,17 @@ pub fn save_editor_project_layout_and_return( } } +#[spacetimedb::procedure] +pub fn save_editor_project_layout_ack( + ctx: &mut ProcedureContext, + input: EditorProjectLayoutSaveInput, +) -> EditorProjectLayoutSaveProcedureResult { + match ctx.try_with_tx(|tx| persist_editor_project_layout(tx, input.clone())) { + Ok(ack) => editor_project_layout_save_ok(ack), + Err(message) => editor_project_layout_save_error(message), + } +} + #[spacetimedb::procedure] pub fn rename_editor_project_and_return( ctx: &mut ProcedureContext, @@ -510,6 +555,25 @@ pub fn create_editor_project_resource_and_return( } } +#[spacetimedb::procedure] +pub fn repair_editor_project_resource_media_and_return( + ctx: &mut ProcedureContext, + input: EditorProjectResourceMediaRepairInput, +) -> EditorProjectResourceProcedureResult { + match ctx.try_with_tx(|tx| repair_editor_project_resource_media(tx, input.clone())) { + Ok(resource) => EditorProjectResourceProcedureResult { + ok: true, + resource: Some(resource), + error_message: None, + }, + Err(message) => EditorProjectResourceProcedureResult { + ok: false, + resource: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn get_editor_asset_library_and_return( ctx: &mut ProcedureContext, @@ -576,6 +640,17 @@ pub fn update_editor_asset_and_return( } } +#[spacetimedb::procedure] +pub fn repair_editor_asset_media_and_return( + ctx: &mut ProcedureContext, + input: EditorAssetMediaRepairInput, +) -> EditorAssetProcedureResult { + match ctx.try_with_tx(|tx| repair_editor_asset_media(tx, input.clone())) { + Ok(asset) => editor_asset_ok(Some(asset)), + Err(message) => editor_asset_error(message), + } +} + #[spacetimedb::procedure] pub fn delete_editor_asset_and_return( ctx: &mut ProcedureContext, @@ -750,6 +825,14 @@ fn save_editor_project_layout( ctx: &ReducerContext, input: EditorProjectLayoutSaveInput, ) -> Result { + let ack = persist_editor_project_layout(ctx, input)?; + build_project_snapshot(ctx, ack.project_id.as_str()) +} + +fn persist_editor_project_layout( + ctx: &ReducerContext, + input: EditorProjectLayoutSaveInput, +) -> Result { let project_id = normalize_required(&input.project_id, "editor_project.project_id")?; let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?; let input_layers_json = normalize_layout_json(input.layers_json)?; @@ -762,6 +845,7 @@ fn save_editor_project_layout( Some(&project), now, )?; + let canvas_id = canvas.canvas_id.clone(); let _ = migrate_canvas_layer_asset_metadata( ctx, project.project_id.as_str(), @@ -803,7 +887,11 @@ fn save_editor_project_layout( updated_at: now, }); - build_project_snapshot(ctx, project_id.as_str()) + Ok(EditorProjectLayoutSaveAck { + project_id, + canvas_id, + updated_at_micros: now.to_micros_since_unix_epoch(), + }) } fn create_editor_project_resource( @@ -870,6 +958,62 @@ fn create_editor_project_resource( .ok_or_else(|| "画布资源创建失败".to_string()) } +fn repair_editor_project_resource_media( + ctx: &ReducerContext, + input: EditorProjectResourceMediaRepairInput, +) -> Result { + let resource_id = + normalize_required(&input.resource_id, "editor_project_resource.resource_id")?; + let owner_user_id = normalize_required( + &input.owner_user_id, + "editor_project_resource.owner_user_id", + )?; + let resource = + require_owned_project_resource(ctx, resource_id.as_str(), owner_user_id.as_str())?; + let image_src = normalize_required(&input.image_src, "editor_project_resource.image_src")?; + let object_key = normalize_required(&input.object_key, "editor_project_resource.object_key")?; + let asset_object_id = normalize_required( + &input.asset_object_id, + "editor_project_resource.asset_object_id", + )?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + + ctx.db + .editor_project_resource() + .resource_id() + .delete(&resource_id); + ctx.db + .editor_project_resource() + .insert(EditorProjectResource { + resource_id: resource.resource_id.clone(), + project_id: resource.project_id, + owner_user_id: resource.owner_user_id, + asset_object_id: Some(asset_object_id), + image_src, + object_key: Some(object_key), + width: resource.width, + height: resource.height, + source_type: resource.source_type, + prompt: resource.prompt, + actual_prompt: resource.actual_prompt, + model: resource.model, + provider: resource.provider, + task_id: resource.task_id, + source_resource_id: resource.source_resource_id, + created_at: resource.created_at, + updated_at: now, + asset_kind: resource.asset_kind, + generation_inputs_json: resource.generation_inputs_json, + }); + + ctx.db + .editor_project_resource() + .resource_id() + .find(&resource_id) + .map(resource_snapshot_from_row) + .ok_or_else(|| "画布资源媒体修复失败".to_string()) +} + fn get_editor_asset_library( ctx: &ReducerContext, input: EditorAssetLibraryGetInput, @@ -1076,6 +1220,50 @@ fn update_editor_asset( .ok_or_else(|| "素材更新失败".to_string()) } +fn repair_editor_asset_media( + ctx: &ReducerContext, + input: EditorAssetMediaRepairInput, +) -> Result { + let asset_id = normalize_required(&input.asset_id, "editor_asset.asset_id")?; + let owner_user_id = normalize_required(&input.owner_user_id, "editor_asset.owner_user_id")?; + let asset = require_owned_asset(ctx, asset_id.as_str(), owner_user_id.as_str())?; + let image_src = normalize_required(&input.image_src, "editor_asset.image_src")?; + let object_key = normalize_required(&input.object_key, "editor_asset.object_key")?; + let asset_object_id = + normalize_required(&input.asset_object_id, "editor_asset.asset_object_id")?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + + ctx.db.editor_asset().asset_id().delete(&asset_id); + ctx.db.editor_asset().insert(EditorAsset { + asset_id: asset.asset_id.clone(), + owner_user_id: asset.owner_user_id, + folder_id: asset.folder_id, + label: asset.label, + asset_object_id: Some(asset_object_id), + image_src, + object_key: Some(object_key), + width: asset.width, + height: asset.height, + source_type: asset.source_type, + prompt: asset.prompt, + actual_prompt: asset.actual_prompt, + model: asset.model, + provider: asset.provider, + 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() + .asset_id() + .find(&asset_id) + .map(asset_snapshot_from_row) + .ok_or_else(|| "素材媒体修复失败".to_string()) +} + fn delete_editor_asset( ctx: &ReducerContext, input: EditorAssetDeleteInput, @@ -1211,6 +1399,24 @@ fn require_owned_project( Ok(project) } +fn require_owned_project_resource( + ctx: &ReducerContext, + resource_id: &str, + owner_user_id: &str, +) -> Result { + let resource_key = resource_id.to_string(); + let resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource_key) + .ok_or_else(|| "画布资源不存在".to_string())?; + if resource.owner_user_id != owner_user_id { + return Err("无权访问该画布资源".to_string()); + } + Ok(resource) +} + fn ensure_default_asset_folder( ctx: &ReducerContext, owner_user_id: &str, @@ -1540,6 +1746,24 @@ fn editor_project_error(message: String) -> EditorProjectProcedureResult { } } +fn editor_project_layout_save_ok( + ack: EditorProjectLayoutSaveAck, +) -> EditorProjectLayoutSaveProcedureResult { + EditorProjectLayoutSaveProcedureResult { + ok: true, + ack: Some(ack), + error_message: None, + } +} + +fn editor_project_layout_save_error(message: String) -> EditorProjectLayoutSaveProcedureResult { + EditorProjectLayoutSaveProcedureResult { + ok: false, + ack: None, + error_message: Some(message), + } +} + fn editor_asset_library_ok( library: EditorAssetLibrarySnapshot, ) -> EditorAssetLibraryProcedureResult { diff --git a/src/components/common/PlatformMediaFrame.test.tsx b/src/components/common/PlatformMediaFrame.test.tsx index 842cad642..550a5dc06 100644 --- a/src/components/common/PlatformMediaFrame.test.tsx +++ b/src/components/common/PlatformMediaFrame.test.tsx @@ -9,6 +9,7 @@ import { PlatformMediaFrame } from './PlatformMediaFrame'; vi.mock('../ResolvedAssetImage', () => ({ ResolvedAssetImage: ({ src, + objectKey, fallbackSrc, alt, className, @@ -16,6 +17,7 @@ vi.mock('../ResolvedAssetImage', () => ({ ...imageProps }: { src?: string; + objectKey?: string; fallbackSrc?: string; alt: string; className?: string; @@ -24,6 +26,7 @@ vi.mock('../ResolvedAssetImage', () => ({ {alt} { expect(image.getAttribute('data-refresh-key')).toBe('session-1:level-1'); }); +test('passes objectKey to the resolved asset image', () => { + render( + , + ); + + const image = screen.getByRole('img', { name: '规范图' }); + + expect(image.getAttribute('data-object-key')).toBe( + 'generated-character-drafts/editor/spec.png', + ); +}); + +test('renders an image when only objectKey is available', () => { + render( + , + ); + + const image = screen.getByRole('img', { name: '仅 objectKey 规范图' }); + + expect(image.getAttribute('src')).toBe(null); + expect(image.getAttribute('data-object-key')).toBe( + 'generated-character-drafts/editor/spec-only.png', + ); + expect(screen.queryByText('规范图')).toBeNull(); +}); + test('supports portrait aspect for vertical preview assets', () => { render( & { src?: string | null; + objectKey?: string | null; fallbackSrc?: string | null; alt: string; fallbackLabel: string; @@ -88,6 +89,7 @@ export const PlatformMediaFrame = forwardRef< >(function PlatformMediaFrame( { src, + objectKey, fallbackSrc, alt, fallbackLabel, @@ -108,7 +110,9 @@ export const PlatformMediaFrame = forwardRef< }, ref, ) { - const imageSrc = src?.trim() || fallbackSrc?.trim() || ''; + const hasImageSource = Boolean( + src?.trim() || objectKey?.trim() || fallbackSrc?.trim(), + ); const hasOverlay = Boolean(children) || Boolean(previewOverlay); return ( @@ -124,10 +128,11 @@ export const PlatformMediaFrame = forwardRef< .filter(Boolean) .join(' ')} > - {imageSrc ? ( + {hasImageSource ? ( ({ + PlatformMediaFrame: ({ + src, + objectKey, + alt, + previewOverlay, + }: { + src?: string | null; + objectKey?: string | null; + alt: string; + previewOverlay?: ReactNode; + }) => ( +
+ {src?.trim() ? ( + {alt} + ) : null} + {previewOverlay} +
+ ), +})); + function createAsset(overrides: Partial = {}): EditorAsset { return { id: 'asset-1', @@ -164,4 +190,21 @@ describe('ImageCanvasAssetRowView', () => { ?.className, ).toContain('image-canvas-editor__media-preview--variant-'); }); + + it('passes objectKey to image thumbnails in the asset library', () => { + renderAssetRow({ + asset: createAsset({ + label: '规范图', + src: '/generated-character-drafts/editor/spec.png', + objectKey: 'generated-character-drafts/editor/spec.png', + mediaType: 'image', + }), + }); + + const image = screen.getByRole('img', { name: '素材:规范图' }); + + expect(image.getAttribute('data-object-key')).toBe( + 'generated-character-drafts/editor/spec.png', + ); + }); }); diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx index 6a738434b..2217afa40 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -203,6 +203,7 @@ export function ImageCanvasAssetRowView({ ? asset.src : (asset.thumbnailSrc ?? '') } + objectKey={asset.mediaType === 'image' ? asset.objectKey : undefined} titleNode={ isUploadingAsset || isFailedUpload ? {asset.label} : titleNode } diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx index af77f2b38..9709de426 100644 --- a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx @@ -39,6 +39,7 @@ const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); +const getSignedAssetReadUrlMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< @@ -69,6 +70,17 @@ vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); +vi.mock('../../services/assetReadUrlService', async () => { + const actual = + await vi.importActual( + '../../services/assetReadUrlService', + ); + return { + ...actual, + getSignedAssetReadUrl: getSignedAssetReadUrlMock, + }; +}); + function createSizedPngFile(name: string, width: number, height: number) { const bytes = new Uint8Array([ 0x89, @@ -108,6 +120,27 @@ function createSizedPngFile(name: string, width: number, height: number) { return new File([bytes], name, { type: 'image/png' }); } +function createTestImageAsset({ + assetId, + label, +}: { + assetId: string; + label: string; +}) { + const objectKey = `generated-character-drafts/editor/asset-library/image/${assetId}.png`; + return { + assetId, + folderId: 'project', + label, + imageSrc: `/${objectKey}`, + objectKey, + assetObjectId: `assetobj-${assetId}`, + width: 320, + height: 240, + sourceType: 'uploaded' as const, + }; +} + describe('ImageCanvasEditorView asset library integration', () => { setupImageCanvasEditorViewTestLifecycle({ generateEditorImageMock, @@ -129,8 +162,12 @@ describe('ImageCanvasEditorView asset library integration', () => { }); beforeEach(() => { + getSignedAssetReadUrlMock.mockImplementation( + async ({ objectKey }: { objectKey?: string }) => + `https://signed.example.com/${objectKey ?? 'asset'}`, + ); uploadEditorMediaAssetFileMock.mockImplementation( - async (file: File, mediaType: 'video' | 'audio') => ({ + async (file: File, mediaType: 'image' | 'video' | 'audio') => ({ src: `https://signed.example.com/editor/${mediaType}/${file.name}`, legacyPublicPath: `/generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`, objectKey: `generated-character-drafts/editor/asset-library/${mediaType}/${file.name}`, @@ -577,6 +614,8 @@ describe('ImageCanvasEditorView asset library integration', () => { folderId: string; label: string; imageSrc: string; + objectKey?: string | null; + assetObjectId?: string | null; width: number; height: number; sourceType: 'uploaded'; @@ -599,7 +638,11 @@ describe('ImageCanvasEditorView asset library integration', () => { assetId: 'asset-upload-progress', folderId: 'project', label: '素材上传进度.png', - imageSrc: 'data:image/png;base64,cHJvZ3Jlc3M=', + imageSrc: + '/generated-character-drafts/editor/asset-library/image/素材上传进度.png', + objectKey: + 'generated-character-drafts/editor/asset-library/image/素材上传进度.png', + assetObjectId: 'assetobj-editor-image', width: 420, height: 315, sourceType: 'uploaded', @@ -667,24 +710,8 @@ describe('ImageCanvasEditorView asset library integration', () => { }, ], assets: [ - { - assetId: 'asset-a', - folderId: 'project', - label: '账号素材A', - imageSrc: 'data:image/png;base64,YQ==', - width: 320, - height: 240, - sourceType: 'uploaded', - }, - { - assetId: 'asset-b', - folderId: 'project', - label: '账号素材B', - imageSrc: 'data:image/png;base64,Yg==', - width: 320, - height: 240, - sourceType: 'uploaded', - }, + createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }), + createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }), ], }); render(); @@ -718,24 +745,8 @@ describe('ImageCanvasEditorView asset library integration', () => { }, ], assets: [ - { - assetId: 'asset-a', - folderId: 'project', - label: '账号素材A', - imageSrc: 'data:image/png;base64,YQ==', - width: 320, - height: 240, - sourceType: 'uploaded', - }, - { - assetId: 'asset-b', - folderId: 'project', - label: '账号素材B', - imageSrc: 'data:image/png;base64,Yg==', - width: 320, - height: 240, - sourceType: 'uploaded', - }, + createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }), + createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }), ], }); render(); @@ -781,7 +792,9 @@ describe('ImageCanvasEditorView asset library integration', () => { createEditorProjectResourceMock.mockResolvedValueOnce({ resourceId: 'resource-added-asset-a', projectId: 'editor-project-default', - imageSrc: 'data:image/png;base64,YQ==', + imageSrc: '/generated-character-drafts/editor/asset-library/image/asset-a.png', + objectKey: 'generated-character-drafts/editor/asset-library/image/asset-a.png', + assetObjectId: 'assetobj-asset-a', width: 320, height: 240, sourceType: 'uploaded', @@ -805,15 +818,7 @@ describe('ImageCanvasEditorView asset library integration', () => { }, ], assets: [ - { - assetId: 'asset-a', - folderId: 'project', - label: '账号素材A', - imageSrc: 'data:image/png;base64,YQ==', - width: 320, - height: 240, - sourceType: 'uploaded', - }, + createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }), ], }); render(); @@ -852,24 +857,8 @@ describe('ImageCanvasEditorView asset library integration', () => { }, ], assets: [ - { - assetId: 'asset-a', - folderId: 'project', - label: '账号素材A', - imageSrc: 'data:image/png;base64,YQ==', - width: 320, - height: 240, - sourceType: 'uploaded', - }, - { - assetId: 'asset-b', - folderId: 'project', - label: '账号素材B', - imageSrc: 'data:image/png;base64,Yg==', - width: 320, - height: 240, - sourceType: 'uploaded', - }, + createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }), + createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }), ], }); render(); @@ -970,7 +959,11 @@ describe('ImageCanvasEditorView asset library integration', () => { expect(createEditorAssetMock).toHaveBeenCalledWith( expect.objectContaining({ label: '测试上传.png', - imageSrc: expect.stringMatching(/^data:image\/png;base64,/u), + imageSrc: + '/generated-character-drafts/editor/asset-library/image/测试上传.png', + objectKey: + 'generated-character-drafts/editor/asset-library/image/测试上传.png', + assetObjectId: 'assetobj-editor-image', }), ); expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 5f3a56dc6..9bfa2b394 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -331,12 +331,16 @@ describe('ImageCanvasEditorModel', () => { { frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', + objectKey: 'generated-character-drafts/editor/frame01.png', + assetObjectId: 'assetobj-frame-01', width: 1024, height: 1024, }, { frameIndex: 2, imageSrc: '/generated-character-drafts/editor/frame02.png', + objectKey: 'generated-character-drafts/editor/frame02.png', + assetObjectId: 'assetobj-frame-02', width: 1024, height: 1024, }, diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 33dd17702..594e6ca96 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -186,6 +186,69 @@ export function createLayerFromAsset( } satisfies CanvasLayer; } +export function isInlineEditorMediaSource(value: string | null | undefined) { + const normalizedValue = value?.trim().toLowerCase() ?? ''; + return ( + normalizedValue.startsWith('data:image/') || + normalizedValue.startsWith('data:video/') || + normalizedValue.startsWith('data:audio/') || + normalizedValue.startsWith('blob:') + ); +} + +function isSignedEditorMediaSource(value: string | null | undefined) { + const normalizedValue = value?.trim().toLowerCase() ?? ''; + return ( + /^(?:https?:)?\/\//u.test(normalizedValue) && + (normalizedValue.includes('x-oss-signature') || + normalizedValue.includes('x-oss-credential') || + normalizedValue.includes('expires=') || + normalizedValue.includes('signature=')) + ); +} + +function normalizeObjectKeyPath(objectKey: string | null | undefined) { + const normalizedObjectKey = objectKey?.trim().replace(/^\/+/u, '') ?? ''; + return normalizedObjectKey ? `/${normalizedObjectKey}` : undefined; +} + +function serializeOptionalMediaSource( + source: string | null | undefined, + objectKey?: string | null, +) { + const normalizedSource = source?.trim() ?? ''; + if (!normalizedSource) { + return undefined; + } + if ( + isInlineEditorMediaSource(normalizedSource) || + isSignedEditorMediaSource(normalizedSource) + ) { + return normalizeObjectKeyPath(objectKey); + } + return normalizedSource; +} + +function serializeImageSequenceFrames( + frames: CanvasLayer['imageSequenceFrames'], +) { + if (!frames?.length) { + return undefined; + } + const serializedFrames = frames.flatMap((frame) => { + const imageSrc = serializeOptionalMediaSource(frame.imageSrc, frame.objectKey); + return imageSrc + ? [ + { + ...frame, + imageSrc, + }, + ] + : []; + }); + return serializedFrames.length ? serializedFrames : undefined; +} + export function serializeLayer( layer: CanvasLayer, ): EditorProjectLayerSnapshot { @@ -202,9 +265,15 @@ export function serializeLayer( zIndex: layer.zIndex, sourceType: layer.sourceType, mediaType: layer.mediaType, - thumbnailSrc: layer.thumbnailSrc, - imageSequenceFrames: layer.imageSequenceFrames, - previewVideoPath: layer.previewVideoPath, + thumbnailSrc: serializeOptionalMediaSource( + layer.thumbnailSrc, + layer.objectKey, + ), + imageSequenceFrames: serializeImageSequenceFrames(layer.imageSequenceFrames), + previewVideoPath: serializeOptionalMediaSource( + layer.previewVideoPath, + layer.objectKey, + ), prompt: layer.prompt, actualPrompt: layer.actualPrompt, model: layer.model, @@ -834,6 +903,8 @@ function hydrateImageSequenceFrames( { frameIndex: numberFromSnapshot(snapshot.frameIndex, index + 1), imageSrc, + objectKey: stringOrUndefined(snapshot.objectKey), + assetObjectId: stringOrUndefined(snapshot.assetObjectId), width: numberFromSnapshot(snapshot.width, 0), height: numberFromSnapshot(snapshot.height, 0), }, diff --git a/src/components/image-editor/ImageCanvasEditorPrimitives.tsx b/src/components/image-editor/ImageCanvasEditorPrimitives.tsx index 2cf75a5b6..bb8726f7d 100644 --- a/src/components/image-editor/ImageCanvasEditorPrimitives.tsx +++ b/src/components/image-editor/ImageCanvasEditorPrimitives.tsx @@ -59,6 +59,7 @@ export type SidebarMediaItemProps = { title: string; detail: string; imageSrc: string; + objectKey?: string | null; imageAlt: string; selected?: boolean; primaryLabel: string; @@ -85,6 +86,7 @@ export function SidebarMediaItem({ title, detail, imageSrc, + objectKey, imageAlt, selected = false, primaryLabel, @@ -129,6 +131,7 @@ export function SidebarMediaItem({ > ) { // 中文注释:JSON 包装字段还有少量额外字节;这里按 src 字符串长度估算,前后端都会再兜底校验 64MB。 return references.reduce((sum, reference) => sum + reference.src.length, 0); @@ -243,7 +250,11 @@ export function buildImageGenerationSubmissionPlan({ imageSize: SPEC_GENERATION_IMAGE_SIZE, kind: 'spec', ...(dialog.specReference?.src - ? { referenceImageSrcs: [dialog.specReference.src] } + ? { + referenceImageSrcs: [ + resolveImageReferenceSubmissionSource(dialog.specReference), + ], + } : {}), }, result: { @@ -262,8 +273,12 @@ export function buildImageGenerationSubmissionPlan({ if (dialog.mode === 'character') { const referenceImageSrcs = [ - dialog.characterSpecReference?.src, - ...(dialog.characterReferences ?? []).map((reference) => reference.src), + dialog.characterSpecReference + ? resolveImageReferenceSubmissionSource(dialog.characterSpecReference) + : null, + ...(dialog.characterReferences ?? []).map((reference) => + resolveImageReferenceSubmissionSource(reference), + ), ].filter((src): src is string => Boolean(src)); const imageModel = normalizeEditorImageModel(dialog.imageModel); return { @@ -292,9 +307,11 @@ export function buildImageGenerationSubmissionPlan({ if (dialog.mode === 'ui-design') { const imageModel = IMAGE_MODEL_GPT_IMAGE_2; - const referenceImageSrcs = [dialog.uiDesignSpecReference?.src].filter( - (src): src is string => Boolean(src), - ); + const referenceImageSrcs = [ + dialog.uiDesignSpecReference + ? resolveImageReferenceSubmissionSource(dialog.uiDesignSpecReference) + : null, + ].filter((src): src is string => Boolean(src)); return { kind: 'image', normalizedPrompt, @@ -343,7 +360,7 @@ export function buildImageGenerationSubmissionPlan({ ...(dialog.publicationReferences?.length ? { referenceImageSrcs: dialog.publicationReferences.map( - (reference) => reference.src, + (reference) => resolveImageReferenceSubmissionSource(reference), ), } : {}), @@ -463,7 +480,7 @@ export function buildImageGenerationSubmissionPlan({ ...(dialog.generationReferences?.length ? { referenceImageSrcs: dialog.generationReferences.map( - (reference) => reference.src, + (reference) => resolveImageReferenceSubmissionSource(reference), ), } : {}), @@ -510,7 +527,9 @@ export function buildIconSpritesheetGenerationSubmissionPlan( ok: true, iconDescriptions, input: { - referenceImageSrc: dialog.iconSpecReference.src, + referenceImageSrc: resolveImageReferenceSubmissionSource( + dialog.iconSpecReference, + ), iconDescriptions, model: rememberImageModel, aspectRatio: dialog.aspectRatio ?? '1:1', diff --git a/src/components/image-editor/ImageCanvasLayerPanelView.tsx b/src/components/image-editor/ImageCanvasLayerPanelView.tsx index e19e2599a..aa03a6150 100644 --- a/src/components/image-editor/ImageCanvasLayerPanelView.tsx +++ b/src/components/image-editor/ImageCanvasLayerPanelView.tsx @@ -53,6 +53,7 @@ export function ImageCanvasLayerPanelView({ .filter(Boolean) .join(' · ')} imageSrc={layer.src} + objectKey={layer.mediaType === 'image' ? layer.objectKey : undefined} imageAlt={`图层缩略图:${layer.title}`} selected={selectedLayerId === layer.id} primaryLabel={`选择图层${layer.title}`} diff --git a/src/components/image-editor/ImageCanvasUploadModel.test.ts b/src/components/image-editor/ImageCanvasUploadModel.test.ts index 299201111..d3f782149 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.test.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.test.ts @@ -449,6 +449,8 @@ describe('ImageCanvasUploadModel', () => { uploadIndex: 1, fileName: '画布素材.png', imageSrc: 'data:image/png;base64,canvas', + objectKey: 'generated-character-drafts/editor/asset-library/image/画布素材.png', + assetObjectId: 'assetobj-image', imageDimensions: { width: 1536, height: 1024 }, canvasPoint: { x: 110, y: 120 }, canvasSize: { width: 900, height: 640 }, @@ -462,6 +464,9 @@ describe('ImageCanvasUploadModel', () => { height: 1024, originalWidth: 1536, originalHeight: 1024, + objectKey: + 'generated-character-drafts/editor/asset-library/image/画布素材.png', + assetObjectId: 'assetobj-image', }); }); diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index 87065ed53..c2e56661d 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -522,6 +522,8 @@ export function createUploadCanvasLayer({ uploadIndex, fileName, imageSrc, + objectKey, + assetObjectId, mediaType = 'image', imageDimensions, durationSeconds, @@ -532,6 +534,8 @@ export function createUploadCanvasLayer({ uploadIndex: number; fileName?: string; imageSrc: string; + objectKey?: string | null; + assetObjectId?: string | null; mediaType?: CanvasMediaType; imageDimensions?: UploadImageDimensions; durationSeconds?: number; @@ -573,6 +577,8 @@ export function createUploadCanvasLayer({ zIndex: uploadIndex + 10, sourceType: 'uploaded', sourceAssetId: `upload-${uploadIndex}`, + objectKey: objectKey ?? undefined, + assetObjectId: assetObjectId ?? undefined, durationSeconds, }; } diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 084b6ac22..3c03efea1 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -35,6 +35,10 @@ const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorVideoMock = vi.hoisted(() => vi.fn()); +const generateEditorSoundEffectMock = vi.hoisted(() => vi.fn()); +const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn()); +const loadEditorProjectMock = vi.hoisted(() => vi.fn()); +const getExternalGenerationJobStatusMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorImageReference', () => ({ resolveEditorImageReferenceDataUrl: resolveEditorImageReferenceDataUrlMock, @@ -54,9 +58,16 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, generateEditorVideo: generateEditorVideoMock, + generateEditorSoundEffect: generateEditorSoundEffectMock, + generateEditorBackgroundMusic: generateEditorBackgroundMusicMock, + loadEditorProject: loadEditorProjectMock, }; }); +vi.mock('../../services/external-generation', () => ({ + getExternalGenerationJobStatus: getExternalGenerationJobStatusMock, +})); + vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({ renderUiDesignAssetExtractionMarkedImage: renderUiDesignAssetExtractionMarkedImageMock, @@ -119,6 +130,29 @@ function createIconResult(name: string, imageSrc: string) { }; } +function createQueueState( + overrides: Partial<{ + operationId: string; + status: 'queued' | 'running' | 'completed' | 'failed'; + phaseLabel: string; + phaseDetail: string; + progress: number; + error: string | null; + updatedAtMicros: number; + }> = {}, +) { + return { + operationId: 'editor-generation-operation-1', + status: 'completed' as const, + phaseLabel: '完成', + phaseDetail: '', + progress: 1, + error: null, + updatedAtMicros: 1, + ...overrides, + }; +} + function parseLayerRect(text: string, layerId: string) { const entry = text .split('|') @@ -156,6 +190,7 @@ function SubmissionWorkflowHarness({ initialLayers = [createLayer()], projectId = undefined, applyProjectSnapshot = undefined, + onWalletBalanceMayHaveChanged = undefined, }: { initialDialog?: GenerateDialogState | null; initialQuickEditPanel?: QuickEditPanelState | null; @@ -166,6 +201,9 @@ function SubmissionWorkflowHarness({ applyProjectSnapshot?: Parameters< typeof useImageCanvasGenerationSubmissionWorkflow >[0]['applyProjectSnapshot']; + onWalletBalanceMayHaveChanged?: Parameters< + typeof useImageCanvasGenerationSubmissionWorkflow + >[0]['onWalletBalanceMayHaveChanged']; }) { const [layers, setLayers] = useState(initialLayers); const [activeTool, setActiveTool] = useState('generate'); @@ -230,6 +268,7 @@ function SubmissionWorkflowHarness({ projectId, assetFolderId: projectId ? 'project' : undefined, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, }); return ( @@ -395,6 +434,10 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { generateEditorIconSpritesheetMock.mockReset(); generateEditorImageMock.mockReset(); generateEditorVideoMock.mockReset(); + generateEditorSoundEffectMock.mockReset(); + generateEditorBackgroundMusicMock.mockReset(); + loadEditorProjectMock.mockReset(); + getExternalGenerationJobStatusMock.mockReset(); resolveEditorImageReferenceDataUrlMock.mockImplementation( async (src: string) => `data:image/png;base64,${src.split('/').pop()}`, ); @@ -447,6 +490,29 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(screen.getByTestId('fit-count').textContent).toBe('1'); }); + it('refreshes the wallet balance after inline image generation succeeds', async () => { + const refreshWalletBalance = vi.fn(); + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: '快速修图' }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); + fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' })); + fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(refreshWalletBalance).toHaveBeenCalledTimes(1); + }); + }); + it('places quick edit result with the new image placement logic away from existing assets', async () => { const blockingLayer = createLayer({ id: 'layer-blocker', @@ -562,9 +628,11 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); it('keeps failed quick edit panels visible with the normalized prompt', async () => { + const refreshWalletBalance = vi.fn(); generateEditorImageMock.mockRejectedValueOnce(new Error('修图失败')); render( { 'layer-source:failed:快速编辑图片:修图失败', ); }); + expect(refreshWalletBalance).toHaveBeenCalledTimes(1); }); it('submits character redraws through the character image prompt contract', async () => { @@ -939,6 +1008,118 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); + it('resolves uploaded objectKey references before submitting image generation', async () => { + resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce( + 'data:image/jpeg;base64,normalized-ref', + ); + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: '参考图生成' }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + + await waitFor(() => { + expect( + resolveEditorImageReferenceDataUrlForGenerationMock, + ).toHaveBeenCalledWith('generated-character-drafts/editor/refs/ref.png'); + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: '参考图生成', + referenceImageSrcs: ['data:image/jpeg;base64,normalized-ref'], + }), + ); + }); + }); + + it('refreshes the wallet balance after a queued generation reaches a terminal state', async () => { + const applyProjectSnapshot = vi.fn(); + const refreshWalletBalance = vi.fn(); + const backendProject = { + projectId: 'editor-project-1', + title: '队列项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-23T08:00:00.000Z', + }; + loadEditorProjectMock.mockResolvedValueOnce(backendProject); + generateEditorImageMock.mockResolvedValueOnce({ + ...createGenerated({ + imageSrc: '/generated-editor-images/project/image.png', + width: 1024, + height: 1024, + prompt: '队列生成', + }), + queueState: createQueueState(), + }); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + + await waitFor(() => { + expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-1'); + }); + expect(refreshWalletBalance).toHaveBeenCalledTimes(1); + expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject); + expect(getExternalGenerationJobStatusMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('layers').textContent).not.toContain( + 'layer-generated-1', + ); + }); + it('does not add a local layer when project generation returns without a backend snapshot', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ @@ -1047,7 +1228,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { prompt: expect.stringContaining('【宣发素材类型】详情页单图'), size: '720x1280', kind: 'publication-material', - model: 'gemini-3.1-flash-image-preview', + model: 'gpt-image-2', aspectRatio: '9:16', imageSize: '1K', referenceImageSrcs: ['data:image/png;base64,large-ref'], @@ -1294,6 +1475,61 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); + it('resolves uploaded objectKey icon spec before submitting spritesheets', async () => { + resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce( + 'data:image/jpeg;base64,normalized-icon-spec', + ); + generateEditorIconSpritesheetMock.mockResolvedValueOnce({ + spritesheetImageSrc: 'data:image/png;base64,sheet', + spritesheetWidth: 512, + spritesheetHeight: 512, + prompt: '图标素材', + actualPrompt: '图标素材', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'task-icons', + iconImageSrcs: [ + createIconResult('返回按钮', 'data:image/png;base64,back'), + ], + }); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交图标' })); + + await waitFor(() => { + expect( + resolveEditorImageReferenceDataUrlForGenerationMock, + ).toHaveBeenCalledWith( + 'generated-character-drafts/editor/specs/icon-spec.png', + ); + expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( + expect.objectContaining({ + referenceImageSrc: 'data:image/jpeg;base64,normalized-icon-spec', + iconDescriptions: ['返回按钮'], + }), + ); + }); + }); + it('extracts UI design assets into a spritesheet layer and sliced icon layers', async () => { extractEditorUiDesignAssetsMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 0079b4120..d52a45c71 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -132,15 +132,13 @@ type GenerationSubmissionWorkflowOptions = { assetFolderId?: string | null; upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void; applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; + onWalletBalanceMayHaveChanged?: () => void; }; -async function normalizePublicationReferenceImages( +async function normalizeImageGenerationReferenceImages( input: Parameters[0], ) { - if ( - input.kind !== 'publication-material' || - !input.referenceImageSrcs?.length - ) { + if (!input.referenceImageSrcs?.length) { return input; } @@ -154,6 +152,17 @@ async function normalizePublicationReferenceImages( }; } +async function normalizeIconSpritesheetReferenceImage( + input: Parameters[0], +) { + return { + ...input, + referenceImageSrc: await resolveEditorImageReferenceDataUrlForGeneration( + input.referenceImageSrc, + ), + }; +} + function delay(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } @@ -164,6 +173,30 @@ function queuedStateFromResponse( return response?.queueState ?? null; } +function notifyWalletBalanceMayHaveChanged(callback?: () => void) { + callback?.(); +} + +async function runEditorGenerationWithWalletRefresh( + operation: Promise, + onWalletBalanceMayHaveChanged?: () => void, +): Promise { + try { + const response = await operation; + if ( + !queuedStateFromResponse( + response as { queueState?: ExternalGenerationJobStatusRecord | null }, + ) + ) { + notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged); + } + return response; + } catch (error) { + notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged); + throw error; + } +} + async function waitForEditorGenerationQueue( queueState: ExternalGenerationJobStatusRecord, ) { @@ -188,12 +221,17 @@ async function applyQueuedEditorGenerationProject( response: { queueState?: ExternalGenerationJobStatusRecord | null }, projectId: string | null | undefined, applyProjectSnapshot: ((project: EditorProjectSnapshot) => void) | undefined, + onWalletBalanceMayHaveChanged?: () => void, ) { const queueState = queuedStateFromResponse(response); if (!queueState) { return false; } - await waitForEditorGenerationQueue(queueState); + try { + await waitForEditorGenerationQueue(queueState); + } finally { + notifyWalletBalanceMayHaveChanged(onWalletBalanceMayHaveChanged); + } if (projectId && applyProjectSnapshot) { applyProjectSnapshot(await loadEditorProject(projectId)); } @@ -271,6 +309,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ assetFolderId, upsertGeneratedAsset, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, }: GenerationSubmissionWorkflowOptions) { const addGeneratedLayersToCanvas = useCallback( (nextLayers: CanvasLayer[]) => { @@ -684,28 +723,32 @@ export function useImageCanvasGenerationSubmissionWorkflow({ originalWidth: ICON_FRAME_ORIGINAL_SIZE.width, originalHeight: ICON_FRAME_ORIGINAL_SIZE.height, }; - const generated = await extractEditorUiDesignAssets({ - sourceImageSrc, - projectId, - generationInputs, - assetFolderId, - spritesheetLabel: `${sourceLayer.title} 素材图集`, - aspectRatio: extractionPlan.aspectRatio, - imageSize: extractionPlan.imageSize, - ...(projectId - ? { - canvasCompletion: { - title: `${sourceLayer.title} 素材图集`, - placeholder: extractionFrame, - }, - } - : {}), - }); + const generated = await runEditorGenerationWithWalletRefresh( + extractEditorUiDesignAssets({ + sourceImageSrc, + projectId, + generationInputs, + assetFolderId, + spritesheetLabel: `${sourceLayer.title} 素材图集`, + aspectRatio: extractionPlan.aspectRatio, + imageSize: extractionPlan.imageSize, + ...(projectId + ? { + canvasCompletion: { + title: `${sourceLayer.title} 素材图集`, + placeholder: extractionFrame, + }, + } + : {}), + }), + onWalletBalanceMayHaveChanged, + ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, ) ) { return; @@ -744,6 +787,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ fitLayers, projectId, assetFolderId, + onWalletBalanceMayHaveChanged, ], ); @@ -785,29 +829,35 @@ export function useImageCanvasGenerationSubmissionWorkflow({ }); try { + const iconGenerationInput = + await normalizeIconSpritesheetReferenceImage(submissionPlan.input); const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); - const generated = await generateEditorIconSpritesheet({ - ...submissionPlan.input, - projectId, - generationInputs: submissionPlan.generationInputs, - assetFolderId, - ...(projectId && canvasCompletionPlaceholder - ? { - canvasCompletion: { - dialogId: canvasDialog.id, - title: '图标素材图集', - placeholder: canvasCompletionPlaceholder, - }, - } - : {}), - }); + const generated = await runEditorGenerationWithWalletRefresh( + generateEditorIconSpritesheet({ + ...iconGenerationInput, + projectId, + generationInputs: submissionPlan.generationInputs, + assetFolderId, + ...(projectId && canvasCompletionPlaceholder + ? { + canvasCompletion: { + dialogId: canvasDialog.id, + title: '图标素材图集', + placeholder: canvasCompletionPlaceholder, + }, + } + : {}), + }), + onWalletBalanceMayHaveChanged, + ); rememberImageModel(submissionPlan.rememberImageModel); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, ) ) { return; @@ -841,6 +891,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ projectId, assetFolderId, updateCanvasGenerationDialogById, + onWalletBalanceMayHaveChanged, ], ); @@ -959,36 +1010,40 @@ export function useImageCanvasGenerationSubmissionWorkflow({ normalizedPrompt, quickEditSourceLayer, ); - const generated = await generateEditorCharacterAnimation({ - sourceLayerId: quickEditSourceLayer.id, - sourceImageSrc: - resolveCharacterAnimationSourceImageSrc(quickEditSourceLayer), - sourceWidth: quickEditSourceLayer.originalWidth, - sourceHeight: quickEditSourceLayer.originalHeight, - promptText: normalizedPrompt, - resolution: animationResolution, - ratio: 'same', - frameCount: animationFrameCount, - durationSeconds: animationDurationSeconds, - model: CHARACTER_ANIMATION_MODEL, - projectId, - generationInputs, - sourceResourceId: quickEditSourceLayer.resourceId, - ...(projectId && quickEditCanvasCompletionPlaceholder - ? { - canvasCompletion: { - dialogId: quickEditDialogId, - title: `${quickEditSourceLayer.title} 快速编辑`, - placeholder: quickEditCanvasCompletionPlaceholder, - }, - } - : {}), - }); + const generated = await runEditorGenerationWithWalletRefresh( + generateEditorCharacterAnimation({ + sourceLayerId: quickEditSourceLayer.id, + sourceImageSrc: + resolveCharacterAnimationSourceImageSrc(quickEditSourceLayer), + sourceWidth: quickEditSourceLayer.originalWidth, + sourceHeight: quickEditSourceLayer.originalHeight, + promptText: normalizedPrompt, + resolution: animationResolution, + ratio: 'same', + frameCount: animationFrameCount, + durationSeconds: animationDurationSeconds, + model: CHARACTER_ANIMATION_MODEL, + projectId, + generationInputs, + sourceResourceId: quickEditSourceLayer.resourceId, + ...(projectId && quickEditCanvasCompletionPlaceholder + ? { + canvasCompletion: { + dialogId: quickEditDialogId, + title: `${quickEditSourceLayer.title} 快速编辑`, + placeholder: quickEditCanvasCompletionPlaceholder, + }, + } + : {}), + }), + onWalletBalanceMayHaveChanged, + ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, ) ) { return; @@ -1022,37 +1077,41 @@ export function useImageCanvasGenerationSubmissionWorkflow({ normalizedPrompt, quickEditReferences, ); - const generated = await generateEditorVideo({ - prompt: normalizedPrompt, - model: videoModel, - aspectRatio: '16:9', - durationSeconds: videoDurationSeconds, - resolution: videoResolution, - mode: 'std', - sound: 'off', - ...(extraReferenceImageSrcs.length - ? { referenceImageSrcs: extraReferenceImageSrcs } - : {}), - referenceVideoSrcs: [sourceVideoSrc], - projectId, - generationInputs, - sourceResourceId: quickEditSourceLayer.resourceId, - assetKind: quickEditSourceLayer.assetKind, - ...(projectId && quickEditCanvasCompletionPlaceholder - ? { - canvasCompletion: { - dialogId: quickEditDialogId, - title: `${quickEditSourceLayer.title} 快速编辑`, - placeholder: quickEditCanvasCompletionPlaceholder, - }, - } - : {}), - }); + const generated = await runEditorGenerationWithWalletRefresh( + generateEditorVideo({ + prompt: normalizedPrompt, + model: videoModel, + aspectRatio: '16:9', + durationSeconds: videoDurationSeconds, + resolution: videoResolution, + mode: 'std', + sound: 'off', + ...(extraReferenceImageSrcs.length + ? { referenceImageSrcs: extraReferenceImageSrcs } + : {}), + referenceVideoSrcs: [sourceVideoSrc], + projectId, + generationInputs, + sourceResourceId: quickEditSourceLayer.resourceId, + assetKind: quickEditSourceLayer.assetKind, + ...(projectId && quickEditCanvasCompletionPlaceholder + ? { + canvasCompletion: { + dialogId: quickEditDialogId, + title: `${quickEditSourceLayer.title} 快速编辑`, + placeholder: quickEditCanvasCompletionPlaceholder, + }, + } + : {}), + }), + onWalletBalanceMayHaveChanged, + ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, ) ) { return; @@ -1106,42 +1165,46 @@ export function useImageCanvasGenerationSubmissionWorkflow({ const isCharacterRedraw = panelMode === 'redraw' && quickEditSourceLayer.assetKind === 'character'; - const generated = await generateEditorImage({ - prompt: normalizedPrompt, - size: quickEditSize, - aspectRatio: quickEditAspectRatio, - imageSize: quickEditImageSize, - kind: isCharacterRedraw ? 'character' : 'quick-edit', - model: normalizeEditorImageModel(quickEditPanel.model), - referenceImageSrcs: [ - ...extraReferenceImageSrcs, - sourceReferenceImageSrc, - ], - projectId, - assetKind: quickEditSourceLayer.assetKind, - generationInputs, - assetFolderId, - assetLabel: `${quickEditSourceLayer.title} ${ - panelMode === 'redraw' ? '重绘' : '快速编辑' - }`, - sourceResourceId: quickEditSourceLayer.resourceId, - ...(projectId && quickEditCanvasCompletionPlaceholder - ? { - canvasCompletion: { - dialogId: quickEditDialogId, - title: `${quickEditSourceLayer.title} ${ - panelMode === 'redraw' ? '重绘' : '快速编辑' - }`, - placeholder: quickEditCanvasCompletionPlaceholder, - }, - } - : {}), - }); + const generated = await runEditorGenerationWithWalletRefresh( + generateEditorImage({ + prompt: normalizedPrompt, + size: quickEditSize, + aspectRatio: quickEditAspectRatio, + imageSize: quickEditImageSize, + kind: isCharacterRedraw ? 'character' : 'quick-edit', + model: normalizeEditorImageModel(quickEditPanel.model), + referenceImageSrcs: [ + ...extraReferenceImageSrcs, + sourceReferenceImageSrc, + ], + projectId, + assetKind: quickEditSourceLayer.assetKind, + generationInputs, + assetFolderId, + assetLabel: `${quickEditSourceLayer.title} ${ + panelMode === 'redraw' ? '重绘' : '快速编辑' + }`, + sourceResourceId: quickEditSourceLayer.resourceId, + ...(projectId && quickEditCanvasCompletionPlaceholder + ? { + canvasCompletion: { + dialogId: quickEditDialogId, + title: `${quickEditSourceLayer.title} ${ + panelMode === 'redraw' ? '重绘' : '快速编辑' + }`, + placeholder: quickEditCanvasCompletionPlaceholder, + }, + } + : {}), + }), + onWalletBalanceMayHaveChanged, + ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, ) ) { return; @@ -1204,6 +1267,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ setQuickEditPanel, updateCanvasGenerationDialogById, viewport, + onWalletBalanceMayHaveChanged, ]); const submitImageGeneration = useCallback( @@ -1258,30 +1322,34 @@ export function useImageCanvasGenerationSubmissionWorkflow({ originalHeight: 1024, }, ); - 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, - ...(projectId && editCanvasCompletionPlaceholder - ? { - canvasCompletion: { - dialogId: canvasDialog?.id, - title: `${submissionPlan.sourceLayer.title} 修改结果`, - placeholder: editCanvasCompletionPlaceholder, - }, - } - : {}), - }); + const generated = await runEditorGenerationWithWalletRefresh( + editEditorImage({ + prompt: submissionPlan.normalizedPrompt, + sourceImageSrc: referenceImageSrc, + projectId, + assetKind: submissionPlan.sourceLayer.assetKind, + generationInputs: submissionPlan.generationInputs, + assetFolderId, + assetLabel: `${submissionPlan.sourceLayer.title} 修改结果`, + sourceResourceId: submissionPlan.sourceLayer.resourceId, + ...(projectId && editCanvasCompletionPlaceholder + ? { + canvasCompletion: { + dialogId: canvasDialog?.id, + title: `${submissionPlan.sourceLayer.title} 修改结果`, + placeholder: editCanvasCompletionPlaceholder, + }, + } + : {}), + }), + onWalletBalanceMayHaveChanged, + ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, ) ) { return; @@ -1297,25 +1365,29 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } else if (submissionPlan.kind === 'video') { const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); - const generated = await generateEditorVideo({ - ...submissionPlan.input, - projectId, - generationInputs: submissionPlan.result.generationInputs, - ...(projectId && canvasCompletionPlaceholder - ? { - canvasCompletion: { - dialogId: canvasDialog?.id, - title: submissionPlan.result.title, - placeholder: canvasCompletionPlaceholder, - }, - } - : {}), - }); + const generated = await runEditorGenerationWithWalletRefresh( + generateEditorVideo({ + ...submissionPlan.input, + projectId, + generationInputs: submissionPlan.result.generationInputs, + ...(projectId && canvasCompletionPlaceholder + ? { + canvasCompletion: { + dialogId: canvasDialog?.id, + title: submissionPlan.result.title, + placeholder: canvasCompletionPlaceholder, + }, + } + : {}), + }), + onWalletBalanceMayHaveChanged, + ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, ) ) { return; @@ -1334,9 +1406,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } else if (submissionPlan.kind === 'audio') { const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); - const generated = + const generated = await runEditorGenerationWithWalletRefresh( submissionPlan.audioKind === 'sound-effect' - ? await generateEditorSoundEffect({ + ? generateEditorSoundEffect({ ...submissionPlan.input, projectId, generationInputs: submissionPlan.result.generationInputs, @@ -1350,7 +1422,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } : {}), }) - : await generateEditorBackgroundMusic({ + : generateEditorBackgroundMusic({ ...submissionPlan.input, projectId, generationInputs: submissionPlan.result.generationInputs, @@ -1363,12 +1435,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({ }, } : {}), - }); + }), + onWalletBalanceMayHaveChanged, + ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, ) ) { return; @@ -1386,36 +1461,38 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ); } else { const imageGenerationInput = - submissionPlan.input.kind === 'publication-material' - ? await normalizePublicationReferenceImages(submissionPlan.input) - : submissionPlan.input; + await normalizeImageGenerationReferenceImages(submissionPlan.input); const resultTitle = submissionPlan.result.title ?? `生成图片 ${layerCounterRef.current + 1}`; const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); - const generated = await generateEditorImage({ - ...imageGenerationInput, - projectId, - assetKind: submissionPlan.result.assetKind, - generationInputs: submissionPlan.result.generationInputs, - assetFolderId, - assetLabel: resultTitle, - ...(projectId && canvasCompletionPlaceholder - ? { - canvasCompletion: { - dialogId: canvasDialog?.id, - title: resultTitle, - placeholder: canvasCompletionPlaceholder, - }, - } - : {}), - }); + const generated = await runEditorGenerationWithWalletRefresh( + generateEditorImage({ + ...imageGenerationInput, + projectId, + assetKind: submissionPlan.result.assetKind, + generationInputs: submissionPlan.result.generationInputs, + assetFolderId, + assetLabel: resultTitle, + ...(projectId && canvasCompletionPlaceholder + ? { + canvasCompletion: { + dialogId: canvasDialog?.id, + title: resultTitle, + placeholder: canvasCompletionPlaceholder, + }, + } + : {}), + }), + onWalletBalanceMayHaveChanged, + ); if ( await applyQueuedEditorGenerationProject( generated, projectId, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, ) ) { return; @@ -1487,6 +1564,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ setGenerateDialog, updateCanvasGenerationDialogById, upsertGeneratedAsset, + onWalletBalanceMayHaveChanged, ], ); @@ -1533,26 +1611,30 @@ export function useImageCanvasGenerationSubmissionWorkflow({ const canvasCompletionPlaceholder = canvasDialog ? getGeneratingDialogPlaceholder(canvasDialog) : undefined; - const result = await generateEditorCharacterAnimation({ - ...submissionPlan.input, - projectId, - generationInputs, - sourceResourceId: characterAnimationSourceLayer.resourceId, - ...(canvasDialog && projectId && canvasCompletionPlaceholder - ? { - canvasCompletion: { - dialogId: canvasDialog.id, - title: '角色动作', - placeholder: canvasCompletionPlaceholder, - }, - } - : {}), - }); + const result = await runEditorGenerationWithWalletRefresh( + generateEditorCharacterAnimation({ + ...submissionPlan.input, + projectId, + generationInputs, + sourceResourceId: characterAnimationSourceLayer.resourceId, + ...(canvasDialog && projectId && canvasCompletionPlaceholder + ? { + canvasCompletion: { + dialogId: canvasDialog.id, + title: '角色动作', + placeholder: canvasCompletionPlaceholder, + }, + } + : {}), + }), + onWalletBalanceMayHaveChanged, + ); if ( await applyQueuedEditorGenerationProject( result, projectId, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, ) ) { return; @@ -1625,6 +1707,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ projectId, setCharacterAnimationPanel, updateCanvasGenerationDialogById, + onWalletBalanceMayHaveChanged, ]); return useMemo( diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index 06329c9ee..3fcecaa60 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -95,6 +95,7 @@ type ImageCanvasGenerationSurfaceOptions = { assetFolderId?: string | null; upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void; applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; + onWalletBalanceMayHaveChanged?: () => void; }; function renderEditorPortal(node: ReactNode) { @@ -172,6 +173,7 @@ export function useImageCanvasGenerationSurface({ assetFolderId, upsertGeneratedAsset, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, }: ImageCanvasGenerationSurfaceOptions) { const toolbarOptionCloseTimerRef = useRef void; applyProjectSnapshot?: (project: EditorProjectSnapshot) => void; + onWalletBalanceMayHaveChanged?: () => void; }; export function useImageCanvasGenerationWorkflow({ @@ -350,6 +351,7 @@ export function useImageCanvasGenerationWorkflow({ assetFolderId, upsertGeneratedAsset, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, }: GenerationWorkflowOptions) { const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] = @@ -1231,6 +1233,7 @@ export function useImageCanvasGenerationWorkflow({ assetFolderId, upsertGeneratedAsset, applyProjectSnapshot, + onWalletBalanceMayHaveChanged, }); const setEffectiveCharacterAnimationPanel: Dispatch< diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 82cab7936..33b03fa37 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -10,11 +10,14 @@ import type { CanvasViewport, } from './ImageCanvasEditorTypes'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; +import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient'; const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); +const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY = + 'genarrative.imageCanvas.projectSnapshot.v1:recent'; vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< @@ -51,6 +54,16 @@ function createLayer( }; } +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + function ProjectPersistenceHarness({ canAccessProtectedData = true, initialGenerationDialogs = [], @@ -144,7 +157,13 @@ function ProjectPersistenceHarness({