合并画布同源生成器恢复

This commit is contained in:
2026-06-26 00:29:27 +08:00
79 changed files with 5045 additions and 1114 deletions
+1 -1
View File
@@ -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`
+41 -1
View File
@@ -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 新 jobschema / 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
View File
@@ -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,
+63
View File
@@ -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) => {
+10 -3
View File
@@ -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,
}
+2 -1
View File
@@ -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,
};
+11 -9
View File
@@ -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(_)));
}
}
+55
View File
@@ -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
View File
@@ -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