合并画布同源生成器恢复
This commit is contained in:
+1
-1
@@ -30,7 +30,7 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView
|
||||
|
||||
`/editor/agent` 浏览器内 AI Web 工程编辑器的静态 SPA 沙箱预览 MVP,采用“平台编辑器壳 + api-server 控制面 + 独立 runner worker + 独立预览域”四层结构;技术方案、威胁模型和验收清单见 [【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md)、[【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md](./technical/【安全模型】AIWeb工程Runner与预览隔离威胁模型-2026-06-13.md) 和 [【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md](./technical/【测试用例】AIWeb工程静态预览MVP验收清单-2026-06-13.md)。P1 先用确定性 mock Agent 生成结构化 patch、真实打通项目 / 快照 / 构建 / artifact / 预览闭环,落地拆分见 [【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md](./technical/【技术方案】EditorAgentMockAgentP1落地计划-2026-06-15.md)。
|
||||
|
||||
`/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,第一版采用前端 JSZip 打包画布中有效图层引用的上传图、生成图和修改结果,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。
|
||||
`/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,采用前端 JSZip 打包画布中有效图层引用的上传图、生成图、修改结果和角色动作序列帧;动作图层右键“导出为”提供序列帧 ZIP(含前端生成的 `preview.gif`)与 Spine JSON ZIP 两个二级选项,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。
|
||||
|
||||
图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
- 背景:外部生成队列已经承载画板和玩法的付费生成,但前端只展示排队概览,缺少可追溯任务列表、后端确认状态、完成提示补弹和退款记录到任务的追踪关系。
|
||||
- 决策:`external_generation_job` 同时作为正式生成任务列表事实源,保存 `price_mud_points`、`refund_ledger_id` 和 `notification_acknowledged_at`;新增 `external_generation_job_event` 追加状态转换审计。BFF 新增当前账号任务列表和 acknowledge 接口;前端只展示后端任务状态,完成 / 失败提示关闭时由后端写确认时间,未确认终态任务在下次登录后按列表集中弹出。任务触发的钱包扣费 / 退款流水 metadata 必须写 `externalGenerationJobId`,本机退款 outbox 重放也保留该任务 ID。
|
||||
- 2026-06-25 追加:平台壳的当前账号任务列表只在登录、网络恢复、页面回到前台或已有 queued/running/未确认终态任务时刷新;空队列刷新一次后不保持 4 秒轮询,避免 `/api/runtime/external-generation/jobs` 在无任务时持续请求。
|
||||
- 影响范围:`spacetime-module` 外部生成 schema / procedure、`spacetime-client` bindings/facade、`api-server` 外部生成 BFF、worker 失败回写和资产计费退款链路、平台入口“我的”页任务卡和完成提示弹窗。
|
||||
- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wallet_refund_outbox --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
@@ -131,7 +132,7 @@
|
||||
## 2026-06-18 图片画布 UI 设计图提取素材保留图集
|
||||
|
||||
- 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。
|
||||
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet`,返回结构复用图标 spritesheet 响应。图标生成与 UI 提取都必须把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。
|
||||
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。`,返回结构复用图标 spritesheet 响应。图标生成与 UI 提取都必须把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。
|
||||
- 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。
|
||||
- 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。
|
||||
@@ -1030,6 +1031,7 @@
|
||||
|
||||
- 背景:生成图片如果以完整 OSS 私有 bucket URL 进入前端,浏览器会裸连 OSS 并遇到 403 或绕过现有 `/api/assets/read-url` 签名缓存;同时旧对象缺少 `Cache-Control` 时只能走 `ETag` / `Last-Modified` 协商缓存,容易被误解为需要 api-server 本地磁盘缓存。
|
||||
- 决策:OSS 继续作为 generated 私有资产源站,api-server 只签发短期读 URL,不做本地磁盘静态资源兜底。前端收到同 bucket 的 `https://*.oss-*.aliyuncs.com/generated-*` 地址时,必须先归一为 legacy public path,再复用 `/api/assets/read-url` 和本地 signed URL 缓存。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`,缓存职责交给 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN。
|
||||
- 2026-06-25 追加:前端需要读取 generated/private 资源字节时,也应先通过 `/api/assets/read-url` 获取 signed OSS URL 并由浏览器直接下载字节;`/api/assets/read-bytes` 只作为换签或 OSS 读取失败后的 fallback,不作为默认文件代理路径。
|
||||
- 影响范围:`src/services/assetReadUrlService.ts`、`server-rs/crates/platform-oss`、`shared-contracts` direct upload form fields、`api-server` assets DTO 映射、后端契约文档和开发运维排障口径。
|
||||
- 验证方式:完整 OSS generated URL 应触发 `/api/assets/read-url?legacyPublicPath=...`,同一路径、同一 `refreshKey` 版本且未临近过期时复用本地 signed URL;`platform-oss` 的 `PostObject` policy / form fields 和 `PutObject` 请求头都应包含 immutable `Cache-Control`,且 `PutObject` V4 签名的 `AdditionalHeaders` 包含该普通请求头。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/platform-oss/README.md`。
|
||||
|
||||
@@ -79,6 +79,14 @@
|
||||
- 验证:`ImageCanvasGenerationLayerModel` 应断言动作结果 `src` 为首帧且 `mediaType="image-sequence"`;画布集成测试应出现 `画布序列帧:角色动作` 图片播放器,不应出现角色动作 `<video>`;导出测试应断言角色动作下载和画布素材导出都包含序列帧 ZIP / frames 目录。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationLayerModel.ts`、`src/components/image-editor/ImageCanvasWorldView.tsx`、`src/components/image-editor/ImageCanvasExportModel.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 图片画布序列帧播放不要复用普通图片淡入样式
|
||||
|
||||
- 现象:角色动作序列帧播放时看起来像每帧之间在渐变或闪烁。
|
||||
- 原因:序列帧播放器每帧切换可低至 40ms,默认约 125ms 一帧;如果帧 `<img>` 复用普通图片的 `image-canvas-editor__layer-image--loading/--loaded`,其中 `opacity 180ms ease` 会跨过下一帧切换,形成类似交叉淡入淡出的视觉。
|
||||
- 处理:`ImageCanvasImageSequenceFrame` 只使用序列帧专属 class,帧显隐用同步 `opacity` 硬切,并显式 `transition: none`;保留“下一帧未加载时继续显示上一帧”的 readiness gate。
|
||||
- 验证:`ImageCanvasWorldView.test.tsx` 应断言序列帧 `<img>` 不带普通图片 loading/loaded class,且 style 中 `transition` 为 `none`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasWorldView.tsx`、`src/index.css`、`src/components/image-editor/ImageCanvasWorldView.test.tsx`。
|
||||
|
||||
## Vidu 文生音频线上网关可能要求 sound 字段
|
||||
|
||||
- 现象:画板点击 `生成游戏音效` 后,请求返回 `Failed to deserialize the JSON body into the target type: missing field sound`。
|
||||
@@ -127,6 +135,14 @@
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasInteractionModel.test.ts`,并在多素材画布拖拽时确认参考线仍能命中邻近图层且 pointermove 不再明显掉帧。
|
||||
- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/ImageCanvasInteractionModel.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 图片画布视口拖动卡顿先查自动保存和小地图合帧
|
||||
|
||||
- 现象:素材多或序列帧多时,拖动小地图视口框或手型平移明显卡顿,像是接口慢或 CSS 动画掉帧,但网络请求不一定异常。
|
||||
- 原因:`pointermove` 高频修改 `viewport` 会触发画布重渲染、小地图模型重算和工程持久化 effect;持久化链路会同步 `serializeCanvasLayout`、`JSON.stringify` 并写 sessionStorage。远端 PATCH 有防抖也挡不住本地同步缓存写入。
|
||||
- 处理:把 viewport 拖动标记为临时交互;拖动中只更新画布显示,不触发项目保存、session cache 写入或封面快照上传,`pointerup` / `pointercancel` 后保存最终 viewport。小地图拖动的 `updateViewportFromMinimapDrag` 必须用 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧。
|
||||
- 验证:`npm run test -- src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx --reporter verbose` 应覆盖小地图拖动合帧、平移 / 小地图 viewport 交互边界,以及拖动期间不写 sessionStorage / 不调用 `saveEditorProjectLayout`。
|
||||
- 关联:`src/components/image-editor/useImageCanvasViewportControls.ts`、`src/components/image-editor/useImageCanvasStageInteractions.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 图片编辑器宣发素材生成器刷新后不要丢快照
|
||||
|
||||
- 现象:图片画布刷新后,宣发素材生成卡片消失,或卡片仍在但游戏名、分类、描述和参考图丢失。
|
||||
@@ -139,7 +155,7 @@
|
||||
|
||||
- 现象:图片画布快速编辑站内示例图、历史 generated 图或 OSS generated 图时,后端返回 `修改图片参考图必须是图片 Data URL。`。
|
||||
- 原因:快速编辑直接把图层 `src` 塞进 `/api/editor/images/generations` 的 `referenceImageSrcs`;默认示例图和部分持久化图层的 `src` 是 `/creation-type-references/*.webp`、`/generated-*` 或 OSS URL,而 `api-server` 的编辑参考图解析只接收 `data:image/*;base64,...`。
|
||||
- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 走 `/api/assets/read-bytes` 避免 CORS,普通 public 路径直接 fetch。
|
||||
- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 先走 `/api/assets/read-url` 换签后由浏览器直读 OSS,直读失败时才 fallback 到 `/api/assets/read-bytes`,普通 public 路径直接 fetch。
|
||||
- 验证:`npm run test -- src/services/image-editor/editorImageReference.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx -t "editorImageReference|converts non-data-url quick edit source images before submitting references"`。
|
||||
- 关联:`src/services/image-editor/editorImageReference.ts`、`src/components/image-editor/ImageCanvasEditorView.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -183,6 +199,14 @@
|
||||
- 验证:Network 中 `/api/editor/projects*`、`PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/common/PlatformMediaFrame.tsx`、`src/services/assetReadUrlService.ts`。
|
||||
|
||||
## 图片画布项目封面上传失败要有本地展示兜底
|
||||
|
||||
- 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。
|
||||
- 原因:项目封面快照需要先在浏览器生成 Blob,再上传 OSS 并创建 `assetKind: "project-cover-snapshot"` 项目资源;本地 dev 或 OSS CORS 异常时,Blob 生成成功但上传失败,服务端不会产生正式封面资源。
|
||||
- 处理:服务端 `project-cover-snapshot` 仍是跨设备正式封面;前端在生成封面 Blob 后立即把 Blob 以项目 ID 写入 IndexedDB,仅作为当前浏览器展示兜底。项目列表读取时优先使用服务端封面资源,其次使用本地 IndexedDB 封面,最后才退回可见画布图层或占位。IndexedDB 兜底不得写入项目快照、不得进入 `editor_project_resource`,也不得替代 OSS / asset object 正式持久化。
|
||||
- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。
|
||||
- 关联:`src/services/image-editor/editorProjectCoverCache.ts`、`src/components/project/ProjectGalleryView.tsx`、`src/components/project/ProjectCanvasCover.tsx`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`。
|
||||
|
||||
## 图片画布框选预览要复用源图换签缓存
|
||||
|
||||
- 现象:UI 设计素材提取或快速编辑框选时,画布上的红色框选还在,但底部“框选区域预览”卡片变成空白。
|
||||
@@ -247,6 +271,14 @@
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationPlacementModel.ts`、`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## 图片画布生成类 composer 打开后必须自动进入可见安全区
|
||||
|
||||
- 现象:生成器、快速编辑、裁扩或角色动作面板打开后,面板可能在当前画布视口外,或被底部工具栏 / 左下 dock 盖住,用户只看到一部分甚至完全看不到输入框。
|
||||
- 原因:placement 只负责选择画布世界坐标里的占位落点,面板实际 DOM 宽高、`translateX(-50%)`、移动端 fixed 样式和工具栏覆盖区域没有反向修正 viewport。
|
||||
- 处理:所有画布内 composer / 面板渲染后统一走 `resolveViewportForOverlayVisibility(...)`,用真实 DOM 矩形和工具栏安全边界只平移 viewport;新增入口不要在各自按钮 handler 里写独立偏移。
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasGenerationSurface.test.tsx`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasOverlayModel.ts`、`src/components/image-editor/useImageCanvasGenerationSurface.tsx`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## 图片画布图片改造也必须创建独立生成器占位
|
||||
|
||||
- 现象:点击图片图层的“改造”后,输入框直接挂在原图上,提交时既不像其它生成入口一样有独立占位,也容易让用户误以为会覆盖源图。
|
||||
@@ -343,6 +375,14 @@
|
||||
- 验证:重启 worker 后日志应先出现“非 HTTP 进程跳过 SpacetimeDB 认证快照恢复”,随后出现 `external generation worker 已启动`;同一时间窗口不应再因为 `export_auth_store_snapshot_from_tables` 缺表而阻止 job claim。HTTP `api-server` 的认证恢复日志和 503 降级语义保持不变。
|
||||
- 关联:`server-rs/crates/api-server/src/main.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/external_generation_worker_controller.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 本地旧 external-generation-worker 会抢队列并暴露成 procedure 超时
|
||||
|
||||
- 现象:角色 / 画布生成的外部 provider 与 OSS 上传已成功,但 worker 写回 `editor_project_resource` 等业务资源时报 `SpacetimeDB procedure 调用超时`,日志里可能还能看到旧 worker 二进制对 procedure 返回值做 BSATN 反序列化失败。
|
||||
- 原因:本地 `npm run dev` / `npm run dev:api-server` 默认 `GENARRATIVE_PROCESS_ROLE=all`,会自己消费队列;如果之前手动启动的同仓库、同 database `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程没有退出,旧二进制会继续 claim 新 job,schema / binding 已更新的当前进程反而没有拿到这次任务。
|
||||
- 处理:Linux 本地默认 `all` 角色启动前,`scripts/dev.mjs` 会扫描同仓库、同 SpacetimeDB server / database、同 `server-rs/target/debug/api-server` 的遗留 `external-generation-worker` 并停止;显式 `GENARRATIVE_PROCESS_ROLE=api` 做生产式拆分验证时不清理独立 worker。
|
||||
- 验证:`ps -eo pid,ppid,lstart,cmd | rg 'server-rs/target/debug/api-server'` 只应看到当前 `all` 或显式拆分下预期的进程;`/healthz` 和 `/readyz` 成功后,生成 job 应由当前进程消费并把业务资源写回。
|
||||
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 外部生成 worker 业务写回必须同事务校验 lease guard
|
||||
|
||||
- 现象:worker `complete/fail` 已校验 `worker_id + lease_token`,但如果玩法 session / work profile 写回在此之前单独调用,过期 worker 仍可能先写入业务状态,随后才在 job complete/fail 阶段失败;带计费包装的旧 worker 还可能因为 stale guard 错误触发补偿退款。
|
||||
|
||||
@@ -64,7 +64,24 @@
|
||||
└─ manifest.txt
|
||||
```
|
||||
|
||||
角色动作单图层导出时,直接生成独立 ZIP:
|
||||
角色动作单图层右键“导出为”展开二级菜单:
|
||||
|
||||
- `序列帧导出(zip)` 生成普通序列帧 ZIP,保留完整 `frames/`,并由前端把成功读取的帧拼成 `preview.gif`。
|
||||
- `Spine 导出(zip)` 生成 Spine JSON ZIP,保留 `frames/`,并附带 `skeleton.json`。
|
||||
|
||||
普通序列帧 ZIP 结构:
|
||||
|
||||
```text
|
||||
角色动作-Sequence.zip
|
||||
├─ frames/
|
||||
│ ├─ frame-01.png
|
||||
│ └─ frame-02.png
|
||||
├─ preview.gif
|
||||
├─ metadata.json
|
||||
└─ manifest.txt
|
||||
```
|
||||
|
||||
Spine JSON ZIP 结构:
|
||||
|
||||
```text
|
||||
角色动作-SpineJSON.zip
|
||||
@@ -149,9 +166,10 @@ assetObjectId > objectKey > sourceAssetId > src
|
||||
4. 对每个素材源读取 Blob:
|
||||
- `data:image/...` 直接转换为 Blob。
|
||||
- 同源或可访问 URL 使用 `fetch` 拉取 Blob。
|
||||
- 私有 generated / OSS 素材必须走同源 `/api/assets/read-bytes` 读取字节;不要在导出流程里直接 `fetch` OSS 签名 URL,避免浏览器 CORS 拦截。
|
||||
- 私有 generated / OSS 素材先走 `/api/assets/read-url` 换签并由浏览器直接 `fetch` OSS 签名 URL;只有换签或 OSS 字节读取失败时,才 fallback 到同源 `/api/assets/read-bytes`。
|
||||
- `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`。
|
||||
- `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。
|
||||
- 画布素材 ZIP 内的 `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。
|
||||
- 单图层普通序列帧导出写入 `preview.gif`,由前端基于成功读取的帧生成动画预览,不依赖压缩包读取时再临时播放 PNG。
|
||||
- 拉取失败时记录失败项,不中断整个导出。
|
||||
5. 使用 `JSZip` 写入 `images/`、`metadata.json` 和 `manifest.txt`。
|
||||
6. `zip.generateAsync({ type: 'blob' })` 生成文件。
|
||||
@@ -176,6 +194,8 @@ assetObjectId > objectKey > sourceAssetId > src
|
||||
- 图层的锁定、翻转、分组状态写入元数据。
|
||||
- `data:image` 图片不经过网络请求即可导出。
|
||||
- URL 图片 fetch 失败时不中断其他素材导出。
|
||||
- 动作图层右键菜单把 `导出为` 作为一级入口,二级菜单提供 `序列帧导出(zip)` 和 `Spine 导出(zip)`。
|
||||
- 单图层普通序列帧 ZIP 包含 `frames/`、`preview.gif`、`metadata.json` 和 `manifest.txt`,且不包含 `skeleton.json`。
|
||||
- 序列帧导出的 `skeleton.json` 可被独立验证器解析并预览。
|
||||
|
||||
## Spine JSON 验证器
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。
|
||||
- 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。
|
||||
- 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。
|
||||
- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。`PATCH /api/editor/projects/{projectId}` 只返回 `{ projectId, canvasId, updatedAt }` 轻量 ack,不再返回完整 project,前端必须以后续显式读取或生成完成返回的后端快照作为项目真相。
|
||||
- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。`PATCH /api/editor/projects/{projectId}` 只返回 `{ projectId, canvasId, updatedAt }` 轻量 ack,不再返回完整 project,前端必须以后续显式读取或生成完成返回的后端快照作为项目真相。
|
||||
- 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。
|
||||
- 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
- `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。
|
||||
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。
|
||||
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。
|
||||
- `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。
|
||||
- `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。
|
||||
- `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
|
||||
- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
- 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。参考图请求顺序为额外参考图在前、原图作为隐式最后一张提交,信息面板输入快照只展示用户额外选择的参考图。
|
||||
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
|
||||
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。
|
||||
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。
|
||||
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。
|
||||
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
|
||||
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。
|
||||
- 快速编辑的额外参考图同样必须在前端读取成图片 Data URL 后提交;后端 `referenceImageSrcs` 上限为 9 张,承载 8 张额外参考图加 1 张原图。
|
||||
@@ -116,7 +116,7 @@
|
||||
- `生成角色动作` 的完成结果同样按视频素材处理:图层主 `src` 使用后端返回的 `previewVideoPath`,`mediaType` 固定为 `video`,`assetKind` 固定为 `character-animation`,首个透明帧只写入 `thumbnailSrc` 供 poster 和后续动作源使用。下载和 ZIP 导出必须因此得到 mp4/webm/mov 等真实视频文件,不能回退为首帧 PNG;后端抽帧、绿幕去背和帧素材落盘流程仍完整保留。
|
||||
- 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
|
||||
- 图层面板支持按图层名、生成信息、模型、任务和媒体类型搜索;支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
|
||||
- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动。
|
||||
- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。
|
||||
- 鼠标滚轮默认垂直滚动画布视口;按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。
|
||||
- 工程刷新后能从后端恢复资源、图层布局和 viewport。
|
||||
- “我的”页项目入口能进入 `/project`;项目页能列出工程、重命名 / 删除单个工程、批量选择和批量删除;点击工程后进入 `/editor/canvas?projectid=<projectId>` 并按 query 加载该工程。
|
||||
|
||||
@@ -53,7 +53,7 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` 会用空的 `RUSTC_WRAP
|
||||
|
||||
Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg,`npm run dev` / `npm run dev:api-server` 会自动把该目录加入本次 `api-server` 子进程 `Path`,并注入 `CHARACTER_ANIMATION_FFMPEG_PATH` / `CHARACTER_ANIMATION_FFPROBE_PATH` 的绝对路径。这样即使外层终端或长期运行的 dev 进程是在安装 FFmpeg 之前启动,角色动画抽帧也不会继续因为 `ffmpeg: program not found` 失败;若手动配置了上述环境变量或 `GENARRATIVE_CHARACTER_ANIMATION_*` 前缀变量,显式配置优先。
|
||||
|
||||
开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true` 和 `GENARRATIVE_PROCESS_ROLE=all`,因此密码登录在本地开发环境可直接注册未知手机号账号,且本地 `api-server` 会同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。生产环境仍按 `api-server` 配置默认关闭密码自动注册,并由独立 worker 进程消费队列。
|
||||
开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true` 和 `GENARRATIVE_PROCESS_ROLE=all`,因此密码登录在本地开发环境可直接注册未知手机号账号,且本地 `api-server` 会同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。Linux 本地默认 `all` 角色启动前,dev 脚本会停止当前仓库、同一个 SpacetimeDB server / database 下遗留的 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程,避免旧 worker 二进制继续抢同一条队列并在业务写回时制造 procedure 超时;显式拆分 `api` / `external-generation-worker` 做生产式验证时不会触发这项清理。生产环境仍按 `api-server` 配置默认关闭密码自动注册,并由独立 worker 进程消费队列。
|
||||
|
||||
本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前进入持久队列的外部生成动作包括:拼图 `compile_puzzle_draft` / `generate_puzzle_images` / `generate_puzzle_ui_background`,跳一跳 `compile-draft` / `regenerate-tiles`,拼消消 `compile-draft` / `regenerate-atlas`,敲木鱼 `compile-draft` / `regenerate-hit-object`,以及图片画布 `editor_image_generation` / `editor_image_edit` / `editor_icon_spritesheet_generation` / `editor_ui_design_asset_extraction` / `editor_character_animation_generation` / `editor_video_generation` / `editor_sound_effect_generation` / `editor_background_music_generation`。非外部 provider 生成动作继续 inline,不进入队列。显式把本地进程角色设为 `api` 且没有 worker 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -99,6 +99,7 @@
|
||||
- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。
|
||||
- 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。
|
||||
- 快速编辑点击生成后不在原图上播放生成中遮罩,而是立即创建独立 `Quick Edit Generator` 画布生成占位并播放生成中动画;该占位必须复用新建图片的 placement 避让逻辑,和已有素材 / 生成占位至少保留 32px 画布间距,不允许固定放到原图右侧后压住其它素材;生成成功后结果落在该占位框位置,失败时占位标记失败并恢复快速编辑面板。
|
||||
- 任何会打开画布内 composer / 面板的入口,必须在面板渲染后通过统一 overlay 可见性校正检查真实 DOM 矩形;如果面板超出画布视口,或底部工具栏 / 左下 dock 会遮住面板,就只平移当前 viewport 让面板完整进入安全区域。新增生成类入口不要在按钮 handler 里手写单独的避让偏移。
|
||||
|
||||
## 画布悬浮信息
|
||||
|
||||
@@ -183,7 +184,7 @@
|
||||
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
|
||||
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
|
||||
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
|
||||
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
|
||||
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。` 生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
|
||||
- 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。
|
||||
- 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。
|
||||
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
- 后端固定使用 `gpt-image-2` 图片编辑链路,并固定提示词:
|
||||
|
||||
```text
|
||||
仅提取被红色框框选的素材并整理成spritesheet
|
||||
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。
|
||||
```
|
||||
|
||||
- 后端收到 spritesheet 后复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。
|
||||
|
||||
+185
@@ -5,6 +5,7 @@ import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
watch,
|
||||
@@ -817,6 +818,182 @@ async function stopProcess(child, label) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseProcessEnvBlock(rawEnv) {
|
||||
return String(rawEnv ?? '')
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
.reduce((env, entry) => {
|
||||
const separatorIndex = entry.indexOf('=');
|
||||
if (separatorIndex <= 0) {
|
||||
return env;
|
||||
}
|
||||
env[entry.slice(0, separatorIndex)] = entry.slice(separatorIndex + 1);
|
||||
return env;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function normalizeProcessLinkTarget(path) {
|
||||
return String(path ?? '').replace(/ \(deleted\)$/u, '');
|
||||
}
|
||||
|
||||
function isSamePathForDevProcess(left, right) {
|
||||
const normalizedLeft = normalizePath(normalizeProcessLinkTarget(left));
|
||||
const normalizedRight = normalizePath(normalizeProcessLinkTarget(right));
|
||||
return normalizedLeft === normalizedRight;
|
||||
}
|
||||
|
||||
function isStaleExternalGenerationWorkerProcess({
|
||||
cwd,
|
||||
env,
|
||||
exe,
|
||||
expectedDatabase,
|
||||
expectedExePath,
|
||||
expectedRepoRoot,
|
||||
expectedSpacetimeServer,
|
||||
pid,
|
||||
}) {
|
||||
if (!Number.isInteger(pid) || pid === process.pid) {
|
||||
return false;
|
||||
}
|
||||
if (env.GENARRATIVE_PROCESS_ROLE !== 'external-generation-worker') {
|
||||
return false;
|
||||
}
|
||||
if (String(env.GENARRATIVE_SPACETIME_SERVER_URL ?? '') !== expectedSpacetimeServer) {
|
||||
return false;
|
||||
}
|
||||
if (String(env.GENARRATIVE_SPACETIME_DATABASE ?? '') !== expectedDatabase) {
|
||||
return false;
|
||||
}
|
||||
if (!isSamePathForDevProcess(cwd, expectedRepoRoot)) {
|
||||
return false;
|
||||
}
|
||||
return isSamePathForDevProcess(exe, expectedExePath);
|
||||
}
|
||||
|
||||
function readLinuxApiServerProcessSnapshot(pid) {
|
||||
try {
|
||||
return {
|
||||
cwd: readlinkSync(`/proc/${pid}/cwd`),
|
||||
env: parseProcessEnvBlock(readFileSync(`/proc/${pid}/environ`, 'utf8')),
|
||||
exe: readlinkSync(`/proc/${pid}/exe`),
|
||||
pid,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error?.code === 'ENOENT' ||
|
||||
error?.code === 'EACCES' ||
|
||||
error?.code === 'EPERM'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function listLinuxProcessIds(procDir = '/proc') {
|
||||
try {
|
||||
return readdirSync(procDir)
|
||||
.map((name) => Number.parseInt(name, 10))
|
||||
.filter(Number.isInteger);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isLinuxProcessAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForLinuxProcessExit(pid, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isLinuxProcessAlive(pid)) {
|
||||
return true;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
return !isLinuxProcessAlive(pid);
|
||||
}
|
||||
|
||||
async function stopLinuxProcessId(pid, label) {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') {
|
||||
return true;
|
||||
}
|
||||
console.warn(`[dev:${label}] 停止旧进程失败 pid=${pid}: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await waitForLinuxProcessExit(pid, 5000)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') {
|
||||
return true;
|
||||
}
|
||||
console.warn(`[dev:${label}] 强制停止旧进程失败 pid=${pid}: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
return waitForLinuxProcessExit(pid, 1000);
|
||||
}
|
||||
|
||||
async function stopStaleLocalExternalGenerationWorkers({
|
||||
database,
|
||||
logStream = null,
|
||||
repoRootPath = repoRoot,
|
||||
processRole,
|
||||
spacetimeServer,
|
||||
}) {
|
||||
if (process.platform !== 'linux') {
|
||||
return [];
|
||||
}
|
||||
if (processRole !== 'all') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const expectedExePath = resolve(repoRootPath, 'server-rs/target/debug/api-server');
|
||||
const stopped = [];
|
||||
for (const pid of listLinuxProcessIds()) {
|
||||
const snapshot = readLinuxApiServerProcessSnapshot(pid);
|
||||
if (
|
||||
!snapshot ||
|
||||
!isStaleExternalGenerationWorkerProcess({
|
||||
...snapshot,
|
||||
expectedDatabase: database,
|
||||
expectedExePath,
|
||||
expectedRepoRoot: repoRootPath,
|
||||
expectedSpacetimeServer: spacetimeServer,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = snapshot.env.GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID
|
||||
? `${pid}(${snapshot.env.GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID})`
|
||||
: String(pid);
|
||||
if (await stopLinuxProcessId(pid, 'api-server')) {
|
||||
stopped.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
if (stopped.length > 0) {
|
||||
const line = `[dev:api-server] 已停止同库旧 external-generation-worker 进程: ${stopped.join(', ')}\n`;
|
||||
process.stdout.write(line);
|
||||
logStream?.write(line);
|
||||
}
|
||||
return stopped;
|
||||
}
|
||||
|
||||
function stopWindowsProcessTree(pid) {
|
||||
if (!pid) {
|
||||
return;
|
||||
@@ -1436,6 +1613,12 @@ class DevRunner {
|
||||
mergedEnv.GENARRATIVE_API_SERVER_LOG_FILE = logFile;
|
||||
|
||||
stopExistingWindowsApiServer(logStream);
|
||||
await stopStaleLocalExternalGenerationWorkers({
|
||||
database: this.options.database,
|
||||
logStream,
|
||||
processRole: mergedEnv.GENARRATIVE_PROCESS_ROLE,
|
||||
spacetimeServer: this.state.spacetimeServer,
|
||||
});
|
||||
|
||||
console.log(`[dev:api-server] log: ${logFile}`);
|
||||
console.log(
|
||||
@@ -2278,7 +2461,9 @@ export {
|
||||
DevRunner,
|
||||
isDirectModuleExecution,
|
||||
isSpacetimePublishPermissionError,
|
||||
isStaleExternalGenerationWorkerProcess,
|
||||
normalizeCargoVersionRequirement,
|
||||
parseProcessEnvBlock,
|
||||
parseArgs,
|
||||
parseSpacetimeToolVersion,
|
||||
resolveDevStackStatePath,
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
DevRunner,
|
||||
isDirectModuleExecution,
|
||||
isSpacetimePublishPermissionError,
|
||||
isStaleExternalGenerationWorkerProcess,
|
||||
normalizeCargoVersionRequirement,
|
||||
parseProcessEnvBlock,
|
||||
parseArgs,
|
||||
parseSpacetimeToolVersion,
|
||||
resolveDevStackStatePath,
|
||||
@@ -282,6 +284,67 @@ describe('dev scheduler api-server env', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler local worker cleanup', () => {
|
||||
const expected = {
|
||||
expectedDatabase: 'xushi-p4wfr',
|
||||
expectedExePath: '/repo/Genarrative/server-rs/target/debug/api-server',
|
||||
expectedRepoRoot: '/repo/Genarrative',
|
||||
expectedSpacetimeServer: 'http://127.0.0.1:10002',
|
||||
};
|
||||
|
||||
test('解析 /proc environ 的 NUL 分隔格式', () => {
|
||||
expect(parseProcessEnvBlock('A=1\0B=two=parts\0\0')).toEqual({
|
||||
A: '1',
|
||||
B: 'two=parts',
|
||||
});
|
||||
});
|
||||
|
||||
test('识别同仓库同库的旧 external-generation-worker', () => {
|
||||
expect(
|
||||
isStaleExternalGenerationWorkerProcess({
|
||||
...expected,
|
||||
cwd: '/repo/Genarrative',
|
||||
env: {
|
||||
GENARRATIVE_PROCESS_ROLE: 'external-generation-worker',
|
||||
GENARRATIVE_SPACETIME_DATABASE: 'xushi-p4wfr',
|
||||
GENARRATIVE_SPACETIME_SERVER_URL: 'http://127.0.0.1:10002',
|
||||
},
|
||||
exe: '/repo/Genarrative/server-rs/target/debug/api-server (deleted)',
|
||||
pid: 12345,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('不同数据库或仓库的 worker 不会被当成本地旧进程', () => {
|
||||
expect(
|
||||
isStaleExternalGenerationWorkerProcess({
|
||||
...expected,
|
||||
cwd: '/repo/Genarrative',
|
||||
env: {
|
||||
GENARRATIVE_PROCESS_ROLE: 'external-generation-worker',
|
||||
GENARRATIVE_SPACETIME_DATABASE: 'other-db',
|
||||
GENARRATIVE_SPACETIME_SERVER_URL: 'http://127.0.0.1:10002',
|
||||
},
|
||||
exe: '/repo/Genarrative/server-rs/target/debug/api-server',
|
||||
pid: 12345,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isStaleExternalGenerationWorkerProcess({
|
||||
...expected,
|
||||
cwd: '/repo/Other',
|
||||
env: {
|
||||
GENARRATIVE_PROCESS_ROLE: 'external-generation-worker',
|
||||
GENARRATIVE_SPACETIME_DATABASE: 'xushi-p4wfr',
|
||||
GENARRATIVE_SPACETIME_SERVER_URL: 'http://127.0.0.1:10002',
|
||||
},
|
||||
exe: '/repo/Other/server-rs/target/debug/api-server',
|
||||
pid: 12345,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler Rust build env', () => {
|
||||
test('local dev Rust env bypasses project sccache wrapper', () => {
|
||||
const env = buildLocalRustProcessEnv(
|
||||
|
||||
@@ -271,7 +271,7 @@ pub(crate) fn should_skip_asset_operation_billing_for_connectivity(
|
||||
error: &SpacetimeClientError,
|
||||
) -> bool {
|
||||
match error {
|
||||
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout => true,
|
||||
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => true,
|
||||
SpacetimeClientError::Build(message)
|
||||
| SpacetimeClientError::Procedure(message)
|
||||
| SpacetimeClientError::Runtime(message) => {
|
||||
|
||||
@@ -935,12 +935,17 @@ fn map_big_fish_client_error(error: SpacetimeClientError) -> AppError {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||
SpacetimeClientError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
let message = match &error {
|
||||
SpacetimeClientError::Timeout => "SpacetimeDB 会话读取超时,请稍后重试。".to_string(),
|
||||
SpacetimeClientError::Timeout(stage) => {
|
||||
format!(
|
||||
"SpacetimeDB 会话读取超时({}),请稍后重试。",
|
||||
stage.as_str()
|
||||
)
|
||||
}
|
||||
SpacetimeClientError::ConnectDropped => {
|
||||
"SpacetimeDB 会话连接已断开,请稍后重试。".to_string()
|
||||
}
|
||||
@@ -983,6 +988,8 @@ fn current_timestamp_micros_to_string(value: i64) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use spacetime_client::SpacetimeClientStage;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -1001,7 +1008,7 @@ mod tests {
|
||||
#[test]
|
||||
fn big_fish_gallery_keeps_timeout_errors_visible() {
|
||||
assert!(!should_soft_fallback_big_fish_gallery(
|
||||
&SpacetimeClientError::Timeout
|
||||
&SpacetimeClientError::Timeout(SpacetimeClientStage::ProcedureResult)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use spacetime_client::SpacetimeClientStage;
|
||||
|
||||
pub(super) fn map_big_fish_session_response(
|
||||
session: BigFishSessionRecord,
|
||||
@@ -237,7 +238,7 @@ pub(super) async fn load_big_fish_session_with_retry(
|
||||
.await
|
||||
{
|
||||
Ok(session) => return Ok(session),
|
||||
Err(error @ SpacetimeClientError::Timeout)
|
||||
Err(error @ SpacetimeClientError::Timeout(_))
|
||||
| Err(error @ SpacetimeClientError::ConnectDropped) => {
|
||||
last_retryable_error = Some(error);
|
||||
if attempt == 0 {
|
||||
@@ -249,7 +250,11 @@ pub(super) async fn load_big_fish_session_with_retry(
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_retryable_error.unwrap_or(SpacetimeClientError::Timeout))
|
||||
Err(
|
||||
last_retryable_error.unwrap_or(SpacetimeClientError::Timeout(
|
||||
SpacetimeClientStage::ProcedureResult,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn map_record_anchor_pack_to_domain(
|
||||
|
||||
@@ -81,7 +81,7 @@ const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
|
||||
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
|
||||
const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
|
||||
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT: &str =
|
||||
"仅提取被红色框框选的素材并整理成spritesheet";
|
||||
"仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。";
|
||||
const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image";
|
||||
const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project";
|
||||
const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character";
|
||||
@@ -4847,7 +4847,7 @@ mod tests {
|
||||
fn editor_ui_design_asset_extraction_prompt_is_fixed() {
|
||||
assert_eq!(
|
||||
EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT,
|
||||
"仅提取被红色框框选的素材并整理成spritesheet"
|
||||
"仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ fn map_public_work_client_error(error: SpacetimeClientError) -> AppError {
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||
SpacetimeClientError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
|
||||
SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
@@ -382,7 +382,7 @@ pub(super) fn puzzle_bad_request(
|
||||
pub(super) fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let status = match &error {
|
||||
SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE,
|
||||
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||
SpacetimeClientError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
|
||||
error if should_skip_asset_operation_billing_for_connectivity(error) => {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
}
|
||||
@@ -450,7 +450,7 @@ pub(super) fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
} else if matches!(
|
||||
error,
|
||||
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout
|
||||
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_)
|
||||
) || should_skip_asset_operation_billing_for_connectivity(&error)
|
||||
{
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
@@ -485,7 +485,7 @@ pub(super) fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError
|
||||
StatusCode::BAD_GATEWAY
|
||||
}
|
||||
SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE,
|
||||
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||
SpacetimeClientError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
|
||||
@@ -1604,6 +1604,7 @@ fn is_missing_creation_entry_config_procedure(error: &SpacetimeClientError) -> b
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use module_ai::{AiTaskKind, generate_ai_task_id};
|
||||
use spacetime_client::SpacetimeClientStage;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -1618,7 +1619,7 @@ mod tests {
|
||||
&SpacetimeClientError::Procedure("No such procedure".to_string()),
|
||||
));
|
||||
assert!(!is_missing_creation_entry_config_procedure(
|
||||
&SpacetimeClientError::Timeout,
|
||||
&SpacetimeClientError::Timeout(SpacetimeClientStage::ProcedureResult),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -1455,7 +1455,7 @@ fn map_spacetime_error(error: SpacetimeClientError) -> AppError {
|
||||
{
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||
SpacetimeClientError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
|
||||
SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
@@ -360,7 +360,7 @@ pub enum SpacetimeClientError {
|
||||
ConnectDropped,
|
||||
Procedure(String),
|
||||
Runtime(String),
|
||||
Timeout,
|
||||
Timeout(SpacetimeClientStage),
|
||||
}
|
||||
|
||||
const DEFAULT_PROCEDURE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
@@ -711,7 +711,7 @@ impl SpacetimeClient {
|
||||
.map_err(|_| {
|
||||
SpacetimeStageError::new(
|
||||
SpacetimeClientStage::PoolAcquire,
|
||||
SpacetimeClientError::Timeout,
|
||||
SpacetimeClientError::Timeout(SpacetimeClientStage::PoolAcquire),
|
||||
)
|
||||
})?
|
||||
.map_err(|error| {
|
||||
@@ -804,7 +804,7 @@ impl SpacetimeClient {
|
||||
.map_err(|_| {
|
||||
SpacetimeStageError::new(
|
||||
SpacetimeClientStage::ConnectBuild,
|
||||
SpacetimeClientError::Timeout,
|
||||
SpacetimeClientError::Timeout(SpacetimeClientStage::ConnectBuild),
|
||||
)
|
||||
})?
|
||||
.map_err(|error| {
|
||||
@@ -821,7 +821,7 @@ impl SpacetimeClient {
|
||||
.map_err(|_| {
|
||||
SpacetimeStageError::new(
|
||||
SpacetimeClientStage::ConnectHandshake,
|
||||
SpacetimeClientError::Timeout,
|
||||
SpacetimeClientError::Timeout(SpacetimeClientStage::ConnectHandshake),
|
||||
)
|
||||
})?
|
||||
.map_err(|_| {
|
||||
@@ -945,7 +945,7 @@ impl SpacetimeClient {
|
||||
.map_err(|_| {
|
||||
SpacetimeStageError::new(
|
||||
SpacetimeClientStage::ReadModelSubscribe,
|
||||
SpacetimeClientError::Timeout,
|
||||
SpacetimeClientError::Timeout(SpacetimeClientStage::ReadModelSubscribe),
|
||||
)
|
||||
})?
|
||||
.map_err(|_| {
|
||||
@@ -970,7 +970,7 @@ impl SpacetimeClient {
|
||||
// 超时后必须统一归还租约;若连接已先一步断开则回传断线,否则标记坏连接并回传超时。
|
||||
fn resolve_timeout_error(
|
||||
connection: Option<&PooledConnection>,
|
||||
_stage: SpacetimeClientStage,
|
||||
stage: SpacetimeClientStage,
|
||||
) -> SpacetimeClientError {
|
||||
if let Some(connection) = connection {
|
||||
if connection.is_broken() {
|
||||
@@ -979,7 +979,7 @@ impl SpacetimeClient {
|
||||
connection.mark_broken();
|
||||
}
|
||||
|
||||
SpacetimeClientError::Timeout
|
||||
SpacetimeClientError::Timeout(stage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1127,7 +1127,9 @@ impl fmt::Display for SpacetimeClientError {
|
||||
f.write_str(message)
|
||||
}
|
||||
Self::ConnectDropped => f.write_str("SpacetimeDB 连接在返回结果前已断开"),
|
||||
Self::Timeout => f.write_str("SpacetimeDB procedure 调用超时"),
|
||||
Self::Timeout(stage) => {
|
||||
f.write_str(&format!("SpacetimeDB {} 调用超时", stage.as_str()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1241,6 +1243,6 @@ mod tests {
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.stage, SpacetimeClientStage::PoolAcquire);
|
||||
assert!(matches!(error.error, SpacetimeClientError::Timeout));
|
||||
assert!(matches!(error.error, SpacetimeClientError::Timeout(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +294,61 @@ describe('App navigation history', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('缺少 projectid 的项目画布直达会替换回创作页', () => {
|
||||
window.history.replaceState(null, '', '/editor/canvas');
|
||||
const replaceStateSpy = vi.spyOn(window.history, 'replaceState');
|
||||
|
||||
renderApp();
|
||||
|
||||
expect(screen.getByTestId('selection-stage').textContent).toBe(
|
||||
'creation-home',
|
||||
);
|
||||
expect(window.location.pathname).toBe('/creation');
|
||||
expect(window.location.search).toBe('');
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(
|
||||
{ [APP_HISTORY_STATE_KEY]: true },
|
||||
'',
|
||||
'/creation',
|
||||
);
|
||||
|
||||
replaceStateSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('带 projectid 的项目画布直达保持编辑器阶段', () => {
|
||||
mockMatchMedia(false);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/editor/canvas?projectid=project-from-url',
|
||||
);
|
||||
|
||||
renderApp();
|
||||
|
||||
expect(screen.getByTestId('selection-stage').textContent).toBe(
|
||||
'image-editor',
|
||||
);
|
||||
expect(window.location.pathname).toBe('/editor/canvas');
|
||||
expect(window.location.search).toBe('?projectid=project-from-url');
|
||||
});
|
||||
|
||||
test('历史回到缺少 projectid 的项目画布时会替换回创作页', async () => {
|
||||
mockMatchMedia(false);
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
|
||||
renderApp();
|
||||
|
||||
window.history.pushState(null, '', '/editor/canvas');
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('selection-stage').textContent).toBe(
|
||||
'creation-home',
|
||||
);
|
||||
expect(window.location.pathname).toBe('/creation');
|
||||
expect(window.location.search).toBe('');
|
||||
});
|
||||
|
||||
test('项目画布导航只写入一次带 projectid 的历史记录', async () => {
|
||||
mockMatchMedia(true);
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
|
||||
+32
-5
@@ -25,6 +25,7 @@ import {
|
||||
readPublicWorkCodeFromLocationSearch,
|
||||
resolveInitialSelectionStageFromPath,
|
||||
resolvePathForSelectionStage,
|
||||
shouldRedirectEditorCanvasWithoutProject,
|
||||
} from './routing/appPageRoutes';
|
||||
import type { RpgRuntimeAppIntent } from './RpgRuntimeApp';
|
||||
import {
|
||||
@@ -62,6 +63,23 @@ function isRpgRuntimeRoute(pathname: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInitialAppSelectionStage() {
|
||||
if (
|
||||
shouldRedirectEditorCanvasWithoutProject(
|
||||
window.location.pathname,
|
||||
window.location.search,
|
||||
)
|
||||
) {
|
||||
replaceAppHistoryPath('/creation');
|
||||
return 'creation-home';
|
||||
}
|
||||
|
||||
return resolveInitialSelectionStageFromPath(
|
||||
window.location.pathname,
|
||||
getInitialPlatformDesktopLayout(),
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const authUi = useAuthUi();
|
||||
const runtimeIntentTokenRef = useRef(0);
|
||||
@@ -75,11 +93,8 @@ export default function App() {
|
||||
const [isRuntimeActive, setIsRuntimeActive] = useState(() =>
|
||||
isRpgRuntimeRoute(window.location.pathname),
|
||||
);
|
||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
|
||||
resolveInitialSelectionStageFromPath(
|
||||
window.location.pathname,
|
||||
getInitialPlatformDesktopLayout(),
|
||||
),
|
||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(
|
||||
resolveInitialAppSelectionStage,
|
||||
);
|
||||
const [runtimeReturnStage, setRuntimeReturnStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
@@ -111,6 +126,18 @@ export default function App() {
|
||||
window.history.state,
|
||||
);
|
||||
|
||||
if (
|
||||
shouldRedirectEditorCanvasWithoutProject(
|
||||
window.location.pathname,
|
||||
window.location.search,
|
||||
)
|
||||
) {
|
||||
replaceAppHistoryPath('/creation');
|
||||
setIsRuntimeActive(false);
|
||||
setRawSelectionStage('creation-home');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRpgRuntimeRoute(window.location.pathname)) {
|
||||
setIsRuntimeActive(true);
|
||||
return;
|
||||
|
||||
@@ -16,14 +16,27 @@ import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
import {
|
||||
calculateEditorImageGenerationPrice,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
|
||||
type ReferenceLabelFormatter = (
|
||||
reference: CharacterReferenceImage,
|
||||
index: number,
|
||||
) => string;
|
||||
|
||||
type ReferenceTone =
|
||||
| 'default'
|
||||
| 'spec'
|
||||
| 'icon'
|
||||
| 'ui'
|
||||
| 'quick-edit'
|
||||
| 'video'
|
||||
| 'audio';
|
||||
|
||||
type ImageCanvasBasicGenerationComposerViewProps = {
|
||||
dialog: GenerateDialogState;
|
||||
style: CSSProperties;
|
||||
@@ -37,11 +50,41 @@ type ImageCanvasBasicGenerationComposerViewProps = {
|
||||
anchor: HTMLElement | null,
|
||||
placement: 'above' | 'below',
|
||||
) => CSSProperties;
|
||||
onRequestUpload: (target: 'generation-reference') => void;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onPickReferenceFromCanvas?: () => void;
|
||||
onToggleReferenceMenu?: () => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
dialogLabel?: string;
|
||||
includeDimensions?: boolean;
|
||||
includeModel?: boolean;
|
||||
includeReferences?: boolean;
|
||||
promptLabel?: string;
|
||||
promptPlaceholder?: string;
|
||||
optionLabelPrefix?: string;
|
||||
dimensionRatioAriaLabelPrefix?: string;
|
||||
dimensionSizeAriaLabelPrefix?: string;
|
||||
referenceUploadTarget?: UploadTarget;
|
||||
referenceButtonIcon?: ReactNode;
|
||||
referenceButtonLabel?: string;
|
||||
referenceButtonAriaLabel?: string;
|
||||
referenceMenuLabel?: string;
|
||||
referenceSlotTone?: ReferenceTone;
|
||||
referenceSlotDisabled?: boolean;
|
||||
referenceLabelFormatter?: ReferenceLabelFormatter;
|
||||
referenceAriaLabelFormatter?: ReferenceLabelFormatter;
|
||||
referenceRemoveLabelFormatter?: ReferenceLabelFormatter;
|
||||
showReferenceAddButton?: boolean;
|
||||
formClassName?: string;
|
||||
footerClassName?: string;
|
||||
promptClassName?: string;
|
||||
referenceSlotClassName?: string;
|
||||
submitButtonClassName?: string;
|
||||
submitLabel?: string;
|
||||
submitAriaLabel?: string;
|
||||
submittingStatusLabel?: string;
|
||||
};
|
||||
|
||||
function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
return {
|
||||
...dialog,
|
||||
@@ -50,31 +93,6 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
};
|
||||
}
|
||||
|
||||
function ReferenceChip({
|
||||
reference,
|
||||
index,
|
||||
onRemove,
|
||||
}: {
|
||||
reference: CharacterReferenceImage;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const label = reference.label || `参考图${index + 1}`;
|
||||
return (
|
||||
<ImageCanvasReferenceSlot
|
||||
tone="default"
|
||||
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
|
||||
imageSrc={reference.src}
|
||||
objectKey={reference.objectKey}
|
||||
label={label}
|
||||
ariaLabel={label}
|
||||
title={reference.label}
|
||||
onRemove={onRemove}
|
||||
removeLabel={`删除${label}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasBasicGenerationComposerView({
|
||||
dialog,
|
||||
style,
|
||||
@@ -86,12 +104,60 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
renderEditorPortal = (node) => node,
|
||||
buildPortalMenuStyle = () => ({}),
|
||||
onRequestUpload,
|
||||
onPickReferenceFromCanvas,
|
||||
onToggleReferenceMenu,
|
||||
onRememberImageModel = () => {},
|
||||
onSubmit,
|
||||
dialogLabel,
|
||||
includeDimensions = true,
|
||||
includeModel = true,
|
||||
includeReferences = true,
|
||||
promptLabel,
|
||||
promptPlaceholder,
|
||||
optionLabelPrefix,
|
||||
dimensionRatioAriaLabelPrefix,
|
||||
dimensionSizeAriaLabelPrefix,
|
||||
referenceUploadTarget = 'generation-reference',
|
||||
referenceButtonIcon = <ImageIcon className="h-4 w-4" aria-hidden="true" />,
|
||||
referenceButtonLabel = '参考图',
|
||||
referenceButtonAriaLabel = '添加参考图',
|
||||
referenceMenuLabel = '参考图来源',
|
||||
referenceSlotTone = 'default',
|
||||
referenceSlotDisabled = false,
|
||||
referenceLabelFormatter,
|
||||
referenceAriaLabelFormatter,
|
||||
referenceRemoveLabelFormatter,
|
||||
showReferenceAddButton = true,
|
||||
formClassName,
|
||||
footerClassName,
|
||||
promptClassName = 'image-canvas-editor__generation-prompt',
|
||||
referenceSlotClassName,
|
||||
submitButtonClassName = 'image-canvas-editor__generation-submit',
|
||||
submitLabel = '生成',
|
||||
submitAriaLabel = '生成',
|
||||
submittingStatusLabel = '生成中',
|
||||
}: ImageCanvasBasicGenerationComposerViewProps) {
|
||||
const references = dialog.generationReferences ?? [];
|
||||
const isQuickEdit = dialog.mode === 'quick-edit';
|
||||
const resolvedDialogLabel =
|
||||
dialogLabel ?? optionLabelPrefix ?? (isQuickEdit ? '改造图片' : '生成图片');
|
||||
const resolvedPromptLabel =
|
||||
promptLabel ?? (isQuickEdit ? '改造提示词' : '生成提示词');
|
||||
const resolvedPromptPlaceholder =
|
||||
promptPlaceholder ??
|
||||
(isQuickEdit ? '想怎么改造这张图?' : '今天想生成什么画面?');
|
||||
const resolvedOptionLabelPrefix =
|
||||
optionLabelPrefix ?? (isQuickEdit ? '改造图片' : '生成图片');
|
||||
const resolvedReferenceButtonLabel = referenceButtonLabel;
|
||||
const resolvedReferenceButtonAriaLabel =
|
||||
referenceButtonAriaLabel ?? `添加${resolvedReferenceButtonLabel}`;
|
||||
const hasReferenceMenu = includeReferences && onRequestUpload;
|
||||
const finalFormClassName =
|
||||
formClassName ??
|
||||
'image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image';
|
||||
const finalFooterClassName =
|
||||
footerClassName ?? 'image-canvas-editor__generation-composer-footer';
|
||||
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: isGenerationReferenceMenuOpen,
|
||||
boundaryRefs: [generationReferenceButtonRef],
|
||||
@@ -101,10 +167,10 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image"
|
||||
className={finalFormClassName}
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label={isQuickEdit ? '改造图片' : '生成图片'}
|
||||
aria-label={resolvedDialogLabel}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -113,102 +179,133 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
{references.map((reference, index) => (
|
||||
<ReferenceChip
|
||||
key={reference.id}
|
||||
reference={reference}
|
||||
index={index}
|
||||
onRemove={() =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
generationReferences: (
|
||||
currentDialog.generationReferences ?? []
|
||||
).filter((item) => item.id !== reference.id),
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<ImageCanvasReferenceSlot
|
||||
buttonRef={generationReferenceButtonRef}
|
||||
tone="default"
|
||||
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
|
||||
label="参考图"
|
||||
ariaLabel="添加参考图"
|
||||
disabled={dialog.status === 'generating'}
|
||||
isAdd
|
||||
onClick={() => onToggleReferenceMenu?.()}
|
||||
/>
|
||||
</div>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label={isQuickEdit ? '改造提示词' : '生成提示词'}
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder={isQuickEdit ? '想怎么改造这张图?' : '今天想生成什么画面?'}
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
prompt: event.target.value,
|
||||
{includeReferences ? (
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
{references.map((reference, index) => {
|
||||
const label =
|
||||
(referenceLabelFormatter?.(reference, index) ??
|
||||
reference.label) ||
|
||||
`参考图${index + 1}`;
|
||||
const ariaLabel =
|
||||
referenceAriaLabelFormatter?.(reference, index) ?? label;
|
||||
const removeLabel =
|
||||
referenceRemoveLabelFormatter?.(reference, index) ??
|
||||
`删除${ariaLabel}`;
|
||||
|
||||
return (
|
||||
<ImageCanvasReferenceSlot
|
||||
key={reference.id}
|
||||
tone={referenceSlotTone}
|
||||
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
|
||||
imageSrc={reference.src}
|
||||
objectKey={reference.objectKey}
|
||||
label={label}
|
||||
ariaLabel={ariaLabel}
|
||||
title={reference.label}
|
||||
onRemove={() =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
generationReferences: (
|
||||
currentDialog.generationReferences ?? []
|
||||
).filter((item) => item.id !== reference.id),
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
removeLabel={removeLabel}
|
||||
className={referenceSlotClassName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showReferenceAddButton ? (
|
||||
<ImageCanvasReferenceSlot
|
||||
buttonRef={generationReferenceButtonRef}
|
||||
tone={referenceSlotTone}
|
||||
icon={referenceButtonIcon}
|
||||
label={resolvedReferenceButtonLabel}
|
||||
ariaLabel={resolvedReferenceButtonAriaLabel}
|
||||
disabled={
|
||||
dialog.status === 'generating' || referenceSlotDisabled
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="image-canvas-editor__generation-composer-footer">
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix={isQuickEdit ? '改造图片' : '生成图片'}
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
model: dialog.imageModel,
|
||||
imageSize: dialog.imageSize,
|
||||
})}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
isAdd
|
||||
onClick={() => onToggleReferenceMenu?.()}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label={resolvedPromptLabel}
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder={resolvedPromptPlaceholder}
|
||||
size="sm"
|
||||
density="compact"
|
||||
className={promptClassName}
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
prompt: event.target.value,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{dialog.status === 'generating' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="info"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="image-canvas-editor__generate-status"
|
||||
role="status"
|
||||
>
|
||||
生成中
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="image-canvas-editor__generate-status"
|
||||
role="alert"
|
||||
>
|
||||
{dialog.errorMessage}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<div className={finalFooterClassName}>
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions={includeDimensions}
|
||||
includeModel={includeModel}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
|
||||
dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix}
|
||||
optionLabelPrefix={resolvedOptionLabelPrefix}
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
model: dialog.imageModel,
|
||||
imageSize: dialog.imageSize,
|
||||
})}
|
||||
submitLabel={submitLabel}
|
||||
submitAriaLabel={submitAriaLabel}
|
||||
submitButtonClassName={submitButtonClassName}
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
/>
|
||||
</div>
|
||||
{dialog.status === 'generating' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="info"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="image-canvas-editor__generate-status"
|
||||
role="status"
|
||||
>
|
||||
{submittingStatusLabel}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
className="image-canvas-editor__generate-status"
|
||||
role="alert"
|
||||
>
|
||||
{dialog.errorMessage}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
</form>
|
||||
{isGenerationReferenceMenuOpen && generationReferenceButtonRef
|
||||
{hasReferenceMenu &&
|
||||
isGenerationReferenceMenuOpen &&
|
||||
generationReferenceButtonRef
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
|
||||
label="参考图来源"
|
||||
label={referenceMenuLabel}
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
generationReferenceButtonRef.current,
|
||||
@@ -218,7 +315,11 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
<PlatformFloatingMenuItem
|
||||
onClick={() => {
|
||||
setIsGenerationReferenceMenuOpen?.(false);
|
||||
setIsPickingGenerationReferenceFromCanvas?.(true);
|
||||
if (onPickReferenceFromCanvas) {
|
||||
onPickReferenceFromCanvas();
|
||||
} else {
|
||||
setIsPickingGenerationReferenceFromCanvas?.(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
从画布中选择
|
||||
@@ -227,7 +328,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
onClick={() => {
|
||||
setIsGenerationReferenceMenuOpen?.(false);
|
||||
setIsPickingGenerationReferenceFromCanvas?.(false);
|
||||
onRequestUpload('generation-reference');
|
||||
onRequestUpload(referenceUploadTarget);
|
||||
}}
|
||||
>
|
||||
上传图片
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
return {
|
||||
@@ -33,6 +33,7 @@ function renderContextMenus(
|
||||
canvasClipboard: null,
|
||||
imageContextMenu: null,
|
||||
imageContextMenuLayer: null,
|
||||
contextMenuLayer: null,
|
||||
contextShouldShowLayer: false,
|
||||
contextShouldUnlockLayer: false,
|
||||
onPasteCanvasClipboard: vi.fn(),
|
||||
@@ -82,7 +83,10 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
});
|
||||
|
||||
it('renders layer context commands and forwards layer operations', () => {
|
||||
const layer = createLayer({ assetKind: 'character' });
|
||||
const layer = createLayer({
|
||||
assetKind: 'character',
|
||||
mediaType: 'image-sequence',
|
||||
});
|
||||
const props = renderContextMenus({
|
||||
contextMenu: {
|
||||
kind: 'layer',
|
||||
@@ -93,6 +97,7 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
},
|
||||
canvasClipboard: { layers: [layer], mode: 'copy' },
|
||||
imageContextMenuLayer: layer,
|
||||
contextMenuLayer: layer,
|
||||
contextShouldShowLayer: true,
|
||||
contextShouldUnlockLayer: true,
|
||||
});
|
||||
@@ -104,6 +109,17 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '显示' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '解锁' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' }));
|
||||
expect(
|
||||
screen
|
||||
.getByRole('menuitem', { name: '导出为' })
|
||||
.getAttribute('aria-haspopup'),
|
||||
).toBe('menu');
|
||||
fireEvent.click(
|
||||
screen.getByRole('menuitem', { name: '序列帧导出(zip)' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole('menuitem', { name: 'Spine 导出(zip)' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
|
||||
|
||||
@@ -114,6 +130,14 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
expect(props.onToggleContextLayerVisibility).toHaveBeenCalledTimes(1);
|
||||
expect(props.onToggleContextLayerLock).toHaveBeenCalledTimes(1);
|
||||
expect(props.onFlipContextLayers).toHaveBeenCalledWith('x');
|
||||
expect(props.onExportContextLayer).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ mode: 'sequence-with-preview' }),
|
||||
);
|
||||
expect(props.onExportContextLayer).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ mode: 'spine-json' }),
|
||||
);
|
||||
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
|
||||
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
|
||||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { PlatformFloatingMenu, PlatformFloatingMenuItem } from '../common/PlatformFloatingMenu';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import type {
|
||||
CanvasClipboard,
|
||||
CanvasContextMenuState,
|
||||
@@ -13,6 +16,7 @@ type ImageCanvasContextMenusViewProps = {
|
||||
canvasClipboard: CanvasClipboard | null;
|
||||
imageContextMenu: ImageContextMenuState | null;
|
||||
imageContextMenuLayer: CanvasLayer | null;
|
||||
contextMenuLayer: CanvasLayer | null;
|
||||
contextShouldShowLayer: boolean;
|
||||
contextShouldUnlockLayer: boolean;
|
||||
onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void;
|
||||
@@ -24,7 +28,9 @@ type ImageCanvasContextMenusViewProps = {
|
||||
onToggleContextLayerVisibility: () => void;
|
||||
onToggleContextLayerLock: () => void;
|
||||
onFlipContextLayers: (axis: 'x' | 'y') => void;
|
||||
onExportContextLayer: () => void;
|
||||
onExportContextLayer: (options?: {
|
||||
mode?: 'spine-json' | 'sequence-with-preview';
|
||||
}) => void;
|
||||
onDeleteContextLayers: () => void;
|
||||
onDeleteLayerById: (layerId: string | null) => void;
|
||||
onCloseContextMenu: () => void;
|
||||
@@ -42,6 +48,7 @@ export function ImageCanvasContextMenusView({
|
||||
canvasClipboard,
|
||||
imageContextMenu,
|
||||
imageContextMenuLayer,
|
||||
contextMenuLayer,
|
||||
contextShouldShowLayer,
|
||||
contextShouldUnlockLayer,
|
||||
onPasteCanvasClipboard,
|
||||
@@ -64,6 +71,8 @@ export function ImageCanvasContextMenusView({
|
||||
onOpenLayerMetadata,
|
||||
onOpenCharacterAnimationPanel,
|
||||
}: ImageCanvasContextMenusViewProps) {
|
||||
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextMenu ? (
|
||||
@@ -182,7 +191,11 @@ export function ImageCanvasContextMenusView({
|
||||
移动至底层
|
||||
</button>
|
||||
<hr />
|
||||
<button type="button" role="menuitem" onClick={onGroupContextLayers}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onGroupContextLayers}
|
||||
>
|
||||
创建组
|
||||
</button>
|
||||
<button
|
||||
@@ -221,9 +234,52 @@ export function ImageCanvasContextMenusView({
|
||||
>
|
||||
垂直翻转
|
||||
</button>
|
||||
<button type="button" role="menuitem" onClick={onExportContextLayer}>
|
||||
导出为
|
||||
</button>
|
||||
{isImageSequenceLayer ? (
|
||||
<div className="image-canvas-editor__context-submenu">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-haspopup="menu"
|
||||
className="image-canvas-editor__context-submenu-trigger"
|
||||
>
|
||||
<span>导出为</span>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__context-submenu-panel"
|
||||
label="动作导出选项"
|
||||
placement="bottom-start"
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() =>
|
||||
onExportContextLayer({
|
||||
mode: 'sequence-with-preview',
|
||||
})
|
||||
}
|
||||
>
|
||||
序列帧导出(zip)
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() =>
|
||||
onExportContextLayer({ mode: 'spine-json' })
|
||||
}
|
||||
>
|
||||
Spine 导出(zip)
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => onExportContextLayer()}
|
||||
>
|
||||
导出为
|
||||
</button>
|
||||
)}
|
||||
<hr />
|
||||
{imageContextMenuLayer ? (
|
||||
<>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user