新增图片画布编辑器
新增 /editor 图片画布入口与 Lovart 风格画布交互 新增图片画布工程和资源持久化的 SpacetimeDB 表、绑定与 api-server BFF 接入图片生成和修改的 VectorEngine gpt-image-2 后端通道 完善素材库文件夹、重命名、上传删除、图层和元数据交互 补充图片画布技术方案、领域词、执行跟踪和浏览器 smoke 截图
This commit is contained in:
@@ -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`。
|
||||
|
||||
16
CONTEXT.md
16
CONTEXT.md
@@ -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
65
TRACKING.md
Normal 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 素材库修正:素材栏按文件夹分组,文件夹支持折叠和新建;上传入口可定向到当前文件夹,上传素材进入素材库并支持删除,内置素材只保留添加和重命名。
|
||||
74
docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
Normal file
74
docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
Normal 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、锁定、隐藏和多选。
|
||||
- 图像编辑:将占位工具替换为裁剪、抠图、局部修改、蒙版和导出等真实能力。
|
||||
@@ -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`
|
||||
|
||||
BIN
output/playwright/editor-layout.png
Normal file
BIN
output/playwright/editor-layout.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 416 KiB |
BIN
output/playwright/editor-left-integrated-light.png
Normal file
BIN
output/playwright/editor-left-integrated-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 574 KiB |
BIN
output/playwright/editor-lovart-panel-popup-final.png
Normal file
BIN
output/playwright/editor-lovart-panel-popup-final.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 402 KiB |
BIN
output/playwright/editor-no-outer-background.png
Normal file
BIN
output/playwright/editor-no-outer-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 577 KiB |
BIN
output/playwright/editor-plain-background.png
Normal file
BIN
output/playwright/editor-plain-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 448 KiB |
@@ -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(
|
||||
|
||||
581
server-rs/crates/api-server/src/editor_project.rs
Normal file
581
server-rs/crates/api-server/src/editor_project.rs
Normal 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")
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
62
server-rs/crates/api-server/src/modules/editor_project.rs
Normal file
62
server-rs/crates/api-server/src/modules/editor_project.rs
Normal 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,
|
||||
)),
|
||||
)
|
||||
}
|
||||
122
server-rs/crates/spacetime-client/src/editor_project.rs
Normal file
122
server-rs/crates/spacetime-client/src/editor_project.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
226
server-rs/crates/spacetime-client/src/mapper/editor_project.rs
Normal file
226
server-rs/crates/spacetime-client/src/mapper/editor_project.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
500
server-rs/crates/spacetime-module/src/editor_project_storage.rs
Normal file
500
server-rs/crates/spacetime-module/src/editor_project_storage.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
518
src/components/image-editor/ImageCanvasEditorView.test.tsx
Normal file
518
src/components/image-editor/ImageCanvasEditorView.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
1951
src/components/image-editor/ImageCanvasEditorView.tsx
Normal file
1951
src/components/image-editor/ImageCanvasEditorView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
905
src/index.css
905
src/index.css
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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'],
|
||||
|
||||
251
src/services/image-editor/editorProjectClient.test.ts
Normal file
251
src/services/image-editor/editorProjectClient.test.ts
Normal 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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
209
src/services/image-editor/editorProjectClient.ts
Normal file
209
src/services/image-editor/editorProjectClient.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user