新增图片画布编辑器

新增 /editor 图片画布入口与 Lovart 风格画布交互

新增图片画布工程和资源持久化的 SpacetimeDB 表、绑定与 api-server BFF

接入图片生成和修改的 VectorEngine gpt-image-2 后端通道

完善素材库文件夹、重命名、上传删除、图层和元数据交互

补充图片画布技术方案、领域词、执行跟踪和浏览器 smoke 截图
This commit is contained in:
2026-06-13 16:22:18 +08:00
parent f8a80cd795
commit 747473024d
53 changed files with 6694 additions and 29 deletions

View File

@@ -113,6 +113,7 @@
- 影响范围:`CONTEXT.md`、拼消消 PRD / 技术方案、平台玩法链路文档、`shared-contracts` / `packages/shared``api-server``spacetime-module``spacetime-client`、作品架 / 广场 / 统一作品详情 / runtime 前端分流。
- 验证方式PRD 和技术方案必须覆盖资产槽位、素材工作表风险、切片验证、恢复语义、API 命名空间和验证命令;实现侧至少运行 `npm run spacetime:generate``npm run check:spacetime-schema``npm run check:spacetime-runtime-access``npm run check:server-rs-ddd``npm run typecheck``npm run check:encoding`、相关前端测试和 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链
- 背景:`Genarrative-Server-Provision``DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。
@@ -784,7 +785,7 @@
- 验证方式:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml``cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`;本地 api-server 重启后检查 `/healthz`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``.hermes/shared-memory/pitfalls.md`
## 2026-05-19 系列素材 n*n 图集抽为 api-server 通用模块
## 2026-05-19 系列素材 n\*n 图集抽为 api-server 通用模块
- 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。
- 决策:`server-rs/crates/api-server/src/generated_asset_sheets.rs` 作为通用系列素材图集模块,`n` 作为必选 `grid_size` 参数;物品名称 prompt 模板与特殊设定 prompt 作为可选输入;模块负责 sheet prompt、`n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造,以及 sheet / item / special prompt 的 base64 元数据持久化。玩法只负责生图 provider、计费、slot 规划、失败回写和把通用切片结果映射回自身 DTO / 草稿 / runtime 字段。
@@ -1736,3 +1737,11 @@
- 影响范围:`api-server` 资产计费包裹、钱包退款补偿、拼图首图后台生成、`spacetime-module` 拼图 task 表、`spacetime-client` bindings/facade、前端 API request id 复用和后端架构文档。
- 验证方式:`npm run spacetime:generate``npm run check:spacetime-schema``npm run check:spacetime-runtime-access``node scripts/check-server-rs-ddd-boundaries.mjs``cargo check -p api-server --manifest-path server-rs/Cargo.toml``cargo test -p api-server --manifest-path server-rs/Cargo.toml wallet_refund_outbox``cargo test -p api-server --manifest-path server-rs/Cargo.toml asset_operation``npm run test -- src/services/apiClient.test.ts``npm run check:encoding`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-11 图片画布编辑器作为独立画布工程接入
- 背景:网站需要新增 Lovart 风格图片画布编辑器能力,既要支持素材栏、平移缩放、工具模式、吸附线、元数据窗口和修改结果并排展示,也要能保存当前用户的画布视图、图层布局和资源元数据。
- 决策:主站新增 `/editor` 对应 `image-editor` 阶段,编辑器作为独立图片画布工程挂在平台壳下,并在创作 Tab 提供入口;工程与资源通过 `editor_project` / `editor_project_resource` 落到 SpacetimeDB`spacetime-client` facade 和 `/api/editor/projects*` BFF 读写。图片生成 / 修改 provider、计费和真实任务进度暂不接入本期修改结果允许使用 mock 生成资源,但必须按生成资源元数据形状保存。
- 影响范围主站路由、平台创作入口、图片画布编辑器组件、editor project API client、`api-server` BFF、`spacetime-client` facade、`spacetime-module` 表 / procedure、后端数据契约文档和前端架构文档。
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run test -- src/services/image-editor/editorProjectClient.test.ts``npm run typecheck``cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema``npm run check:encoding``git diff --check`、headless Playwright smoke。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`

View File

@@ -12,6 +12,22 @@ _Avoid_: 默认对话式 Agent 工作台、默认轻输入 Agent 工作台、复
角色形象、UI 背景、容器、封面、分享图等单张图资产的统一输入与重生成方式,统一通过 `CreativeImageInputPanel` 表达上传、AI 重绘、参考图、历史图和删除确认。
_Avoid_: 在玩法页面内手写上传、参考图、重绘、预览、删除确认
**图片画布工程**:
独立 `/editor` 中可保存、恢复和继续编辑的图片画布工作状态,包含画布视图、图层布局和资源引用;用于多图对比、生成结果衍生和画布级编辑,不替代玩法页面内的单图资产编辑。
_Avoid_: 玩法结果页单图槽位、发布态作品、只存在前端内存里的临时画布
**画布资源**:
图片画布工程中可被一个或多个图层引用的图片资源记录,保存 OSS 对象引用、上传 / 生成来源、提示词、模型、任务和尺寸等资源元数据;同一资源可以在工程布局中出现多次。
_Avoid_: 图层位置、前端 hover / selected 状态、直接内嵌图片二进制
**图层布局**:
图片画布工程中描述资源实例如何摆放的画布结构,包含 resourceId、位置、尺寸、缩放、层级和选中所需的稳定图层 ID布局属于工程快照不属于画布资源本身。
_Avoid_: 把同一资源的全局元数据和某一次摆放坐标混在同一条资源记录里
**生成资源**:
由图片生成或图片修改流程产生的画布资源必须记录来源资源、提示词、实际提示词、模型、provider、任务 ID 和生成时间;本期 `/editor` 的生成修改先允许 mock 生成资源,但仍按生成资源元数据形状保存。
_Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录的生成结果
**系列素材图集生成**:
一组同类素材的统一批量生成方式采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO

65
TRACKING.md Normal file
View File

@@ -0,0 +1,65 @@
# 图片画布编辑器 Lovart 化执行跟踪
更新时间:`2026-06-12`
## 目标
- 先落文档、测试用例和进度跟踪文件。
- 再实施 `/editor` 的 Lovart 风格画布交互、缩放二级菜单、吸附线、元数据窗口、真实生成 / 修改入口和工程持久化。
## 进度
| 阶段 | 状态 | 记录 |
| --- | --- | --- |
| 文档 | 已完成 | 已补领域词、技术方案和后端表 / API 边界。 |
| 测试用例 | 已完成 | 已补前端交互测试和 editor project client 测试。 |
| 后端持久化 | 已完成 | 已新增 editor project/resource 表、SpacetimeDB procedure、spacetime-client facade 与 api-server BFF。 |
| 前端交互 | 已完成 | 已实现缩放菜单、工具模式、Space 抓手、中键平移、吸附线、元数据弹窗和右侧真实修改结果。 |
| 验证 | 已完成 | 聚焦测试、类型检查、Rust 检查、schema guard、编码检查、diff 空白检查和浏览器 smoke 已通过。 |
## 待办清单
- [x] 更新图片画布编辑器技术方案,明确 v2 范围。
- [x] 补充 `/editor` 领域词,区分图片画布工程和单图资产编辑。
- [x] 添加前端组件交互测试。
- [x] 添加 editor project API client 测试。
- [x] 新增 `editor_project``editor_project_resource` 表。
- [x] 同步 SpacetimeDB migration 表清单、生成绑定和后端架构表目录。
- [x] 新增 api-server `/api/editor/projects*` BFF。
- [x] 接入前端自动保存与资源创建 API。
- [x] 实现 Lovart 风格缩放菜单和快捷键。
- [x] 实现 AI 画布底部工具栏、工具模式和临时抓手。
- [x] 实现核心吸附线和拖拽吸附。
- [x] 实现生成资源元数据窗口和真实修改右侧结果。
- [x] 执行并记录验证命令。
## 决策记录
- `/editor` 的长期领域对象命名为“图片画布工程”。
- 资源表保存 OSS 引用和上传 / 生成元数据;图层位置、缩放、层级保存在 project 布局 JSON。
- 本期工程与资源持久化真实落库;图片生成 / 修改已接入 api-server VectorEngine BFF暂不接入计费和队列进度。
- `适合视图` 的语义为显示画布所有可见元素。
- 吸附范围为核心对齐:左右 / 上下边缘和水平 / 垂直中心线。
- 缩放控件采用 Lovart 风格百分比按钮和二级菜单。
## 验证记录
- `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx`
- `npm run test -- src/services/image-editor/editorProjectClient.test.ts`
- `npm run test -- src/routing/appPageRoutes.test.ts`
- `npm run typecheck`
- `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`
- `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- `npm run check:spacetime-schema`
- `npm run check:encoding`
- `git diff --check`
- Headless Playwright smoke`http://127.0.0.1:10000/editor` 可展示画布、缩放菜单、底部工具栏和图片工具栏;只启动 `dev:web``/api/*` 代理 500 属于未启动后端的预期现象。
- 2026-06-12 Lovart 布局修正 smoke`http://127.0.0.1:10000/editor` 已移除左侧竖向工具栏和右侧独立图层栏;素材、已生成文件、图层统一收在左侧可折叠面板,中央画布和周边面板保持浅色一体布局;截图留存于 `output/playwright/editor-left-integrated-light.png`
- 2026-06-12 外圈背景修正 smoke`http://127.0.0.1:10000/editor` 的编辑器宿主和画布根容器已铺成同一块白色工作台,不再通过圆角边框露出底部平台背景;截图留存于 `output/playwright/editor-no-outer-background.png`
- 2026-06-12 画布背景与原生菜单修正 smoke`http://127.0.0.1:10000/editor` 已拦截编辑器区域右键菜单,禁用长按文本选择 / iOS callout并移除画布网格线与棋盘格底纹截图留存于 `output/playwright/editor-plain-background.png`
- 2026-06-12 Lovart 面板与外层 padding 修正:`/editor` 外层 `platform-ui-shell` 已按 `image-editor` stage 使用 `p-0 bg-white`,其它页面保留原 padding已生成文件和图层改为画布左下入口按钮触发的浮层面板不再堆叠在左侧素材栏浏览器样式检查确认 shell padding 为 `0px`、画布背景无 `background-image`,截图留存于 `output/playwright/editor-lovart-panel-popup-final.png`
- 2026-06-12 侧栏切换修正:删除“已生成文件”入口,删除侧栏内展开 / 折叠按钮;画布左下仅保留“素材”和“图层”入口,二者复用同一个左侧栏,点击当前已打开入口会关闭侧栏。
- 2026-06-12 工具栏能力修正:删除底部“局部修改工具”入口;上传工具接入隐藏文件选择并将图片加入画布图层;图片浮动工具栏的删除按钮改为真实删除当前图层。
- 2026-06-12 生成工具修正:移除拼图素材的生成图 mock 元数据,使其作为普通素材显示;底部生成工具改为先打开生成图片对话框,再进入生成中状态并把生成结果加入画布,生成结果保留元数据与修改入口。
- 2026-06-13 真实生图修正:`/api/editor/images/generations``/api/editor/images/edits` 统一走 api-server VectorEngine `gpt-image-2` BFF前端不再创建 mock 成功图,生成 / 修改失败会留在对话框内显示错误;生成图右上角 `{}` 元数据按钮可直接点击打开元数据窗口。
- 2026-06-13 素材库修正:素材栏按文件夹分组,文件夹支持折叠和新建;上传入口可定向到当前文件夹,上传素材进入素材库并支持删除,内置素材只保留添加和重命名。

View File

@@ -0,0 +1,74 @@
# 图片画布编辑器 Lovart 化与持久化接入方案
## 背景
网站需要新增一个面向图片素材的独立 `/editor` 画布入口。第一阶段已经提供纯前端画布体验,本轮把它升级为 Lovart 风格的 AI 图片画布:支持更接近专业画布的拖拽、缩放、吸附、工具模式、元数据查看和图片修改结果并排展示,同时补上工程与资源持久化。
## V2 边界
- 主站新增 `/editor` 路由,进入独立图片画布编辑器阶段。
- 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
- 画布中的图片可展示、悬浮显示图片尺寸与边框,点击后在图片上方显示浮动工具栏。
- 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成、局部修改 / 蒙版、文字、形状 / 标注、导出。
- 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。
- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。
- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。
- 对生成资源执行修改时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图。
- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine `gpt-image-2`:纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits`。前端不持有 provider 密钥;上游失败或配置缺失时只在对话框展示失败,不创建 mock 成功图。
## 交互规则
- `适合视图` 的正式语义为“显示画布所有可见元素”,不再回到固定 `x/y/scale`
- 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。
- 缩放菜单支持 `Ctrl/Cmd +``Ctrl/Cmd -``Shift + 1`;快捷键只改变 viewport不修改工程资源。
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;拖拽结束后只保存最终图层布局,不保存临时参考线。
- 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。
- 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。
## 数据与持久化
- 新增 `editor_project` 表保存图片画布工程:`projectId``ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。
- 新增 `editor_project_resource` 表保存画布资源:`resourceId``projectId``ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、创建时间和更新时间。
- 图片文件本体继续走 OSS浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
- 资源表只保存资源元数据;图层位置、尺寸、缩放、层级和选中所需 ID 保存在 `editor_project` 的布局 JSON。
- 前端不直接订阅 SpacetimeDB统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
- 未登录用户可以使用本地演示态,但不触发工程自动保存。
## 后端接口
- `GET /api/editor/projects/recent`:读取当前用户最近编辑的图片画布工程,没有则返回 `project: null`
- `POST /api/editor/projects`:创建图片画布工程。
- `GET /api/editor/projects/{projectId}`:读取指定工程及资源列表。
- `PATCH /api/editor/projects/{projectId}`:保存 viewport 与图层布局快照。
- `POST /api/editor/projects/{projectId}/resources`:创建画布资源记录,接收上传资源或真实生成资源元数据。
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine `gpt-image-2` 生成图片,返回 data URL、尺寸、prompt、model、provider 和 taskId。
- `POST /api/editor/images/edits`:按提示词和当前生成图 Data URL 调用 VectorEngine edits返回新的生成图片元数据。
所有写接口都必须校验 Bearer 登录态和 owner接口只返回当前用户有权读取的工程与资源。
## 实现约束
- 前端只维护表现、交互和临时 UI 状态,工程真相以后端 project/resource 快照为准。
- 示例素材可继续复用 `public/creation-type-references/` 下的站内图片;用户上传和后续生成资源必须通过资源记录表达。
- 不把 hover、dragging、临时吸附线、Space 临时抓手等瞬时 UI 状态写入后端。
- 不在 UI 中加入大段功能说明,编辑器界面只展示必要的工具、素材和状态信息。
- 不复用或改写 `CreativeImageInputPanel` 的单图资产编辑语义;`/editor` 是独立图片画布工程。
## 验收用例
- 缩放百分比按钮能打开 Lovart 风格菜单,菜单项能放大、缩小、显示所有元素和缩放到固定比例。
- `显示画布所有元素` 按可见图层外接矩形计算 viewport。
- 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。
- 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。
- 生成资源显示元数据按钮元数据窗口展示来源、prompt、model、provider、task、尺寸和 OSS 引用。
- 修改生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图。
- 工程刷新后能从后端恢复资源、图层布局和 viewport。
## 后续扩展点
- 接入图片生成 / 修改计费、队列进度状态、OSS 落盘和更完整失败审计。
- 资产库接入:素材栏从用户资产、历史生成图或上传结果读取。
- 图层模型:引入稳定 layer id、z-index、锁定、隐藏和多选。
- 图像编辑:将占位工具替换为裁剪、抠图、局部修改、蒙版和导出等真实能力。

View File

@@ -59,7 +59,7 @@ npm run check:server-rs-ddd
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
- 平台基础能力:`/api/llm/*``/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
- 资产基础能力:`/api/assets/direct-upload-tickets``/api/assets/sts-upload-credentials``/api/assets/objects/*``/api/assets/read-*`,负责直传、确认、绑定和读取。
- 创作 / 游玩支撑能力:`/api/creation-entry/config``/api/ai/tasks*``/api/runtime/chat/*``/api/runtime/settings``/api/runtime/save/snapshot``/api/profile/browse-history``/api/profile/save-archives*``/api/profile/play-stats``/api/assets/history``/api/assets/character-visual/*``/api/assets/character-animation/*``/api/assets/character-workflow-cache*``/api/assets/hyper3d/*``/api/runtime/custom-world/asset-studio/*`
- 创作 / 游玩支撑能力:`/api/creation-entry/config``/api/ai/tasks*``/api/runtime/chat/*``/api/runtime/settings``/api/runtime/save/snapshot``/api/profile/browse-history``/api/profile/save-archives*``/api/profile/play-stats``/api/assets/history``/api/assets/character-visual/*``/api/assets/character-animation/*``/api/assets/character-workflow-cache*``/api/assets/hyper3d/*``/api/runtime/custom-world/asset-studio/*``/api/editor/projects*`
- 后台入口配置:`/admin/api/creation-entry/config``/admin/api/creation-entry/config/banners``/admin/api/creation-entry/config/interactions`
- 自定义世界 / RPG`/api/runtime/custom-world*``/api/story/*``/api/runtime/chat/*`
- 拼图:`/api/runtime/puzzle/*`
@@ -423,6 +423,20 @@ npm run check:server-rs-ddd
- Rust 结构体:`DatabaseMigrationOperator`
- 源码:`server-rs/crates/spacetime-module/src/migration.rs`
### `editor_project`
- Rust 结构体:`EditorProject`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布工程真相表,保存 owner、标题、viewport typed columns 与图层布局 JSON只通过 `/api/editor/projects*` BFF 和 `spacetime-client` facade 读写。
- 索引:`by_editor_project_owner_user_id` 用于读取当前用户最近编辑工程。
### `editor_project_resource`
- Rust 结构体:`EditorProjectResource`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布资源元数据表,保存上传 / 生成 / mock 生成图片的 OSS 引用、尺寸、来源类型、prompt、provider、task 和源资源关系;图片本体仍由资产 / OSS 链路承载。
- 索引:`by_editor_project_resource_project_id``by_editor_project_resource_owner_user_id`
### `inventory_slot`
- Rust 结构体:`InventorySlot`

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

View File

@@ -43,6 +43,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::auth::router(state.clone()))
.merge(modules::profile::router(state.clone()))
.merge(modules::assets::router(state.clone()))
.merge(modules::editor_project::router(state.clone()))
.merge(modules::platform::router(state.clone()))
.merge(modules::play_flow::router(state.clone()))
.route(

View File

@@ -0,0 +1,581 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectRecord,
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError,
};
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
openai_image_generation::{
GPT_IMAGE_2_MODEL, OpenAiReferenceImage, build_openai_image_http_client,
create_openai_image_edit_with_references, create_openai_image_generation,
require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
};
const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-";
const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024;
const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectCreateRequest {
title: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorCanvasViewportPayload {
x: f64,
y: f64,
scale: f64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectLayoutSaveRequest {
viewport: EditorCanvasViewportPayload,
layers: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourceCreateRequest {
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: String,
prompt: Option<String>,
actual_prompt: Option<String>,
model: Option<String>,
provider: Option<String>,
task_id: Option<String>,
source_resource_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorImageGenerationRequest {
prompt: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorImageEditRequest {
prompt: String,
source_image_src: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResponse {
project: EditorProjectPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectRecentResponse {
project: Option<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourceResponse {
resource: EditorProjectResourcePayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorImageGenerationResponse {
image_src: String,
width: u32,
height: u32,
source_type: &'static str,
prompt: String,
actual_prompt: Option<String>,
model: &'static str,
provider: &'static str,
task_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectPayload {
project_id: String,
title: String,
viewport: EditorCanvasViewportPayload,
layers: Value,
resources: Vec<EditorProjectResourcePayload>,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourcePayload {
resource_id: String,
project_id: String,
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: String,
prompt: Option<String>,
actual_prompt: Option<String>,
model: Option<String>,
provider: Option<String>,
task_id: Option<String>,
source_resource_id: Option<String>,
created_at: String,
updated_at: String,
}
pub async fn load_recent_editor_project(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = authenticated.claims().user_id().to_string();
let project = state
.spacetime_client()
.get_recent_editor_project(owner_user_id)
.await
.map_err(map_editor_project_error)?
.map(editor_project_payload_from_record);
Ok(json_success_body(
Some(&request_context),
EditorProjectRecentResponse { project },
))
}
pub async fn create_editor_project(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectCreateRequest>,
) -> Result<Json<Value>, AppError> {
let now_micros = current_utc_micros();
let title = payload
.title
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string());
let project = state
.spacetime_client()
.create_editor_project(EditorProjectCreateRecordInput {
project_id: build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(),
title,
now_micros,
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn get_editor_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id,
owner_user_id: authenticated.claims().user_id().to_string(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn save_editor_project_layout(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectLayoutSaveRequest>,
) -> Result<Json<Value>, AppError> {
let layers_json = serialize_editor_layers(payload.layers)?;
let project = state
.spacetime_client()
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
project_id,
owner_user_id: authenticated.claims().user_id().to_string(),
viewport: payload.viewport.into_record(),
layers_json,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn create_editor_project_resource(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectResourceCreateRequest>,
) -> Result<Json<Value>, AppError> {
let resource = state
.spacetime_client()
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
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),
width: payload.width,
height: payload.height,
source_type: payload.source_type,
prompt: normalize_optional_string(payload.prompt),
actual_prompt: normalize_optional_string(payload.actual_prompt),
model: normalize_optional_string(payload.model),
provider: normalize_optional_string(payload.provider),
task_id: normalize_optional_string(payload.task_id),
source_resource_id: normalize_optional_string(payload.source_resource_id),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectResourceResponse {
resource: editor_project_resource_payload_from_record(resource),
},
))
}
pub async fn generate_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorImageGenerationRequest>,
) -> Result<Json<Value>, AppError> {
let prompt = payload.prompt.trim().to_string();
if prompt.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-generation",
"message": "生成提示词不能为空",
})),
);
}
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(authenticated.claims().user_id().to_string()),
None,
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,
&settings,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
EDITOR_IMAGE_GENERATION_SIZE,
1,
&[],
"图片画布生成图片",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图片",
}))
})?;
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())
);
Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src,
width,
height,
source_type: "generated",
prompt,
actual_prompt: generated.actual_prompt,
model: GPT_IMAGE_2_MODEL,
provider: "VectorEngine",
task_id: generated.task_id,
},
))
}
pub async fn edit_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorImageEditRequest>,
) -> Result<Json<Value>, AppError> {
let prompt = payload.prompt.trim().to_string();
if prompt.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "修改提示词不能为空",
})),
);
}
let reference_image = parse_editor_reference_image(payload.source_image_src.as_str())?;
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(authenticated.claims().user_id().to_string()),
None,
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_edit_with_references(
&http_client,
&settings,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
EDITOR_IMAGE_GENERATION_SIZE,
1,
&[reference_image],
"图片画布修改图片",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图片",
}))
})?;
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())
);
Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src,
width,
height,
source_type: "generated",
prompt,
actual_prompt: generated.actual_prompt,
model: GPT_IMAGE_2_MODEL,
provider: "VectorEngine",
task_id: generated.task_id,
},
))
}
fn editor_project_payload_from_record(record: EditorProjectRecord) -> EditorProjectPayload {
EditorProjectPayload {
project_id: record.project_id,
title: record.title,
viewport: EditorCanvasViewportPayload {
x: record.viewport.x,
y: record.viewport.y,
scale: record.viewport.scale,
},
layers: record.layers,
resources: record
.resources
.into_iter()
.map(editor_project_resource_payload_from_record)
.collect(),
updated_at: record.updated_at,
}
}
fn editor_project_resource_payload_from_record(
record: EditorProjectResourceRecord,
) -> EditorProjectResourcePayload {
EditorProjectResourcePayload {
resource_id: record.resource_id,
project_id: record.project_id,
image_src: record.image_src,
object_key: record.object_key,
asset_object_id: record.asset_object_id,
width: record.width,
height: record.height,
source_type: record.source_type,
prompt: record.prompt,
actual_prompt: record.actual_prompt,
model: record.model,
provider: record.provider,
task_id: record.task_id,
source_resource_id: record.source_resource_id,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
impl EditorCanvasViewportPayload {
fn into_record(self) -> EditorCanvasViewportRecord {
EditorCanvasViewportRecord {
x: self.x,
y: self.y,
scale: self.scale,
}
}
}
fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
let payload = serde_json::to_string(&layers).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
"message": format!("图层布局无法序列化:{error}"),
}))
})?;
if payload.len() > EDITOR_LAYOUT_MAX_BYTES {
return Err(
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({
"provider": "editor-project",
"message": "图层布局过大",
})),
);
}
Ok(payload)
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}
fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, AppError> {
let Some((header, data)) = source.trim().split_once(',') else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"field": "sourceImageSrc",
"message": "修改图片参考图必须是图片 Data URL。",
})),
);
};
let Some(mime_type) = header
.strip_prefix("data:")
.and_then(|value| value.strip_suffix(";base64"))
.filter(|value| value.starts_with("image/"))
else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"field": "sourceImageSrc",
"message": "修改图片参考图必须是 base64 图片 Data URL。",
})),
);
};
let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"field": "sourceImageSrc",
"message": format!("修改图片参考图解码失败:{error}"),
}))
})?;
if bytes.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"field": "sourceImageSrc",
"message": "修改图片参考图为空。",
})),
);
}
let extension = match mime_type {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/webp" => "webp",
_ => "png",
};
Ok(OpenAiReferenceImage {
bytes,
mime_type: mime_type.to_string(),
file_name: format!("editor-reference.{extension}"),
})
}
fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
match error {
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "editor-project",
"message": message,
}))
}
SpacetimeClientError::Procedure(message) if message.contains("不存在") => {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "editor-project",
"message": message,
}))
}
SpacetimeClientError::Runtime(message) => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
"message": message,
}))
}
other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": other.to_string(),
})),
}
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}

View File

@@ -36,6 +36,7 @@ mod custom_world_asset_prompts;
mod custom_world_foundation_draft;
mod custom_world_result_prompts;
mod custom_world_rpg_draft_prompts;
mod editor_project;
mod edutainment_baby_drawing;
mod edutainment_baby_object;
mod error_middleware;

View File

@@ -4,6 +4,7 @@ pub mod auth;
pub mod bark_battle;
pub mod big_fish;
pub mod custom_world;
pub mod editor_project;
pub mod edutainment;
pub mod health;
pub mod internal;

View File

@@ -0,0 +1,62 @@
use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
auth::require_bearer_auth,
editor_project::{
create_editor_project, create_editor_project_resource, edit_editor_image,
generate_editor_image, get_editor_project, load_recent_editor_project,
save_editor_project_layout,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/editor/projects/recent",
get(load_recent_editor_project).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/projects",
post(create_editor_project).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/projects/{project_id}",
get(get_editor_project)
.patch(save_editor_project_layout)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/projects/{project_id}/resources",
post(create_editor_project_resource).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/images/generations",
post(generate_editor_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/editor/images/edits",
post(edit_editor_image).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
}

View File

@@ -0,0 +1,122 @@
use super::*;
impl SpacetimeClient {
pub async fn create_editor_project(
&self,
input: EditorProjectCreateRecordInput,
) -> Result<EditorProjectRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"create_editor_project_and_return",
move |connection, sender| {
connection
.procedures()
.create_editor_project_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_project_required_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
}
pub async fn get_recent_editor_project(
&self,
owner_user_id: String,
) -> Result<Option<EditorProjectRecord>, SpacetimeClientError> {
let procedure_input = EditorProjectGetRecentInput { owner_user_id };
self.call_after_connect(
"get_recent_editor_project_and_return",
move |connection, sender| {
connection
.procedures()
.get_recent_editor_project_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_project_optional_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn get_editor_project(
&self,
input: EditorProjectGetRecordInput,
) -> Result<EditorProjectRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"get_editor_project_and_return",
move |connection, sender| {
connection
.procedures()
.get_editor_project_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_project_required_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
}
pub async fn save_editor_project_layout(
&self,
input: EditorProjectLayoutSaveRecordInput,
) -> Result<EditorProjectRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"save_editor_project_layout_and_return",
move |connection, sender| {
connection
.procedures()
.save_editor_project_layout_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_project_required_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn create_editor_project_resource(
&self,
input: EditorProjectResourceCreateRecordInput,
) -> Result<EditorProjectResourceRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"create_editor_project_resource_and_return",
move |connection, sender| {
connection
.procedures()
.create_editor_project_resource_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
}
}

View File

@@ -30,8 +30,11 @@ pub use mapper::{
CustomWorldPublishGateRecord, CustomWorldPublishWorldRecord,
CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord,
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
CustomWorldWorkSummaryRecord, JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType,
JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
CustomWorldWorkSummaryRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord,
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, JumpHopActionRequest,
JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty,
JumpHopDraftResponse, JumpHopGalleryCardResponse,
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath,
JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
@@ -112,6 +115,7 @@ pub use bark_battle::{
pub mod big_fish;
pub mod combat;
pub mod custom_world;
pub mod editor_project;
pub mod inventory;
pub mod jump_hop;
pub mod match3d;

View File

@@ -8,6 +8,7 @@ mod big_fish;
mod combat;
mod common;
mod custom_world;
mod editor_project;
mod inventory;
mod jump_hop;
mod match3d;
@@ -39,6 +40,11 @@ pub use self::combat::{
BarkBattleDraftConfigRecord, BarkBattleRunRecord, BarkBattleRuntimeConfigRecord,
ResolveCombatActionRecord,
};
pub use self::editor_project::{
EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectRecord,
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord,
};
pub use self::common::{
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput,
@@ -178,6 +184,10 @@ pub(crate) use self::custom_world::{
parse_rpg_agent_operation_status_record, parse_rpg_agent_operation_type_record,
parse_rpg_agent_stage_record,
};
pub(crate) use self::editor_project::{
map_editor_project_optional_procedure_result, map_editor_project_required_procedure_result,
map_editor_project_resource_procedure_result,
};
pub(crate) use self::inventory::{
map_runtime_inventory_state_procedure_result, map_runtime_item_reward_item_snapshot,
map_runtime_item_reward_item_snapshot_back,

View File

@@ -0,0 +1,226 @@
use super::*;
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct EditorCanvasViewportRecord {
pub x: f64,
pub y: f64,
pub scale: f64,
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct EditorProjectRecord {
pub project_id: String,
pub owner_user_id: String,
pub title: String,
pub viewport: EditorCanvasViewportRecord,
pub layers: serde_json::Value,
pub resources: Vec<EditorProjectResourceRecord>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EditorProjectResourceRecord {
pub resource_id: String,
pub project_id: String,
pub asset_object_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: Option<String>,
pub actual_prompt: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorProjectCreateRecordInput {
pub project_id: String,
pub owner_user_id: String,
pub title: String,
pub now_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorProjectGetRecordInput {
pub project_id: String,
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorProjectLayoutSaveRecordInput {
pub project_id: String,
pub owner_user_id: String,
pub viewport: EditorCanvasViewportRecord,
pub layers_json: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorProjectResourceCreateRecordInput {
pub resource_id: String,
pub project_id: String,
pub owner_user_id: String,
pub asset_object_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: Option<String>,
pub actual_prompt: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub updated_at_micros: i64,
}
impl From<EditorProjectCreateRecordInput> for crate::module_bindings::EditorProjectCreateInput {
fn from(input: EditorProjectCreateRecordInput) -> Self {
Self {
project_id: input.project_id,
owner_user_id: input.owner_user_id,
title: input.title,
now_micros: input.now_micros,
}
}
}
impl From<EditorProjectGetRecordInput> for crate::module_bindings::EditorProjectGetInput {
fn from(input: EditorProjectGetRecordInput) -> Self {
Self {
project_id: input.project_id,
owner_user_id: input.owner_user_id,
}
}
}
impl From<EditorProjectLayoutSaveRecordInput>
for crate::module_bindings::EditorProjectLayoutSaveInput
{
fn from(input: EditorProjectLayoutSaveRecordInput) -> Self {
Self {
project_id: input.project_id,
owner_user_id: input.owner_user_id,
viewport: crate::module_bindings::EditorProjectViewportSnapshot {
x: input.viewport.x,
y: input.viewport.y,
scale: input.viewport.scale,
},
layers_json: input.layers_json,
updated_at_micros: input.updated_at_micros,
}
}
}
impl From<EditorProjectResourceCreateRecordInput>
for crate::module_bindings::EditorProjectResourceCreateInput
{
fn from(input: EditorProjectResourceCreateRecordInput) -> Self {
Self {
resource_id: input.resource_id,
project_id: input.project_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,
width: input.width,
height: input.height,
source_type: input.source_type,
prompt: input.prompt,
actual_prompt: input.actual_prompt,
model: input.model,
provider: input.provider,
task_id: input.task_id,
source_resource_id: input.source_resource_id,
updated_at_micros: input.updated_at_micros,
}
}
}
pub(crate) fn map_editor_project_optional_procedure_result(
result: EditorProjectProcedureResult,
) -> Result<Option<EditorProjectRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
result.project.map(map_editor_project_snapshot).transpose()
}
pub(crate) fn map_editor_project_required_procedure_result(
result: EditorProjectProcedureResult,
) -> Result<EditorProjectRecord, SpacetimeClientError> {
map_editor_project_optional_procedure_result(result)?
.ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布工程快照"))
}
pub(crate) fn map_editor_project_resource_procedure_result(
result: EditorProjectResourceProcedureResult,
) -> Result<EditorProjectResourceRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
result
.resource
.map(map_editor_project_resource_snapshot)
.ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布资源快照"))
}
fn map_editor_project_snapshot(
snapshot: EditorProjectSnapshot,
) -> Result<EditorProjectRecord, SpacetimeClientError> {
let layers = serde_json::from_str(&snapshot.layers_json).map_err(|error| {
SpacetimeClientError::validation_failed(format!("图片画布图层布局 JSON 无法解析:{error}"))
})?;
Ok(EditorProjectRecord {
project_id: snapshot.project_id,
owner_user_id: snapshot.owner_user_id,
title: snapshot.title,
viewport: EditorCanvasViewportRecord {
x: snapshot.viewport.x,
y: snapshot.viewport.y,
scale: snapshot.viewport.scale,
},
layers,
resources: snapshot
.resources
.into_iter()
.map(map_editor_project_resource_snapshot)
.collect(),
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
})
}
fn map_editor_project_resource_snapshot(
snapshot: EditorProjectResourceSnapshot,
) -> EditorProjectResourceRecord {
EditorProjectResourceRecord {
resource_id: snapshot.resource_id,
project_id: snapshot.project_id,
asset_object_id: snapshot.asset_object_id,
image_src: snapshot.image_src,
object_key: snapshot.object_key,
width: snapshot.width,
height: snapshot.height,
source_type: snapshot.source_type,
prompt: snapshot.prompt,
actual_prompt: snapshot.actual_prompt,
model: snapshot.model,
provider: snapshot.provider,
task_id: snapshot.task_id,
source_resource_id: snapshot.source_resource_id,
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
}
}

View File

@@ -234,6 +234,8 @@ pub mod create_battle_state_and_return_procedure;
pub mod create_battle_state_reducer;
pub mod create_big_fish_session_procedure;
pub mod create_custom_world_agent_session_procedure;
pub mod create_editor_project_and_return_procedure;
pub mod create_editor_project_resource_and_return_procedure;
pub mod create_jump_hop_agent_session_procedure;
pub mod create_match_3_d_agent_session_procedure;
pub mod create_profile_recharge_order_and_return_procedure;
@@ -343,6 +345,20 @@ pub mod delete_visual_novel_work_procedure;
pub mod delete_wooden_fish_work_procedure;
pub mod drag_puzzle_piece_or_group_procedure;
pub mod drop_square_hole_shape_procedure;
pub mod editor_project_create_input_type;
pub mod editor_project_get_input_type;
pub mod editor_project_get_recent_input_type;
pub mod editor_project_layout_save_input_type;
pub mod editor_project_procedure_result_type;
pub mod editor_project_resource_create_input_type;
pub mod editor_project_resource_procedure_result_type;
pub mod editor_project_resource_snapshot_type;
pub mod editor_project_resource_table;
pub mod editor_project_resource_type;
pub mod editor_project_snapshot_type;
pub mod editor_project_table;
pub mod editor_project_type;
pub mod editor_project_viewport_snapshot_type;
pub mod ensure_analytics_date_dimension_for_date_reducer;
pub mod equip_inventory_item_input_type;
pub mod execute_custom_world_agent_action_procedure;
@@ -373,6 +389,7 @@ pub mod get_custom_world_agent_session_procedure;
pub mod get_custom_world_gallery_detail_by_code_procedure;
pub mod get_custom_world_gallery_detail_procedure;
pub mod get_custom_world_library_detail_procedure;
pub mod get_editor_project_and_return_procedure;
pub mod get_jump_hop_agent_session_procedure;
pub mod get_jump_hop_leaderboard_procedure;
pub mod get_jump_hop_run_procedure;
@@ -394,6 +411,7 @@ pub mod get_puzzle_clear_work_profile_procedure;
pub mod get_puzzle_gallery_detail_procedure;
pub mod get_puzzle_run_procedure;
pub mod get_puzzle_work_detail_procedure;
pub mod get_recent_editor_project_and_return_procedure;
pub mod get_runtime_inventory_state_procedure;
pub mod get_runtime_setting_or_default_procedure;
pub mod get_runtime_snapshot_procedure;
@@ -913,6 +931,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_and_return_procedure;
pub mod save_puzzle_form_draft_procedure;
pub mod save_puzzle_generated_images_procedure;
pub mod save_puzzle_ui_background_procedure;
@@ -1349,6 +1368,8 @@ pub use create_battle_state_and_return_procedure::create_battle_state_and_return
pub use create_battle_state_reducer::create_battle_state;
pub use create_big_fish_session_procedure::create_big_fish_session;
pub use create_custom_world_agent_session_procedure::create_custom_world_agent_session;
pub use create_editor_project_and_return_procedure::create_editor_project_and_return;
pub use create_editor_project_resource_and_return_procedure::create_editor_project_resource_and_return;
pub use create_jump_hop_agent_session_procedure::create_jump_hop_agent_session;
pub use create_match_3_d_agent_session_procedure::create_match_3_d_agent_session;
pub use create_profile_recharge_order_and_return_procedure::create_profile_recharge_order_and_return;
@@ -1458,6 +1479,20 @@ pub use delete_visual_novel_work_procedure::delete_visual_novel_work;
pub use delete_wooden_fish_work_procedure::delete_wooden_fish_work;
pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group;
pub use drop_square_hole_shape_procedure::drop_square_hole_shape;
pub use editor_project_create_input_type::EditorProjectCreateInput;
pub use editor_project_get_input_type::EditorProjectGetInput;
pub use editor_project_get_recent_input_type::EditorProjectGetRecentInput;
pub use editor_project_layout_save_input_type::EditorProjectLayoutSaveInput;
pub use editor_project_procedure_result_type::EditorProjectProcedureResult;
pub use editor_project_resource_create_input_type::EditorProjectResourceCreateInput;
pub use editor_project_resource_procedure_result_type::EditorProjectResourceProcedureResult;
pub use editor_project_resource_snapshot_type::EditorProjectResourceSnapshot;
pub use editor_project_resource_table::*;
pub use editor_project_resource_type::EditorProjectResource;
pub use editor_project_snapshot_type::EditorProjectSnapshot;
pub use editor_project_table::*;
pub use editor_project_type::EditorProject;
pub use editor_project_viewport_snapshot_type::EditorProjectViewportSnapshot;
pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date;
pub use equip_inventory_item_input_type::EquipInventoryItemInput;
pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action;
@@ -1488,6 +1523,7 @@ pub use get_custom_world_agent_session_procedure::get_custom_world_agent_session
pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gallery_detail_by_code;
pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail;
pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail;
pub use get_editor_project_and_return_procedure::get_editor_project_and_return;
pub use get_jump_hop_agent_session_procedure::get_jump_hop_agent_session;
pub use get_jump_hop_leaderboard_procedure::get_jump_hop_leaderboard;
pub use get_jump_hop_run_procedure::get_jump_hop_run;
@@ -1509,6 +1545,7 @@ pub use get_puzzle_clear_work_profile_procedure::get_puzzle_clear_work_profile;
pub use get_puzzle_gallery_detail_procedure::get_puzzle_gallery_detail;
pub use get_puzzle_run_procedure::get_puzzle_run;
pub use get_puzzle_work_detail_procedure::get_puzzle_work_detail;
pub use get_recent_editor_project_and_return_procedure::get_recent_editor_project_and_return;
pub use get_runtime_inventory_state_procedure::get_runtime_inventory_state;
pub use get_runtime_setting_or_default_procedure::get_runtime_setting_or_default;
pub use get_runtime_snapshot_procedure::get_runtime_snapshot;
@@ -2028,6 +2065,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_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;
pub use save_puzzle_ui_background_procedure::save_puzzle_ui_background;
@@ -2547,6 +2585,8 @@ pub struct DbUpdate {
custom_world_session: __sdk::TableUpdate<CustomWorldSession>,
database_migration_import_chunk: __sdk::TableUpdate<DatabaseMigrationImportChunk>,
database_migration_operator: __sdk::TableUpdate<DatabaseMigrationOperator>,
editor_project: __sdk::TableUpdate<EditorProject>,
editor_project_resource: __sdk::TableUpdate<EditorProjectResource>,
inventory_slot: __sdk::TableUpdate<InventorySlot>,
jump_hop_agent_session: __sdk::TableUpdate<JumpHopAgentSessionRow>,
jump_hop_event: __sdk::TableUpdate<JumpHopEventRow>,
@@ -2759,6 +2799,12 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
"database_migration_operator" => db_update.database_migration_operator.append(
database_migration_operator_table::parse_table_update(table_update)?,
),
"editor_project" => db_update
.editor_project
.append(editor_project_table::parse_table_update(table_update)?),
"editor_project_resource" => db_update.editor_project_resource.append(
editor_project_resource_table::parse_table_update(table_update)?,
),
"inventory_slot" => db_update
.inventory_slot
.append(inventory_slot_table::parse_table_update(table_update)?),
@@ -3219,6 +3265,15 @@ impl __sdk::DbUpdate for DbUpdate {
&self.database_migration_operator,
)
.with_updates_by_pk(|row| &row.operator_identity);
diff.editor_project = cache
.apply_diff_to_table::<EditorProject>("editor_project", &self.editor_project)
.with_updates_by_pk(|row| &row.project_id);
diff.editor_project_resource = cache
.apply_diff_to_table::<EditorProjectResource>(
"editor_project_resource",
&self.editor_project_resource,
)
.with_updates_by_pk(|row| &row.resource_id);
diff.inventory_slot = cache
.apply_diff_to_table::<InventorySlot>("inventory_slot", &self.inventory_slot)
.with_updates_by_pk(|row| &row.slot_id);
@@ -3746,6 +3801,12 @@ impl __sdk::DbUpdate for DbUpdate {
"database_migration_operator" => db_update
.database_migration_operator
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"editor_project" => db_update
.editor_project
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"editor_project_resource" => db_update
.editor_project_resource
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"inventory_slot" => db_update
.inventory_slot
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
@@ -4113,6 +4174,12 @@ impl __sdk::DbUpdate for DbUpdate {
"database_migration_operator" => db_update
.database_migration_operator
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"editor_project" => db_update
.editor_project
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"editor_project_resource" => db_update
.editor_project_resource
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"inventory_slot" => db_update
.inventory_slot
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
@@ -4406,6 +4473,8 @@ pub struct AppliedDiff<'r> {
custom_world_session: __sdk::TableAppliedDiff<'r, CustomWorldSession>,
database_migration_import_chunk: __sdk::TableAppliedDiff<'r, DatabaseMigrationImportChunk>,
database_migration_operator: __sdk::TableAppliedDiff<'r, DatabaseMigrationOperator>,
editor_project: __sdk::TableAppliedDiff<'r, EditorProject>,
editor_project_resource: __sdk::TableAppliedDiff<'r, EditorProjectResource>,
inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>,
jump_hop_agent_session: __sdk::TableAppliedDiff<'r, JumpHopAgentSessionRow>,
jump_hop_event: __sdk::TableAppliedDiff<'r, JumpHopEventRow>,
@@ -4686,6 +4755,16 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.database_migration_operator,
event,
);
callbacks.invoke_table_row_callbacks::<EditorProject>(
"editor_project",
&self.editor_project,
event,
);
callbacks.invoke_table_row_callbacks::<EditorProjectResource>(
"editor_project_resource",
&self.editor_project_resource,
event,
);
callbacks.invoke_table_row_callbacks::<InventorySlot>(
"inventory_slot",
&self.inventory_slot,
@@ -5768,6 +5847,8 @@ impl __sdk::SpacetimeModule for RemoteModule {
custom_world_session_table::register_table(client_cache);
database_migration_import_chunk_table::register_table(client_cache);
database_migration_operator_table::register_table(client_cache);
editor_project_table::register_table(client_cache);
editor_project_resource_table::register_table(client_cache);
inventory_slot_table::register_table(client_cache);
jump_hop_agent_session_table::register_table(client_cache);
jump_hop_event_table::register_table(client_cache);
@@ -5888,6 +5969,8 @@ impl __sdk::SpacetimeModule for RemoteModule {
"custom_world_session",
"database_migration_import_chunk",
"database_migration_operator",
"editor_project",
"editor_project_resource",
"inventory_slot",
"jump_hop_agent_session",
"jump_hop_event",

View File

@@ -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_create_input_type::EditorProjectCreateInput;
use super::editor_project_procedure_result_type::EditorProjectProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct CreateEditorProjectAndReturnArgs {
pub input: EditorProjectCreateInput,
}
impl __sdk::InModule for CreateEditorProjectAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `create_editor_project_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait create_editor_project_and_return {
fn create_editor_project_and_return(&self, input: EditorProjectCreateInput) {
self.create_editor_project_and_return_then(input, |_, _| {});
}
fn create_editor_project_and_return_then(
&self,
input: EditorProjectCreateInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl create_editor_project_and_return for super::RemoteProcedures {
fn create_editor_project_and_return_then(
&self,
input: EditorProjectCreateInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorProjectProcedureResult>(
"create_editor_project_and_return",
CreateEditorProjectAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -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_resource_create_input_type::EditorProjectResourceCreateInput;
use super::editor_project_resource_procedure_result_type::EditorProjectResourceProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct CreateEditorProjectResourceAndReturnArgs {
pub input: EditorProjectResourceCreateInput,
}
impl __sdk::InModule for CreateEditorProjectResourceAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `create_editor_project_resource_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait create_editor_project_resource_and_return {
fn create_editor_project_resource_and_return(&self, input: EditorProjectResourceCreateInput) {
self.create_editor_project_resource_and_return_then(input, |_, _| {});
}
fn create_editor_project_resource_and_return_then(
&self,
input: EditorProjectResourceCreateInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectResourceProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl create_editor_project_resource_and_return for super::RemoteProcedures {
fn create_editor_project_resource_and_return_then(
&self,
input: EditorProjectResourceCreateInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectResourceProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorProjectResourceProcedureResult>(
"create_editor_project_resource_and_return",
CreateEditorProjectResourceAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,18 @@
// 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 EditorProjectCreateInput {
pub project_id: String,
pub owner_user_id: String,
pub title: String,
pub now_micros: i64,
}
impl __sdk::InModule for EditorProjectCreateInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,16 @@
// 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 EditorProjectGetInput {
pub project_id: String,
pub owner_user_id: String,
}
impl __sdk::InModule for EditorProjectGetInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,15 @@
// 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 EditorProjectGetRecentInput {
pub owner_user_id: String,
}
impl __sdk::InModule for EditorProjectGetRecentInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,21 @@
// 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_viewport_snapshot_type::EditorProjectViewportSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorProjectLayoutSaveInput {
pub project_id: String,
pub owner_user_id: String,
pub viewport: EditorProjectViewportSnapshot,
pub layers_json: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for EditorProjectLayoutSaveInput {
type Module = super::RemoteModule;
}

View File

@@ -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_snapshot_type::EditorProjectSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorProjectProcedureResult {
pub ok: bool,
pub project: Option<EditorProjectSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for EditorProjectProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,30 @@
// 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 EditorProjectResourceCreateInput {
pub resource_id: String,
pub project_id: String,
pub owner_user_id: String,
pub asset_object_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: Option<String>,
pub actual_prompt: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for EditorProjectResourceCreateInput {
type Module = super::RemoteModule;
}

View File

@@ -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_resource_snapshot_type::EditorProjectResourceSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorProjectResourceProcedureResult {
pub ok: bool,
pub resource: Option<EditorProjectResourceSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for EditorProjectResourceProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,30 @@
// 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 EditorProjectResourceSnapshot {
pub resource_id: String,
pub project_id: String,
pub asset_object_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: Option<String>,
pub actual_prompt: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
impl __sdk::InModule for EditorProjectResourceSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,161 @@
// 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 super::editor_project_resource_type::EditorProjectResource;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `editor_project_resource`.
///
/// Obtain a handle from the [`EditorProjectResourceTableAccess::editor_project_resource`] method on [`super::RemoteTables`],
/// like `ctx.db.editor_project_resource()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.editor_project_resource().on_insert(...)`.
pub struct EditorProjectResourceTableHandle<'ctx> {
imp: __sdk::TableHandle<EditorProjectResource>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `editor_project_resource`.
///
/// Implemented for [`super::RemoteTables`].
pub trait EditorProjectResourceTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`EditorProjectResourceTableHandle`], which mediates access to the table `editor_project_resource`.
fn editor_project_resource(&self) -> EditorProjectResourceTableHandle<'_>;
}
impl EditorProjectResourceTableAccess for super::RemoteTables {
fn editor_project_resource(&self) -> EditorProjectResourceTableHandle<'_> {
EditorProjectResourceTableHandle {
imp: self
.imp
.get_table::<EditorProjectResource>("editor_project_resource"),
ctx: std::marker::PhantomData,
}
}
}
pub struct EditorProjectResourceInsertCallbackId(__sdk::CallbackId);
pub struct EditorProjectResourceDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for EditorProjectResourceTableHandle<'ctx> {
type Row = EditorProjectResource;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = EditorProjectResource> + '_ {
self.imp.iter()
}
type InsertCallbackId = EditorProjectResourceInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> EditorProjectResourceInsertCallbackId {
EditorProjectResourceInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: EditorProjectResourceInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = EditorProjectResourceDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> EditorProjectResourceDeleteCallbackId {
EditorProjectResourceDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: EditorProjectResourceDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct EditorProjectResourceUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for EditorProjectResourceTableHandle<'ctx> {
type UpdateCallbackId = EditorProjectResourceUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> EditorProjectResourceUpdateCallbackId {
EditorProjectResourceUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: EditorProjectResourceUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `resource_id` unique index on the table `editor_project_resource`,
/// which allows point queries on the field of the same name
/// via the [`EditorProjectResourceResourceIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.editor_project_resource().resource_id().find(...)`.
pub struct EditorProjectResourceResourceIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<EditorProjectResource, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> EditorProjectResourceTableHandle<'ctx> {
/// Get a handle on the `resource_id` unique index on the table `editor_project_resource`.
pub fn resource_id(&self) -> EditorProjectResourceResourceIdUnique<'ctx> {
EditorProjectResourceResourceIdUnique {
imp: self.imp.get_unique_constraint::<String>("resource_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> EditorProjectResourceResourceIdUnique<'ctx> {
/// Find the subscribed row whose `resource_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<EditorProjectResource> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<EditorProjectResource>("editor_project_resource");
_table.add_unique_constraint::<String>("resource_id", |row| &row.resource_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<EditorProjectResource>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<EditorProjectResource>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `EditorProjectResource`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait editor_project_resourceQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `EditorProjectResource`.
fn editor_project_resource(&self) -> __sdk::__query_builder::Table<EditorProjectResource>;
}
impl editor_project_resourceQueryTableAccess for __sdk::QueryTableAccessor {
fn editor_project_resource(&self) -> __sdk::__query_builder::Table<EditorProjectResource> {
__sdk::__query_builder::Table::new("editor_project_resource")
}
}

View File

@@ -0,0 +1,101 @@
// 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 EditorProjectResource {
pub resource_id: String,
pub project_id: String,
pub owner_user_id: String,
pub asset_object_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: Option<String>,
pub actual_prompt: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for EditorProjectResource {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `EditorProjectResource`.
///
/// Provides typed access to columns for query building.
pub struct EditorProjectResourceCols {
pub resource_id: __sdk::__query_builder::Col<EditorProjectResource, String>,
pub project_id: __sdk::__query_builder::Col<EditorProjectResource, String>,
pub owner_user_id: __sdk::__query_builder::Col<EditorProjectResource, String>,
pub asset_object_id: __sdk::__query_builder::Col<EditorProjectResource, Option<String>>,
pub image_src: __sdk::__query_builder::Col<EditorProjectResource, String>,
pub object_key: __sdk::__query_builder::Col<EditorProjectResource, Option<String>>,
pub width: __sdk::__query_builder::Col<EditorProjectResource, u32>,
pub height: __sdk::__query_builder::Col<EditorProjectResource, u32>,
pub source_type: __sdk::__query_builder::Col<EditorProjectResource, String>,
pub prompt: __sdk::__query_builder::Col<EditorProjectResource, Option<String>>,
pub actual_prompt: __sdk::__query_builder::Col<EditorProjectResource, Option<String>>,
pub model: __sdk::__query_builder::Col<EditorProjectResource, Option<String>>,
pub provider: __sdk::__query_builder::Col<EditorProjectResource, Option<String>>,
pub task_id: __sdk::__query_builder::Col<EditorProjectResource, Option<String>>,
pub source_resource_id: __sdk::__query_builder::Col<EditorProjectResource, Option<String>>,
pub created_at: __sdk::__query_builder::Col<EditorProjectResource, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<EditorProjectResource, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for EditorProjectResource {
type Cols = EditorProjectResourceCols;
fn cols(table_name: &'static str) -> Self::Cols {
EditorProjectResourceCols {
resource_id: __sdk::__query_builder::Col::new(table_name, "resource_id"),
project_id: __sdk::__query_builder::Col::new(table_name, "project_id"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
asset_object_id: __sdk::__query_builder::Col::new(table_name, "asset_object_id"),
image_src: __sdk::__query_builder::Col::new(table_name, "image_src"),
object_key: __sdk::__query_builder::Col::new(table_name, "object_key"),
width: __sdk::__query_builder::Col::new(table_name, "width"),
height: __sdk::__query_builder::Col::new(table_name, "height"),
source_type: __sdk::__query_builder::Col::new(table_name, "source_type"),
prompt: __sdk::__query_builder::Col::new(table_name, "prompt"),
actual_prompt: __sdk::__query_builder::Col::new(table_name, "actual_prompt"),
model: __sdk::__query_builder::Col::new(table_name, "model"),
provider: __sdk::__query_builder::Col::new(table_name, "provider"),
task_id: __sdk::__query_builder::Col::new(table_name, "task_id"),
source_resource_id: __sdk::__query_builder::Col::new(table_name, "source_resource_id"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `EditorProjectResource`.
///
/// Provides typed access to indexed columns for query building.
pub struct EditorProjectResourceIxCols {
pub owner_user_id: __sdk::__query_builder::IxCol<EditorProjectResource, String>,
pub project_id: __sdk::__query_builder::IxCol<EditorProjectResource, String>,
pub resource_id: __sdk::__query_builder::IxCol<EditorProjectResource, String>,
}
impl __sdk::__query_builder::HasIxCols for EditorProjectResource {
type IxCols = EditorProjectResourceIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
EditorProjectResourceIxCols {
owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"),
project_id: __sdk::__query_builder::IxCol::new(table_name, "project_id"),
resource_id: __sdk::__query_builder::IxCol::new(table_name, "resource_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for EditorProjectResource {}

View File

@@ -0,0 +1,25 @@
// 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_snapshot_type::EditorProjectResourceSnapshot;
use super::editor_project_viewport_snapshot_type::EditorProjectViewportSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorProjectSnapshot {
pub project_id: String,
pub owner_user_id: String,
pub title: String,
pub viewport: EditorProjectViewportSnapshot,
pub layers_json: String,
pub resources: Vec<EditorProjectResourceSnapshot>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
impl __sdk::InModule for EditorProjectSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,159 @@
// 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 super::editor_project_type::EditorProject;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `editor_project`.
///
/// Obtain a handle from the [`EditorProjectTableAccess::editor_project`] method on [`super::RemoteTables`],
/// like `ctx.db.editor_project()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.editor_project().on_insert(...)`.
pub struct EditorProjectTableHandle<'ctx> {
imp: __sdk::TableHandle<EditorProject>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `editor_project`.
///
/// Implemented for [`super::RemoteTables`].
pub trait EditorProjectTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`EditorProjectTableHandle`], which mediates access to the table `editor_project`.
fn editor_project(&self) -> EditorProjectTableHandle<'_>;
}
impl EditorProjectTableAccess for super::RemoteTables {
fn editor_project(&self) -> EditorProjectTableHandle<'_> {
EditorProjectTableHandle {
imp: self.imp.get_table::<EditorProject>("editor_project"),
ctx: std::marker::PhantomData,
}
}
}
pub struct EditorProjectInsertCallbackId(__sdk::CallbackId);
pub struct EditorProjectDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for EditorProjectTableHandle<'ctx> {
type Row = EditorProject;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = EditorProject> + '_ {
self.imp.iter()
}
type InsertCallbackId = EditorProjectInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> EditorProjectInsertCallbackId {
EditorProjectInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: EditorProjectInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = EditorProjectDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> EditorProjectDeleteCallbackId {
EditorProjectDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: EditorProjectDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct EditorProjectUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for EditorProjectTableHandle<'ctx> {
type UpdateCallbackId = EditorProjectUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> EditorProjectUpdateCallbackId {
EditorProjectUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: EditorProjectUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `project_id` unique index on the table `editor_project`,
/// which allows point queries on the field of the same name
/// via the [`EditorProjectProjectIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.editor_project().project_id().find(...)`.
pub struct EditorProjectProjectIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<EditorProject, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> EditorProjectTableHandle<'ctx> {
/// Get a handle on the `project_id` unique index on the table `editor_project`.
pub fn project_id(&self) -> EditorProjectProjectIdUnique<'ctx> {
EditorProjectProjectIdUnique {
imp: self.imp.get_unique_constraint::<String>("project_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> EditorProjectProjectIdUnique<'ctx> {
/// Find the subscribed row whose `project_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<EditorProject> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<EditorProject>("editor_project");
_table.add_unique_constraint::<String>("project_id", |row| &row.project_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<EditorProject>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<EditorProject>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `EditorProject`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait editor_projectQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `EditorProject`.
fn editor_project(&self) -> __sdk::__query_builder::Table<EditorProject>;
}
impl editor_projectQueryTableAccess for __sdk::QueryTableAccessor {
fn editor_project(&self) -> __sdk::__query_builder::Table<EditorProject> {
__sdk::__query_builder::Table::new("editor_project")
}
}

View File

@@ -0,0 +1,75 @@
// 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 EditorProject {
pub project_id: String,
pub owner_user_id: String,
pub title: String,
pub viewport_x: f64,
pub viewport_y: f64,
pub viewport_scale: f64,
pub layers_json: String,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for EditorProject {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `EditorProject`.
///
/// Provides typed access to columns for query building.
pub struct EditorProjectCols {
pub project_id: __sdk::__query_builder::Col<EditorProject, String>,
pub owner_user_id: __sdk::__query_builder::Col<EditorProject, String>,
pub title: __sdk::__query_builder::Col<EditorProject, String>,
pub viewport_x: __sdk::__query_builder::Col<EditorProject, f64>,
pub viewport_y: __sdk::__query_builder::Col<EditorProject, f64>,
pub viewport_scale: __sdk::__query_builder::Col<EditorProject, f64>,
pub layers_json: __sdk::__query_builder::Col<EditorProject, String>,
pub created_at: __sdk::__query_builder::Col<EditorProject, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<EditorProject, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for EditorProject {
type Cols = EditorProjectCols;
fn cols(table_name: &'static str) -> Self::Cols {
EditorProjectCols {
project_id: __sdk::__query_builder::Col::new(table_name, "project_id"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
title: __sdk::__query_builder::Col::new(table_name, "title"),
viewport_x: __sdk::__query_builder::Col::new(table_name, "viewport_x"),
viewport_y: __sdk::__query_builder::Col::new(table_name, "viewport_y"),
viewport_scale: __sdk::__query_builder::Col::new(table_name, "viewport_scale"),
layers_json: __sdk::__query_builder::Col::new(table_name, "layers_json"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `EditorProject`.
///
/// Provides typed access to indexed columns for query building.
pub struct EditorProjectIxCols {
pub owner_user_id: __sdk::__query_builder::IxCol<EditorProject, String>,
pub project_id: __sdk::__query_builder::IxCol<EditorProject, String>,
}
impl __sdk::__query_builder::HasIxCols for EditorProject {
type IxCols = EditorProjectIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
EditorProjectIxCols {
owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"),
project_id: __sdk::__query_builder::IxCol::new(table_name, "project_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for EditorProject {}

View File

@@ -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 EditorProjectViewportSnapshot {
pub x: f64,
pub y: f64,
pub scale: f64,
}
impl __sdk::InModule for EditorProjectViewportSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -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_get_input_type::EditorProjectGetInput;
use super::editor_project_procedure_result_type::EditorProjectProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetEditorProjectAndReturnArgs {
pub input: EditorProjectGetInput,
}
impl __sdk::InModule for GetEditorProjectAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_editor_project_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_editor_project_and_return {
fn get_editor_project_and_return(&self, input: EditorProjectGetInput) {
self.get_editor_project_and_return_then(input, |_, _| {});
}
fn get_editor_project_and_return_then(
&self,
input: EditorProjectGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl get_editor_project_and_return for super::RemoteProcedures {
fn get_editor_project_and_return_then(
&self,
input: EditorProjectGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorProjectProcedureResult>(
"get_editor_project_and_return",
GetEditorProjectAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -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_get_recent_input_type::EditorProjectGetRecentInput;
use super::editor_project_procedure_result_type::EditorProjectProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetRecentEditorProjectAndReturnArgs {
pub input: EditorProjectGetRecentInput,
}
impl __sdk::InModule for GetRecentEditorProjectAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_recent_editor_project_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_recent_editor_project_and_return {
fn get_recent_editor_project_and_return(&self, input: EditorProjectGetRecentInput) {
self.get_recent_editor_project_and_return_then(input, |_, _| {});
}
fn get_recent_editor_project_and_return_then(
&self,
input: EditorProjectGetRecentInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl get_recent_editor_project_and_return for super::RemoteProcedures {
fn get_recent_editor_project_and_return_then(
&self,
input: EditorProjectGetRecentInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorProjectProcedureResult>(
"get_recent_editor_project_and_return",
GetRecentEditorProjectAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -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_procedure_result_type::EditorProjectProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct SaveEditorProjectLayoutAndReturnArgs {
pub input: EditorProjectLayoutSaveInput,
}
impl __sdk::InModule for SaveEditorProjectLayoutAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `save_editor_project_layout_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait save_editor_project_layout_and_return {
fn save_editor_project_layout_and_return(&self, input: EditorProjectLayoutSaveInput) {
self.save_editor_project_layout_and_return_then(input, |_, _| {});
}
fn save_editor_project_layout_and_return_then(
&self,
input: EditorProjectLayoutSaveInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl save_editor_project_layout_and_return for super::RemoteProcedures {
fn save_editor_project_layout_and_return_then(
&self,
input: EditorProjectLayoutSaveInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorProjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorProjectProcedureResult>(
"save_editor_project_layout_and_return",
SaveEditorProjectLayoutAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,500 @@
use crate::*;
const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
const EDITOR_PROJECT_MAX_TITLE_CHARS: usize = 80;
const EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES: usize = 256 * 1024;
const EDITOR_PROJECT_SOURCE_TYPES: [&str; 3] = ["uploaded", "generated", "mock_generated"];
#[spacetimedb::table(
accessor = editor_project,
index(accessor = by_editor_project_owner_user_id, btree(columns = [owner_user_id]))
)]
pub struct EditorProject {
#[primary_key]
project_id: String,
owner_user_id: String,
title: String,
viewport_x: f64,
viewport_y: f64,
viewport_scale: f64,
layers_json: String,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = editor_project_resource,
index(accessor = by_editor_project_resource_project_id, btree(columns = [project_id])),
index(accessor = by_editor_project_resource_owner_user_id, btree(columns = [owner_user_id]))
)]
pub struct EditorProjectResource {
#[primary_key]
resource_id: String,
project_id: String,
owner_user_id: String,
asset_object_id: Option<String>,
image_src: String,
object_key: Option<String>,
width: u32,
height: u32,
source_type: String,
prompt: Option<String>,
actual_prompt: Option<String>,
model: Option<String>,
provider: Option<String>,
task_id: Option<String>,
source_resource_id: Option<String>,
created_at: Timestamp,
updated_at: Timestamp,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectViewportSnapshot {
pub x: f64,
pub y: f64,
pub scale: f64,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectCreateInput {
pub project_id: String,
pub owner_user_id: String,
pub title: String,
pub now_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorProjectGetInput {
pub project_id: String,
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorProjectGetRecentInput {
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectLayoutSaveInput {
pub project_id: String,
pub owner_user_id: String,
pub viewport: EditorProjectViewportSnapshot,
pub layers_json: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorProjectResourceCreateInput {
pub resource_id: String,
pub project_id: String,
pub owner_user_id: String,
pub asset_object_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: Option<String>,
pub actual_prompt: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorProjectResourceSnapshot {
pub resource_id: String,
pub project_id: String,
pub asset_object_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: Option<String>,
pub actual_prompt: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
pub task_id: Option<String>,
pub source_resource_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectSnapshot {
pub project_id: String,
pub owner_user_id: String,
pub title: String,
pub viewport: EditorProjectViewportSnapshot,
pub layers_json: String,
pub resources: Vec<EditorProjectResourceSnapshot>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectProcedureResult {
pub ok: bool,
pub project: Option<EditorProjectSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct EditorProjectResourceProcedureResult {
pub ok: bool,
pub resource: Option<EditorProjectResourceSnapshot>,
pub error_message: Option<String>,
}
#[spacetimedb::procedure]
pub fn create_editor_project_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectCreateInput,
) -> EditorProjectProcedureResult {
match ctx.try_with_tx(|tx| create_editor_project(tx, input.clone())) {
Ok(project) => editor_project_ok(Some(project)),
Err(message) => editor_project_error(message),
}
}
#[spacetimedb::procedure]
pub fn get_recent_editor_project_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectGetRecentInput,
) -> EditorProjectProcedureResult {
match ctx.try_with_tx(|tx| get_recent_editor_project(tx, input.clone())) {
Ok(project) => editor_project_ok(project),
Err(message) => editor_project_error(message),
}
}
#[spacetimedb::procedure]
pub fn get_editor_project_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectGetInput,
) -> EditorProjectProcedureResult {
match ctx.try_with_tx(|tx| get_editor_project(tx, input.clone())) {
Ok(project) => editor_project_ok(Some(project)),
Err(message) => editor_project_error(message),
}
}
#[spacetimedb::procedure]
pub fn save_editor_project_layout_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectLayoutSaveInput,
) -> EditorProjectProcedureResult {
match ctx.try_with_tx(|tx| save_editor_project_layout(tx, input.clone())) {
Ok(project) => editor_project_ok(Some(project)),
Err(message) => editor_project_error(message),
}
}
#[spacetimedb::procedure]
pub fn create_editor_project_resource_and_return(
ctx: &mut ProcedureContext,
input: EditorProjectResourceCreateInput,
) -> EditorProjectResourceProcedureResult {
match ctx.try_with_tx(|tx| create_editor_project_resource(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),
},
}
}
fn create_editor_project(
ctx: &ReducerContext,
input: EditorProjectCreateInput,
) -> Result<EditorProjectSnapshot, String> {
let project_id = normalize_required(&input.project_id, "editor_project.project_id")?;
let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?;
let title = normalize_title(&input.title);
let now = Timestamp::from_micros_since_unix_epoch(input.now_micros);
if ctx
.db
.editor_project()
.project_id()
.find(&project_id)
.is_some()
{
return Err("图片画布工程已存在".to_string());
}
ctx.db.editor_project().insert(EditorProject {
project_id: project_id.clone(),
owner_user_id,
title,
viewport_x: 0.0,
viewport_y: 0.0,
viewport_scale: 1.0,
layers_json: "[]".to_string(),
created_at: now,
updated_at: now,
});
build_project_snapshot(ctx, project_id.as_str())
}
fn get_recent_editor_project(
ctx: &ReducerContext,
input: EditorProjectGetRecentInput,
) -> Result<Option<EditorProjectSnapshot>, String> {
let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?;
let mut projects = ctx
.db
.editor_project()
.by_editor_project_owner_user_id()
.filter(&owner_user_id)
.collect::<Vec<_>>();
projects.sort_by(|left, right| {
right
.updated_at
.to_micros_since_unix_epoch()
.cmp(&left.updated_at.to_micros_since_unix_epoch())
.then_with(|| right.project_id.cmp(&left.project_id))
});
projects
.first()
.map(|project| build_project_snapshot(ctx, project.project_id.as_str()))
.transpose()
}
fn get_editor_project(
ctx: &ReducerContext,
input: EditorProjectGetInput,
) -> Result<EditorProjectSnapshot, String> {
let project_id = normalize_required(&input.project_id, "editor_project.project_id")?;
let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?;
let project = require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?;
build_project_snapshot(ctx, project.project_id.as_str())
}
fn save_editor_project_layout(
ctx: &ReducerContext,
input: EditorProjectLayoutSaveInput,
) -> Result<EditorProjectSnapshot, String> {
let project_id = normalize_required(&input.project_id, "editor_project.project_id")?;
let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?;
let layers_json = normalize_layout_json(input.layers_json)?;
let project = require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?;
ctx.db.editor_project().project_id().delete(&project_id);
ctx.db.editor_project().insert(EditorProject {
project_id: project.project_id.clone(),
owner_user_id: project.owner_user_id,
title: project.title,
viewport_x: input.viewport.x,
viewport_y: input.viewport.y,
viewport_scale: input.viewport.scale.clamp(0.01, 8.0),
layers_json,
created_at: project.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(input.updated_at_micros),
});
build_project_snapshot(ctx, project_id.as_str())
}
fn create_editor_project_resource(
ctx: &ReducerContext,
input: EditorProjectResourceCreateInput,
) -> Result<EditorProjectResourceSnapshot, String> {
let resource_id = normalize_required(
&input.resource_id,
"editor_project_resource.resource_id",
)?;
let project_id = normalize_required(&input.project_id, "editor_project_resource.project_id")?;
let owner_user_id = normalize_required(
&input.owner_user_id,
"editor_project_resource.owner_user_id",
)?;
require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?;
let image_src = normalize_required(&input.image_src, "editor_project_resource.image_src")?;
let source_type = normalize_required(
&input.source_type,
"editor_project_resource.source_type",
)?;
if !EDITOR_PROJECT_SOURCE_TYPES.contains(&source_type.as_str()) {
return Err("画布资源来源类型只支持 uploaded、generated 或 mock_generated".to_string());
}
if input.width == 0 || input.height == 0 {
return Err("画布资源尺寸必须大于 0".to_string());
}
if ctx
.db
.editor_project_resource()
.resource_id()
.find(&resource_id)
.is_some()
{
return Err("画布资源已存在".to_string());
}
let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
ctx.db
.editor_project_resource()
.insert(EditorProjectResource {
resource_id: resource_id.clone(),
project_id,
owner_user_id,
asset_object_id: normalize_optional(input.asset_object_id),
image_src,
object_key: normalize_optional(input.object_key),
width: input.width,
height: input.height,
source_type,
prompt: normalize_optional(input.prompt),
actual_prompt: normalize_optional(input.actual_prompt),
model: normalize_optional(input.model),
provider: normalize_optional(input.provider),
task_id: normalize_optional(input.task_id),
source_resource_id: normalize_optional(input.source_resource_id),
created_at: now,
updated_at: now,
});
ctx.db
.editor_project_resource()
.resource_id()
.find(&resource_id)
.map(resource_snapshot_from_row)
.ok_or_else(|| "画布资源创建失败".to_string())
}
fn build_project_snapshot(
ctx: &ReducerContext,
project_id: &str,
) -> Result<EditorProjectSnapshot, String> {
let project_key = project_id.to_string();
let project = ctx
.db
.editor_project()
.project_id()
.find(&project_key)
.ok_or_else(|| "图片画布工程不存在".to_string())?;
let mut resources = ctx
.db
.editor_project_resource()
.by_editor_project_resource_project_id()
.filter(&project_key)
.map(resource_snapshot_from_row)
.collect::<Vec<_>>();
resources.sort_by(|left, right| {
left.created_at_micros
.cmp(&right.created_at_micros)
.then_with(|| left.resource_id.cmp(&right.resource_id))
});
Ok(EditorProjectSnapshot {
project_id: project.project_id,
owner_user_id: project.owner_user_id,
title: project.title,
viewport: EditorProjectViewportSnapshot {
x: project.viewport_x,
y: project.viewport_y,
scale: project.viewport_scale,
},
layers_json: project.layers_json,
resources,
created_at_micros: project.created_at.to_micros_since_unix_epoch(),
updated_at_micros: project.updated_at.to_micros_since_unix_epoch(),
})
}
fn require_owned_project(
ctx: &ReducerContext,
project_id: &str,
owner_user_id: &str,
) -> Result<EditorProject, String> {
let project_key = project_id.to_string();
let project = ctx
.db
.editor_project()
.project_id()
.find(&project_key)
.ok_or_else(|| "图片画布工程不存在".to_string())?;
if project.owner_user_id != owner_user_id {
return Err("无权访问该图片画布工程".to_string());
}
Ok(project)
}
fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResourceSnapshot {
EditorProjectResourceSnapshot {
resource_id: row.resource_id,
project_id: row.project_id,
asset_object_id: row.asset_object_id,
image_src: row.image_src,
object_key: row.object_key,
width: row.width,
height: row.height,
source_type: row.source_type,
prompt: row.prompt,
actual_prompt: row.actual_prompt,
model: row.model,
provider: row.provider,
task_id: row.task_id,
source_resource_id: row.source_resource_id,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn normalize_required(value: &str, field: &str) -> Result<String, String> {
let normalized = value.trim();
if normalized.is_empty() {
return Err(format!("{field} 不能为空"));
}
Ok(normalized.to_string())
}
fn normalize_optional(value: Option<String>) -> Option<String> {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}
fn normalize_title(value: &str) -> String {
let title = value.trim();
if title.is_empty() {
return EDITOR_PROJECT_DEFAULT_TITLE.to_string();
}
title.chars().take(EDITOR_PROJECT_MAX_TITLE_CHARS).collect()
}
fn normalize_layout_json(value: String) -> Result<String, String> {
if value.len() > EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES {
return Err("图片画布图层布局过大".to_string());
}
serde_json::from_str::<JsonValue>(&value)
.map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?;
Ok(value)
}
fn editor_project_ok(project: Option<EditorProjectSnapshot>) -> EditorProjectProcedureResult {
EditorProjectProcedureResult {
ok: true,
project,
error_message: None,
}
}
fn editor_project_error(message: String) -> EditorProjectProcedureResult {
EditorProjectProcedureResult {
ok: false,
project: None,
error_message: Some(message),
}
}

View File

@@ -30,6 +30,7 @@ mod bark_battle;
mod big_fish;
mod custom_world;
mod domain_types;
mod editor_project_storage;
mod entry;
mod gameplay;
mod jump_hop;
@@ -50,6 +51,7 @@ pub use bark_battle::*;
pub use big_fish::*;
pub use custom_world::*;
pub use domain_types::*;
pub use editor_project_storage::*;
pub use entry::*;
pub use gameplay::*;
pub use jump_hop::*;

View File

@@ -228,6 +228,8 @@ macro_rules! migration_tables {
asset_object,
asset_entity_binding,
asset_event,
editor_project,
editor_project_resource,
puzzle_agent_session,
puzzle_background_compile_task,
puzzle_agent_message,

View File

@@ -133,6 +133,10 @@ export default function App() {
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
const isImageEditorStage = selectionStage === 'image-editor';
const platformShellSurfaceClass = isImageEditorStage
? 'bg-white p-0'
: 'bg-[image:var(--platform-body-fill)] p-2 sm:p-4';
if (isRuntimeActive) {
return (
@@ -150,7 +154,7 @@ export default function App() {
return (
<div
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden bg-[image:var(--platform-body-fill)] p-2 font-sans text-[var(--platform-text-strong)] sm:p-4`}
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden ${platformShellSurfaceClass} font-sans text-[var(--platform-text-strong)]`}
>
<PlatformEntryFlowShell
selectionStage={selectionStage}

View File

@@ -0,0 +1,518 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ImageCanvasEditorView } from './ImageCanvasEditorView';
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
>('../../services/image-editor/editorProjectClient');
return {
...actual,
editEditorImage: editEditorImageMock,
generateEditorImage: generateEditorImageMock,
};
});
function dispatchPointerEvent(
target: Element,
type: string,
init: MouseEventInit & { pointerId: number },
) {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
...init,
});
Object.defineProperty(event, 'pointerId', { value: init.pointerId });
fireEvent(target, event);
}
describe('ImageCanvasEditorView', () => {
afterEach(() => {
vi.restoreAllMocks();
generateEditorImageMock.mockReset();
editEditorImageMock.mockReset();
});
it('toggles the shared sidebar from canvas panel buttons', () => {
render(<ImageCanvasEditorView />);
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
const assetsButton = within(panelToolbar).getByRole('button', { name: '打开素材' });
const layersButton = within(panelToolbar).getByRole('button', { name: '打开图层' });
expect(within(sidebar).getByText('素材')).toBeTruthy();
expect(within(sidebar).getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
expect(assetsButton.getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByRole('button', { name: '打开已生成文件' })).toBeNull();
expect(screen.queryByRole('button', { name: '收起素材栏' })).toBeNull();
expect(screen.queryByRole('button', { name: '展开素材栏' })).toBeNull();
fireEvent.click(layersButton);
const layerSidebar = screen.getByRole('complementary', { name: '图片资源栏' });
expect(within(layerSidebar).getByText('图层')).toBeTruthy();
expect(
within(layerSidebar).getByRole('button', { name: '选择图层拼图素材' }),
).toBeTruthy();
expect(layersButton.getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
fireEvent.click(layersButton);
expect(screen.queryByRole('complementary', { name: '图片资源栏' })).toBeNull();
expect(layersButton.getAttribute('aria-pressed')).toBe('false');
});
it('groups assets by folder and renames sidebar materials', async () => {
const user = userEvent.setup();
render(<ImageCanvasEditorView />);
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
expect(within(sidebar).getByRole('region', { name: '项目素材' })).toBeTruthy();
expect(within(sidebar).getByRole('region', { name: '参考素材' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '重命名素材拼图素材' }));
const renameInput = screen.getByLabelText('重命名素材拼图素材');
await user.clear(renameInput);
await user.type(renameInput, '主视觉素材');
await user.click(screen.getByRole('button', { name: '保存素材拼图素材名称' }));
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
await user.click(screen.getByRole('button', { name: '添加主视觉素材' }));
expect(screen.getByAltText('画布图片:主视觉素材')).toBeTruthy();
});
it('collapses folders, creates upload folders, and deletes uploaded materials', async () => {
const user = userEvent.setup();
const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image');
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: createObjectUrlSpy,
});
render(<ImageCanvasEditorView />);
await user.click(screen.getByRole('button', { name: '折叠项目素材' }));
expect(screen.queryByRole('button', { name: '添加拼图素材' })).toBeNull();
await user.click(screen.getByRole('button', { name: '展开项目素材' }));
expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '新建素材文件夹' }));
await user.type(screen.getByLabelText('素材文件夹名称'), '角色上传');
await user.click(screen.getByRole('button', { name: '保存素材文件夹' }));
const uploadInput = screen.getByLabelText('上传图片文件');
await user.click(screen.getByRole('button', { name: '上传到角色上传' }));
await userEvent.upload(
uploadInput,
new File(['image'], '角色草图.png', { type: 'image/png' }),
);
await user.click(screen.getByRole('button', { name: '打开素材' }));
const customFolder = screen.getByRole('region', { name: '角色上传' });
expect(within(customFolder).getByRole('button', { name: '添加角色草图.png' })).toBeTruthy();
expect(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' })).toBeTruthy();
await user.click(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' }));
expect(screen.queryByRole('button', { name: '添加角色草图.png' })).toBeNull();
expect(screen.getByAltText('画布图片:角色草图.png')).toBeTruthy();
});
it('shows image size on hover and placeholder toolbar after selecting a layer', () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(<ImageCanvasEditorView />);
const canvasImage = screen.getByAltText('画布图片:拼图素材');
fireEvent.mouseEnter(canvasImage.closest('button')!);
expect(screen.getByText('420 x 420 px')).toBeTruthy();
fireEvent.pointerDown(canvasImage.closest('button')!, {
button: 0,
pointerId: 1,
clientX: 120,
clientY: 120,
});
const cropButton = screen.getByRole('button', { name: '裁剪占位' });
fireEvent.pointerDown(cropButton, {
button: 0,
pointerId: 2,
clientX: 120,
clientY: 96,
});
fireEvent.click(cropButton);
expect(alertSpy).toHaveBeenCalledWith('裁剪功能建设中');
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
});
it('treats puzzle material as a normal asset without generated metadata tools', () => {
render(<ImageCanvasEditorView />);
fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, {
button: 0,
pointerId: 61,
clientX: 120,
clientY: 120,
});
expect(screen.queryByRole('button', { name: '查看拼图素材元数据' })).toBeNull();
expect(screen.queryByRole('button', { name: '修改图片' })).toBeNull();
});
it('deletes the selected layer from the floating toolbar', () => {
render(<ImageCanvasEditorView />);
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, {
button: 0,
pointerId: 51,
clientX: 120,
clientY: 120,
});
fireEvent.click(screen.getByRole('button', { name: '删除图片' }));
expect(screen.queryByAltText('画布图片:拼图素材')).toBeNull();
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
});
it('uploads an image file as a new canvas layer', async () => {
const createObjectUrlSpy = vi.fn(() => 'blob:uploaded-image');
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: createObjectUrlSpy,
});
render(<ImageCanvasEditorView />);
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
expect(within(bottomToolbar).queryByRole('button', { name: '局部修改工具' })).toBeNull();
fireEvent.click(within(bottomToolbar).getByRole('button', { name: '上传工具' }));
await userEvent.upload(
screen.getByLabelText('上传图片文件'),
new File(['image'], '测试上传.png', { type: 'image/png' }),
);
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy();
expect(screen.getByRole('button', { name: '选择图层测试上传.png' })).toBeTruthy();
});
it('blocks the browser context menu inside the editor workspace', () => {
render(<ImageCanvasEditorView />);
const editor = screen.getByRole('region', { name: '图片画布编辑器' });
const contextMenuEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
const wasNotCanceled = editor.dispatchEvent(contextMenuEvent);
expect(wasNotCanceled).toBe(false);
expect(contextMenuEvent.defaultPrevented).toBe(true);
});
it('switches the shared sidebar between assets and layers', () => {
render(<ImageCanvasEditorView />);
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
expect(within(sidebar).getByText('素材')).toBeTruthy();
expect(within(sidebar).queryByText('已生成文件')).toBeNull();
expect(within(sidebar).queryByText('图层')).toBeNull();
expect(screen.queryByRole('toolbar', { name: '画布主工具栏' })).toBeNull();
expect(screen.queryByRole('complementary', { name: '图层面板' })).toBeNull();
expect(screen.queryByRole('dialog', { name: '已生成文件' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
const layersPanel = screen.getByRole('complementary', { name: '图片资源栏' });
expect(
within(layersPanel).getByRole('button', { name: '选择图层拼图素材' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '选择图层大鱼素材' }));
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '查看大鱼素材元数据' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
expect(screen.getByRole('button', { name: '添加拼图素材' })).toBeTruthy();
});
it('adds assets from the sidebar and supports zoom buttons', () => {
render(<ImageCanvasEditorView />);
expect(screen.getByRole('button', { name: '当前缩放比例 82%' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 82%' }));
fireEvent.click(screen.getByRole('menuitem', { name: '放大' }));
expect(screen.getByRole('button', { name: '当前缩放比例 95%' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
expect(screen.getByRole('complementary', { name: '图片资源栏' })).toBeTruthy();
});
it('offers Lovart-style zoom menu commands', async () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 82%' }));
expect(screen.getByRole('menu', { name: '缩放菜单' })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: '显示画布所有元素' })).toBeTruthy();
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' }));
expect(screen.getByRole('button', { name: '当前缩放比例 100%' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' }));
expect(screen.getByRole('button', { name: '当前缩放比例 50%' })).toBeTruthy();
});
it('opens a generation dialog before creating a generated layer', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '一张明亮的拼图主视觉',
actualPrompt: '一张明亮的拼图主视觉',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-real-task-1',
});
render(<ImageCanvasEditorView />);
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
fireEvent.click(within(bottomToolbar).getByRole('button', { name: '生成工具' }));
const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
expect(generateDialog).toBeTruthy();
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张明亮的拼图主视觉' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(screen.getByRole('status').textContent).toContain('生成中');
expect(generateEditorImageMock).toHaveBeenCalledWith({
prompt: '一张明亮的拼图主视觉',
});
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
});
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
const metadataButtons = screen.getAllByRole('button', {
name: /查看生成图片 .*元数据/,
});
expect(metadataButtons[0]).toBeTruthy();
});
it('shows generation errors instead of falling back to mock images', async () => {
generateEditorImageMock.mockRejectedValueOnce(new Error('VectorEngine 未配置'));
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张真实生成失败的图' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(screen.getByRole('status').textContent).toContain('生成中');
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toContain('VectorEngine 未配置');
});
expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull();
});
it('switches tools and restores the previous tool after holding Space', async () => {
const user = userEvent.setup();
render(<ImageCanvasEditorView />);
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
const selectTool = within(bottomToolbar).getByRole('button', { name: '选择工具' });
const textTool = within(bottomToolbar).getByRole('button', { name: '文字工具' });
const handTool = within(bottomToolbar).getByRole('button', { name: '抓手工具' });
expect(selectTool.getAttribute('aria-pressed')).toBe('true');
await user.click(textTool);
expect(textTool.getAttribute('aria-pressed')).toBe('true');
fireEvent.keyDown(window, { code: 'Space', key: ' ' });
expect(handTool.getAttribute('aria-pressed')).toBe('true');
fireEvent.keyUp(window, { code: 'Space', key: ' ' });
expect(textTool.getAttribute('aria-pressed')).toBe('true');
});
it('switches away from hand tool from the bottom toolbar', async () => {
const user = userEvent.setup();
render(<ImageCanvasEditorView />);
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
const handTool = within(bottomToolbar).getByRole('button', { name: '抓手工具' });
const textTool = within(bottomToolbar).getByRole('button', { name: '文字工具' });
await user.click(handTool);
expect(handTool.getAttribute('aria-pressed')).toBe('true');
await user.click(textTool);
expect(textTool.getAttribute('aria-pressed')).toBe('true');
expect(handTool.getAttribute('aria-pressed')).toBe('false');
});
it('pans with the middle mouse button without leaving select mode', async () => {
render(<ImageCanvasEditorView />);
const viewport = screen.getByLabelText('画布工作区');
const middlePointerDown = new MouseEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 1,
buttons: 4,
clientX: 260,
clientY: 220,
});
Object.defineProperty(middlePointerDown, 'pointerId', { value: 11 });
fireEvent(viewport, middlePointerDown);
await waitFor(() => {
expect(viewport.className).toContain('image-canvas-editor__viewport--panning');
});
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
expect(
within(bottomToolbar)
.getByRole('button', { name: '选择工具' })
.getAttribute('aria-pressed'),
).toBe('true');
});
it('shows snap guides when dragging a layer near another layer alignment', async () => {
render(<ImageCanvasEditorView />);
const puzzleLayer = screen.getByAltText('画布图片:拼图素材').closest('button')!;
dispatchPointerEvent(puzzleLayer, 'pointerdown', {
button: 0,
pointerId: 21,
clientX: 120,
clientY: 120,
});
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
pointerId: 21,
clientX: 499,
clientY: 169,
});
expect(screen.getByTestId('image-canvas-editor-snap-guide-vertical')).toBeTruthy();
expect(screen.getByTestId('image-canvas-editor-snap-guide-horizontal')).toBeTruthy();
});
it('can switch tools after a layer drag started without pointer release', async () => {
const user = userEvent.setup();
render(<ImageCanvasEditorView />);
fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, {
button: 0,
pointerId: 41,
clientX: 120,
clientY: 120,
});
fireEvent.pointerMove(screen.getByLabelText('画布工作区'), {
pointerId: 41,
clientX: 220,
clientY: 160,
});
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
const textTool = within(bottomToolbar).getByRole('button', { name: '文字工具' });
await user.click(textTool);
expect(textTool.getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByTestId('image-canvas-editor-snap-guide-vertical')).toBeNull();
});
it('opens generated image metadata from the corner button and creates a real right-side edit result', async () => {
generateEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '一张可修改的生成图',
actualPrompt: '一张可修改的生成图',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-real-task-2',
});
editEditorImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,ZWRpdGVkLWltYWdl',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '把画面改成黄昏光线',
actualPrompt: '把画面改成黄昏光线',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-real-edit-1',
});
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张可修改的生成图' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
});
const metadataCornerButton = screen.getAllByRole('button', {
name: /查看生成图片 .*元数据/,
})[0];
if (!metadataCornerButton) {
throw new Error('metadata corner button should exist');
}
fireEvent.click(metadataCornerButton);
const metadataDialog = screen.getByRole('dialog', { name: /生成图片 .*元数据/ });
expect(metadataDialog).toBeTruthy();
expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '修改图片' }));
const editDialog = screen.getByRole('dialog', { name: '修改图片' });
expect(editDialog).toBeTruthy();
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '把画面改成黄昏光线' },
});
fireEvent.click(screen.getByRole('button', { name: '修改' }));
expect(screen.getByRole('status').textContent).toContain('修改中');
expect(editEditorImageMock).toHaveBeenCalledWith({
prompt: '把画面改成黄昏光线',
sourceImageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
});
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '修改图片' })).toBeNull();
});
expect(screen.getByAltText(/画布图片:生成图片 .* 修改结果/)).toBeTruthy();
expect(screen.getByRole('button', { name: '当前缩放比例 100%' })).toBeTruthy();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { Loader2 } from 'lucide-react';
import { Image as ImageIcon, Loader2 } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import {
type Dispatch,
@@ -1077,6 +1077,13 @@ const CustomWorldGenerationView = lazy(async () => {
};
});
const ImageCanvasEditorView = lazy(async () => {
const module = await import('../image-editor/ImageCanvasEditorView');
return {
default: module.ImageCanvasEditorView,
};
});
const UnifiedCreationWorkspace = lazy(async () => {
const module = await import('../unified-creation/UnifiedCreationWorkspace');
return {
@@ -4791,16 +4798,22 @@ export function PlatformEntryFlowShellImpl({
() => pendingPlatformTaskCompletionDialog,
[pendingPlatformTaskCompletionDialog],
);
const activePlatformTaskCompletionDialog = resolveActivePlatformDialog(
currentPlatformTaskCompletionDialog,
dismissedPlatformTaskCompletionDialogKey,
buildPlatformTaskCompletionDialogDismissKey,
);
const activePlatformErrorDialog = resolveActivePlatformDialog(
currentPlatformErrorDialog,
dismissedPlatformErrorDialogKey,
buildPlatformErrorDialogDismissKey,
);
const activePlatformTaskCompletionDialog =
selectionStage === 'image-editor'
? null
: resolveActivePlatformDialog(
currentPlatformTaskCompletionDialog,
dismissedPlatformTaskCompletionDialogKey,
buildPlatformTaskCompletionDialogDismissKey,
);
const activePlatformErrorDialog =
selectionStage === 'image-editor'
? null
: resolveActivePlatformDialog(
currentPlatformErrorDialog,
dismissedPlatformErrorDialogKey,
buildPlatformErrorDialogDismissKey,
);
const closePlatformErrorDialog = useCallback(() => {
if (!currentPlatformErrorDialog) {
return;
@@ -14558,9 +14571,24 @@ export function PlatformEntryFlowShellImpl({
) : null}
</Suspense>
);
const creationStartContent = renderCreationHubContent(
'start-only',
'正在加载创作大厅...',
const creationStartContent = (
<div className="image-editor-creation-entry-stack">
<button
type="button"
className="image-editor-creation-entry"
onClick={() => setSelectionStage('image-editor')}
aria-label="打开图片编辑器"
>
<span className="image-editor-creation-entry__icon">
<ImageIcon className="h-5 w-5" />
</span>
<span className="image-editor-creation-entry__body">
<span></span>
<span></span>
</span>
</button>
{renderCreationHubContent('start-only', '正在加载创作大厅...')}
</div>
);
const draftHubContent = renderCreationHubContent(
'works-only',
@@ -14570,6 +14598,21 @@ export function PlatformEntryFlowShellImpl({
return (
<>
<AnimatePresence mode="wait">
{selectionStage === 'image-editor' && (
<motion.div
key="image-editor"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="image-editor-stage-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载编辑器..." />}
>
<ImageCanvasEditorView />
</Suspense>
</motion.div>
)}
{selectionStage === 'platform' && (
<motion.div
key="platform-home"

View File

@@ -1,6 +1,4 @@
import type {
CustomWorldAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile } from '../../types';
@@ -15,6 +13,7 @@ export type CustomWorldRuntimeLaunchOptions = {
export type SelectionStage =
| 'platform'
| 'image-editor'
| 'profile-feedback'
| 'work-detail'
| 'detail'
@@ -70,7 +69,10 @@ export type SelectionStage =
export type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null;
export type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null;
export type CustomWorldResultViewSource =
| 'saved-profile'
| 'agent-draft'
| null;
export type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';

View File

@@ -2,6 +2,7 @@ import type { SelectionStage } from './platformEntryTypes';
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
platform: true,
'image-editor': true,
'profile-feedback': false,
'work-detail': true,
detail: true,

View File

@@ -3034,6 +3034,911 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
color: var(--platform-text-strong);
}
.image-editor-stage-shell {
background: #ffffff;
}
.image-canvas-editor {
position: relative;
display: flex;
min-height: 0;
min-width: 0;
height: 100%;
gap: 0;
overflow: hidden;
background: #ffffff;
color: #1f2937;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.image-editor-creation-entry-stack {
display: grid;
min-width: 0;
gap: 0.8rem;
}
.image-editor-creation-entry {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 4.4rem;
width: 100%;
border: 1px solid rgba(75, 181, 170, 0.22);
border-radius: 0.5rem;
background: linear-gradient(
135deg,
rgba(75, 181, 170, 0.16),
rgba(223, 161, 94, 0.1)
),
var(--platform-subpanel-fill);
padding: 0.7rem;
color: var(--platform-text-strong);
text-align: left;
box-shadow: 0 16px 32px rgba(43, 68, 64, 0.12);
transition:
transform 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease;
}
.image-editor-creation-entry:hover {
transform: translateY(-1px);
border-color: rgba(75, 181, 170, 0.5);
box-shadow: 0 20px 38px rgba(43, 68, 64, 0.16);
}
.image-editor-creation-entry__icon {
display: grid;
width: 2.7rem;
height: 2.7rem;
flex-shrink: 0;
place-items: center;
border-radius: 0.45rem;
background: rgba(75, 181, 170, 0.18);
color: #238a82;
}
.image-editor-creation-entry__body {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.image-editor-creation-entry__body span:first-child {
font-size: 0.98rem;
font-weight: 900;
}
.image-editor-creation-entry__body span + span {
color: var(--platform-text-soft);
font-size: 0.76rem;
font-weight: 760;
}
.image-canvas-editor__sidebar {
display: flex;
width: min(18.5rem, 34vw);
min-width: 15.5rem;
flex-direction: column;
overflow: hidden;
border-right: 1px solid #d9dee8;
background: #ffffff;
}
.image-canvas-editor__sidebar-header {
display: flex;
min-height: 3.4rem;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 1px solid #d9dee8;
padding: 0.65rem;
}
.image-canvas-editor__sidebar-title,
.image-canvas-editor__title-block h1 {
margin: 0;
font-size: 0.9rem;
font-weight: 850;
letter-spacing: 0;
}
.image-canvas-editor__sidebar-count,
.image-canvas-editor__title-block span {
display: block;
color: #64748b;
font-size: 0.72rem;
font-weight: 750;
}
.image-canvas-editor__icon-button,
.image-canvas-editor__zoom-trigger,
.image-canvas-editor__zoom-menu button,
.image-canvas-editor__floating-toolbar button,
.image-canvas-editor__bottom-toolbar button,
.image-canvas-editor__metadata-header button,
.image-canvas-editor__reset-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #d9dee8;
background: #ffffff;
color: #334155;
transition:
transform 160ms ease,
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease;
}
.image-canvas-editor__icon-button:hover,
.image-canvas-editor__zoom-trigger:hover,
.image-canvas-editor__zoom-menu button:hover,
.image-canvas-editor__floating-toolbar button:hover,
.image-canvas-editor__bottom-toolbar button:hover,
.image-canvas-editor__metadata-header button:hover,
.image-canvas-editor__reset-button:hover {
transform: translateY(-1px);
border-color: #8fb8ff;
background: #eef5ff;
color: #1d4ed8;
}
.image-canvas-editor__icon-button {
width: 2.2rem;
height: 2.2rem;
flex-shrink: 0;
border-radius: 0.45rem;
}
.image-canvas-editor__asset-list {
display: grid;
min-height: 0;
align-content: start;
gap: 0.7rem;
overflow-y: auto;
padding: 0.65rem;
}
.image-canvas-editor__asset-folder {
display: grid;
gap: 0.45rem;
}
.image-canvas-editor__asset-folder-header {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.35rem;
color: #475569;
font-size: 0.76rem;
font-weight: 850;
}
.image-canvas-editor__asset-folder-header button {
display: inline-flex;
width: 1.8rem;
height: 1.8rem;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 0.36rem;
background: transparent;
color: #64748b;
}
.image-canvas-editor__asset-folder-header button:hover {
border-color: #d9dee8;
background: #f8fafc;
color: #1d4ed8;
}
.image-canvas-editor__asset-folder-header span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__asset-folder-header span + span {
color: #94a3b8;
font-size: 0.7rem;
font-weight: 820;
}
.image-canvas-editor__asset-folder-list {
display: grid;
gap: 0.5rem;
}
.image-canvas-editor__asset-row {
display: grid;
grid-template-columns: 4.4rem minmax(0, 1fr) auto;
min-height: 5.1rem;
gap: 0.55rem;
align-items: center;
border: 1px solid #e2e8f0;
border-radius: 0.45rem;
background: #f8fafc;
padding: 0.45rem;
color: #1f2937;
transition:
transform 160ms ease,
border-color 160ms ease,
background-color 160ms ease;
}
.image-canvas-editor__asset-row:hover {
transform: translateY(-1px);
border-color: #8fb8ff;
background: #eef5ff;
}
.image-canvas-editor__asset-button {
display: block;
border: 0;
background: transparent;
padding: 0;
}
.image-canvas-editor__asset-thumb {
display: block;
overflow: hidden;
aspect-ratio: 1;
border-radius: 0.35rem;
background: linear-gradient(
45deg,
rgba(148, 163, 184, 0.18) 25%,
transparent 25%
),
linear-gradient(-45deg, rgba(148, 163, 184, 0.18) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(148, 163, 184, 0.18) 75%),
linear-gradient(-45deg, transparent 75%, rgba(148, 163, 184, 0.18) 75%);
background-position:
0 0,
0 0.5rem,
0.5rem -0.5rem,
-0.5rem 0;
background-size: 1rem 1rem;
}
.image-canvas-editor__asset-thumb img,
.image-canvas-editor__layer img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-canvas-editor__asset-meta {
display: grid;
min-width: 0;
gap: 0.2rem;
font-size: 0.84rem;
font-weight: 820;
}
.image-canvas-editor__asset-meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__asset-meta input {
min-width: 0;
height: 1.8rem;
border: 1px solid #8fb8ff;
border-radius: 0.35rem;
background: #ffffff;
padding: 0 0.45rem;
color: #1f2937;
font: inherit;
font-size: 0.78rem;
outline: none;
}
.image-canvas-editor__asset-actions {
display: inline-flex;
gap: 0.22rem;
}
.image-canvas-editor__asset-actions button,
.image-canvas-editor__folder-create button {
display: inline-flex;
width: 1.9rem;
height: 1.9rem;
align-items: center;
justify-content: center;
border: 1px solid #d9dee8;
border-radius: 0.42rem;
background: #ffffff;
color: #475569;
}
.image-canvas-editor__asset-actions button:hover,
.image-canvas-editor__folder-create button:hover {
border-color: #8fb8ff;
background: #ffffff;
color: #1d4ed8;
}
.image-canvas-editor__folder-create {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.35rem;
border: 1px solid #d9dee8;
border-radius: 0.45rem;
background: #f8fafc;
padding: 0.4rem;
}
.image-canvas-editor__folder-create input {
min-width: 0;
height: 2rem;
border: 1px solid #8fb8ff;
border-radius: 0.35rem;
background: #ffffff;
padding: 0 0.5rem;
color: #1f2937;
font: inherit;
font-size: 0.78rem;
font-weight: 800;
outline: none;
}
.image-canvas-editor__asset-meta span + span {
color: #64748b;
font-size: 0.72rem;
font-weight: 760;
}
.image-canvas-editor__layers-list {
display: grid;
min-height: 0;
align-content: start;
gap: 0.45rem;
overflow-y: auto;
padding: 0.65rem;
}
.image-canvas-editor__layer-row {
display: grid;
grid-template-columns: 2.7rem minmax(0, 1fr);
min-height: 3.45rem;
align-items: center;
gap: 0.55rem;
border: 1px solid #e2e8f0;
border-radius: 0.45rem;
background: #f8fafc;
padding: 0.38rem;
color: #1f2937;
text-align: left;
transition:
border-color 160ms ease,
background-color 160ms ease;
}
.image-canvas-editor__layer-row:hover,
.image-canvas-editor__layer-row--selected {
border-color: #8fb8ff;
background: #eef5ff;
}
.image-canvas-editor__layer-row-thumb {
display: block;
overflow: hidden;
width: 2.7rem;
height: 2.7rem;
border-radius: 0.35rem;
background: #e2e8f0;
}
.image-canvas-editor__layer-row-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-canvas-editor__layer-row-meta {
display: grid;
min-width: 0;
gap: 0.18rem;
font-size: 0.78rem;
font-weight: 820;
}
.image-canvas-editor__layer-row-meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-canvas-editor__layer-row-meta span + span {
color: #64748b;
font-size: 0.68rem;
font-weight: 760;
}
.image-canvas-editor__main {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
background: #ffffff;
}
.image-canvas-editor__topbar {
display: flex;
min-height: 3.4rem;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 1px solid #d9dee8;
background: #ffffff;
padding: 0.55rem 0.7rem;
}
.image-canvas-editor__zoom-menu-wrap {
position: relative;
z-index: 20;
display: inline-flex;
align-items: center;
}
.image-canvas-editor__zoom-trigger {
min-width: 4.15rem;
height: 2.25rem;
border-radius: 0.45rem;
padding: 0 0.65rem;
font-size: 0.78rem;
font-weight: 850;
}
.image-canvas-editor__zoom-menu {
position: absolute;
top: calc(100% + 0.45rem);
right: 0;
display: grid;
min-width: 12rem;
gap: 0.2rem;
border: 1px solid #d9dee8;
border-radius: 0.5rem;
background: #ffffff;
padding: 0.35rem;
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.16);
}
.image-canvas-editor__zoom-menu button {
justify-content: flex-start;
min-height: 2rem;
width: 100%;
border-radius: 0.38rem;
padding: 0 0.65rem;
font-size: 0.76rem;
font-weight: 820;
}
.image-canvas-editor__floating-toolbar button,
.image-canvas-editor__bottom-toolbar button,
.image-canvas-editor__metadata-header button,
.image-canvas-editor__reset-button {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.45rem;
}
.image-canvas-editor__viewport {
position: relative;
min-width: 0;
min-height: 0;
flex: 1;
overflow: hidden;
cursor: default;
background-color: #f8fafc;
touch-action: none;
}
.image-canvas-editor__viewport--panning {
cursor: grabbing;
}
.image-canvas-editor__viewport--tool-hand {
cursor: grab;
}
.image-canvas-editor__world {
position: absolute;
left: 0;
top: 0;
transform-origin: 0 0;
}
.image-canvas-editor__layer {
position: absolute;
display: block;
overflow: visible;
border: 2px solid transparent;
border-radius: 0.15rem;
background: transparent;
padding: 0;
cursor: move;
}
.image-canvas-editor__layer img {
display: block;
border-radius: 0.1rem;
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.18);
pointer-events: none;
}
.image-canvas-editor__layer--hovered,
.image-canvas-editor__layer--selected {
border-color: #4bb5aa;
}
.image-canvas-editor__layer--selected {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.9);
}
.image-canvas-editor__metadata-corner {
position: absolute;
right: 0.35rem;
top: 0.35rem;
z-index: 2;
display: grid;
width: 1.35rem;
height: 1.35rem;
place-items: center;
border-radius: 999px;
background: rgba(16, 15, 14, 0.78);
color: #ffffff;
}
.image-canvas-editor__metadata-corner:hover {
background: rgba(3, 105, 161, 0.88);
}
.image-canvas-editor__size-badge {
position: absolute;
left: 0.35rem;
top: 0.35rem;
z-index: 2;
border-radius: 0.3rem;
background: rgba(16, 15, 14, 0.82);
padding: 0.18rem 0.35rem;
color: #ffffff;
font-size: 0.72rem;
font-weight: 850;
line-height: 1.2;
white-space: nowrap;
}
.image-canvas-editor__floating-toolbar {
position: absolute;
z-index: 8;
display: inline-flex;
gap: 0.3rem;
border: 1px solid #d9dee8;
border-radius: 0.5rem;
background: #ffffff;
padding: 0.28rem;
box-shadow: 0 18px 34px rgba(15, 23, 42, 0.18);
transform: translate(-50%, -100%);
}
.image-canvas-editor__reset-button {
position: absolute;
right: 0.85rem;
bottom: 0.85rem;
z-index: 9;
}
.image-canvas-editor__panel-dock {
position: absolute;
left: 0.85rem;
bottom: 0.85rem;
z-index: 11;
display: inline-flex;
gap: 0.35rem;
border: 1px solid #d9dee8;
border-radius: 0.5rem;
background: #ffffff;
padding: 0.35rem;
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.14);
}
.image-canvas-editor__panel-dock button {
display: inline-flex;
width: 2.25rem;
height: 2.25rem;
align-items: center;
justify-content: center;
border: 1px solid #d9dee8;
border-radius: 0.45rem;
background: #ffffff;
color: #334155;
transition:
transform 160ms ease,
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease;
}
.image-canvas-editor__panel-dock button:hover,
.image-canvas-editor__panel-dock button[aria-pressed='true'] {
transform: translateY(-1px);
border-color: #38bdf8;
background: #e0f2fe;
color: #0369a1;
}
.image-canvas-editor__bottom-toolbar {
position: absolute;
left: 50%;
bottom: 0.85rem;
z-index: 10;
display: inline-flex;
gap: 0.35rem;
max-width: min(calc(100% - 6.6rem), 34rem);
overflow-x: auto;
border: 1px solid #d9dee8;
border-radius: 0.5rem;
background: #ffffff;
padding: 0.35rem;
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.16);
transform: translateX(-50%);
}
.image-canvas-editor__bottom-toolbar button[aria-pressed='true'] {
border-color: #38bdf8;
background: #e0f2fe;
color: #0369a1;
}
.image-canvas-editor__snap-guide {
position: absolute;
z-index: 7;
pointer-events: none;
background: #4bb5aa;
box-shadow: 0 0 0 1px rgba(75, 181, 170, 0.28);
}
.image-canvas-editor__snap-guide--vertical {
top: 0;
width: 2px;
height: 100%;
transform: translateX(-1px);
}
.image-canvas-editor__snap-guide--horizontal {
left: 0;
width: 100%;
height: 2px;
transform: translateY(-1px);
}
.image-canvas-editor__rulers {
position: absolute;
z-index: 4;
pointer-events: none;
background: rgba(241, 245, 249, 0.92);
}
.image-canvas-editor__rulers--top {
left: 0;
right: 0;
top: 0;
height: 1.25rem;
border-bottom: 1px solid #d9dee8;
}
.image-canvas-editor__rulers--left {
left: 0;
top: 0;
bottom: 0;
width: 1.25rem;
border-right: 1px solid #d9dee8;
}
.image-canvas-editor__modal-backdrop {
position: fixed;
inset: 0;
z-index: 120;
display: grid;
place-items: center;
background: rgba(8, 7, 6, 0.58);
padding: 1rem;
}
.image-canvas-editor__metadata-dialog {
width: min(28rem, calc(100vw - 2rem));
max-height: min(32rem, calc(100vh - 2rem));
overflow: auto;
border: 1px solid #d9dee8;
border-radius: 0.5rem;
background: #ffffff;
color: #1f2937;
box-shadow: 0 26px 70px rgba(15, 23, 42, 0.22);
}
.image-canvas-editor__generate-dialog {
width: min(30rem, calc(100vw - 2rem));
overflow: hidden;
border: 1px solid #d9dee8;
border-radius: 0.5rem;
background: #ffffff;
color: #1f2937;
box-shadow: 0 26px 70px rgba(15, 23, 42, 0.22);
}
.image-canvas-editor__metadata-header {
display: flex;
min-height: 3rem;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 1px solid #d9dee8;
padding: 0.55rem 0.7rem;
}
.image-canvas-editor__metadata-header h2 {
margin: 0;
font-size: 0.9rem;
font-weight: 850;
letter-spacing: 0;
}
.image-canvas-editor__generate-body {
display: grid;
gap: 0.7rem;
padding: 0.85rem;
}
.image-canvas-editor__generate-body textarea {
min-height: 7rem;
resize: vertical;
border: 1px solid #d9dee8;
border-radius: 0.45rem;
background: #f8fafc;
padding: 0.7rem;
color: #1f2937;
font: inherit;
font-size: 0.82rem;
font-weight: 720;
outline: none;
}
.image-canvas-editor__generate-body textarea:focus {
border-color: #38bdf8;
background: #ffffff;
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18);
}
.image-canvas-editor__generate-status {
border-radius: 0.45rem;
background: #eef5ff;
padding: 0.55rem 0.65rem;
color: #0369a1;
font-size: 0.78rem;
font-weight: 850;
}
.image-canvas-editor__generate-status--error {
background: #fee2e2;
color: #b91c1c;
}
.image-canvas-editor__generate-submit {
justify-self: end;
min-width: 5.5rem;
min-height: 2.25rem;
border: 1px solid #38bdf8;
border-radius: 0.45rem;
background: #0ea5e9;
color: #ffffff;
font-size: 0.8rem;
font-weight: 850;
}
.image-canvas-editor__generate-submit:disabled {
cursor: wait;
opacity: 0.68;
}
.image-canvas-editor__metadata-grid {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr);
gap: 0.55rem 0.75rem;
margin: 0;
padding: 0.85rem;
font-size: 0.78rem;
}
.image-canvas-editor__metadata-grid dt {
color: #64748b;
font-weight: 820;
}
.image-canvas-editor__metadata-grid dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #1f2937;
font-weight: 760;
}
@media (max-width: 760px) {
.image-canvas-editor {
flex-direction: column;
gap: 0;
}
.image-canvas-editor__sidebar {
width: 100%;
min-width: 0;
max-height: 14rem;
flex-shrink: 0;
border-right: 0;
border-bottom: 1px solid #d9dee8;
}
.image-canvas-editor__asset-list {
grid-auto-flow: column;
grid-auto-columns: minmax(15rem, 78vw);
overflow-x: auto;
overflow-y: hidden;
}
.image-canvas-editor__layers-list {
grid-auto-flow: column;
grid-auto-columns: minmax(12.5rem, 70vw);
overflow-x: auto;
overflow-y: hidden;
}
.image-canvas-editor__main {
min-height: 0;
flex: 1;
}
.image-canvas-editor__topbar {
min-height: 3.2rem;
}
.image-canvas-editor__title-block h1 {
font-size: 0.84rem;
}
.image-canvas-editor__floating-toolbar button,
.image-canvas-editor__bottom-toolbar button,
.image-canvas-editor__reset-button {
width: 2rem;
height: 2rem;
}
.image-canvas-editor__floating-toolbar {
max-width: calc(100% - 1.5rem);
overflow-x: auto;
}
.image-canvas-editor__bottom-toolbar {
left: 0.75rem;
right: 0.75rem;
max-width: none;
transform: none;
}
.image-canvas-editor__panel-dock {
left: 0.75rem;
bottom: 3.85rem;
}
}
.platform-bottom-nav {
position: relative;
box-sizing: border-box;

View File

@@ -21,6 +21,12 @@ describe('appPageRoutes', () => {
);
});
it('resolves the image editor route', () => {
expect(resolveSelectionStageFromPath('/editor')).toBe('image-editor');
expect(resolveSelectionStageFromPath('/EDITOR/')).toBe('image-editor');
expect(resolvePathForSelectionStage('image-editor')).toBe('/editor');
});
it('resolves jump-hop creation, gallery and runtime routes', () => {
expect(resolveSelectionStageFromPath('/creation/jump-hop')).toBe(
'jump-hop-workspace',
@@ -58,9 +64,9 @@ describe('appPageRoutes', () => {
expect(resolveSelectionStageFromPath('/creation/puzzle-clear')).toBe(
'puzzle-clear-workspace',
);
expect(resolveSelectionStageFromPath('/creation/puzzle-clear/generating')).toBe(
'puzzle-clear-generating',
);
expect(
resolveSelectionStageFromPath('/creation/puzzle-clear/generating'),
).toBe('puzzle-clear-generating');
expect(resolveSelectionStageFromPath('/creation/puzzle-clear/result')).toBe(
'puzzle-clear-result',
);
@@ -85,9 +91,9 @@ describe('appPageRoutes', () => {
expect(resolveSelectionStageFromPath('/creation/wooden-fish')).toBe(
'wooden-fish-workspace',
);
expect(resolveSelectionStageFromPath('/creation/wooden-fish/generating')).toBe(
'wooden-fish-generating',
);
expect(
resolveSelectionStageFromPath('/creation/wooden-fish/generating'),
).toBe('wooden-fish-generating');
expect(resolveSelectionStageFromPath('/creation/wooden-fish/result')).toBe(
'wooden-fish-result',
);

View File

@@ -11,6 +11,7 @@ export const PUBLIC_WORK_QUERY_PARAM = 'work';
const STAGE_ROUTE_ENTRIES = [
['platform', '/'],
['image-editor', '/editor'],
['profile-feedback', '/profile/feedback'],
['work-detail', '/works/detail'],
['detail', '/worlds/detail'],

View File

@@ -0,0 +1,251 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createEditorProject,
createEditorProjectResource,
editEditorImage,
generateEditorImage,
loadOrCreateRecentEditorProject,
saveEditorProjectLayout,
} from './editorProjectClient';
const requestJsonMock = vi.hoisted(() => vi.fn());
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('editorProjectClient', () => {
afterEach(() => {
requestJsonMock.mockReset();
});
it('loads the recent project without creating a duplicate when it exists', async () => {
requestJsonMock.mockResolvedValueOnce({
project: {
projectId: 'editor-project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
},
});
const project = await loadOrCreateRecentEditorProject();
expect(project.projectId).toBe('editor-project-1');
expect(requestJsonMock).toHaveBeenCalledTimes(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/projects/recent',
{ method: 'GET' },
'读取图片画布工程失败',
expect.objectContaining({ authImpact: 'local' }),
);
});
it('creates a default project when there is no recent project', async () => {
requestJsonMock
.mockResolvedValueOnce({ project: null })
.mockResolvedValueOnce({
project: {
projectId: 'editor-project-created',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
},
});
const project = await loadOrCreateRecentEditorProject();
expect(project.projectId).toBe('editor-project-created');
expect(requestJsonMock).toHaveBeenNthCalledWith(
2,
'/api/editor/projects',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: '未命名画布' }),
}),
'创建图片画布工程失败',
expect.objectContaining({ authImpact: 'local' }),
);
});
it('saves viewport and layer layout through the project API', async () => {
requestJsonMock.mockResolvedValueOnce({
project: {
projectId: 'editor-project-1',
title: '未命名画布',
viewport: { x: 12, y: 24, scale: 0.5 },
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
},
});
await saveEditorProjectLayout('editor-project-1', {
viewport: { x: 12, y: 24, scale: 0.5 },
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/projects/editor-project-1',
expect.objectContaining({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
viewport: { x: 12, y: 24, scale: 0.5 },
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
}),
}),
'保存图片画布工程失败',
expect.objectContaining({ authImpact: 'local' }),
);
});
it('creates a resource with upload or generated metadata', async () => {
requestJsonMock.mockResolvedValueOnce({
resource: {
resourceId: 'resource-generated-1',
projectId: 'editor-project-1',
imageSrc: '/generated-editor-assets/project/image.png',
objectKey: 'generated-editor-assets/project/image.png',
width: 2048,
height: 2048,
sourceType: 'generated',
prompt: 'dragon knight',
model: 'gpt-image-2',
},
});
await createEditorProjectResource('editor-project-1', {
imageSrc: '/generated-editor-assets/project/image.png',
objectKey: 'generated-editor-assets/project/image.png',
width: 2048,
height: 2048,
sourceType: 'generated',
prompt: 'dragon knight',
model: 'gpt-image-2',
sourceResourceId: 'resource-source',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/projects/editor-project-1/resources',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageSrc: '/generated-editor-assets/project/image.png',
objectKey: 'generated-editor-assets/project/image.png',
width: 2048,
height: 2048,
sourceType: 'generated',
prompt: 'dragon knight',
model: 'gpt-image-2',
sourceResourceId: 'resource-source',
}),
}),
'创建图片画布资源失败',
expect.objectContaining({ authImpact: 'local' }),
);
});
it('creates an explicit project from title input', async () => {
requestJsonMock.mockResolvedValueOnce({
project: {
projectId: 'editor-project-explicit',
title: '角色设定板',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
},
});
await createEditorProject({ title: '角色设定板' });
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/projects',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: '角色设定板' }),
}),
'创建图片画布工程失败',
expect.objectContaining({ authImpact: 'local' }),
);
});
it('generates editor images through the backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,abc',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '一张画布图片',
actualPrompt: '一张画布图片',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'vector-task-1',
});
const result = await generateEditorImage({ prompt: '一张画布图片' });
expect(result.taskId).toBe('vector-task-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/images/generations',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: '一张画布图片' }),
}),
'生成图片失败',
expect.objectContaining({
authImpact: 'local',
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
}),
);
});
it('edits editor images through the backend BFF', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,edited',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '把画面改成黄昏光线',
actualPrompt: '把画面改成黄昏光线',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'vector-edit-1',
});
const result = await editEditorImage({
prompt: '把画面改成黄昏光线',
sourceImageSrc: 'data:image/png;base64,source',
});
expect(result.taskId).toBe('vector-edit-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/images/edits',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: '把画面改成黄昏光线',
sourceImageSrc: 'data:image/png;base64,source',
}),
}),
'修改图片失败',
expect.objectContaining({
authImpact: 'local',
timeoutMs: 1_200_000,
retry: { maxRetries: 0 },
}),
);
});
});

View File

@@ -0,0 +1,209 @@
import { requestJson } from '../apiClient';
const EDITOR_PROJECT_API_BASE = '/api/editor/projects';
const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
const DEFAULT_PROJECT_TITLE = '未命名画布';
const EDITOR_PROJECT_REQUEST_OPTIONS = {
authImpact: 'local' as const,
};
export type EditorCanvasViewport = {
x: number;
y: number;
scale: number;
};
export type EditorProjectLayerSnapshot = Record<string, unknown> & {
layerId: string;
resourceId: string;
};
export type EditorProjectResourceSourceType =
| 'uploaded'
| 'generated'
| 'mock_generated';
export type EditorProjectResourceSnapshot = {
resourceId: string;
projectId: string;
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
sourceType: EditorProjectResourceSourceType;
prompt?: string | null;
actualPrompt?: string | null;
model?: string | null;
provider?: string | null;
taskId?: string | null;
sourceResourceId?: string | null;
createdAt?: string;
updatedAt?: string;
};
export type EditorImageGenerationInput = {
prompt: string;
};
export type EditorImageEditInput = {
prompt: string;
sourceImageSrc: string;
};
export type EditorImageGenerationResult = {
imageSrc: string;
width: number;
height: number;
sourceType: 'generated';
prompt: string;
actualPrompt?: string | null;
model: string;
provider: string;
taskId: string;
};
export type EditorProjectSnapshot = {
projectId: string;
title: string;
viewport: EditorCanvasViewport;
layers: EditorProjectLayerSnapshot[];
resources: EditorProjectResourceSnapshot[];
updatedAt: string;
};
export type EditorProjectCreateInput = {
title?: string;
};
export type EditorProjectLayoutSaveInput = {
viewport: EditorCanvasViewport;
layers: EditorProjectLayerSnapshot[];
};
export type EditorProjectResourceCreateInput = {
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
sourceType: EditorProjectResourceSourceType;
prompt?: string | null;
actualPrompt?: string | null;
model?: string | null;
provider?: string | null;
taskId?: string | null;
sourceResourceId?: string | null;
};
type EditorProjectResponse = {
project: EditorProjectSnapshot;
};
type EditorProjectRecentResponse = {
project: EditorProjectSnapshot | null;
};
type EditorProjectResourceResponse = {
resource: EditorProjectResourceSnapshot;
};
type EditorImageGenerationResponse = EditorImageGenerationResult;
function jsonRequest(method: 'POST' | 'PATCH', body: Record<string, unknown>) {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
};
}
export async function loadRecentEditorProject() {
return requestJson<EditorProjectRecentResponse>(
`${EDITOR_PROJECT_API_BASE}/recent`,
{ method: 'GET' },
'读取图片画布工程失败',
EDITOR_PROJECT_REQUEST_OPTIONS,
);
}
export async function createEditorProject(input: EditorProjectCreateInput = {}) {
const response = await requestJson<EditorProjectResponse>(
EDITOR_PROJECT_API_BASE,
jsonRequest('POST', { title: input.title?.trim() || DEFAULT_PROJECT_TITLE }),
'创建图片画布工程失败',
EDITOR_PROJECT_REQUEST_OPTIONS,
);
return response.project;
}
export async function loadOrCreateRecentEditorProject() {
const response = await loadRecentEditorProject();
if (response.project) {
return response.project;
}
return createEditorProject({ title: DEFAULT_PROJECT_TITLE });
}
export async function saveEditorProjectLayout(
projectId: string,
input: EditorProjectLayoutSaveInput,
) {
const response = await requestJson<EditorProjectResponse>(
`${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`,
jsonRequest('PATCH', {
viewport: input.viewport,
layers: input.layers,
}),
'保存图片画布工程失败',
EDITOR_PROJECT_REQUEST_OPTIONS,
);
return response.project;
}
export async function createEditorProjectResource(
projectId: string,
input: EditorProjectResourceCreateInput,
) {
const response = await requestJson<EditorProjectResourceResponse>(
`${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}/resources`,
jsonRequest('POST', { ...input }),
'创建图片画布资源失败',
EDITOR_PROJECT_REQUEST_OPTIONS,
);
return response.resource;
}
export async function generateEditorImage(input: EditorImageGenerationInput) {
return requestJson<EditorImageGenerationResponse>(
EDITOR_IMAGE_GENERATION_API,
jsonRequest('POST', { prompt: input.prompt }),
'生成图片失败',
{
...EDITOR_PROJECT_REQUEST_OPTIONS,
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
},
);
}
export async function editEditorImage(input: EditorImageEditInput) {
return requestJson<EditorImageGenerationResponse>(
EDITOR_IMAGE_EDIT_API,
jsonRequest('POST', {
prompt: input.prompt,
sourceImageSrc: input.sourceImageSrc,
}),
'修改图片失败',
{
...EDITOR_PROJECT_REQUEST_OPTIONS,
timeoutMs: 1_200_000,
retry: {
maxRetries: 0,
},
},
);
}