瘦身画板媒体保存链路

改造画板项目保存响应为轻量 ack

统一生成上传媒体以 objectKey 持久化

补齐素材库缩略图换签与本地轻量缓存

刷新生成后泥点余额并补充回归测试
This commit is contained in:
2026-06-23 17:20:10 +08:00
parent 624ab91f66
commit f5ede50ba9
45 changed files with 3150 additions and 502 deletions
@@ -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>"``objectKey``assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage``objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `objectKey` 时读出归一成 `/<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/<projectId>`、生成接口和资料接口混杂,429 行常见 `request_time=0.000``upstream_status=-`error log 写 `limiting connections by zone "genarrative_api_conn"`
@@ -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>"``objectKey``assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*``data:video/*``data:audio/*``blob:`。旧数据读取时如果已有 `objectKey``imageSrc` 归一成 `/<objectKey>`;没有 `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 引用和 `/<objectKey>` 轻量路径,不允许把 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 快照。
File diff suppressed because it is too large Load Diff
@@ -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,
@@ -162,6 +162,29 @@ impl SpacetimeClient {
.await
}
pub async fn save_editor_project_layout_ack(
&self,
input: EditorProjectLayoutSaveRecordInput,
) -> Result<EditorProjectLayoutSaveAckRecord, SpacetimeClientError> {
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<EditorProjectResourceRecord, SpacetimeClientError> {
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<EditorAssetRecord, SpacetimeClientError> {
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,
+5 -4
View File
@@ -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,
@@ -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,
@@ -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<EditorProjectResourceCreateRecordInput>
}
}
impl From<EditorProjectResourceMediaRepairRecordInput>
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<EditorAssetFolderCreateRecordInput>
for crate::module_bindings::EditorAssetFolderCreateInput
{
@@ -375,6 +418,21 @@ impl From<EditorAssetUpdateRecordInput> for crate::module_bindings::EditorAssetU
}
}
impl From<EditorAssetMediaRepairRecordInput>
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<EditorAssetDeleteRecordInput> 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<EditorProjectLayoutSaveAckRecord, SpacetimeClientError> {
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<Vec<EditorProjectRecord>, SpacetimeClientError> {
@@ -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;
@@ -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;
}
@@ -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;
}
@@ -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<EditorProjectLayoutSaveAck>,
pub error_message: Option<String>,
}
impl __sdk::InModule for EditorProjectLayoutSaveProcedureResult {
type Module = super::RemoteModule;
}
@@ -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;
}
@@ -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<EditorAssetProcedureResult, __sdk::InternalError>,
) + 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<EditorAssetProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorAssetProcedureResult>(
"repair_editor_asset_media_and_return",
RepairEditorAssetMediaAndReturnArgs { input },
__callback,
);
}
}
@@ -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<EditorProjectResourceProcedureResult, __sdk::InternalError>,
) + 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<EditorProjectResourceProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorProjectResourceProcedureResult>(
"repair_editor_project_resource_media_and_return",
RepairEditorProjectResourceMediaAndReturnArgs { input },
__callback,
);
}
}
@@ -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<EditorProjectLayoutSaveProcedureResult, __sdk::InternalError>,
) + 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<EditorProjectLayoutSaveProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorProjectLayoutSaveProcedureResult>(
"save_editor_project_layout_ack",
SaveEditorProjectLayoutAckArgs { input },
__callback,
);
}
}
@@ -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<String>,
}
#[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<EditorProjectLayoutSaveAck>,
pub error_message: Option<String>,
}
#[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<EditorProjectSnapshot, String> {
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<EditorProjectLayoutSaveAck, String> {
let project_id = normalize_required(&input.project_id, "editor_project.project_id")?;
let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?;
let 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<EditorProjectResourceSnapshot, String> {
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<EditorAssetSnapshot, String> {
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<EditorProjectResource, String> {
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 {
@@ -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', () => ({
<img
{...imageProps}
src={src ?? fallbackSrc}
data-object-key={objectKey ?? undefined}
data-fallback-src={fallbackSrc}
data-refresh-key={refreshKey ?? undefined}
alt={alt}
@@ -222,6 +225,45 @@ test('supports standard aspect, bare surface and refresh key', () => {
expect(image.getAttribute('data-refresh-key')).toBe('session-1:level-1');
});
test('passes objectKey to the resolved asset image', () => {
render(
<PlatformMediaFrame
src="/generated-character-drafts/editor/spec.png"
objectKey="generated-character-drafts/editor/spec.png"
alt="规范图"
fallbackLabel="规范图"
aspect="square"
surface="bare"
/>,
);
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(
<PlatformMediaFrame
objectKey="generated-character-drafts/editor/spec-only.png"
alt="仅 objectKey 规范图"
fallbackLabel="规范图"
aspect="square"
surface="bare"
/>,
);
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(
<PlatformMediaFrame
+7 -2
View File
@@ -29,6 +29,7 @@ type PlatformMediaFrameProps = Omit<
'children' | 'className'
> & {
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 ? (
<ResolvedAssetImage
{...imageProps}
src={src?.trim() || undefined}
objectKey={objectKey?.trim() || undefined}
fallbackSrc={fallbackSrc?.trim() || undefined}
alt={alt}
loading={loading}
@@ -1,11 +1,37 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { EditorAsset } from './ImageCanvasEditorTypes';
import { ImageCanvasAssetRowView } from './ImageCanvasAssetRowView';
vi.mock('../common/PlatformMediaFrame', () => ({
PlatformMediaFrame: ({
src,
objectKey,
alt,
previewOverlay,
}: {
src?: string | null;
objectKey?: string | null;
alt: string;
previewOverlay?: ReactNode;
}) => (
<div className="platform-media-frame">
{src?.trim() ? (
<img
src={src}
alt={alt}
data-object-key={objectKey?.trim() || undefined}
/>
) : null}
{previewOverlay}
</div>
),
}));
function createAsset(overrides: Partial<EditorAsset> = {}): 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',
);
});
});
@@ -203,6 +203,7 @@ export function ImageCanvasAssetRowView({
? asset.src
: (asset.thumbnailSrc ?? '')
}
objectKey={asset.mediaType === 'image' ? asset.objectKey : undefined}
titleNode={
isUploadingAsset || isFailedUpload ? <span>{asset.label}</span> : titleNode
}
@@ -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<typeof import('../../services/assetReadUrlService')>(
'../../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(<ImageCanvasEditorView />);
@@ -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(<ImageCanvasEditorView />);
@@ -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(<ImageCanvasEditorView />);
@@ -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(<ImageCanvasEditorView />);
@@ -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();
@@ -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,
},
@@ -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),
},
@@ -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({
>
<PlatformMediaFrame
src={imageSrc}
objectKey={objectKey}
alt={imageAlt}
fallbackLabel={title}
aspect="square"

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