diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8fbf4c8ce..1075149ff 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -527,7 +527,7 @@ - 背景:外部图片生成已从 HTTP 长请求迁到 `external_generation_job` 队列;跳一跳、拼消消和敲木鱼继续扩展时需要统一 job 粒度、前端等待展示和本地 / 生产验证口径。 - 决策:队列 BFF 暴露用户可见队列概览 `GET /api/runtime/external-generation/queue-overview` 和单 job 状态 `GET /api/runtime/external-generation/jobs/{jobId}`;首版固定“单动作单 job”,不拆提示词 / 生图 / 切图 / 持久化等阶段 job。进入队列的范围为跳一跳 `compile-draft` / `regenerate-tiles`、拼消消 `compile-draft` / `regenerate-atlas`、敲木鱼 `compile-draft` / `regenerate-hit-object` 图片资产动作;非外部图片生成动作继续 inline。 - 影响范围:外部生成 worker Module、api-server BFF、生成页等待展示、跳一跳 / 拼消消 / 敲木鱼创作与结果页生成动作、本地和生产验证文档。 -- 验证方式:本地 `npm run dev` 默认保留 inline 开发体验;验证 worker 队列、等待展示、lease 或扩缩容时显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 并启动 worker,或运行 `npm run container:worker-smoke -- smoke`。部署后确认 `/healthz`、`/readyz`、队列概览 BFF、单 job 状态和对应玩法 session/detail 状态都能收敛。 +- 验证方式:本地 `npm run dev` 与 `npm run dev:api-server` 默认注入 `GENARRATIVE_PROCESS_ROLE=all`,同一 Rust 进程监听 HTTP 并消费外部生成队列;验证生产式拆分角色、lease 或扩缩容时分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`,或运行 `npm run container:worker-smoke -- smoke`。部署后确认 `/healthz`、`/readyz`、队列概览 BFF、单 job 状态和对应玩法 session/detail 状态都能收敛。 - 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 51a1c1dd6..9f2bf1792 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -247,6 +247,22 @@ - 验证:`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`。 +## 图片画布图片改造也必须创建独立生成器占位 + +- 现象:点击图片图层的“改造”后,输入框直接挂在原图上,提交时既不像其它生成入口一样有独立占位,也容易让用户误以为会覆盖源图。 +- 原因:图片改造复用了旧 `QuickEditPanelState` / redraw 面板路径,只把源图选中并在原图附近打开快速编辑框,没有进入统一的 `CanvasGenerationDialogState` 占位链路。 +- 处理:图片和用户快照图层的“改造”统一创建 `mode="quick-edit"` 的 generation dialog,占位仍走 `ImageCanvasGenerationPlacementModel`;源图作为隐式最后一张参考图提交,并把“当前图”提示词归一到对应参考图编号。音频改造继续走音频生成器路径,非图片 fallback 才保留旧面板。参考图 Data URL 提交前可压缩,但浏览器图片解码卡住时必须超时透传原图,不能阻塞生成请求。 +- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorImageReference.test.ts -- --runInBand`,以及 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "hides quick edit and redraw panels|opens generated image info|shows the quick edit generator" -- --runInBand`。 +- 关联:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorImageReference.ts`。 + +## 图片画布生成完成应用项目快照后也要刷新素材库 + +- 现象:部分素材生成成功后画布上已经出现结果,但左侧素材库没有立刻出现新素材,刷新页面后才显示。 +- 原因:生成接口带 `project` 快照时,前端只调用 `applyProjectSnapshot(...)` 刷新画布布局;左侧素材库状态仍停留在首次 `loadEditorAssetLibrary()` 的结果。只有少数图片分支手动 `upsertGeneratedAsset`,图标素材图集、视频、音频、排队完成后重新 `loadEditorProject` 等分支不会统一更新素材库。 +- 处理:素材库 hook 必须提供显式 `refreshAssetLibrary()`;传给生成工作流的项目快照应用函数应包装为“先应用项目快照,再刷新素材库”。新增生成分支不要在各自分支散落刷新逻辑,除非是无项目快照的本地图层回填,才继续使用 `generatedAssetSnapshot` / `upsertGeneratedAsset`。 +- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "refreshes the asset library after an icon generation project snapshot is applied" -- --runInBand`;`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx -- --runInBand`。 +- 关联:`src/components/image-editor/useImageCanvasAssetLibrary.ts`、`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`。 + ## Windows 本地 dev 不要把 RUSTC_WRAPPER 绕过写成 rustc - 现象:Windows 上执行 `npm run dev:api-server` 时,api-server 在 Cargo 启动阶段失败,日志出现 `error: multiple input filenames provided (first two filenames are ... rustc.exe and -)`,`/healthz` 无法访问。 diff --git a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md index bac119faa..66a94e92b 100644 --- a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md +++ b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md @@ -56,12 +56,29 @@ │ ├─ frames/ │ │ ├─ frame-01.png │ │ └─ frame-02.png +│ ├─ skeleton.json +│ ├─ README.md │ ├─ metadata.json │ └─ manifest.txt ├─ metadata.json └─ manifest.txt ``` +角色动作单图层导出时,直接生成独立 ZIP: + +```text +角色动作-SpineJSON.zip +├─ frames/ +│ ├─ frame-01.png +│ └─ frame-02.png +├─ skeleton.json +├─ README.md +├─ metadata.json +└─ manifest.txt +``` + +`skeleton.json` 使用 Spine JSON 4.2 结构表达序列帧:一个 `root` bone、一个 `character` slot、每帧一个 region attachment,`animations.animation.slots.character.attachment` 按时间切换 attachment。它不是 `.spine` 编辑器工程,也不包含 mesh、权重、IK 或拆件绑定。 + ## 元数据结构 `metadata.json` 记录项目、导出时间、图层和图片文件映射: @@ -134,6 +151,7 @@ assetObjectId > objectKey > sourceAssetId > src - 同源或可访问 URL 使用 `fetch` 拉取 Blob。 - 私有 generated / OSS 素材必须走同源 `/api/assets/read-bytes` 读取字节;不要在导出流程里直接 `fetch` OSS 签名 URL,避免浏览器 CORS 拦截。 - `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`。 + - `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。 - 拉取失败时记录失败项,不中断整个导出。 5. 使用 `JSZip` 写入 `images/`、`metadata.json` 和 `manifest.txt`。 6. `zip.generateAsync({ type: 'blob' })` 生成文件。 @@ -158,6 +176,32 @@ assetObjectId > objectKey > sourceAssetId > src - 图层的锁定、翻转、分组状态写入元数据。 - `data:image` 图片不经过网络请求即可导出。 - URL 图片 fetch 失败时不中断其他素材导出。 +- 序列帧导出的 `skeleton.json` 可被独立验证器解析并预览。 + +## Spine JSON 验证器 + +导出产物验证项目位于 `tools/spine-json-export-validator/`,独立于主站构建。它用 `@esotericsoftware/spine-webgl` 的 `SkeletonJson`、自定义 `AttachmentLoader`、`GLTexture` 和 `SceneRenderer` 直接验证导出的 `skeleton.json + frames/`,不要求 atlas。 + +首次使用安装验证器自己的依赖: + +```bash +npm --prefix tools/spine-json-export-validator install +``` + +本地启动: + +```bash +npm run spine-export-validator:dev +``` + +检查命令: + +```bash +npm run spine-export-validator:typecheck +npm run spine-export-validator:build +``` + +验证器支持根目录 `skeleton.json`,也支持画布素材 ZIP 内 `sequences/<编号-标题>/skeleton.json`。当前只验证 region attachment;如果未来导出 mesh、sequence attachment 或多动画命名,需要同步扩展验证器的 loader 和 UI 摘要。 ## 后续扩展 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 3cc32e059..6093f3207 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -208,11 +208,11 @@ npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.tes 本地 smoke: ```bash -GENARRATIVE_PROCESS_ROLE=all npm run dev +npm run dev curl -f http://127.0.0.1:/healthz ``` -本地 `npm run dev` 默认保持 `inline` 开发体验:未显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,普通本地联调可以同步确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行。需要验证 worker 队列、BFF 队列状态、lease 重领或扩缩容时,必须显式使用 `queue`,并启动 worker 角色;可以用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 做临时单进程 smoke,也可以使用隔离容器 smoke。 +本地 `npm run dev` 与 `npm run dev:api-server` 默认注入 `GENARRATIVE_PROCESS_ROLE=all`,同一 Rust 进程同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。需要验证生产式拆分角色、lease 重领或扩缩容时,再分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`,也可以使用隔离容器 smoke。 生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index fbca02498..4cc568e2b 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -53,11 +53,9 @@ 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`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。 +开发态 `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` 默认保留 inline 开发体验:未显式设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,外部生成 handler 会同步复用 worker executor,完成后返回 `completed`,便于快速确认 provider、OSS 和 SpacetimeDB 写回链路。inline 不创建 `external_generation_job`,也不能验证 worker lease、队列等待展示或动态扩缩容。 - -本地排查外部内容生成 worker 队列时,必须显式使用 queue,例如 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server`,让同一 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,不进入队列。worker 数量为 0 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。 +本地排查外部内容生成 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。 `我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。 @@ -387,7 +385,7 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. 生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。 -`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`external-generation-controller` 只管理 worker systemd 实例,`all` 仅用于本地或临时 smoke,不隐式启动 controller。外部生成策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制;生产和容器压测默认保持 `queue`,本地 `npm run dev` 默认保留 `inline` 开发体验,只有显式配置 `queue` 才会落 `external_generation_job`。`inline` 只用于本地或低并发同步排查,HTTP handler 会直接复用 worker executor,完成后返回 `completed`,但不会落 `external_generation_job`,也不能通过增加 worker 进程扩吞吐。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;生产默认由 `genarrative-external-generation-controller.service` 读取 `get_external_generation_queue_stats_and_return`,按 `claimable_pending + running_active + expired_running` 计算目标 worker 数,并对 `genarrative-external-generation-worker@N.service` 精确执行 `systemctl start/stop`。controller 参数模板是 `deploy/env/external-generation-controller.env.example`:默认保底 `MIN_WORKERS=1`、上限 `MAX_WORKERS=8`、每 worker 目标 `TARGET_JOBS_PER_WORKER=2`、`POLL_INTERVAL_MS=10000`、连续 `SCALE_DOWN_IDLE_ROUNDS=6` 轮完全空闲才缩容;缩容每轮只停止最高编号的一个实例,且不主动停止 `@1`。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会安装 worker 模板、controller unit 和两份专属 env 模板,默认 enable 首个 `genarrative-external-generation-worker@1.service` 与 `genarrative-external-generation-controller.service`;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active,同时重启并验活 controller。手动兜底扩容仍可用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`;controller 下轮会按队列压力修正到目标实例数。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`,若不希望重启 controller 可传 `--no-worker-controller`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。首版 worker 粒度是单动作单 job,不拆阶段 job;当前外部生成动作覆盖拼图、跳一跳、拼消消、敲木鱼和图片画布编辑器生成入口,纯元信息保存、发布、试玩启动、运行态动作和公开读取继续 inline。图片画布 worker 成功后由后端保存 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只轮询 job 并重新读取项目快照。当前生成业务失败只做用户重新触发,不做自动业务重试,避免 worker 退款和重试成功之间产生钱包账本漂移。 +`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`external-generation-controller` 只管理 worker systemd 实例,`all` 仅用于本地或临时 smoke,不隐式启动 controller。外部生成策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制;生产和容器压测默认保持 `queue`,本地 `npm run dev` / `npm run dev:api-server` 默认由 dev 脚本注入 `GENARRATIVE_PROCESS_ROLE=all`,如果外部生成策略为 `queue` 会由同一进程消费队列。`inline` 只用于本地或低并发同步排查,HTTP handler 会直接复用 worker executor,完成后返回 `completed`,但不会落 `external_generation_job`,也不能通过增加 worker 进程扩吞吐。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;生产默认由 `genarrative-external-generation-controller.service` 读取 `get_external_generation_queue_stats_and_return`,按 `claimable_pending + running_active + expired_running` 计算目标 worker 数,并对 `genarrative-external-generation-worker@N.service` 精确执行 `systemctl start/stop`。controller 参数模板是 `deploy/env/external-generation-controller.env.example`:默认保底 `MIN_WORKERS=1`、上限 `MAX_WORKERS=8`、每 worker 目标 `TARGET_JOBS_PER_WORKER=2`、`POLL_INTERVAL_MS=10000`、连续 `SCALE_DOWN_IDLE_ROUNDS=6` 轮完全空闲才缩容;缩容每轮只停止最高编号的一个实例,且不主动停止 `@1`。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会安装 worker 模板、controller unit 和两份专属 env 模板,默认 enable 首个 `genarrative-external-generation-worker@1.service` 与 `genarrative-external-generation-controller.service`;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active,同时重启并验活 controller。手动兜底扩容仍可用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`;controller 下轮会按队列压力修正到目标实例数。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`,若不希望重启 controller 可传 `--no-worker-controller`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。首版 worker 粒度是单动作单 job,不拆阶段 job;当前外部生成动作覆盖拼图、跳一跳、拼消消、敲木鱼和图片画布编辑器生成入口,纯元信息保存、发布、试玩启动、运行态动作和公开读取继续 inline。图片画布 worker 成功后由后端保存 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只轮询 job 并重新读取项目快照。当前生成业务失败只做用户重新触发,不做自动业务重试,避免 worker 退款和重试成功之间产生钱包账本漂移。 `Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等通用构建链依赖。因 VectorEngine 图片上游 POST 已改用 `libcurl`,当前 Linux release 构建出的 `api-server` 运行时需要 `OPENSSL_3.2.0` 符号;Ubuntu 24.04 apt 默认只提供 OpenSSL 3.0.x,不能直接满足该符号版本。Provision 会把 OpenSSL `3.2.0` 独立安装到 `/opt/genarrative/openssl-3.2.0`,校验官方 tarball SHA256,并只通过 `genarrative-api.service` 的 `LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 让 api-server 使用,避免替换系统 OpenSSL 或影响 ssh / nginx / apt。Ubuntu / apt 目标机为完成这一步会安装 `build-essential`、`ca-certificates`、`curl`、`perl`、`tar` 等 OpenSSL 运行时自举工具;这只服务于独立 OpenSSL 运行时安装,不代表 provision 重新承担 api-server 构建职责。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。 diff --git a/package.json b/package.json index 6d15185b0..87c2d3280 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,9 @@ "deploy:pingora-direct-rollback": "bash scripts/deploy/pingora-direct-rollback.sh", "assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs", "assets:match3d-style-references": "node scripts/generate-match3d-style-references.mjs", + "spine-export-validator:dev": "npm --prefix tools/spine-json-export-validator run dev --", + "spine-export-validator:typecheck": "npm --prefix tools/spine-json-export-validator run typecheck", + "spine-export-validator:build": "npm --prefix tools/spine-json-export-validator run build", "check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs", "check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs", "check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs", diff --git a/scripts/dev.mjs b/scripts/dev.mjs index b5c204c0e..4fcd73b11 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -2253,6 +2253,7 @@ function buildApiServerProcessEnv({ ...baseEnv, // 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。 GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true', + GENARRATIVE_PROCESS_ROLE: baseEnv.GENARRATIVE_PROCESS_ROLE || 'all', GENARRATIVE_API_HOST: options.apiHost, GENARRATIVE_API_PORT: String(options.apiPort), GENARRATIVE_API_LOG: options.apiLog, diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 1d7e4c515..a95ff9981 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -211,10 +211,22 @@ describe('dev scheduler api-server env', () => { }); expect(env.GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED).toBe('true'); + expect(env.GENARRATIVE_PROCESS_ROLE).toBe('all'); expect(env.GENARRATIVE_API_PORT).toBe('9091'); expect(env.GENARRATIVE_SPACETIME_SERVER_URL).toBe('http://127.0.0.1:3199'); }); + test('dev 脚本保留显式 api-server 进程角色', () => { + const {options} = parseArgs(['api-server'], {}); + const env = buildApiServerProcessEnv({ + baseEnv: {GENARRATIVE_PROCESS_ROLE: 'api'}, + options, + state: {spacetimeServer: 'http://127.0.0.1:3199'}, + }); + + expect(env.GENARRATIVE_PROCESS_ROLE).toBe('api'); + }); + test('Windows 本地 dev 自动注入已安装的 FFmpeg 路径', () => { const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-ffmpeg-')); try { diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index d6b9bf71d..2036eeb09 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -91,6 +91,7 @@ export function ImageCanvasBasicGenerationComposerView({ onSubmit, }: ImageCanvasBasicGenerationComposerViewProps) { const references = dialog.generationReferences ?? []; + const isQuickEdit = dialog.mode === 'quick-edit'; useImageCanvasFloatingOptionDismiss({ isOpen: isGenerationReferenceMenuOpen, boundaryRefs: [generationReferenceButtonRef], @@ -103,7 +104,7 @@ export function ImageCanvasBasicGenerationComposerView({ className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image" style={style} role="dialog" - aria-label="生成图片" + aria-label={isQuickEdit ? '改造图片' : '生成图片'} onPointerDown={(event) => event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); @@ -145,10 +146,10 @@ export function ImageCanvasBasicGenerationComposerView({ { expect(props.onToggleContextLayerLock).toHaveBeenCalledTimes(1); expect(props.onFlipContextLayers).toHaveBeenCalledWith('x'); expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer); + expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1); + expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1); expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1); }); + it('closes the standalone image menu after opening character animation', () => { + const layer = createLayer({ assetKind: 'character' }); + const props = renderContextMenus({ + imageContextMenu: { layerId: layer.id, x: 20, y: 22 }, + imageContextMenuLayer: layer, + }); + + fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' })); + + expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer); + expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1); + }); + it('renders the image context menu when no canvas context menu is open', () => { const layer = createLayer({ assetKind: 'character' }); const props = renderContextMenus({ diff --git a/src/components/image-editor/ImageCanvasContextMenusView.tsx b/src/components/image-editor/ImageCanvasContextMenusView.tsx index e3b9019c0..46cd1ad49 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.tsx @@ -249,9 +249,11 @@ export function ImageCanvasContextMenusView({ @@ -300,7 +302,10 @@ export function ImageCanvasContextMenusView({ {imageContextMenuLayer.assetKind === 'character' ? ( onOpenCharacterAnimationPanel(imageContextMenuLayer)} + onClick={() => { + onOpenCharacterAnimationPanel(imageContextMenuLayer); + onCloseImageContextMenu(); + }} > 生成动画 diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index b8bcdcc38..833aea8e0 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -985,8 +985,8 @@ describe('ImageCanvasEditorView generation integration', () => { clientY: 120, }); fireEvent.click(screen.getByRole('button', { name: '改造' })); - expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy(); - expect(screen.queryByRole('button', { name: '关闭重绘图片' })).toBeNull(); + expect(screen.getByRole('dialog', { name: '改造图片' })).toBeTruthy(); + expect(screen.getByLabelText('快速编辑生成占位图')).toBeTruthy(); fireEvent.pointerDown(viewport, { button: 0, @@ -995,7 +995,7 @@ describe('ImageCanvasEditorView generation integration', () => { clientY: 190, }); - expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull(); + expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); }); it('does not render a standalone close button on the generation composer', () => { @@ -2413,6 +2413,148 @@ describe('ImageCanvasEditorView generation integration', () => { expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy(); }); + it('refreshes the asset library after an icon generation project snapshot is applied', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-icon-library-refresh', + title: '图标素材刷新画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-icon-spec-refresh', + resourceId: 'resource-icon-spec-refresh', + title: '清爽按钮图标规范', + src: 'data:image/png;base64,icon-spec-refresh', + x: 240, + y: 80, + width: 160, + height: 120, + originalWidth: 2048, + originalHeight: 1152, + zIndex: 11, + sourceType: 'generated', + assetKind: 'icon-spec', + }, + ], + resources: [], + updatedAt: '2026-06-15T00:00:00.000Z', + }); + loadEditorAssetLibraryMock + .mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [], + }) + .mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createGeneratedAsset({ + assetId: 'asset-icon-spritesheet-refresh', + folderId: 'project', + label: '清爽按钮素材图集', + imageSrc: 'data:image/png;base64,icon-sheet-refresh', + width: 512, + height: 512, + assetKind: 'icon-spritesheet', + }), + ], + }); + const spritesheetResource = createGeneratedResource({ + resourceId: 'resource-icon-spritesheet-refresh', + projectId: 'editor-project-icon-library-refresh', + imageSrc: 'data:image/png;base64,icon-sheet-refresh', + width: 512, + height: 512, + assetKind: 'icon-spritesheet', + }); + generateEditorIconSpritesheetMock.mockResolvedValueOnce({ + spritesheetImageSrc: 'data:image/png;base64,icon-sheet-refresh', + spritesheetWidth: 512, + spritesheetHeight: 512, + iconImageSrcs: [], + prompt: '图标 prompt', + actualPrompt: '图标 prompt', + model: 'gemini-3.1-flash-image-preview', + provider: 'VectorEngine', + taskId: 'icon-library-refresh-task', + priceMudPoints: 3, + spritesheetResource, + spritesheetAsset: createGeneratedAsset({ + assetId: 'asset-icon-spritesheet-refresh', + folderId: 'project', + label: '清爽按钮素材图集', + imageSrc: 'data:image/png;base64,icon-sheet-refresh', + width: 512, + height: 512, + assetKind: 'icon-spritesheet', + }), + project: { + projectId: 'editor-project-icon-library-refresh', + title: '图标素材刷新画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [spritesheetResource], + updatedAt: '2026-06-15T00:01:00.000Z', + }, + }); + render(); + + await screen.findByAltText('画布图片:清爽按钮图标规范'); + await waitFor(() => { + expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(1); + }); + expect( + screen.queryByRole('button', { name: '添加清爽按钮素材图集' }), + ).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); + const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); + fireEvent.click( + within(iconPanel).getByRole('button', { name: '图标规范' }), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); + fireEvent.pointerDown( + screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!, + { + button: 0, + pointerId: 182, + clientX: 260, + clientY: 100, + }, + ); + fireEvent.change( + within(iconPanel).getByRole('textbox', { name: '素材描述' }), + { + target: { value: '返回按钮' }, + }, + ); + fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' })); + + await waitFor(() => { + expect(generateEditorIconSpritesheetMock).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2); + }); + expect( + await screen.findByRole('button', { name: '添加清爽按钮素材图集' }), + ).toBeTruthy(); + }); + it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { @@ -3457,12 +3599,12 @@ describe('ImageCanvasEditorView generation integration', () => { expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '改造' })); - const editDialog = screen.getByRole('dialog', { name: '重绘图片' }); + const editDialog = screen.getByRole('dialog', { name: '改造图片' }); expect(editDialog).toBeTruthy(); - const editPrompt = within(editDialog).getByLabelText('重绘提示词'); + const editPrompt = within(editDialog).getByLabelText('改造提示词'); expect(editPrompt.className).toContain('platform-text-field'); expect(editPrompt.className).toContain( - 'image-canvas-editor__quick-edit-prompt', + 'image-canvas-editor__generation-prompt', ); fireEvent.change(editPrompt, { target: { value: '把画面改成黄昏光线' }, @@ -3484,32 +3626,28 @@ describe('ImageCanvasEditorView generation integration', () => { }); await waitFor(() => { - expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); }); - expect(screen.getByAltText(/画布图片:生成图片 .* 重绘/)).toBeTruthy(); + expect(screen.getByAltText(/画布图片:生成图片 .* 快速编辑/)).toBeTruthy(); fireEvent.click( screen.getAllByRole('button', { - name: /查看生成图片 .* 重绘图片信息/u, + name: /查看生成图片 .* 快速编辑图片信息/u, })[0]!, ); const editedMetadataDialog = screen.getByRole('dialog', { name: '图片信息', }); expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull(); - expect(within(editedMetadataDialog).getByText('重绘提示词')).toBeTruthy(); + expect(within(editedMetadataDialog).getByText('快速编辑提示词')).toBeTruthy(); expect( within(editedMetadataDialog).getByText('把画面改成黄昏光线'), ).toBeTruthy(); - expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy(); - expect( - within(editedMetadataDialog).getByText(/^生成图片 \d+$/u), - ).toBeTruthy(); expect( screen.getByRole('button', { name: /当前缩放比例 \d+%/u }), ).toBeTruthy(); }); - it('keeps the redraw panel open after generation starts while keeping the source preview visible', async () => { + it('shows the quick edit generator after redraw starts while keeping the source preview visible', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-edit-generating', title: '重绘生成中画布', @@ -3555,20 +3693,18 @@ describe('ImageCanvasEditorView generation integration', () => { clientY: 180, }); fireEvent.click(screen.getByRole('button', { name: '改造' })); - const editDialog = screen.getByRole('dialog', { name: '重绘图片' }); - fireEvent.change(within(editDialog).getByLabelText('重绘提示词'), { + const editDialog = screen.getByRole('dialog', { name: '改造图片' }); + fireEvent.change(within(editDialog).getByLabelText('改造提示词'), { target: { value: '改成雨夜灯光' }, }); fireEvent.click(within(editDialog).getByRole('button', { name: '生成' })); - expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); + expect(screen.getByLabelText('快速编辑生成占位图')).toBeTruthy(); expect(screen.getByAltText('画布图片:待重绘')).toBeTruthy(); expect(sourceLayer.className).not.toContain( 'image-canvas-editor__layer--generating', ); - expect( - within(editDialog).getByRole('button', { name: '生成中' }), - ).toBeTruthy(); }); it('hides the quick edit panel after generation starts while keeping the source preview visible', async () => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index e9be6c4f6..c2150ec9e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -13,6 +13,7 @@ import { createEditorAsset, createEditorProjectResource, type EditorAssetSnapshot, + type EditorProjectSnapshot, loadEditorGenerationPricing, } from '../../services/image-editor/editorProjectClient'; import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'; @@ -394,6 +395,7 @@ export function ImageCanvasEditorView() { handleAssetMarqueePointerDown, handleAssetMarqueePointerMove, handleAssetMarqueePointerUp, + refreshAssetLibrary, } = useImageCanvasAssetLibrary({ assetListRef, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, @@ -670,6 +672,13 @@ export function ImageCanvasEditorView() { canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, openEditorLoginModal, }); + const applyGeneratedProjectSnapshot = useCallback( + (project: EditorProjectSnapshot) => { + applyProjectSnapshot(project); + void refreshAssetLibrary(); + }, + [applyProjectSnapshot, refreshAssetLibrary], + ); const persistUpdatedLayerResource = useCallback( (layer: CanvasLayer) => { if (!projectId) { @@ -788,7 +797,7 @@ export function ImageCanvasEditorView() { projectId, assetFolderId: activeUploadFolderId, upsertGeneratedAsset, - applyProjectSnapshot, + applyProjectSnapshot: applyGeneratedProjectSnapshot, onWalletBalanceMayHaveChanged: refreshEditorWalletBalance, }); const { diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index 6479edb40..ba822dc14 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; +import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { buildLayerExportMetadata, + buildSpineImageSequenceJson, + buildSpineImageSequenceReadme, dataUrlToBlob, formatExportDate, getImageExtensionFromTypeOrSrc, @@ -11,7 +14,6 @@ import { readLayerImageSequenceFrameBlob, sanitizeExportFilePart, } from './ImageCanvasExportModel'; -import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { DEFAULT_ICON_DESCRIPTIONS, UI_DESIGN_ASSET_EXTRACTION_PROMPT, @@ -169,6 +171,56 @@ describe('ImageCanvasExportModel', () => { }); }); + it('builds a Spine JSON attachment timeline from image sequence frames', () => { + const layer = buildLayer({ + mediaType: 'image-sequence', + originalWidth: 192, + originalHeight: 256, + durationSeconds: 0.5, + }); + const spineJson = buildSpineImageSequenceJson({ + layer, + frames: [ + { frameIndex: 1, fileName: 'frame-01.png', width: 192, height: 256 }, + { frameIndex: 2, fileName: 'frame-02.png', width: 192, height: 256 }, + { frameIndex: 3, fileName: 'frame-03.png', width: 192, height: 256 }, + { frameIndex: 4, fileName: 'frame-04.png', width: 192, height: 256 }, + ], + }); + + expect(spineJson.skeleton).toMatchObject({ + images: './frames/', + fps: 8, + width: 192, + height: 256, + }); + expect(spineJson.bones).toEqual([{ name: 'root' }]); + expect(spineJson.slots[0]).toEqual({ + name: 'character', + bone: 'root', + attachment: 'animation_0001', + }); + expect( + spineJson.skins[0]!.attachments.character.animation_0001, + ).toMatchObject({ + type: 'region', + path: 'frame-01', + y: 128, + width: 192, + height: 256, + }); + expect( + spineJson.animations.animation.slots.character.attachment, + ).toEqual([ + { time: 0, name: 'animation_0001' }, + { time: 0.125, name: 'animation_0002' }, + { time: 0.25, name: 'animation_0003' }, + { time: 0.375, name: 'animation_0004' }, + { time: 0.5, name: 'animation_0001' }, + ]); + expect(buildSpineImageSequenceReadme(layer)).toContain('skeleton.json'); + }); + it('reads private object-key assets through same-origin bytes before exporting', async () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url: string) => { diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index 320878a48..a4aaab76d 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -1,9 +1,11 @@ -import { readAssetBytes } from '../../services/assetReadUrlService'; +import { + readAssetBytes, +} from '../../services/assetReadUrlService'; import type { CanvasAssetExportMetadata, CanvasGenerationInputField, - CanvasMediaType, CanvasLayer, + CanvasMediaType, } from './ImageCanvasEditorTypes'; import { DEFAULT_ICON_DESCRIPTIONS, @@ -210,6 +212,119 @@ export function getImageSequenceFrameFileName( return `frame-${frameNumber}.${extension}`; } +export type ExportedImageSequenceFrame = { + frameIndex: number; + fileName: string; + width: number; + height: number; +}; + +const DEFAULT_SPINE_VERSION = '4.2.00'; +const SPINE_SEQUENCE_BONE_NAME = 'root'; +const SPINE_SEQUENCE_SLOT_NAME = 'character'; +const SPINE_SEQUENCE_ANIMATION_NAME = 'animation'; + +function stripFileExtension(fileName: string) { + return fileName.replace(/\.[^.]+$/u, ''); +} + +function roundSpineTime(value: number) { + return Number(value.toFixed(6)); +} + +export function buildSpineImageSequenceJson({ + layer, + frames, +}: { + layer: CanvasLayer; + frames: ExportedImageSequenceFrame[]; +}) { + if (!frames.length) { + throw new Error('序列帧为空'); + } + + const durationSeconds = + typeof layer.durationSeconds === 'number' && layer.durationSeconds > 0 + ? layer.durationSeconds + : frames.length; + const frameDuration = durationSeconds / frames.length; + const skeletonWidth = frames[0]?.width || layer.originalWidth; + const skeletonHeight = frames[0]?.height || layer.originalHeight; + const attachments = Object.fromEntries( + frames.map((frame, index) => { + const attachmentName = `${SPINE_SEQUENCE_ANIMATION_NAME}_${String( + index + 1, + ).padStart(4, '0')}`; + return [ + attachmentName, + { + type: 'region', + path: stripFileExtension(frame.fileName), + x: 0, + y: roundSpineTime(frame.height / 2), + width: frame.width, + height: frame.height, + }, + ]; + }), + ); + const timeline = frames.map((_frame, index) => ({ + time: roundSpineTime(index * frameDuration), + name: `${SPINE_SEQUENCE_ANIMATION_NAME}_${String(index + 1).padStart( + 4, + '0', + )}`, + })); + timeline.push({ + time: roundSpineTime(durationSeconds), + name: `${SPINE_SEQUENCE_ANIMATION_NAME}_0001`, + }); + + return { + skeleton: { + spine: DEFAULT_SPINE_VERSION, + images: './frames/', + fps: Math.max(1, Math.round(frames.length / durationSeconds)), + width: skeletonWidth, + height: skeletonHeight, + }, + bones: [{ name: SPINE_SEQUENCE_BONE_NAME }], + slots: [ + { + name: SPINE_SEQUENCE_SLOT_NAME, + bone: SPINE_SEQUENCE_BONE_NAME, + attachment: `${SPINE_SEQUENCE_ANIMATION_NAME}_0001`, + }, + ], + skins: [ + { + name: 'default', + attachments: { + [SPINE_SEQUENCE_SLOT_NAME]: attachments, + }, + }, + ], + animations: { + [SPINE_SEQUENCE_ANIMATION_NAME]: { + slots: { + [SPINE_SEQUENCE_SLOT_NAME]: { + attachment: timeline, + }, + }, + }, + }, + }; +} + +export function buildSpineImageSequenceReadme(layer: CanvasLayer) { + return [ + `素材:${layer.title}`, + '内容:Spine JSON 序列帧导入包', + '导入:在 Spine Editor 中导入 skeleton.json,并保持 frames/ 目录与 skeleton.json 同级。', + '说明:该 JSON 使用单 slot attachment timeline 播放序列帧,不包含拆件、骨骼绑定或 mesh 权重。', + ].join('\n'); +} + export async function blobToUint8Array(blob: Blob) { if (typeof blob.arrayBuffer === 'function') { return new Uint8Array(await blob.arrayBuffer()); diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index c0753550c..325509132 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -985,7 +985,8 @@ export function ImageCanvasGenerationComposerView({ ) : null} - {generateDialog?.mode === 'generate' && + {(generateDialog?.mode === 'generate' || + generateDialog?.mode === 'quick-edit') && generateDialog.composerOpen !== false && generationComposerStyle ? ( { }), ), ).toBe('请先登录后再生成图片'); + expect( + resolveImageGenerationErrorMessage( + new ApiClientError({ + message: '请求冲突(requestId: wallet-one)', + status: 409, + code: 'CONFLICT', + details: { provider: 'profile-wallet' }, + }), + ), + ).toBe('泥点余额不足,请充值后再生成'); + expect( + resolveImageGenerationErrorMessage( + new ApiClientError({ + message: '请求冲突(requestId: conflict-one)', + status: 409, + code: 'CONFLICT', + }), + ), + ).toBe('生成任务未完成,请稍后重试'); }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 791920d3a..501c3ec17 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -1304,7 +1304,10 @@ export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) { return 'Image Generator'; } -export function resolveImageGenerationErrorMessage(error: unknown) { +export function resolveImageGenerationErrorMessage( + error: unknown, + fallbackMessage = '生成图片失败', +) { if ( error instanceof ApiClientError && (error.status === 401 || error.status === 403) @@ -1312,7 +1315,22 @@ export function resolveImageGenerationErrorMessage(error: unknown) { return '请先登录后再生成图片'; } + if (error instanceof ApiClientError && error.status === 409) { + const message = error.message.trim(); + if (message && !message.startsWith('请求冲突')) { + return message; + } + const provider = + typeof error.details?.provider === 'string' + ? error.details.provider.trim() + : ''; + if (provider === 'profile-wallet') { + return '泥点余额不足,请充值后再生成'; + } + return '生成任务未完成,请稍后重试'; + } + return error instanceof Error && error.message.trim() ? error.message - : '生成图片失败'; + : fallbackMessage; } diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 3237de2a7..3ba2a483b 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -118,6 +118,61 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); + it('builds quick-edit canvas generator plans with the source layer as the final reference', () => { + const sourceLayer = createLayer({ + objectKey: 'generated-character-drafts/editor/source.png', + assetKind: 'character', + }); + + const plan = buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'quick-edit', + prompt: '把当前图改成雨天', + status: 'idle', + sourceLayerId: sourceLayer.id, + imageModel: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '4:3', + imageSize: '1K', + generationReferences: [ + { + id: 'style-reference', + label: '风格参考', + src: 'data:image/png;base64,style', + }, + ], + }, + layers: [sourceLayer], + nextGeneratedIndex: 4, + }); + + expect(plan).toMatchObject({ + kind: 'quick-edit', + normalizedPrompt: '把图2改成雨天', + sourceLayer, + input: { + prompt: '把图2改成雨天', + size: '1024x768', + kind: 'character', + model: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '4:3', + imageSize: '1K', + referenceImageSrcs: [ + 'data:image/png;base64,style', + 'generated-character-drafts/editor/source.png', + ], + }, + result: { + title: '源图 快速编辑', + assetKind: 'character', + generationInputs: { + fields: [{ title: '快速编辑提示词', value: '把图2改成雨天' }], + references: [], + }, + }, + rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, + }); + }); + it('builds spec generation plans with reference prompt semantics', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index c2f3d60ef..b9dcc7df0 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -21,6 +21,7 @@ import { buildPublicationMaterialsGenerationInputs, buildPublicationMaterialsGenerationPrompt, buildPublicationMaterialsPrompt, + buildQuickEditGenerationInputs, buildSoundEffectGenerationInputs, buildSpecGenerationInputs, buildSpecPrompt, @@ -38,8 +39,12 @@ import { DEFAULT_VIDEO_WEB_SEARCH_ENABLED, ICON_DESCRIPTION_LIMIT, IMAGE_MODEL_GPT_IMAGE_2, + inferEditorImageAspectRatio, + inferEditorImageSizeLabel, normalizeEditorImageModel, + normalizeQuickEditPromptSourceReference, resolveCharacterAnimationSourceImageSrc, + resolveEditorImageGenerationPixelSize, SEEDANCE_VIDEO_REFERENCE_LIMITS, SPEC_GENERATION_ASPECT_RATIO, SPEC_GENERATION_IMAGE_SIZE, @@ -157,6 +162,18 @@ export type ImageGenerationSubmissionPlan = }; rememberImageModel?: string; } + | { + kind: 'quick-edit'; + normalizedPrompt: string; + sourceLayer: CanvasLayer; + input: EditorImageGenerationInput; + result: { + title: string; + assetKind?: CanvasLayer['assetKind']; + generationInputs: CanvasGenerationInputs; + }; + rememberImageModel?: string; + } | { kind: 'video'; normalizedPrompt: string; @@ -232,6 +249,70 @@ export function buildImageGenerationSubmissionPlan({ }; } + if (dialog.mode === 'quick-edit' && dialog.sourceLayerId) { + const sourceLayer = layers.find( + (layer) => layer.id === dialog.sourceLayerId, + ); + if (!sourceLayer) { + throw new Error('未找到要改造的图片'); + } + const quickEditReferences = dialog.generationReferences ?? []; + const sourceReferenceIndex = quickEditReferences.length + 1; + const basePrompt = dialog.prompt.trim() || '快速编辑图片'; + const normalizedQuickEditPrompt = normalizeQuickEditPromptSourceReference( + basePrompt, + sourceReferenceIndex, + ); + const imageModel = normalizeEditorImageModel(dialog.imageModel); + const aspectRatio = + dialog.aspectRatio ?? + inferEditorImageAspectRatio( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ); + const imageSize = + dialog.imageSize ?? + inferEditorImageSizeLabel( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ); + const outputSize = resolveEditorImageGenerationPixelSize({ + model: imageModel, + aspectRatio, + imageSize, + }); + return { + kind: 'quick-edit', + normalizedPrompt: normalizedQuickEditPrompt, + sourceLayer, + input: { + prompt: normalizedQuickEditPrompt, + size: `${outputSize.width}x${outputSize.height}`, + kind: sourceLayer.assetKind === 'character' ? 'character' : 'quick-edit', + model: imageModel, + aspectRatio, + imageSize, + referenceImageSrcs: [ + ...quickEditReferences.map((reference) => + resolveImageReferenceSubmissionSource(reference), + ), + sourceLayer.objectKey?.trim() || sourceLayer.src, + ], + }, + result: { + title: `${sourceLayer.title} 快速编辑`, + assetKind: sourceLayer.assetKind, + generationInputs: buildQuickEditGenerationInputs( + '快速编辑提示词', + normalizedQuickEditPrompt, + sourceLayer, + quickEditReferences, + ), + }, + rememberImageModel: imageModel, + }; + } + if (dialog.mode === 'spec') { const specType = dialog.specType ?? 'custom'; const specValues = dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType]; diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 1f08c69cf..74c681bfd 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -1,6 +1,13 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { @@ -1091,6 +1098,81 @@ describe('ImageCanvasWorldView', () => { ).toBeTruthy(); }); + it('keeps the last loaded sequence frame visible while the next frame is resolving', () => { + vi.useFakeTimers(); + try { + useResolvedAssetReadUrlMock.mockImplementation( + (source: string) => ({ + resolvedUrl: + source === '/generated-character-drafts/editor/frame01.png' + ? 'https://oss.example.com/frame01.png?signature=1' + : '', + isResolving: + source === '/generated-character-drafts/editor/frame02.png', + shouldResolve: true, + }), + ); + const layer = createLayer({ + title: '角色动作', + src: '/generated-character-drafts/editor/frame01.png', + mediaType: 'image-sequence', + assetKind: 'character-animation', + thumbnailSrc: '/generated-character-drafts/editor/frame01.png', + imageSequenceFrames: [ + { + frameIndex: 1, + imageSrc: '/generated-character-drafts/editor/frame01.png', + objectKey: 'generated-character-drafts/editor/frame01.png', + width: 1024, + height: 1024, + }, + { + frameIndex: 2, + imageSrc: '/generated-character-drafts/editor/frame02.png', + objectKey: 'generated-character-drafts/editor/frame02.png', + width: 1024, + height: 1024, + }, + ], + durationSeconds: 0.1, + }); + + renderWorldView({ layers: [layer] }); + + const layerButton = screen.getByRole('button', { name: '选择角色动作' }); + const firstFrame = within(layerButton).getByAltText( + '画布序列帧:角色动作', + ); + + fireEvent.load(firstFrame); + expect( + layerButton.querySelector('.image-canvas-editor__layer-image-loading'), + ).toBeNull(); + + act(() => { + vi.advanceTimersByTime(60); + }); + + expect(within(layerButton).getByText('2/2')).toBeTruthy(); + expect( + within(layerButton) + .getByAltText('画布序列帧:角色动作') + .getAttribute('src'), + ).toBe('https://oss.example.com/frame01.png?signature=1'); + expect( + layerButton.querySelector('.image-canvas-editor__layer-image-loading'), + ).toBeNull(); + expect(useResolvedAssetReadUrlMock).toHaveBeenCalledWith( + '/generated-character-drafts/editor/frame02.png', + expect.objectContaining({ + objectKey: 'generated-character-drafts/editor/frame02.png', + }), + ); + } finally { + vi.useRealTimers(); + } + }); + it('keeps saved generator placeholders hidden after a result layer exists', () => { renderWorldView({ layers: [createLayer({ id: 'layer-generated', title: '生成图片' })], diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index a58457986..c21917eb4 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -15,7 +15,7 @@ import { Volume2, VolumeX, } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, @@ -700,6 +700,78 @@ function getLayerImageSequenceFrames(layer: CanvasLayer) { : []; } +function getImageSequenceFrameKey( + layer: CanvasLayer, + frame: ReturnType[number], + index: number, +) { + return [ + layer.id, + layer.taskId ?? layer.resourceId ?? '', + frame.frameIndex, + frame.objectKey ?? '', + frame.imageSrc, + index, + ].join(':'); +} + +function ImageCanvasImageSequenceFrame({ + layer, + frame, + frameKey, + isVisible, + isLoaded, + mediaTransform, + onFrameReady, +}: { + layer: CanvasLayer; + frame: ReturnType[number]; + frameKey: string; + isVisible: boolean; + isLoaded: boolean; + mediaTransform?: string; + onFrameReady: (frameKey: string) => void; +}) { + const { resolvedUrl } = useResolvedAssetReadUrl(frame.imageSrc, { + objectKey: frame.objectKey, + refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`, + }); + const frameImageRef = useRef(null); + + useEffect(() => { + if (resolvedUrl && isImageAlreadyDecoded(frameImageRef.current)) { + onFrameReady(frameKey); + } + }, [frameKey, onFrameReady, resolvedUrl]); + + if (!resolvedUrl) { + return null; + } + + return ( + {isVisible onFrameReady(frameKey)} + onError={() => onFrameReady(frameKey)} + /> + ); +} + function ImageCanvasImageSequenceLayer({ layer, mediaTransform, @@ -708,26 +780,57 @@ function ImageCanvasImageSequenceLayer({ mediaTransform?: string; }) { const frames = getLayerImageSequenceFrames(layer); + const frameItems = frames.map((frame, index) => ({ + frame, + index, + key: getImageSequenceFrameKey(layer, frame, index), + })); + const frameSequenceKey = frameItems.map((item) => item.key).join('|'); + const firstFrameKey = frameItems[0]?.key ?? ''; const [frameIndex, setFrameIndex] = useState(0); const [isPlaying, setIsPlaying] = useState(frames.length > 1); - const currentFrame = frames[Math.min(frameIndex, frames.length - 1)]; - const hasFrameSource = Boolean(currentFrame?.imageSrc?.trim()); - const { resolvedUrl } = useResolvedAssetReadUrl(currentFrame?.imageSrc, { - refreshKey: `${layer.taskId ?? layer.resourceId}:${ - currentFrame?.frameIndex ?? frameIndex + 1 - }`, - }); - const frameImageRef = useRef(null); - const [isFrameLoaded, setIsFrameLoaded] = useState(false); + const currentFrameItem = + frameItems[Math.min(frameIndex, frameItems.length - 1)]; + const currentFrameKey = currentFrameItem?.key ?? ''; + const [loadedFrameKeys, setLoadedFrameKeys] = useState>( + () => new Set(), + ); + const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey); + const visibleFrameLoaded = Boolean( + visibleFrameKey && loadedFrameKeys.has(visibleFrameKey), + ); + const hasFrameSource = frameItems.some((item) => item.frame.imageSrc.trim()); + + const handleFrameReady = useCallback((readyFrameKey: string) => { + setLoadedFrameKeys((currentKeys) => { + if (currentKeys.has(readyFrameKey)) { + return currentKeys; + } + const nextKeys = new Set(currentKeys); + nextKeys.add(readyFrameKey); + return nextKeys; + }); + }, []); useEffect(() => { setFrameIndex(0); setIsPlaying(frames.length > 1); - }, [layer.taskId, layer.resourceId, frames.length]); + setLoadedFrameKeys(new Set()); + setVisibleFrameKey(firstFrameKey); + }, [firstFrameKey, frameSequenceKey, frames.length]); useEffect(() => { - setIsFrameLoaded(isImageAlreadyDecoded(frameImageRef.current)); - }, [resolvedUrl]); + if (!currentFrameKey) { + setVisibleFrameKey(''); + return; + } + if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) { + return; + } + setVisibleFrameKey((currentKey) => + currentKey === currentFrameKey ? currentKey : currentFrameKey, + ); + }, [currentFrameKey, loadedFrameKeys, visibleFrameKey]); useEffect(() => { if (!isPlaying || frames.length <= 1) { @@ -749,28 +852,24 @@ function ImageCanvasImageSequenceLayer({ return ( <> - {hasFrameSource && (!resolvedUrl || !isFrameLoaded) ? ( + {hasFrameSource && !visibleFrameLoaded ? ( {activeDialog?.prompt || '-'} + + {activeDialog + ? `${activeDialog.mode}:${activeDialog.imageModel ?? '-'}:${activeDialog.aspectRatio ?? '-'}:${activeDialog.imageSize ?? '-'}` + : '-'} + {dialogs.canvasGenerationDialogs .map( @@ -400,6 +405,22 @@ function GenerationWorkflowHarness({ > 填写生成提示词 +