diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index dacfd637..12557c80 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -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`。 diff --git a/CONTEXT.md b/CONTEXT.md index 233cde60..93a8f0dd 100644 --- a/CONTEXT.md +++ b/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 diff --git a/TRACKING.md b/TRACKING.md new file mode 100644 index 00000000..513cc772 --- /dev/null +++ b/TRACKING.md @@ -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 素材库修正:素材栏按文件夹分组,文件夹支持折叠和新建;上传入口可定向到当前文件夹,上传素材进入素材库并支持删除,内置素材只保留添加和重命名。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md new file mode 100644 index 00000000..92398c1d --- /dev/null +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -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、锁定、隐藏和多选。 +- 图像编辑:将占位工具替换为裁剪、抠图、局部修改、蒙版和导出等真实能力。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index b5d01d81..185e1d3d 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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` diff --git a/output/playwright/editor-layout.png b/output/playwright/editor-layout.png new file mode 100644 index 00000000..9ed5e24e Binary files /dev/null and b/output/playwright/editor-layout.png differ diff --git a/output/playwright/editor-left-integrated-light.png b/output/playwright/editor-left-integrated-light.png new file mode 100644 index 00000000..06fae79f Binary files /dev/null and b/output/playwright/editor-left-integrated-light.png differ diff --git a/output/playwright/editor-lovart-panel-popup-final.png b/output/playwright/editor-lovart-panel-popup-final.png new file mode 100644 index 00000000..dcf77979 Binary files /dev/null and b/output/playwright/editor-lovart-panel-popup-final.png differ diff --git a/output/playwright/editor-no-outer-background.png b/output/playwright/editor-no-outer-background.png new file mode 100644 index 00000000..9bc83c09 Binary files /dev/null and b/output/playwright/editor-no-outer-background.png differ diff --git a/output/playwright/editor-plain-background.png b/output/playwright/editor-plain-background.png new file mode 100644 index 00000000..2081e03c Binary files /dev/null and b/output/playwright/editor-plain-background.png differ diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 5e58ea9e..633e0e00 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs new file mode 100644 index 00000000..8d706ac2 --- /dev/null +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, +} + +#[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, + asset_object_id: Option, + width: u32, + height: u32, + source_type: String, + prompt: Option, + actual_prompt: Option, + model: Option, + provider: Option, + task_id: Option, + source_resource_id: Option, +} + +#[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, +} + +#[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, + 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, + 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, + asset_object_id: Option, + width: u32, + height: u32, + source_type: String, + prompt: Option, + actual_prompt: Option, + model: Option, + provider: Option, + task_id: Option, + source_resource_id: Option, + created_at: String, + updated_at: String, +} + +pub async fn load_recent_editor_project( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, 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, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, 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, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, 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, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, 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 { + 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) -> Option { + value + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) +} + +fn parse_editor_reference_image(source: &str) -> Result { + 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") +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 3b7d8e8c..f5954ba3 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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; diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index 88caf30d..6f040e3f 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -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; diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs new file mode 100644 index 00000000..df039b7e --- /dev/null +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -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 { + 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, + )), + ) +} diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs new file mode 100644 index 00000000..f5d36fef --- /dev/null +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -0,0 +1,122 @@ +use super::*; + +impl SpacetimeClient { + pub async fn create_editor_project( + &self, + input: EditorProjectCreateRecordInput, + ) -> Result { + 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, 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 { + 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 { + 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 { + 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 + } +} diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index fc4cd607..7b012dff 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -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; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 6ba49d7d..2652170a 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs new file mode 100644 index 00000000..25ad47ea --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -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, + 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, + pub image_src: String, + pub object_key: Option, + pub width: u32, + pub height: u32, + pub source_type: String, + pub prompt: Option, + pub actual_prompt: Option, + pub model: Option, + pub provider: Option, + pub task_id: Option, + pub source_resource_id: Option, + 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, + pub image_src: String, + pub object_key: Option, + pub width: u32, + pub height: u32, + pub source_type: String, + pub prompt: Option, + pub actual_prompt: Option, + pub model: Option, + pub provider: Option, + pub task_id: Option, + pub source_resource_id: Option, + pub updated_at_micros: i64, +} + +impl From 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 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 + 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 + 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, 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 { + 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 { + 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 { + 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), + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 55677168..1758d826 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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, database_migration_import_chunk: __sdk::TableUpdate, database_migration_operator: __sdk::TableUpdate, + editor_project: __sdk::TableUpdate, + editor_project_resource: __sdk::TableUpdate, inventory_slot: __sdk::TableUpdate, jump_hop_agent_session: __sdk::TableUpdate, jump_hop_event: __sdk::TableUpdate, @@ -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::("editor_project", &self.editor_project) + .with_updates_by_pk(|row| &row.project_id); + diff.editor_project_resource = cache + .apply_diff_to_table::( + "editor_project_resource", + &self.editor_project_resource, + ) + .with_updates_by_pk(|row| &row.resource_id); diff.inventory_slot = cache .apply_diff_to_table::("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::( + "editor_project", + &self.editor_project, + event, + ); + callbacks.invoke_table_row_callbacks::( + "editor_project_resource", + &self.editor_project_resource, + event, + ); callbacks.invoke_table_row_callbacks::( "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", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_and_return_procedure.rs new file mode 100644 index 00000000..7340d97d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectProcedureResult>( + "create_editor_project_and_return", + CreateEditorProjectAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_resource_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_resource_and_return_procedure.rs new file mode 100644 index 00000000..649d2a3e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_project_resource_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectResourceProcedureResult>( + "create_editor_project_resource_and_return", + CreateEditorProjectResourceAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_create_input_type.rs new file mode 100644 index 00000000..7c6d0bde --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_create_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_get_input_type.rs new file mode 100644 index 00000000..23b6e1da --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_get_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_get_recent_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_get_recent_input_type.rs new file mode 100644 index 00000000..50d185db --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_get_recent_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_input_type.rs new file mode 100644 index 00000000..5d2b9210 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_procedure_result_type.rs new file mode 100644 index 00000000..a7156375 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_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, + pub error_message: Option, +} + +impl __sdk::InModule for EditorProjectProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_create_input_type.rs new file mode 100644 index 00000000..f0576ddb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_create_input_type.rs @@ -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, + pub image_src: String, + pub object_key: Option, + pub width: u32, + pub height: u32, + pub source_type: String, + pub prompt: Option, + pub actual_prompt: Option, + pub model: Option, + pub provider: Option, + pub task_id: Option, + pub source_resource_id: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorProjectResourceCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_procedure_result_type.rs new file mode 100644 index 00000000..cee64645 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_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, + pub error_message: Option, +} + +impl __sdk::InModule for EditorProjectResourceProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs new file mode 100644 index 00000000..5f812b24 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs @@ -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, + pub image_src: String, + pub object_key: Option, + pub width: u32, + pub height: u32, + pub source_type: String, + pub prompt: Option, + pub actual_prompt: Option, + pub model: Option, + pub provider: Option, + pub task_id: Option, + pub source_resource_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorProjectResourceSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_table.rs new file mode 100644 index 00000000..47208d83 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_table.rs @@ -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, + 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::("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 + '_ { + 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, + 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::("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 { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("editor_project_resource"); + _table.add_unique_constraint::("resource_id", |row| &row.resource_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "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; +} + +impl editor_project_resourceQueryTableAccess for __sdk::QueryTableAccessor { + fn editor_project_resource(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("editor_project_resource") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs new file mode 100644 index 00000000..d635e0dc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs @@ -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, + pub image_src: String, + pub object_key: Option, + pub width: u32, + pub height: u32, + pub source_type: String, + pub prompt: Option, + pub actual_prompt: Option, + pub model: Option, + pub provider: Option, + pub task_id: Option, + pub source_resource_id: Option, + 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, + pub project_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub asset_object_id: __sdk::__query_builder::Col>, + pub image_src: __sdk::__query_builder::Col, + pub object_key: __sdk::__query_builder::Col>, + pub width: __sdk::__query_builder::Col, + pub height: __sdk::__query_builder::Col, + pub source_type: __sdk::__query_builder::Col, + pub prompt: __sdk::__query_builder::Col>, + pub actual_prompt: __sdk::__query_builder::Col>, + pub model: __sdk::__query_builder::Col>, + pub provider: __sdk::__query_builder::Col>, + pub task_id: __sdk::__query_builder::Col>, + pub source_resource_id: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +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, + pub project_id: __sdk::__query_builder::IxCol, + pub resource_id: __sdk::__query_builder::IxCol, +} + +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 {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_snapshot_type.rs new file mode 100644 index 00000000..84fbc062 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_snapshot_type.rs @@ -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, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorProjectSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_table.rs new file mode 100644 index 00000000..98c062cf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_table.rs @@ -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, + 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::("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 + '_ { + 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, + 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::("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 { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("editor_project"); + _table.add_unique_constraint::("project_id", |row| &row.project_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "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; +} + +impl editor_projectQueryTableAccess for __sdk::QueryTableAccessor { + fn editor_project(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("editor_project") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_type.rs new file mode 100644 index 00000000..31f80b3d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_type.rs @@ -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, + pub owner_user_id: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub viewport_x: __sdk::__query_builder::Col, + pub viewport_y: __sdk::__query_builder::Col, + pub viewport_scale: __sdk::__query_builder::Col, + pub layers_json: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +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, + pub project_id: __sdk::__query_builder::IxCol, +} + +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 {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_viewport_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_viewport_snapshot_type.rs new file mode 100644 index 00000000..c4caee3f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_viewport_snapshot_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectViewportSnapshot { + pub x: f64, + pub y: f64, + pub scale: f64, +} + +impl __sdk::InModule for EditorProjectViewportSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_project_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_project_and_return_procedure.rs new file mode 100644 index 00000000..07d5c4fe --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_project_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectProcedureResult>( + "get_editor_project_and_return", + GetEditorProjectAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_recent_editor_project_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_recent_editor_project_and_return_procedure.rs new file mode 100644 index 00000000..b1639585 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_recent_editor_project_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectProcedureResult>( + "get_recent_editor_project_and_return", + GetRecentEditorProjectAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_and_return_procedure.rs new file mode 100644 index 00000000..17585bdf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectProcedureResult>( + "save_editor_project_layout_and_return", + SaveEditorProjectLayoutAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs new file mode 100644 index 00000000..db167054 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -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, + image_src: String, + object_key: Option, + width: u32, + height: u32, + source_type: String, + prompt: Option, + actual_prompt: Option, + model: Option, + provider: Option, + task_id: Option, + source_resource_id: Option, + 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, + pub image_src: String, + pub object_key: Option, + pub width: u32, + pub height: u32, + pub source_type: String, + pub prompt: Option, + pub actual_prompt: Option, + pub model: Option, + pub provider: Option, + pub task_id: Option, + pub source_resource_id: Option, + 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, + pub image_src: String, + pub object_key: Option, + pub width: u32, + pub height: u32, + pub source_type: String, + pub prompt: Option, + pub actual_prompt: Option, + pub model: Option, + pub provider: Option, + pub task_id: Option, + pub source_resource_id: Option, + 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, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorProjectProcedureResult { + pub ok: bool, + pub project: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorProjectResourceProcedureResult { + pub ok: bool, + pub resource: Option, + pub error_message: Option, +} + +#[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 { + 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, 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::>(); + + 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 { + 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 { + 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 { + 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 { + 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::>(); + 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 { + 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 { + let normalized = value.trim(); + if normalized.is_empty() { + return Err(format!("{field} 不能为空")); + } + Ok(normalized.to_string()) +} + +fn normalize_optional(value: Option) -> Option { + 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 { + if value.len() > EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES { + return Err("图片画布图层布局过大".to_string()); + } + serde_json::from_str::(&value) + .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + Ok(value) +} + +fn editor_project_ok(project: Option) -> EditorProjectProcedureResult { + EditorProjectProcedureResult { + ok: true, + project, + error_message: None, + } +} + +fn editor_project_error(message: String) -> EditorProjectProcedureResult { + EditorProjectProcedureResult { + ok: false, + project: None, + error_message: Some(message), + } +} diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index c54055ce..0298903f 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -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::*; diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 6de73df5..3ff52004 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -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, diff --git a/src/App.tsx b/src/App.tsx index e16ff02f..912b4363 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 (
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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx new file mode 100644 index 00000000..cfb47c65 --- /dev/null +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -0,0 +1,1951 @@ +import { + Braces, + Check, + ChevronDown, + ChevronRight, + Copy, + Crop, + Download, + Folder, + FolderPlus, + Hand, + ImagePlus, + Info, + Layers, + MousePointer2, + Pencil, + RotateCcw, + Shapes, + SlidersHorizontal, + Sparkles, + Trash2, + Type, + WandSparkles, + X, +} from 'lucide-react'; +import { + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type WheelEvent as ReactWheelEvent, +} from 'react'; + +import { + createEditorProjectResource, + editEditorImage, + type EditorImageGenerationResult, + type EditorProjectLayerSnapshot, + generateEditorImage, + loadOrCreateRecentEditorProject, + saveEditorProjectLayout, +} from '../../services/image-editor/editorProjectClient'; + +type EditorAsset = { + id: string; + label: string; + src: string; + width: number; + height: number; + folderId: string; + sourceKind: 'built-in' | 'uploaded'; + sourceType: CanvasLayer['sourceType']; + prompt?: string; + actualPrompt?: string; + model?: string; + provider?: string; + taskId?: string; + objectKey?: string; + assetObjectId?: string; +}; + +type CanvasLayer = { + id: string; + resourceId: string; + title: string; + src: string; + x: number; + y: number; + width: number; + height: number; + originalWidth: number; + originalHeight: number; + zIndex: number; + sourceType: 'uploaded' | 'generated' | 'mock_generated'; + prompt?: string | null; + actualPrompt?: string | null; + model?: string | null; + provider?: string | null; + taskId?: string | null; + objectKey?: string | null; + assetObjectId?: string | null; + sourceResourceId?: string | null; +}; + +type CanvasViewport = { + x: number; + y: number; + scale: number; +}; + +type CanvasTool = + | 'select' + | 'hand' + | 'upload' + | 'generate' + | 'text' + | 'shape' + | 'export'; + +type SidebarPanel = 'assets' | 'layers'; + +type EditorAssetFolder = { + id: string; + label: string; + collapsed: boolean; +}; + +type GenerateDialogState = { + mode: 'generate' | 'edit'; + prompt: string; + status: 'idle' | 'generating' | 'failed'; + sourceLayerId?: string; + errorMessage?: string; +}; + +type SnapGuide = { + vertical?: number; + horizontal?: number; +}; + +type SnapCandidate = { + position: number; + guide: number; + distance: number; +}; + +type DragState = + | { + kind: 'pan'; + pointerId: number; + startClientX: number; + startClientY: number; + startViewport: CanvasViewport; + } + | { + kind: 'layer'; + pointerId: number; + layerId: string; + startClientX: number; + startClientY: number; + startLayerX: number; + startLayerY: number; + startScale: number; + }; + +const EDITOR_ASSETS: EditorAsset[] = [ + { + id: 'puzzle', + label: '拼图素材', + src: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + folderId: 'project', + sourceKind: 'built-in', + sourceType: 'uploaded', + }, + { + id: 'match3d', + label: '抓大鹅素材', + src: '/creation-type-references/match3d.webp', + width: 640, + height: 640, + folderId: 'project', + sourceKind: 'built-in', + sourceType: 'uploaded', + }, + { + id: 'big-fish', + label: '大鱼素材', + src: '/creation-type-references/big-fish.webp', + width: 720, + height: 405, + folderId: 'references', + sourceKind: 'built-in', + sourceType: 'uploaded', + }, + { + id: 'bark-battle', + label: '声浪素材', + src: '/creation-type-references/bark-battle.webp', + width: 640, + height: 900, + folderId: 'references', + sourceKind: 'built-in', + sourceType: 'uploaded', + }, + { + id: 'visual-novel', + label: '视觉小说素材', + src: '/creation-type-references/visual-novel.webp', + width: 720, + height: 405, + folderId: 'references', + sourceKind: 'built-in', + sourceType: 'uploaded', + }, +]; + +const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ + { id: 'project', label: '项目素材', collapsed: false }, + { id: 'references', label: '参考素材', collapsed: false }, + { id: 'uploads', label: '上传素材', collapsed: false }, +]; + +const INITIAL_LAYERS: CanvasLayer[] = [ + { + id: 'layer-puzzle', + resourceId: 'resource-puzzle', + title: '拼图素材', + src: '/creation-type-references/puzzle.webp', + x: 470, + y: 300, + width: 420, + height: 420, + originalWidth: 640, + originalHeight: 640, + zIndex: 1, + sourceType: 'uploaded', + }, + { + id: 'layer-big-fish', + resourceId: 'resource-big-fish', + title: '大鱼素材', + src: '/creation-type-references/big-fish.webp', + x: 930, + y: 360, + width: 420, + height: 236, + originalWidth: 720, + originalHeight: 405, + zIndex: 2, + sourceType: 'uploaded', + }, +]; + +const CANVAS_WORLD_SIZE = 12000; +const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; +const MIN_SCALE = 0.24; +const MAX_SCALE = 3.2; +const TOOLBAR_HALF_WIDTH = 132; +const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; +const SNAP_THRESHOLD_SCREEN_PX = 18; +const FIT_VIEW_PADDING = 10; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function formatPercent(value: number) { + return `${Math.round(value * 100)}%`; +} + +function triggerPlaceholderAction(label: string) { + window.alert(`${label}功能建设中`); +} + +function createLayerFromAsset( + asset: EditorAsset, + index: number, + viewport: CanvasViewport, + screenCenter: { x: number; y: number }, +): CanvasLayer { + const longestSide = Math.max(asset.width, asset.height); + const sizeRatio = longestSide > 0 ? 360 / longestSide : 1; + const width = Math.round(asset.width * sizeRatio); + const height = Math.round(asset.height * sizeRatio); + const worldCenterX = (screenCenter.x - viewport.x) / viewport.scale; + const worldCenterY = (screenCenter.y - viewport.y) / viewport.scale; + const offset = index * 34; + + return { + id: `layer-${asset.id}-${index}`, + resourceId: `local-resource-${asset.id}-${index}`, + title: asset.label, + src: asset.src, + x: worldCenterX - width / 2 + offset, + y: worldCenterY - height / 2 + offset, + width, + height, + originalWidth: asset.width, + originalHeight: asset.height, + zIndex: index + 10, + sourceType: asset.sourceType, + prompt: asset.prompt, + actualPrompt: asset.actualPrompt, + model: asset.model, + provider: asset.provider, + taskId: asset.taskId, + objectKey: asset.objectKey, + assetObjectId: asset.assetObjectId, + } satisfies CanvasLayer; +} + +function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot { + return { + layerId: layer.id, + resourceId: layer.resourceId, + title: layer.title, + src: layer.src, + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + zIndex: layer.zIndex, + sourceType: layer.sourceType, + prompt: layer.prompt, + actualPrompt: layer.actualPrompt, + model: layer.model, + provider: layer.provider, + taskId: layer.taskId, + objectKey: layer.objectKey, + assetObjectId: layer.assetObjectId, + sourceResourceId: layer.sourceResourceId, + }; +} + +function hydrateLayer(snapshot: EditorProjectLayerSnapshot): CanvasLayer | null { + const resourceId = typeof snapshot.resourceId === 'string' ? snapshot.resourceId : ''; + const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : ''; + const src = typeof snapshot.src === 'string' ? snapshot.src : ''; + const title = typeof snapshot.title === 'string' ? snapshot.title : '画布图片'; + if (!resourceId || !layerId || !src) { + return null; + } + + return { + id: layerId, + resourceId, + title, + src, + x: numberFromSnapshot(snapshot.x, 0), + y: numberFromSnapshot(snapshot.y, 0), + width: numberFromSnapshot(snapshot.width, 320), + height: numberFromSnapshot(snapshot.height, 320), + originalWidth: numberFromSnapshot(snapshot.originalWidth, 320), + originalHeight: numberFromSnapshot(snapshot.originalHeight, 320), + zIndex: numberFromSnapshot(snapshot.zIndex, 1), + sourceType: isCanvasSourceType(snapshot.sourceType) + ? snapshot.sourceType + : 'uploaded', + prompt: stringOrNull(snapshot.prompt), + actualPrompt: stringOrNull(snapshot.actualPrompt), + model: stringOrNull(snapshot.model), + provider: stringOrNull(snapshot.provider), + taskId: stringOrNull(snapshot.taskId), + objectKey: stringOrNull(snapshot.objectKey), + assetObjectId: stringOrNull(snapshot.assetObjectId), + sourceResourceId: stringOrNull(snapshot.sourceResourceId), + }; +} + +function numberFromSnapshot(value: unknown, fallback: number) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function stringOrNull(value: unknown) { + return typeof value === 'string' && value.trim() ? value : null; +} + +function isCanvasSourceType(value: unknown): value is CanvasLayer['sourceType'] { + return value === 'uploaded' || value === 'generated' || value === 'mock_generated'; +} + +function isGeneratedLayer(layer: CanvasLayer) { + return layer.sourceType === 'generated' || layer.sourceType === 'mock_generated'; +} + +function isEditableTarget(event: KeyboardEvent) { + const target = event.target as HTMLElement | null; + if (!target) { + return false; + } + return ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + ); +} + +function getPointerButton(event: ReactPointerEvent) { + const nativeEvent = event.nativeEvent as PointerEvent; + const nativeButtons = Number(nativeEvent.buttons); + if (Number.isFinite(nativeButtons) && (nativeButtons & 4) === 4) { + return 1; + } + const syntheticButtons = Number(event.buttons); + if (Number.isFinite(syntheticButtons) && (syntheticButtons & 4) === 4) { + return 1; + } + const syntheticButton = Number(event.button); + if (Number.isFinite(syntheticButton)) { + return syntheticButton; + } + const nativeButton = Number(nativeEvent.button); + if (Number.isFinite(nativeButton)) { + return nativeButton; + } + return 0; +} + +function getPointerClient(event: ReactPointerEvent) { + const nativeEvent = event.nativeEvent as PointerEvent; + return { + x: Number.isFinite(event.clientX) + ? event.clientX + : Number.isFinite(nativeEvent.clientX) + ? nativeEvent.clientX + : 0, + y: Number.isFinite(event.clientY) + ? event.clientY + : Number.isFinite(nativeEvent.clientY) + ? nativeEvent.clientY + : 0, + }; +} + +function getPointerId(event: ReactPointerEvent) { + const nativeId = (event.nativeEvent as PointerEvent).pointerId; + if (Number.isFinite(event.pointerId)) { + return event.pointerId; + } + return Number.isFinite(nativeId) ? nativeId : -1; +} + +export function ImageCanvasEditorView() { + const canvasViewportRef = useRef(null); + const uploadInputRef = useRef(null); + const dragStateRef = useRef(null); + const layerCounterRef = useRef(INITIAL_LAYERS.length); + const saveTimerRef = useRef(null); + const [projectId, setProjectId] = useState(null); + const [isProjectReady, setIsProjectReady] = useState(false); + const [activeSidebarPanel, setActiveSidebarPanel] = + useState('assets'); + const [viewport, setViewport] = useState({ + x: -260, + y: 70, + scale: 0.82, + }); + const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE); + const [assetFolders, setAssetFolders] = + useState(EDITOR_ASSET_FOLDERS); + const [assets, setAssets] = useState(EDITOR_ASSETS); + const [layers, setLayers] = useState(INITIAL_LAYERS); + const [renamingAsset, setRenamingAsset] = useState<{ + assetId: string; + value: string; + } | null>(null); + const [creatingFolder, setCreatingFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [activeUploadFolderId, setActiveUploadFolderId] = useState('uploads'); + const [selectedLayerId, setSelectedLayerId] = useState( + INITIAL_LAYERS[0]?.id ?? null, + ); + const [hoveredLayerId, setHoveredLayerId] = useState(null); + const [activeTool, setActiveTool] = useState('select'); + const [isSpacePanning, setIsSpacePanning] = useState(false); + const [isPanning, setIsPanning] = useState(false); + const [snapGuide, setSnapGuide] = useState(null); + const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false); + const [metadataLayer, setMetadataLayer] = useState(null); + const [generateDialog, setGenerateDialog] = + useState(null); + + const effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool; + const selectedLayer = useMemo( + () => layers.find((layer) => layer.id === selectedLayerId) ?? null, + [layers, selectedLayerId], + ); + const selectedToolbarStyle = selectedLayer + ? { + left: clamp( + viewport.x + + selectedLayer.x * viewport.scale + + (selectedLayer.width * viewport.scale) / 2, + TOOLBAR_HALF_WIDTH, + Math.max(TOOLBAR_HALF_WIDTH, canvasSize.width - TOOLBAR_HALF_WIDTH), + ), + top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12), + } + : null; + const groupedAssets = useMemo( + () => + assetFolders.map((folder) => ({ + ...folder, + assets: assets.filter((asset) => asset.folderId === folder.id), + })), + [assetFolders, assets], + ); + + useEffect(() => { + let cancelled = false; + loadOrCreateRecentEditorProject() + .then((project) => { + if (cancelled) { + return; + } + setProjectId(project.projectId); + setViewport(project.viewport); + const hydratedLayers = project.layers + .map(hydrateLayer) + .filter((layer): layer is CanvasLayer => Boolean(layer)); + if (hydratedLayers.length > 0) { + layerCounterRef.current = hydratedLayers.length; + setLayers(hydratedLayers); + setSelectedLayerId(hydratedLayers[0]?.id ?? null); + } + setIsProjectReady(true); + }) + .catch(() => { + if (!cancelled) { + setIsProjectReady(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const viewportElement = canvasViewportRef.current; + if (!viewportElement) { + return undefined; + } + + const updateCanvasSize = () => { + setCanvasSize({ + width: viewportElement.clientWidth || DEFAULT_CANVAS_SIZE.width, + height: viewportElement.clientHeight || DEFAULT_CANVAS_SIZE.height, + }); + }; + + updateCanvasSize(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateCanvasSize); + return () => window.removeEventListener('resize', updateCanvasSize); + } + + const observer = new ResizeObserver(updateCanvasSize); + observer.observe(viewportElement); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setActiveSidebarPanel(null); + setIsZoomMenuOpen(false); + setGenerateDialog((currentDialog) => + currentDialog?.status === 'generating' ? currentDialog : null, + ); + return; + } + if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) { + return; + } + event.preventDefault(); + setIsSpacePanning(true); + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (event.code !== 'Space') { + return; + } + event.preventDefault(); + setIsSpacePanning(false); + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, []); + + useEffect(() => { + if (!projectId || !isProjectReady) { + return undefined; + } + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + } + + saveTimerRef.current = window.setTimeout(() => { + saveEditorProjectLayout(projectId, { + viewport, + layers: layers.map(serializeLayer), + }).catch(() => {}); + }, 450); + + return () => { + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + } + }; + }, [isProjectReady, layers, projectId, viewport]); + + const fitLayers = useCallback( + (targetLayers: CanvasLayer[] = layers) => { + if (targetLayers.length === 0) { + return; + } + + const bounds = targetLayers.reduce( + (current, layer) => ({ + minX: Math.min(current.minX, layer.x), + minY: Math.min(current.minY, layer.y), + maxX: Math.max(current.maxX, layer.x + layer.width), + maxY: Math.max(current.maxY, layer.y + layer.height), + }), + { + minX: Number.POSITIVE_INFINITY, + minY: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxY: Number.NEGATIVE_INFINITY, + }, + ); + const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); + const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); + const availableWidth = Math.max(1, canvasSize.width - FIT_VIEW_PADDING * 2); + const availableHeight = Math.max(1, canvasSize.height - FIT_VIEW_PADDING * 2); + const scale = clamp( + Math.min(1, availableWidth / boundsWidth, availableHeight / boundsHeight), + MIN_SCALE, + MAX_SCALE, + ); + + setViewport({ + x: + canvasSize.width / 2 - + (bounds.minX + boundsWidth / 2) * scale, + y: + canvasSize.height / 2 - + (bounds.minY + boundsHeight / 2) * scale, + scale, + }); + }, + [canvasSize.height, canvasSize.width, layers], + ); + + const updateScaleFromCenter = (nextScale: number) => { + const viewportElement = canvasViewportRef.current; + if (!viewportElement) { + setViewport((currentViewport) => ({ + ...currentViewport, + scale: clamp(nextScale, MIN_SCALE, MAX_SCALE), + })); + return; + } + + const rect = viewportElement.getBoundingClientRect(); + const centerX = rect.width > 0 ? rect.width / 2 : canvasSize.width / 2; + const centerY = rect.height > 0 ? rect.height / 2 : canvasSize.height / 2; + setViewport((currentViewport) => { + const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE); + const worldX = (centerX - currentViewport.x) / currentViewport.scale; + const worldY = (centerY - currentViewport.y) / currentViewport.scale; + return { + x: centerX - worldX * scale, + y: centerY - worldY * scale, + scale, + }; + }); + }; + + const addAssetLayer = (asset: EditorAsset) => { + setActiveUploadFolderId(asset.folderId); + layerCounterRef.current += 1; + const nextLayer = createLayerFromAsset( + asset, + layerCounterRef.current, + viewport, + { + x: canvasSize.width / 2, + y: canvasSize.height / 2, + }, + ); + setLayers((currentLayers) => [...currentLayers, nextLayer]); + setSelectedLayerId(nextLayer.id); + setHoveredLayerId(null); + if (projectId) { + createEditorProjectResource(projectId, { + imageSrc: nextLayer.src, + objectKey: nextLayer.objectKey, + assetObjectId: nextLayer.assetObjectId, + width: nextLayer.originalWidth, + height: nextLayer.originalHeight, + sourceType: nextLayer.sourceType, + prompt: nextLayer.prompt, + actualPrompt: nextLayer.actualPrompt, + model: nextLayer.model, + provider: nextLayer.provider, + taskId: nextLayer.taskId, + sourceResourceId: nextLayer.sourceResourceId, + }).catch(() => {}); + } + }; + + const startRenamingAsset = (asset: EditorAsset) => { + setRenamingAsset({ + assetId: asset.id, + value: asset.label, + }); + }; + + const commitAssetRename = (asset: EditorAsset) => { + const nextLabel = renamingAsset?.value.trim(); + if (!nextLabel) { + setRenamingAsset(null); + return; + } + setAssets((currentAssets) => + currentAssets.map((currentAsset) => + currentAsset.id === asset.id + ? { + ...currentAsset, + label: nextLabel, + } + : currentAsset, + ), + ); + setRenamingAsset(null); + }; + + const toggleAssetFolder = (folderId: string) => { + setAssetFolders((currentFolders) => + currentFolders.map((folder) => + folder.id === folderId + ? { + ...folder, + collapsed: !folder.collapsed, + } + : folder, + ), + ); + }; + + const commitNewAssetFolder = () => { + const label = newFolderName.trim(); + if (!label) { + setCreatingFolder(false); + setNewFolderName(''); + return; + } + const folderId = `folder-${Date.now()}`; + setAssetFolders((currentFolders) => [ + ...currentFolders, + { + id: folderId, + label, + collapsed: false, + }, + ]); + setActiveUploadFolderId(folderId); + setCreatingFolder(false); + setNewFolderName(''); + }; + + const deleteUploadedAsset = (asset: EditorAsset) => { + if (asset.sourceKind !== 'uploaded') { + return; + } + setAssets((currentAssets) => + currentAssets.filter((currentAsset) => currentAsset.id !== asset.id), + ); + setRenamingAsset((currentRename) => + currentRename?.assetId === asset.id ? null : currentRename, + ); + }; + + const addUploadedLayer = (file: File) => { + if (!file.type.startsWith('image/')) { + window.alert('请选择图片文件'); + return; + } + + layerCounterRef.current += 1; + const objectUrl = + typeof URL.createObjectURL === 'function' + ? URL.createObjectURL(file) + : ''; + const fallbackWidth = 420; + const fallbackHeight = 315; + const uploadFolderId = + assetFolders.some((folder) => folder.id === activeUploadFolderId) + ? activeUploadFolderId + : 'uploads'; + const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; + const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; + const nextLayer: CanvasLayer = { + id: `layer-upload-${layerCounterRef.current}`, + resourceId: `local-resource-upload-${layerCounterRef.current}`, + title: file.name || '上传图片', + src: objectUrl, + x: worldCenterX - fallbackWidth / 2, + y: worldCenterY - fallbackHeight / 2, + width: fallbackWidth, + height: fallbackHeight, + originalWidth: fallbackWidth, + originalHeight: fallbackHeight, + zIndex: layerCounterRef.current + 10, + sourceType: 'uploaded', + }; + const uploadedAsset: EditorAsset = { + id: `upload-${layerCounterRef.current}`, + label: file.name || '上传图片', + src: objectUrl, + width: fallbackWidth, + height: fallbackHeight, + folderId: uploadFolderId, + sourceKind: 'uploaded', + sourceType: 'uploaded', + }; + + setLayers((currentLayers) => [...currentLayers, nextLayer]); + setAssets((currentAssets) => [...currentAssets, uploadedAsset]); + setAssetFolders((currentFolders) => + currentFolders.map((folder) => + folder.id === uploadFolderId + ? { + ...folder, + collapsed: false, + } + : folder, + ), + ); + setSelectedLayerId(nextLayer.id); + setActiveSidebarPanel('layers'); + + if (objectUrl) { + const uploadedImage = new Image(); + uploadedImage.onload = () => { + const originalWidth = uploadedImage.naturalWidth || fallbackWidth; + const originalHeight = uploadedImage.naturalHeight || fallbackHeight; + const longestSide = Math.max(originalWidth, originalHeight); + const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1; + const width = Math.round(originalWidth * sizeRatio); + const height = Math.round(originalHeight * sizeRatio); + setLayers((currentLayers) => + currentLayers.map((layer) => + layer.id === nextLayer.id + ? { + ...layer, + width, + height, + originalWidth, + originalHeight, + x: worldCenterX - width / 2, + y: worldCenterY - height / 2, + } + : layer, + ), + ); + setAssets((currentAssets) => + currentAssets.map((asset) => + asset.id === uploadedAsset.id + ? { + ...asset, + width: originalWidth, + height: originalHeight, + } + : asset, + ), + ); + }; + uploadedImage.src = objectUrl; + } + }; + + const deleteSelectedLayer = () => { + if (!selectedLayerId) { + return; + } + setLayers((currentLayers) => { + const nextLayers = currentLayers.filter((layer) => layer.id !== selectedLayerId); + const nextSelectedLayer = nextLayers + .slice() + .sort((left, right) => right.zIndex - left.zIndex)[0]; + setSelectedLayerId(nextSelectedLayer?.id ?? null); + return nextLayers; + }); + setHoveredLayerId(null); + setMetadataLayer((currentLayer) => + currentLayer?.id === selectedLayerId ? null : currentLayer, + ); + }; + + const openGenerateDialog = () => { + setGenerateDialog({ + mode: 'generate', + prompt: '', + status: 'idle', + }); + setActiveTool('generate'); + }; + + const openEditDialog = (sourceLayer: CanvasLayer) => { + setMetadataLayer(null); + setGenerateDialog({ + mode: 'edit', + prompt: sourceLayer.prompt + ? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节` + : '', + status: 'idle', + sourceLayerId: sourceLayer.id, + }); + setActiveTool('generate'); + }; + + const addGeneratedResultLayer = ( + generated: EditorImageGenerationResult, + options: { sourceLayer?: CanvasLayer } = {}, + ) => { + layerCounterRef.current += 1; + const generatedIndex = layerCounterRef.current; + const originalWidth = generated.width || 1024; + const originalHeight = generated.height || 1024; + const longestSide = Math.max(originalWidth, originalHeight); + const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1; + const width = Math.round(originalWidth * sizeRatio); + const height = Math.round(originalHeight * sizeRatio); + const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; + const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; + const nextLayer: CanvasLayer = { + id: options.sourceLayer + ? `layer-edit-${generatedIndex}` + : `layer-generated-${generatedIndex}`, + resourceId: options.sourceLayer + ? `local-resource-edit-${generatedIndex}` + : `local-resource-generated-${generatedIndex}`, + title: options.sourceLayer + ? `${options.sourceLayer.title} 修改结果` + : `生成图片 ${generatedIndex}`, + src: generated.imageSrc, + x: options.sourceLayer + ? options.sourceLayer.x + options.sourceLayer.width + 32 + : worldCenterX - width / 2, + y: options.sourceLayer ? options.sourceLayer.y : worldCenterY - height / 2, + width, + height, + originalWidth, + originalHeight, + zIndex: generatedIndex + 10, + sourceType: generated.sourceType, + prompt: generated.prompt, + actualPrompt: generated.actualPrompt ?? generated.prompt, + model: generated.model, + provider: generated.provider, + taskId: generated.taskId, + sourceResourceId: options.sourceLayer?.resourceId, + }; + + setLayers((currentLayers) => [...currentLayers, nextLayer]); + setSelectedLayerId(nextLayer.id); + setActiveSidebarPanel('layers'); + setGenerateDialog(null); + setActiveTool('select'); + if (options.sourceLayer) { + fitLayers([options.sourceLayer, nextLayer]); + } + if (projectId) { + createEditorProjectResource(projectId, { + imageSrc: nextLayer.src, + objectKey: nextLayer.objectKey, + assetObjectId: nextLayer.assetObjectId, + width: nextLayer.originalWidth, + height: nextLayer.originalHeight, + sourceType: nextLayer.sourceType, + prompt: nextLayer.prompt, + actualPrompt: nextLayer.actualPrompt, + model: nextLayer.model, + provider: nextLayer.provider, + taskId: nextLayer.taskId, + sourceResourceId: nextLayer.sourceResourceId, + }).catch(() => {}); + } + }; + + const submitImageGeneration = async (dialog: GenerateDialogState) => { + const normalizedPrompt = + dialog.prompt.trim() || + (dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片'); + setGenerateDialog({ + ...dialog, + prompt: normalizedPrompt, + status: 'generating', + }); + + try { + if (dialog.mode === 'edit') { + const sourceLayer = layers.find((layer) => layer.id === dialog.sourceLayerId); + if (!sourceLayer) { + throw new Error('未找到要修改的图片'); + } + if (!sourceLayer.src.startsWith('data:image/')) { + throw new Error('当前图片缺少可提交的原图数据,请先使用生成图片结果进行修改'); + } + const generated = await editEditorImage({ + prompt: normalizedPrompt, + sourceImageSrc: sourceLayer.src, + }); + addGeneratedResultLayer(generated, { sourceLayer }); + } else { + const generated = await generateEditorImage({ prompt: normalizedPrompt }); + addGeneratedResultLayer(generated); + } + } catch (error) { + setGenerateDialog({ + ...dialog, + prompt: normalizedPrompt, + status: 'failed', + errorMessage: + error instanceof Error && error.message.trim() + ? error.message + : '生成图片失败', + }); + } + }; + + const handleWheel = (event: ReactWheelEvent) => { + event.preventDefault(); + const viewportElement = canvasViewportRef.current; + if (!viewportElement) { + return; + } + + const rect = viewportElement.getBoundingClientRect(); + const pointerX = event.clientX - rect.left; + const pointerY = event.clientY - rect.top; + const scaleMultiplier = event.deltaY > 0 ? 0.9 : 1.1; + + setViewport((currentViewport) => { + const nextScale = clamp( + currentViewport.scale * scaleMultiplier, + MIN_SCALE, + MAX_SCALE, + ); + const worldX = (pointerX - currentViewport.x) / currentViewport.scale; + const worldY = (pointerY - currentViewport.y) / currentViewport.scale; + + return { + x: pointerX - worldX * nextScale, + y: pointerY - worldY * nextScale, + scale: nextScale, + }; + }); + }; + + const startPan = (event: ReactPointerEvent) => { + event.preventDefault(); + const pointer = getPointerClient(event); + canvasViewportRef.current?.setPointerCapture?.(event.pointerId); + setIsPanning(true); + dragStateRef.current = { + kind: 'pan', + pointerId: getPointerId(event), + startClientX: pointer.x, + startClientY: pointer.y, + startViewport: viewport, + }; + }; + + const handleCanvasPointerDown = (event: ReactPointerEvent) => { + const button = getPointerButton(event); + if (button !== 0 || effectiveTool === 'hand') { + startPan(event); + return; + } + + if (button !== 0) { + return; + } + + setSelectedLayerId(null); + }; + + const handleLayerPointerDown = ( + event: ReactPointerEvent, + layer: CanvasLayer, + ) => { + const button = getPointerButton(event); + if (button === 1 || effectiveTool === 'hand') { + event.stopPropagation(); + startPan(event as unknown as ReactPointerEvent); + return; + } + if (button !== 0) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const pointer = getPointerClient(event); + canvasViewportRef.current?.setPointerCapture?.(event.pointerId); + setSelectedLayerId(layer.id); + dragStateRef.current = { + kind: 'layer', + pointerId: getPointerId(event), + layerId: layer.id, + startClientX: pointer.x, + startClientY: pointer.y, + startLayerX: layer.x, + startLayerY: layer.y, + startScale: viewport.scale, + }; + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + const dragState = dragStateRef.current; + const pointerId = getPointerId(event); + if ( + !dragState || + (dragState.pointerId >= 0 && pointerId >= 0 && dragState.pointerId !== pointerId) + ) { + return; + } + + if (dragState.kind === 'pan') { + const pointer = getPointerClient(event); + setViewport({ + ...dragState.startViewport, + x: dragState.startViewport.x + pointer.x - dragState.startClientX, + y: dragState.startViewport.y + pointer.y - dragState.startClientY, + }); + return; + } + + const movingLayer = layers.find((layer) => layer.id === dragState.layerId); + if (!movingLayer) { + return; + } + const pointer = getPointerClient(event); + const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; + const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; + const snapped = resolveSnappedLayerPosition( + movingLayer, + dragState.startLayerX + deltaX, + dragState.startLayerY + deltaY, + layers, + dragState.startScale, + ); + setSnapGuide(snapped.guide); + setLayers((currentLayers) => + currentLayers.map((layer) => + layer.id === dragState.layerId + ? { + ...layer, + x: snapped.x, + y: snapped.y, + } + : layer, + ), + ); + }; + + const finishDrag = (event: ReactPointerEvent) => { + const dragState = dragStateRef.current; + const pointerId = getPointerId(event); + if ( + dragState && + (dragState.pointerId < 0 || pointerId < 0 || dragState.pointerId === pointerId) + ) { + dragStateRef.current = null; + setIsPanning(false); + setSnapGuide(null); + if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) { + canvasViewportRef.current.releasePointerCapture?.(event.pointerId); + } + } + }; + + const switchTool = (tool: CanvasTool) => { + dragStateRef.current = null; + setIsPanning(false); + setSnapGuide(null); + if (tool === 'upload') { + uploadInputRef.current?.click(); + return; + } + if (tool === 'generate') { + openGenerateDialog(); + return; + } + setActiveTool(tool); + }; + + const toggleSidebarPanel = (panel: SidebarPanel) => { + setActiveSidebarPanel((currentPanel) => + currentPanel === panel ? null : panel, + ); + }; + + const toolButtons = [ + { label: '裁剪', icon: Crop }, + { label: '重绘', icon: Sparkles }, + { label: '调整', icon: SlidersHorizontal }, + { label: '复制', icon: Copy }, + ]; + + const canvasTools: Array<{ id: CanvasTool; label: string; icon: typeof MousePointer2 }> = [ + { id: 'select', label: '选择工具', icon: MousePointer2 }, + { id: 'hand', label: '抓手工具', icon: Hand }, + { id: 'upload', label: '上传工具', icon: ImagePlus }, + { id: 'generate', label: '生成工具', icon: WandSparkles }, + { id: 'text', label: '文字工具', icon: Type }, + { id: 'shape', label: '形状标注工具', icon: Shapes }, + { id: 'export', label: '导出工具', icon: Download }, + ]; + + return ( +
event.preventDefault()} + > + { + const file = event.currentTarget.files?.[0]; + if (file) { + addUploadedLayer(file); + } + event.currentTarget.value = ''; + }} + /> + {activeSidebarPanel ? ( + + ) : null} + +
+
+
+

图片编辑器

+ 画布 +
+
+ + {isZoomMenuOpen ? ( +
+ + + + {[0.5, 1, 2].map((scale) => ( + + ))} +
+ ) : null} +
+
+ +
+
+
+
+ {snapGuide?.vertical !== undefined ? ( +
+ ) : null} + {snapGuide?.horizontal !== undefined ? ( +
+ ) : null} + + {layers + .slice() + .sort((left, right) => left.zIndex - right.zIndex) + .map((layer) => { + const isSelected = selectedLayerId === layer.id; + const isHovered = hoveredLayerId === layer.id; + return ( + + ); + })} +
+ + {selectedLayer && selectedToolbarStyle ? ( +
event.stopPropagation()} + > + {toolButtons.map(({ label, icon: Icon }) => ( + + ))} + + {isGeneratedLayer(selectedLayer) ? ( + <> + + + + ) : null} +
+ ) : null} + + + +
event.stopPropagation()} + > + + +
+ +
event.stopPropagation()} + > + {canvasTools.map(({ id, label, icon: Icon }) => ( + + ))} +
+
+
+ + {metadataLayer ? ( +
setMetadataLayer(null)} + > +
event.stopPropagation()} + > +
+

{metadataLayer.title}元数据

+ +
+
+
来源
+
{metadataLayer.sourceType}
+
尺寸
+
+ {metadataLayer.originalWidth} x {metadataLayer.originalHeight} +
+
模型
+
{metadataLayer.model ?? '-'}
+
服务
+
{metadataLayer.provider ?? '-'}
+
任务
+
{metadataLayer.taskId ?? '-'}
+
对象
+
{metadataLayer.objectKey ?? metadataLayer.assetObjectId ?? '-'}
+
Prompt
+
{metadataLayer.prompt ?? '-'}
+
+
+
+ ) : null} + + {generateDialog ? ( +
{ + if (generateDialog.status !== 'generating') { + setGenerateDialog(null); + } + }} + > +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + if (generateDialog.status !== 'generating') { + void submitImageGeneration(generateDialog); + } + }} + > +
+

{generateDialog.mode === 'edit' ? '修改图片' : '生成图片'}

+ +
+
+