Merge remote-tracking branch 'origin/master' into codex/yace

# Conflicts:
#	.hermes/shared-memory/pitfalls.md
This commit is contained in:
kdletters
2026-05-16 23:05:38 +08:00
64 changed files with 10425 additions and 1095 deletions
@@ -1 +0,0 @@
C:/proj/Genarrative/.hermes/skills/behavior-driven-development
+1
View File
@@ -0,0 +1 @@
../../.hermes/skills/behavior-driven-development/
+2 -2
View File
@@ -181,7 +181,7 @@
## 2026-05-12 宝贝识物创作同时生成玩法视觉主题包
- 背景:`宝贝识物` 创作原本只根据两个关键词生成物品透明图,运行态背景、UI、礼物盒和篮子仍使用固定 CSS 绘本风,无法根据“小猪佩琪 / 奥特曼”或“苹果 / 橘子”等创作者提示词做主题化包装。
- 决策:`POST /api/creation/edutainment/baby-object-match/assets` 同一次 image-2 / VectorEngine 调用链返回两个物品图和 `visualPackage`视觉包包含 `background``ui-frame``gift-box``basket``smoke-puff` 五类资源;总风格保持寓教于乐明亮卡通绘本插画风,主题按两个物品关键词匹配,水果偏果园自然,动漫角色 / 玩具偏动漫玩具。物品图和礼物盒 / 篮子 / UI / 烟雾特效资源走透明 PNG 后处理,背景为清爽不遮挡玩法区的环境图;运行态中礼物盒按约 2 倍视觉尺寸展示、篮子按约 1.5 倍展示,礼物盒打开时使用 `smoke-puff` 弹出中央物品并移除礼盒。前端草稿保存该包,运行态消费该包;旧草稿以 `visualPackage = null` 继续使用 CSS 兜底。
- 决策:`POST /api/creation/edutainment/baby-object-match/assets` 同一次 image-2 / VectorEngine 调用链返回两个物品图和 `visualPackage`为降低调用成本,新链路只生成一张 `1024x1024``2x2` 素材 sheet 和一张 `1536x1024` 场景背景图;`2x2` sheet 固定左上物品 A、右上物品 B、左下篮子、右下礼物盒,服务端按格切图并把物品、篮子和礼物盒转透明 PNG。视觉包必需资源为 `background``gift-box``basket`;总风格保持寓教于乐明亮卡通绘本插画风,主题按两个物品关键词匹配。左右手位置指示器是运行态默认静态素材,使用项目内置第一人称半抓握手,不再随每次创作生成。运行态中礼物盒按约 2 倍视觉尺寸展示、篮子按约 1.5 倍展示,中央物品 UI 与篮子物品图标使用固定正方形槽位并等比 `contain` 缩放,礼物盒打开烟雾特效由 CSS 兜底;历史草稿中的 `ui-frame` / `smoke-puff` / `left-hand` / `right-hand` 仅兼容读取或忽略。前端草稿保存该包,运行态消费该包;旧草稿以 `visualPackage = null` 继续使用 CSS 兜底。
- 影响范围:`packages/shared/src/contracts/edutainmentBabyObject.ts``server-rs/crates/api-server/src/edutainment_baby_object.rs``src/services/edutainment-baby-object/babyObjectMatchClient.ts``src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``src/index.css`、宝贝识物 PRD 与技术方案。
- 验证方式:执行宝贝识物 service / runtime 定向测试、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml`、相关 ESLint 与编码检查;真实生图需配置 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY`
- 关联文档:`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
@@ -206,7 +206,7 @@
## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台
- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径。
- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色轮廓和 UI 已拆分为 v2 用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v2.png``picture-book-character-outline-v2.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。
- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色指示器和 UI 已拆分为用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v3.png``picture-book-character-outline-v4.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`其中角色指示器 v4 基于 v2 本地后处理为更细的白色描边样式,内部透明,耳朵、手指、脚趾等细节已弱化,页面显示尺寸相对上一版放大 50%。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。
- 影响范围:`src/index.css``src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。
- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live``--live --only <asset-id>` 应能写出对应 PNG,并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 HUD、状态条或底部草坪。
- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md``docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`
+40 -8
View File
@@ -99,6 +99,14 @@
- 验证:搜索 `server-rs/crates/spacetime-client/src/puzzle.rs` 不应再出现 gallery 主路径调用 `list_puzzle_gallery_then`;执行 `cargo check --manifest-path server-rs/Cargo.toml -p spacetime-client``cargo check --manifest-path server-rs/Cargo.toml -p api-server` 和 schema/runtime access 检查。
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs``server-rs/crates/spacetime-client/src/lib.rs``server-rs/crates/spacetime-client/src/puzzle.rs``/api/runtime/puzzle/gallery`
## 陶泥儿 logo 生图慢请求先缩短 prompt 并单张串行
- 现象:使用 VectorEngine `gpt-image-2-all` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。
- 原因:复杂抽象 logo prompt 同时包含品牌解释、禁用元素、中文结构和多重隐喻时,上游排队与生成时长不稳定;并发或批量运行会放大单条慢请求的影响。
- 处理:先 `--dry-run` 看请求体;真实生成时优先短 prompt、单一造型、单张串行或小批量。失败后不要反复重试同一长 prompt,先压缩到“一个主体 + 一个负形 + 颜色 + 禁用文字/播放键/聊天气泡”再跑。联系表中的中文标签不要通过 PowerShell 管道内联 Python 写入,容易因编码链路显示为问号,可改用英文标签或脚本文件方式。
- 验证:生成文件落在 `public/branding/taonier-logo-*/`,用 Pillow 检查图片尺寸和非空;执行 `node --check scripts/generate-taonier-logo-concepts.mjs``npm run check:encoding``git diff --check`
- 关联:`scripts/generate-taonier-logo-concepts.mjs``docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md`
## 忘记密码后仍提示手机号或密码错误先查认证快照同步
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
@@ -160,10 +168,10 @@
## 宝贝识物选篮误触发先查多套判定和残余轨迹
- 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮。
- 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名和手部轨迹,或在 `correct` / `wrong` 反馈阶段继续累计手部路径,会把抓握、反馈期间残留移动或未知侧别手部误算成下一次选篮。
- 处理:宝贝识物选篮只使用明确 `leftHand` / `rightHand` 的连续横向轨迹阈值;侧别为 `unknown` 的手部轨迹不参与选篮;反馈阶段清空轨迹,不在非 `active` 阶段累计路径。进入关卡和每次正确反馈结束后自动弹出物品,不再用 `open_palm -> grab` 抓握序列激活礼物盒。
- 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物选篮前需要换算为用户身体视角`rightHand` 轨迹代表玩家左手并进入左篮`leftHand` 轨迹代表玩家右手并进入右篮。键鼠调试不走该换算,仍保持鼠标左键=左、右键=右
- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/services/useMocapInput.test.ts`,确认动作名负向测试、未知侧别负向测试和左右手横向轨迹测试通过。
- 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名、连续横向轨迹和左右手固定篮子规则,或在 `correct` / `wrong` 反馈阶段继续累计手部状态,会把反馈期间残留移动或未知侧别手部误算成下一次选篮。
- 处理:宝贝识物当前选篮只允许“手先触碰中央物品 UI,物品绑定到该手,随后拖入左侧或右侧篮子区域”这一套路径;侧别为 `unknown` 的手部不参与抓取或选篮;反馈阶段清空持有状态,不在非 `active` 阶段累计输入。进入关卡和每次正确反馈结束后自动弹出物品,不再用 `open_palm -> grab` 抓握序列激活礼物盒。
- 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物仍需换算为用户身体视角以展示左右手:`rightHand` 坐标代表玩家左手,`leftHand` 坐标代表玩家右手。换算不再决定只能选择哪侧篮子;任意一只手都可以拖物品到任意篮子。键鼠调试保持鼠标左键=左手位置、右键=右手位置,也必须先触碰中央物品再拖入篮子
- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/services/useMocapInput.test.ts`,确认动作名负向测试、未知侧别负向测试、触碰前不能选篮和任意手拖入任意篮子用例通过。
- 关联:`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
## 宝贝爱画左右手反了先查 mocap 摄像头视角换算
@@ -177,11 +185,27 @@
## 宝贝识物创作卡在准备结果页先查长耗时 image-2 请求
- 现象:`/creation/baby-object-match` 创作生成停在“准备结果页”,约 3 分钟后显示“生成失败 / 请求超时”;后端日志可能出现同一路由 `status=502 latency_ms=231291`,或前端已失败但后端稍后返回 200。
- 原因:宝贝识物一次创作会生成 2 张物品图和 `background``ui-frame``gift-box``basket``smoke-puff` 5 张视觉包装图。旧前端只等待 180 秒并对长耗时 POST 自动重试,容易在 VectorEngine 仍在生成时先 abort,再重复发起第二次生成;上游某张图超过后端 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 或返回 5xx 时会表现为 502。
- 处理:`babyObjectMatchClient``/api/creation/edutainment/baby-object-match/assets` 使用 10 分钟超时并取消自动重试;后端并发启动物品图和视觉主题包生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,按资源类别输出开始、完成和耗时日志。
- 验证:运行 `npm run test -- src/services/edutainment-baby-object/babyObjectMatchClient.test.ts src/services/miniGameDraftGenerationProgress.test.ts``cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml` 和编码检查;真实联调时查看 `宝贝识物 image-2 资源生成完成` 耗时是否小于前端超时,若仍 502 再看 `VectorEngine 图片生成上游错误``upstreamStatus/raw_excerpt`
- 原因:宝贝识物创作属于长耗时 image-2 链路。旧前端只等待 180 秒并对长耗时 POST 自动重试,容易在 VectorEngine 仍在生成时先 abort,再重复发起第二次生成;上游某张图超过后端 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 或返回 5xx 时会表现为 502。2026-05-14 后,新链路已从“2 张物品图 + 5 张视觉包装图”收敛为“1 张 `2x2` 素材 sheet + 1 张场景背景图”,左右手位置指示器改为运行态默认静态素材,不再每次创作生成,但仍需要按长耗时链路排查。
- 处理:`babyObjectMatchClient``/api/creation/edutainment/baby-object-match/assets` 使用 10 分钟超时并取消自动重试;后端并发启动 `2x2` 素材 sheet 和场景背景生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,按资源类别输出开始、完成和耗时日志。`2x2` sheet 固定包含物品 A、物品 B、篮子和礼物盒,服务端按格切图并转透明 PNG;`ui-frame` / `smoke-puff` / `left-hand` / `right-hand` 不再作为新生成必需资源。
- 验证:运行 `npm run test -- src/services/edutainment-baby-object/babyObjectMatchClient.test.ts src/services/miniGameDraftGenerationProgress.test.ts``cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml` 和编码检查;真实联调时查看 `宝贝识物 image-2 2x2 素材 sheet 生成完成``宝贝识物 image-2 场景资源生成完成` 和整体 `宝贝识物 image-2 资源生成完成` 耗时是否小于前端超时,若仍 502 再看 `VectorEngine 图片生成上游错误``upstreamStatus/raw_excerpt`
- 关联:`src/services/edutainment-baby-object/babyObjectMatchClient.ts``src/services/miniGameDraftGenerationProgress.ts``server-rs/crates/api-server/src/edutainment_baby_object.rs``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
## 宝贝识物篮子手柄白底先查 sheet 切图后处理
- 现象:`宝贝识物` 新生成的主题篮子在左右手柄、篮口镂空或边缘处仍出现白底块或白色毛边,尤其是 2x2 sheet 背景被抠透明后,封闭镂空区域可能没有被通用边缘连通抠图清理掉。
- 原因:宝贝识物为了降低 image-2 成本,把物品 A、物品 B、篮子和礼物盒放在同一张 `2x2` sheet。通用背景透明处理主要从单格边缘连通背景开始,封闭在篮子手柄内部的近白区域不一定与边缘连通,因此会残留;如果把强力近白清理应用到物品格,又可能误伤白色物品主体。
- 处理:后端 `slice_baby_object_match_sheet` 只在 `BabyObjectMatchSheetSlot::Basket` 编码前执行近白、低饱和 matte 清理;物品格和礼物盒格继续只走通用背景透明处理。sheet prompt 同步要求篮子手柄和篮口镂空处不要留下白底描边或毛边。运行态左右篮子的物品图标和名称 UI 以篮子中心线对齐,避免素材放大后看起来偏移。
- 验证:运行 `cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml``npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx`;真实联调需要重新生成宝贝识物资源,旧草稿中已保存的 base64 篮子图不会自动被新后处理改写。
- 关联:`server-rs/crates/api-server/src/edutainment_baby_object.rs``src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``src/index.css``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
## 宝贝识物物品框被长条素材拉伸先查固定槽位
- 现象:用户用手机、筷子等长条关键词生成素材后,中央物品 UI 或篮子上方物品图标看起来被拉成长框,圆形 UI 失去固定比例。
- 原因:运行态如果让图片固有宽高或外层自适应内容,就会把长条透明 PNG 的主体比例传导到 UI 容器。
- 处理:中央物品 UI 和篮子物品图标都必须使用固定正方形槽位,外层尺寸由 CSS 变量控制;生成素材图片只在槽位内 `object-fit: contain` 等比缩放,不改变外层圆形 UI 框尺寸。
- 验证:用长条物品草稿进入宝贝识物运行态,中央物品框和篮子图标框仍为正圆,长条主体在框内缩小显示。
- 关联:`src/index.css``src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
## 寓教于乐作品和宝贝识物模板同时消失先查入口种子
- 现象:发现页“寓教于乐”分类下已发布的宝贝识物作品突然消失,同时创作界面模板选项中也看不到或无法正常展示 `宝贝识物`
@@ -202,10 +226,18 @@
- 现象:`/child-motion-demo` 背景风格正确,但底部草坪被拉成厚色块、顶部 HUD 或右下状态条像方形面板被横向拉伸,或旧 `picture-book-ui-panel.png` 与新资源叠在一起。
- 原因:早期资源中 `picture-book-ui-panel.png` 是接近方形画布,`picture-book-grass-floor.png` 也含大量透明边界;若 CSS 用 `background-size: 100% 100%` 把同一资源强行铺成 HUD、状态条、开始面板或底部地板,就会出现变形和层叠观感。
- 处理:使用 v2 用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v2.png``picture-book-character-outline-v2.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`;CSS 按资源比例等比缩放,底部草坪只覆盖下沿,HUD / 状态条 / 开始托盘分别引用各自资源。若只需修透明裁切品红边,运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>`,不重新请求 image-2。
- 处理:使用用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v3.png``picture-book-character-outline-v4.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`;CSS 按资源比例等比缩放,底部草坪只覆盖下沿,HUD / 状态条 / 开始托盘分别引用各自资源。角色指示器使用 v4 更细白色描边资源,内部透明且显示尺寸相对上一版放大 50%;若只需修透明裁切品红边或纯描边后处理,运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>`,不重新请求 image-2。
- 验证:用横屏截图检查没有新旧资源叠加、没有方形面板拉成长条、角色和地面指示环不被前景草坪埋住;同时运行 `npm run check:encoding`
- 关联:`scripts/generate-child-motion-demo-assets.mjs``src/index.css``public/child-motion-demo/``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## 儿童动作 Demo 猫咪挥手拆件错位先查动画父级和肩部挂点
- 现象:`/child-motion-demo` 打个招呼阶段的猫咪图和风格正确,但挥手时左右手臂像漂浮在身体旁边,视频里能看到肢体没有稳定接在肩膀上。
- 原因:猫咪身体和手臂如果分别做上下浮动,或手臂使用透明方形画布的默认中心/底部旋转轴,就会在摆动极值时放大肩点偏差;镜像左臂还需要把资源内部连接点换算到镜像后的坐标。
- 处理:`.child-motion-gesture-guide__wave-cat` 父级统一承接 bob 动画,身体层保持静态贴底且层级低于手臂;左右手臂作为同一父级下的兄弟层,只做旋转动画并显示在身体前方。身体使用去掉左右小圆点的 `picture-book-wave-cat-body-guide-v7.png`;手臂 v7 资源当前按身体外缘摆放,圆猫爪掌面朝向玩家;左右侧距为 `12%`,左臂使用原图层与 `60% 78%` 旋转轴,右臂使用镜像图层与 `40% 78%` 旋转轴,动画周期为 `0.47s`,左右手臂不设置错峰延迟;不要把 `scaleX(...)` 和 rotate 放在同一个手臂 wrapper 上。
- 验证:用用户录屏关键帧或离线合成预览检查摆动两端的手臂根部仍贴住肩点;再运行儿童动作 Demo 定向组件测试、ESLint 和 `npm run check:encoding`
- 关联:`src/index.css``public/child-motion-demo/picture-book-wave-cat-body-guide-v7.png``public/child-motion-demo/picture-book-wave-cat-arm-guide-v7.png``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## GPT-image-2 不再读 APIMart 图片配置
- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
# 宝贝识物寓教于乐模板 PRD 2026-05-11
## 1. 目标
新增寓教于乐内容线的创作模板:
```text
宝贝识物
```
创作者必须通过该模板创作并发布作品后,用户才能在寓教于乐板块体验对应关卡。
本模板只服务儿童动作 Demo 内容线,不把普通教育题材作品自动归入寓教于乐。
## 2. 创作输入
创作者必须填写两个物品名称:
1. 物品 A 名称;
2. 物品 B 名称。
两个名称都必须去除首尾空白后非空。当前阶段不新增题材、难度、计时、失败次数、分数、体力或递增规则。
## 3. 生成规则
提交后生成一份宝贝识物草稿,草稿包含:
1. 模板 ID`baby-object-match`
2. 模板名称:`宝贝识物`
3. 两个物品;
4. 两个物品图;
5. 游戏视觉主题包;
6. 作品标签。
素材使用 VectorEngine `gpt-image-2-all` / image-2 生成。图片生成只能走后端接口,前端不得读取、拼接或暴露 `VECTOR_ENGINE_API_KEY`
为降低生成成本,创作提交后只生成两张原始图片:一张 `2x2` 素材 sheet 和一张单独场景背景图。`2x2` 素材 sheet 固定包含左上物品 A、右上物品 B、左下篮子、右下礼物盒。服务端必须按固定格切图,并把物品、篮子和礼物盒转成透明 PNG。只有透明抠图后的两个物品素材才允许写入草稿 `itemAssets` 并进入游戏运行态。左右手位置指示器属于运行态默认规则,使用项目内置静态素材,不在每次创作时生成。
同一次创作还必须生成游戏视觉主题包,必需资源为背景环境、礼物盒、篮子。主题包必须继续保持寓教于乐插画风,并根据用户填写的两个物品关键词匹配主题:例如关键词偏动漫角色或玩具时,背景环境和元素可使用动漫、玩具主题;关键词偏水果时,背景环境和元素可匹配果园、自然主题;其它关键词按其语义匹配合适主题。主题包不得改变关卡玩法规则,不新增文字说明、额外按钮或额外判定规则。
视觉主题包的资源边界:
1. 背景环境图不做透明抠图,但必须保证屏幕中间、中下方和底部左右篮子区域清爽,不遮挡放大后的物品、礼物盒和篮子;
2. 礼物盒资源从 `2x2` 素材 sheet 右下格切出,输出为透明 PNG,运行态按当前礼盒视觉的 2 倍尺寸展示,素材主体必须饱满清晰;
3. 篮子资源从 `2x2` 素材 sheet 左下格切出,输出为透明 PNG,运行态按当前篮子视觉的 1.5 倍尺寸展示,左右篮子仍固定为两个物品对应选项,篮子造型资源可以复用同一张主题篮子图;篮子切图不得保留手柄、篮口或边缘处的白底描边和抠图毛边;
4. 运行态左右手位置指示器使用内置默认静态素材,姿势为用户第一人称看到的半抓握手,不随创作关键词重新生成;
5. 礼物盒打开时的烟雾弹出特效由运行态 CSS 动效兜底;历史草稿如果已有 `smoke-puff` 资源可继续兼容读取,但新生成链路不再单独生成该资源。
当前本地 Demo 阶段已接入真实 image-2 资源链路。创作提交必须成功获得 `generationProvider = "vector-engine-gpt-image-2"` 的两个物品透明 PNG、背景环境图、礼物盒和篮子后,才能进入结果页、试玩或发布;若后端接口、登录态、VectorEngine 配置或上游生成失败,前端必须停留在生成失败状态并展示错误,不得静默回退为占位图。历史草稿中若仍存在 `generationProvider = "placeholder"` 的占位资源,结果页必须提示重新生成,试玩和发布前必须先补齐 image-2 资源。
## 4. 标签规则
发布作品必须携带精确标签:
```text
寓教于乐
```
标签识别只接受精确等于 `寓教于乐`。不接受 `儿童教育``动作教育``寓教于乐 ` 等近似标签。
宝贝识物草稿与发布 payload 中都必须保留该标签。发布后的公开展示、搜索、深链和入口开关继续遵循 `CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md`
## 5. 结果页能力
结果页展示:
1. 作品名称;
2. 两个物品名称;
3. 两个物品图;
4. 标签;
5. 保存草稿;
6. 发布;
7. 试玩。
结果页不展示长规则说明文案。试玩按钮直接进入宝贝识物首关本地运行态。
试玩按钮进入宝贝识物首关运行态,运行态消费当前草稿中的两个物品名称和两张物品图,不重新生成或改写物品内容。
若草稿包含视觉主题包,运行态还必须消费该主题包中的背景环境、礼物盒和篮子资源;左右手位置指示器始终使用内置默认静态素材。旧草稿或接口失败时允许回退到当前 CSS 绘本风兜底。历史草稿中若已有 UI 装饰、左右手或烟雾弹出特效资源,运行态仅做兼容读取或忽略,不作为新链路必需资源。
## 6. 发布后体验
发布完成后作品应进入寓教于乐内容线,并在寓教于乐入口开启时可被板块消费。
入口关闭时,发布作品完全不可见,不能通过推荐、发现普通频道、搜索、作品号、公开详情深链或浏览历史访问。
## 7. 与运行时线程的边界
本 PRD 同步约束首关运行态,已确认规则包括:
1. 进入关卡后先展示两个目标物品:物品 A 居中展示 2 秒,名称 UI 与字体约为默认大小的 2 倍,随后物品和名称飞入左侧篮子预设位置,并在飞行过程中恢复为默认大小;左侧就绪后等待 1 秒,再展示物品 B 并飞入右侧篮子预设位置;全部就绪后等待 1 秒再进入礼物盒入场。
2. 目标展示完成后,首次礼物盒自动打开并弹出首个随机物品;后续每次正确反馈完全结束后重新进入礼物盒入场。
3. 每轮仅中间礼物盒跳出的物品随机;左右两侧篮子固定为当前草稿两个物品的顺序;
4. 下一关按钮当前占位;
5. 不新增用户未确认的计时、失败次数、分数、体力或难度递增。
6. 屏幕中上方字幕固定为“将物品放入对应的篮子里”。
7. 礼物盒位于屏幕中下方并按当前视觉放大一倍,首次进入关卡和每次正确反馈结束后的新轮次都从上方落下后自动打开。
8. 屏幕下方左侧和右侧分别展示两个固定篮子,左侧固定使用草稿第一个物品图,右侧固定使用草稿第二个物品图。
9. 左右篮子按当前视觉放大 50%,物品图标与篮子中心尽量对齐,物品图标下方展示对应物品名称 UI。
10. 礼物盒打开时播放烟雾特效,中央物品从烟雾特效中弹出;物品弹出后礼物盒从舞台移除。
11. 中央物品 UI 和左右篮子上方物品图标都使用固定正方形槽位,生成素材只在槽位内等比缩放;长条形物品不得拉伸外层 UI 框。
12. 运行态实时展示用户左右手位置;任意一只手先接触中央物品 UI 后,中央物品绑定并跟随该手移动,手带物品进入左侧或右侧篮子区域时代表选择对应篮子;选篮不使用动作名判定,也不再使用左手固定选左篮、右手固定选右篮的规则。
13. 正确时展示“真棒”字幕和正确特效;错误时展示“再想一想吧”字幕和错误特效,物品回到中央。
14. 成功 20 次后展示“恭喜你!小朋友!”字幕和特效,并展示“再来一次”和“下一关”按钮。
15. 当前本地 Demo 阶段音效与语音播报接口只预留调用点,不在前端写死外部硬件或服务接口。
## 8. 验收
1. 创作入口显示 `宝贝识物` 并可进入模板表单。
2. 未填写任一物品名称时不能生成草稿。
3. 生成草稿后进入结果页,展示两个物品名称和物品图。
4. 生成草稿后包含视觉主题包,主题包含背景环境、礼物盒、篮子三类必需资源。
5. 草稿标签中始终包含精确 `寓教于乐`
6. 发布 payload 始终包含精确 `寓教于乐`
7. 发布完成后出现分享弹窗或发布完成状态。
8. 前端不读取或暴露 VectorEngine 密钥。
9. 结果页试玩进入宝贝识物运行态,不再显示“试玩关卡正在接入中”。
10. 运行态通过鼠标左键映射左手位置、鼠标右键映射右手位置;调试输入也必须先触碰中央物品,再拖入任一篮子完成选择。
11. 成功 20 次后出现“再来一次”和“下一关”按钮。
12. 使用长条形物品素材时,中央物品 UI 和篮子物品图标仍保持固定正方形槽位,只缩放物品本体。
13. 运行态开局先完成两个目标物品的居中展示和飞入篮子动画,之后才出现礼物盒并进入首轮随机物品。
@@ -0,0 +1,274 @@
# 宝贝识物创作发布实现方案 2026-05-11
## 1. 范围
本方案对应第 2 线程:创作发布线程。
本线程落地:
1. 创作入口配置;
2. 模板表单;
3. 本地草稿生成 service
4. 结果页;
5. 发布 payload 约束;
6. 本地 Demo 运行态;
7. 后端 image-2 / 作品持久化 / 运行态接口预留形状。
本阶段运行态先做浏览器本地 Demo,并消费现有本地 mocap 动作数据源;正式硬件接口和摄像头调教在后续接口稳定后继续接入。
## 2. 前端接入点
新增玩法 ID
```text
baby-object-match
```
用户展示名:
```text
宝贝识物
```
工程接入文件:
1. `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
2. `src/components/platform-entry/platformEntryCreationTypes.ts`
3. `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
`src/config/newWorkEntryConfig.ts` 已迁移删除,不再作为入口事实源。`baby-object-match` 必须存在于 SpacetimeDB `creation_entry_type_config` 默认种子中,默认展示名为 `宝贝识物``visible=true``open=true``sortOrder=90`;前端只通过 `GET /api/creation-entry/config` 读取后端配置并在 `platformEntryCreationTypes.ts` 做展示派生。
`baby-object-match` 必须复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时,创作类型弹层不展示 `宝贝识物`,创作页作品架不展示本地宝贝识物草稿或已发布作品卡,公开发现、搜索、详情、作品号和浏览历史也继续完全不可见。
新增阶段:
```text
baby-object-match-workspace
baby-object-match-generating
baby-object-match-result
baby-object-match-runtime
```
## 3. 契约
前端共享契约放在:
```text
packages/shared/src/contracts/edutainmentBabyObject.ts
```
核心字段:
1. `BabyObjectMatchDraft.templateId = "baby-object-match"`
2. `BabyObjectMatchDraft.templateName = "宝贝识物"`
3. `BabyObjectMatchDraft.themeTags` 必须包含精确 `寓教于乐`
4. `BabyObjectMatchItemAsset.generationProvider` 首版允许为 `vector-engine-gpt-image-2``placeholder`
5. `BabyObjectMatchDraft.visualPackage` 可选承载背景环境、礼物盒和篮子三类必需视觉资源;历史草稿中的 `ui-frame``smoke-puff``left-hand``right-hand` 仅保留运行态兼容读取或忽略;
6. `BabyObjectMatchPublishRequest.draft.themeTags` 发布前必须归一化补齐 `寓教于乐`
## 4. Service 边界
前端 service 放在:
```text
src/services/edutainment-baby-object/babyObjectMatchClient.ts
```
首版提供:
1. `createBabyObjectMatchDraft(payload)`
2. `saveBabyObjectMatchDraft(draft)`
3. `publishBabyObjectMatchWork(payload)`
4. `deleteLocalBabyObjectMatchDraft(profileId)`
5. `regenerateBabyObjectMatchDraftAssets(draft)`
6. `hasBabyObjectMatchPlaceholderAssets(draft)`
当前后端正式作品持久化接口未在本线程扩表落地,因此 service 仍使用本地 Demo 存储草稿和发布状态。由于 image-2 会返回多张 base64 PNG 大图,本地 Demo 草稿必须优先写入 IndexedDB `genarrative-edutainment-baby-object-drafts/drafts`,不得把完整草稿 JSON 写入 `localStorage``localStorage` 仅作为旧版小草稿迁移读取来源,读取后迁移到 IndexedDB 并清理旧 key,避免触发浏览器 `Storage` 配额错误。
物品图片生成已接入后端 image-2 接口:
```text
POST /api/creation/edutainment/baby-object-match/assets
```
请求体:
```json
{
"itemNames": ["苹果", "香蕉"]
}
```
响应体:
```json
{
"assets": [
{
"itemId": "baby-object-item-1",
"itemName": "苹果",
"imageSrc": "data:image/png;base64,...",
"assetObjectId": null,
"generationProvider": "vector-engine-gpt-image-2",
"prompt": "..."
}
],
"visualPackage": {
"themePrompt": "...",
"assets": [
{
"assetId": "baby-object-visual-background",
"assetKind": "background",
"imageSrc": "data:image/png;base64,...",
"assetObjectId": null,
"generationProvider": "vector-engine-gpt-image-2",
"prompt": "..."
}
]
}
}
```
该接口返回从同一张 `2x2` 素材 sheet 切出的两个物品透明 PNG、礼物盒透明 PNG、篮子透明 PNG,以及单独生成的一张背景环境图。本地 Demo 阶段暂不写入 OSS 或 SpacetimeDB `asset_object`。当前创作链路必须真实拿到 `generationProvider = "vector-engine-gpt-image-2"` 的两个物品图和必需视觉主题包后才允许进入结果页;若本地未配置 VectorEngine、登录态失效、接口返回 401/5xx、上游生成失败或响应缺少任一必需资源,前端 service 必须抛出错误并停留在生成失败状态,不得静默回退到占位图。左右手位置指示器是运行态默认静态素材,不在该接口中生成。
为了降低 image-2 调用成本,一次创作只发起两次图片生成:一次 `1024x1024``2x2` 素材 sheet,固定包含左上物品 A、右上物品 B、左下篮子、右下礼物盒;一次 `1536x1024` 的场景背景图。前端 `babyObjectMatchClient` 对该 POST 使用 10 分钟请求超时,且不做自动重试,避免第一次生成仍在后端执行时又发起第二次重复生成。后端并发启动两张图生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,避免某张图 3 分钟附近仍在生成时被后端提前断开。后端日志记录资源开始、完成和耗时,排查时优先按同一次 HTTP 请求查看 `宝贝识物 image-2 2x2 素材 sheet 生成完成``宝贝识物 image-2 场景资源生成完成``VectorEngine 图片生成上游错误`
历史本地草稿中若已保存 `generationProvider = "placeholder"` 的旧占位资源,结果页必须提示“重新生成 image-2 资源”,并禁用试玩和发布。用户点击重新生成、发布或试玩前,前端统一调用 `regenerateBabyObjectMatchDraftAssets(draft)` 补齐资源;补齐失败时保留在结果页并展示错误。
后续正式作品持久化接入时,应补齐:
```text
POST /api/creation/edutainment/baby-object-match/drafts
PUT /api/creation/edutainment/baby-object-match/drafts/{draftId}
POST /api/creation/edutainment/baby-object-match/drafts/{draftId}/publish
```
图片生成必须在后端调用 VectorEngine `gpt-image-2-all`,不得从前端直接调用外部图片接口。
后端 `2x2` 素材 sheet prompt 约束:
1. 锁定寓教于乐板块统一的明亮卡通绘本插画风;
2. 固定四格布局:左上格物品 A,右上格物品 B,左下格篮子,右下格礼物盒;
3. 两个物品格只能围绕对应关键词生成单一主体,不生成背景、场景、人物、手、篮子、礼物盒、文字、水印或 UI;
4. 篮子格只生成一个主体饱满、开口清晰的大号篮子,不放入待分类物品,手柄和篮口镂空处不得留下白底描边或毛边;
5. 礼物盒格只生成一个主体饱满、中心构图的大号礼物盒;
6. 每格使用纯白或接近纯白背景,不绘制网格线、标签、按钮或边框;
7. 服务端按 `2x2` 固定格切图,并按单格边缘采样背景色转透明 PNG,返回的物品、篮子和礼物盒素材必须已完成透明背景后处理;
8. 篮子切图在通用透明背景处理后,还必须额外清理近白、低饱和的白底毛边,优先覆盖手柄镂空、篮口镂空和边缘残留白底;该处理仅应用于篮子格,不应用于两个物品格,避免误伤白色物品主体。
后端场景背景 prompt 约束:
1. 背景图单独生成,总风格继续锁定寓教于乐明亮卡通绘本插画风;
2. 若关键词偏动漫角色、玩具或公仔,背景环境匹配动漫、玩具主题;若关键词偏水果,匹配果园、自然主题;其它关键词按语义匹配合适主题;
3. 背景环境图使用非透明横向图,但必须保证中间、中下方和底部左右篮子区域清爽,给放大后的礼物盒、中央物品和左右篮子预留空间;
4. 背景图不画入礼物盒、篮子、物品、人物、文字或操作 UI;
5. 左右篮子的固定选项规则不受主题包影响,运行态只把 `basket` 作为篮子造型包装复用。
运行态左右手位置指示器不随创作生成。默认素材保存在 `public/edutainment-baby-object/image2-picture-book-hands/baby-object-left-hand-v8-transparent.png``public/edutainment-baby-object/image2-picture-book-hands/baby-object-right-hand-v8-transparent.png`,姿势沿用图1的圆形手与斜向手臂结构,并按寓教于乐明亮绘本插画风完成 image2 填色和风格化处理。后续若要替换默认手型,应更新这两个静态资源和运行态 CSS 默认变量,而不是恢复每次创作的左右手 image-2 生成。
## 5. UI 边界
工作台只展示两个必填输入和生成按钮。
结果页只展示草稿核心信息、两个物品、保存草稿、发布、试玩。不在 UI 内写玩法说明长文案。
移动端优先:表单和结果页使用单列布局,桌面端自然扩展为双列。
## 6. 运行态边界
前端运行态放在:
```text
src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx
```
运行态直接消费 `BabyObjectMatchDraft`,必须使用草稿中的两个物品名称和物品图。
每轮只随机当前从礼物盒跳出的物品;左右篮子不随机交换,左侧固定为草稿 `itemAssets[0]`,右侧固定为草稿 `itemAssets[1]`
若草稿包含 `visualPackage`,运行态通过背景图片层、CSS 变量和图片节点消费:
1. `background`:作为舞台最底层 `ResolvedAssetImage` 背景图;存在该资源时必须关闭默认草地兜底层,避免生成场景被 CSS 草地遮住或弱化;
2. `gift-box`:替换 CSS 礼物盒主体,按旧视觉约 2 倍尺寸展示,只在礼盒入场和打开阶段存在;
3. `basket`:替换篮子主体造型,按旧视觉约 1.5 倍尺寸展示,左右两侧复用同一张主题篮子图;
4. 左右手位置指示器:始终使用运行态默认静态素材;历史草稿中若带有 `left-hand` / `right-hand` 资源,不再作为视觉包完整性或运行时皮肤来源。
左右篮子的选项 UI 必须以篮子中心线为基准居中展示:物品图标位于篮子上方,图标下方展示对应物品名称短标签,左侧固定展示草稿第一个物品,右侧固定展示草稿第二个物品。该名称标签是运行态 UI 的一部分,用于后续只看图案或只看名称的玩法变体预留,但当前不新增额外规则。
历史草稿若包含 `ui-frame``smoke-puff`,运行态继续兼容读取;新生成链路不再把这两类资源作为必需 image-2 产物。礼物盒打开烟雾特效优先使用 CSS 动效兜底,避免为了单个特效额外增加生图调用。
旧草稿或接口失败时 `visualPackage = null`,运行态继续使用现有 CSS 绘本风兜底。
中央物品 UI 与左右篮子上方物品图标必须使用固定正方形槽位,不允许因为生成物品是手机、长条玩具等窄长形状而拉伸外层 UI 框。素材图片在槽位内使用等比 `contain` 缩放,长条形状只缩小主体,不改变圆形 UI 框尺寸。
首关状态机:
1. `intro-left-showing`:物品 A 居中展示 2 秒,名称 UI 和字体约为默认大小的 2 倍,不接受动作判定;
2. `intro-left-flying`:物品 A 和名称飞入左侧篮子预设位置,飞行过程中名称 UI 和字体恢复为默认大小,不接受动作判定;
3. `intro-left-ready`:左侧目标就绪后等待 1 秒,不接受动作判定;
4. `intro-right-showing`:物品 B 居中展示 2 秒,名称 UI 和字体约为默认大小的 2 倍,不接受动作判定;
5. `intro-right-flying`:物品 B 和名称飞入右侧篮子预设位置,飞行过程中名称 UI 和字体恢复为默认大小,不接受动作判定;
6. `intro-right-ready`:右侧目标就绪后等待 1 秒,不接受动作判定;
7. `gift-entering`:礼物盒从上方落下入场动画阶段,不接受动作判定;首次进入该状态必须发生在两个目标展示完成后,后续正确反馈结束后直接进入该状态;
8. `gift-opening`:礼物盒打开并播放烟雾特效阶段,不接受动作判定;
9. `item-appearing`:礼物盒从舞台移除,当前物品从烟雾中出现并停稳,不接受动作判定;
10. `active`:物品彻底出现后才开放选篮判定;
11. `correct`:展示“真棒”反馈,对应篮筐播放正确特效并停顿,成功次数加 1;特效完全结束后重新进入 `gift-entering`,下一轮礼物盒从上方落下,不重复目标展示;
12. `wrong`:展示“再想一想吧”反馈,物品弹回中央;反馈结束后回到 `active`,不重新随机物品;
13. `complete`:成功次数达到 20,展示“恭喜你!小朋友!”和按钮。
动作输入:
1. 运行态实时展示左右手位置,手部位置来自 `useMocapInput` 的明确左/右手坐标;
2. 任意一只手先接触中央物品 UI 后,当前物品绑定到该手并跟随移动;
3. 绑定手带物品进入左侧篮子区域时选择左篮,进入右侧篮子区域时选择右篮;
4. 正确时沿用“真棒”反馈和对应篮筐特效,错误时物品弹回中央并回到可再次抓取状态;
5. 物品被某只手持有时,手部指示器不再压在物品图标中心;左手吸附到当前物品图标左下角,右手吸附到当前物品图标右下角,保持图案主体可读;
6. 不再使用“左手固定选左篮、右手固定选右篮”的规则,也不再使用连续横向轨迹阈值直接选篮。
运行态直接通过 `useMocapInput` 消费本地 mocap WebSocket `/stream`。宝贝识物优先使用 `general.limb_nodes` / `limb_nodes` 里的骨架手腕节点作为左右手指示器、抓取和选篮坐标;若当前帧没有骨架手腕,再回退到每只手的 `wrist` 挂点,最后才回退到 `hand.x / hand.y`。该策略只让 `useMocapInput` 额外暴露 `bodyJoints.leftWrist/rightWrist`,不修改全局掌心派生点规则,避免影响拼图、热身关和其它运行态。选篮不再通过 `wave_left_hand``wave_right_hand``wave` 等动作名触发;侧别为 `unknown` 的手部轨迹不参与抓取或选篮。动作判定只在 `active` 阶段开放,礼盒入场、礼盒打开、物品出现、正确反馈和错误反馈阶段收到的动作包必须清空持有状态并忽略,不允许跨阶段补判定。当前本地 mocap 输出的 handedness 按摄像头视角标记,宝贝识物运行态必须先换算为用户身体视角:骨架 `rightWrist` / `rightHand.wrist` / `rightHand` 坐标映射玩家左手,骨架 `leftWrist` / `leftHand.wrist` / `leftHand` 坐标映射玩家右手;换算只用于展示和抓取手身份,不再决定只能选择哪一侧篮子。草稿试玩、发布后正式体验和热身关后的本地 Demo 都复用同一个运行态,因此三条入口都必须具备同一套动作控制能力。
开发者调试输入:
1. 鼠标左键按下并拖动:映射左手位置;
2. 鼠标右键按下并拖动:映射右手位置;
3. 调试输入同样必须先触碰中央物品,物品绑定到目标手后,再拖入左侧或右侧篮子完成选择。
运行态控制按钮不参与调试输入和选篮判定。左上角返回按钮、完成弹层按钮以及后续新增的运行态控制元素,其 `pointerdown` 不得被舞台拖拽逻辑 `preventDefault` 或指针捕获吞掉,保证游戏进行中仍可直接点击返回。
当前篮子判定仍只认篮子主体附近区域,但在上一版核心区基础上扩大约 50%;命中阈值为左篮 `x <= 0.36 && y >= 0.62`、右篮 `x >= 0.64 && y >= 0.62`,既避免物品尚未贴近篮子主体就提前判定,也避免贴到篮子边缘后仍难以命中。
运行态不得新增计时、失败次数、分数、体力或难度递增规则。
音效和语音播报当前只保留接口预留边界,正式语音接口后续接入。
## 7. 发布约束
发布前必须执行:
1. 两个物品名非空;
2. 两个物品名对应的 asset 存在;
3. 标签补齐精确 `寓教于乐`
4. `publicationStatus``draft` 变为 `published`
发布后首版本地响应返回 `publicWorkCode`,用于分享弹窗;正式后端接入时 public code 生成规则需要纳入统一作品号服务。
## 8. 热身关衔接
`/child-motion-demo` 热身完成后的“开始游戏”按钮进入同一个 `BabyObjectMatchRuntimeShell`
热身关独立 Demo 没有创作者草稿上下文,因此使用固定本地 Demo 草稿承载两个物品,仅用于热身关后验证首关体验;正式平台体验仍必须从 `宝贝识物` 模板创作发布后进入寓教于乐板块。
## 9. 验收命令
```bash
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/edutainment-baby-object/babyObjectMatchClient.test.ts
cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml
npx vitest run src/components/platform-entry/platformEdutainmentVisibility.test.ts src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/custom-world-home/creationWorkShelf.test.ts src/services/useMocapInput.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts
npx eslint src/components/platform-entry/platformEntryCreationTypes.ts src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --ext .ts,.tsx --max-warnings 0
npm run check:encoding
npm run typecheck
npm run build:raw
```
若后续接入真实 Rust API 和 SpacetimeDB 表,再补充 `npm run api-server``/healthz`、Rust contract / api-server / spacetime-client 定向测试和 migration 表目录更新。
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,8 @@
2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。
3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon,删除等破坏性动作继续收口到左滑或长按操作层。
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
5. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈
6. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
## 拼图
@@ -28,8 +29,13 @@
- 图像输入复用 `CreativeImageInputPanel`
- 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。
- 草稿生成会保留关卡图UI 背景;当前不自动生成背景音乐。
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图UI 背景后再变为 `ready`;当前不自动生成背景音乐。
- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_000_000ms` 且不自动重试;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。
- 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。
- 拼图参考图 AI 重绘优先走 VectorEngine `/v1/images/edits`;若编辑接口超时,`api-server` 会降级为 `/v1/images/generations`,并把同一参考图塞进 `image` 数组继续生成,避免参考图草稿整单失败。
- 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏。
- 结果页允许多关卡并行编辑和生成;某一关卡图片生成完成回包只静默更新该关卡素材与生成态,不得自动打开或切换关卡详情面板,避免打断用户正在编辑的其它关卡。
- 拼图 UI 背景是作品运行态背景,不只属于第一关;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺 `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承同作品首个可用 UI 背景,仍缺失时才沿用当前运行态快照背景或默认 UI。
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
@@ -51,24 +57,24 @@
难度映射:
| 难度 | clearCount | difficulty | 总物品数 | 物品种类 |
| --- | ---: | ---: | ---: | ---: |
| 轻松 | 8 | 2 | 24 | 3 |
| 标准 | 12 | 4 | 36 | 9 |
| 进阶 | 16 | 6 | 48 | 15 |
| 硬核 | 21 | 8 | 63 | 21 |
| ---- | ---------: | ---------: | -------: | -------: |
| 轻松 | 8 | 2 | 24 | 3 |
| 标准 | 12 | 4 | 36 | 9 |
| 进阶 | 16 | 6 | 48 | 15 |
| 硬核 | 21 | 8 | 63 | 21 |
当前素材生成流水线:
1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。
2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;草稿完成条件不包含 `backgroundMusic`
2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;作品摘要在素材或背景未完整时下发 `generationStatus=generating`,素材和背景完整后下发 `ready`草稿完成条件不包含 `backgroundMusic`
3. 物品素材不再调用 Hyper3D Rodin,不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材。
4. 物品 sheet 走 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,单张 `1:1` 图固定 `5*5`,每张承载 `5` 个物品、每个物品 `5` 个视角。
5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。
5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并对贴透明背景的弱绿 / 暗绿轮廓像素做去绿污染处理,最后按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。
6. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。
7. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大``中``小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。
8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。
9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。
10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取,避免草稿重进、结果页预览或试玩退回默认素材
10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 容器图。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景 / 默认容器
结果页当前结构:
@@ -81,15 +87,16 @@
运行态当前口径:
- 规则真相在后端;前端只做即时表现、点击候选、飞入、入槽、三消和胜负过渡。
- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品。
- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品;生成 2D PNG 物品必须按当前展示图的 alpha 像素做热区精筛,透明像素、`object-contain` 留白和 `itemSize` 缩小后的空白区不能响应点击
- 物品 DOM 只负责展示,不通过自身 `click` 事件直接提交,避免浏览器后续 click 绕过松手判定造成重复提交。
- 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。
- 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。
- 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`
- 局内容器图在移动端宽度接近屏幕宽度并居中显示,保持原图比例不拉伸;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`
- 局内容器图在移动端宽度大于屏幕宽度并略向下压,当前运行态使用 `w-[min(116vw,42rem)]``top-[54%]` 放大和下移容器图本体,保持原图比例不拉伸且不改变后端物品布局、点击半径或消除规则;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
- `itemSize` 只缩放生成 2D 图片本体:`大` 使用当前默认显示尺寸`中``小` 缩小显示;不改变后端下发的布局半径、点击半径或三消规则。
- `itemSize` 只缩放生成 2D 图片本体:`大``中``小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小`中``小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。
- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。
- 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。
- 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。
@@ -24,7 +24,9 @@ export type BabyObjectMatchVisualAssetKind =
| 'ui-frame'
| 'gift-box'
| 'basket'
| 'smoke-puff';
| 'smoke-puff'
| 'left-hand'
| 'right-hand';
export type BabyObjectMatchVisualAsset = {
assetId: string;
@@ -5,6 +5,7 @@
import type { CreationAudioAsset } from './creationAudio';
export type Match3DWorkPublicationStatus = 'draft' | 'published' | string;
export type Match3DWorkGenerationStatus = 'idle' | 'generating' | 'ready' | string;
export type Match3DGeneratedItemAssetStatus =
| 'pending'
@@ -163,6 +164,7 @@ export interface Match3DWorkSummary {
updatedAt: string;
publishedAt?: string | null;
publishReady: boolean;
generationStatus?: Match3DWorkGenerationStatus | null;
backgroundPrompt?: string | null;
backgroundImageSrc?: string | null;
backgroundImageObjectKey?: string | null;
@@ -2,6 +2,7 @@ import type { JsonObject } from './common';
import type { PuzzleAnchorPack, PuzzleDraftLevel } from './puzzleAgentDraft';
export type PuzzleWorkPublicationStatus = 'draft' | 'published';
export type PuzzleWorkGenerationStatus = PuzzleDraftLevel['generationStatus'];
export interface PuzzleWorkSummary {
workId: string;
@@ -28,6 +29,7 @@ export interface PuzzleWorkSummary {
pointIncentiveTotalPoints?: number;
pointIncentiveClaimablePoints?: number;
publishReady: boolean;
generationStatus?: PuzzleWorkGenerationStatus | null;
levels?: PuzzleDraftLevel[];
}
@@ -158,6 +158,26 @@ const assetDefinitions = [
chromaKeyNote,
].join(''),
},
{
id: 'character-outline-only-v3',
output: 'picture-book-character-outline-v3.png',
sourceOutput: 'picture-book-character-outline-v2.png',
sourceDirectory: 'asset',
transparent: true,
localPostprocess: 'character-outline-only',
prompt:
'本地后处理资源:基于 character-outline-v2 提取用户角色外轮廓,只保留浅青白描边,中间完全透明,不保留原有半透明材质、填充和明暗变化。',
},
{
id: 'character-outline-white-v4',
output: 'picture-book-character-outline-v4.png',
sourceOutput: 'picture-book-character-outline-v2.png',
sourceDirectory: 'asset',
transparent: true,
localPostprocess: 'character-outline-white-thin',
prompt:
'本地后处理资源:基于 character-outline-v2 提取用户角色外轮廓,先弱化耳朵、手指、脚趾等细碎凸起,再输出更细的白色描边,中间完全透明。',
},
{
id: 'hud-strip',
output: 'picture-book-hud-strip-v2.png',
@@ -601,6 +621,16 @@ const assetDefinitions = [
chromaKeyNote,
].join(''),
},
{
id: 'wave-cat-body-guide-v7',
output: 'picture-book-wave-cat-body-guide-v7.png',
sourceOutput: 'picture-book-wave-cat-body-guide-v6.png',
sourceDirectory: 'asset',
transparent: true,
localPostprocess: 'remove-cat-body-shoulder-dots',
prompt:
'本地后处理资源:基于 wave-cat-body-guide-v6 去除身体左右两侧不协调的小圆点,保留猫头、身体、透明边界和整体水彩风格。',
},
{
id: 'wave-cat-arm-guide-v6',
output: 'picture-book-wave-cat-arm-guide-v6.png',
@@ -632,6 +662,37 @@ const assetDefinitions = [
chromaKeyNote,
].join(''),
},
{
id: 'wave-cat-arm-guide-v7',
output: 'picture-book-wave-cat-arm-guide-v7.png',
sourceOutput: 'picture-book-wave-cat-arm-guide-v7-source.png',
size: '1024x1024',
transparent: true,
transparencyCleanup: 'cat-guide',
useWaveCatHeadReference: true,
useWaveCatArmReference: true,
layoutNormalization: {
canvasWidth: 1024,
canvasHeight: 1024,
fit: 'contain',
fillWidth: 0.74,
fillHeight: 0.88,
anchorY: 'bottom',
padding: 20,
},
prompt: [
'请在参考手臂资源的基础上重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂资源,严格作为可动纸偶拆件。只画一条橘白猫手臂:底部是肩膀连接端,向左上方弯曲,末端是一只简化圆猫手。',
'关键修改:末端圆猫爪必须正面对镜头,像在对观众挥手。圆爪正面轮廓要清楚可见,不要转成侧面,不要转向画面内侧或角色中心,不要画成握拳或背面。可以用浅奶油白圆形爪面、柔和高光和非常淡的短弧线表现正面对镜头。',
'猫手必须像多啦A梦式圆手或软玩具圆爪:一个完整圆润圆爪,不画分开的手指,不画尖爪,不画黑色或深色爪垫。若需要爪面细节,只允许非常浅的桃色小圆面或柔和弧线,不能变成真实动物爪垫。',
'手臂短而厚实,像小猫上肢,不要成人类长手臂。资源必须适合网页左右镜像复用和围绕肩部连接点旋转:肩膀连接端在画面底部偏内侧,圆手在画面上方,四周留透明空白。',
'颜色参考输入猫猫头和参考手臂:浅奶油白和淡橘色为主体,少量浅橘斑纹,柔和暖棕或浅橘棕描边;不要纯黑粗描边。',
'请避免粉色大背景、避免主体外侧彩色光晕,主体贴纸外轮廓之外必须直接是纯色背景。资源自身保持清晰不透明,半透明效果由网页 CSS 控制。',
'不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
];
const args = new Map();
@@ -811,6 +872,12 @@ function buildRequestBody(asset, size) {
path.join(assetDir, 'picture-book-wave-cat-head-guide-v2.png'),
);
}
if (asset.useWaveCatArmReference) {
pushReferenceImage(
body,
path.join(assetDir, 'picture-book-wave-cat-arm-guide-v6.png'),
);
}
return body;
}
@@ -864,6 +931,9 @@ function outputPathFor(asset) {
}
function sourceOutputPathFor(asset) {
if (asset.sourceDirectory === 'asset') {
return path.join(assetDir, asset.sourceOutput || asset.output);
}
return path.join(intermediateDir, asset.sourceOutput || asset.output);
}
@@ -1038,6 +1108,92 @@ function removeCharacterOutlineChromaKey(sourcePath, finalPath) {
}
}
function createCharacterOutlineOnlyIndicator(sourcePath, finalPath) {
const script = [
'from PIL import Image, ImageChops, ImageFilter',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'im = Image.open(source).convert("RGBA")',
'alpha = im.getchannel("A")',
'mask = alpha.point(lambda v: 255 if v > 24 else 0)',
'mask = mask.filter(ImageFilter.MaxFilter(5)).filter(ImageFilter.MinFilter(5))',
'outer = mask.filter(ImageFilter.MaxFilter(47))',
'inner = mask.filter(ImageFilter.MinFilter(47))',
'stroke = ImageChops.subtract(outer, inner)',
'stroke = stroke.filter(ImageFilter.GaussianBlur(0.45))',
'glow = stroke.filter(ImageFilter.GaussianBlur(3.0)).point(lambda v: int(v * 0.34))',
'result = Image.new("RGBA", im.size, (0, 0, 0, 0))',
'glow_layer = Image.new("RGBA", im.size, (91, 205, 197, 0))',
'glow_layer.putalpha(glow)',
'line_layer = Image.new("RGBA", im.size, (224, 255, 247, 0))',
'line_layer.putalpha(stroke.point(lambda v: min(235, int(v * 0.92))))',
'result.alpha_composite(glow_layer)',
'result.alpha_composite(line_layer)',
'result.save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to create outline-only character indicator: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function createWhiteCharacterOutlineIndicator(sourcePath, finalPath) {
const script = [
'from pathlib import Path',
'import cv2',
'import numpy as np',
'from PIL import Image',
'import sys',
'source, out = Path(sys.argv[1]), Path(sys.argv[2])',
'rgba = np.array(Image.open(source).convert("RGBA"))',
'alpha = rgba[:, :, 3]',
'mask = (alpha > 24).astype(np.uint8) * 255',
'contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)',
'body = np.zeros_like(mask)',
'if contours:',
' largest = max(contours, key=cv2.contourArea)',
' cv2.drawContours(body, [largest], -1, 255, thickness=cv2.FILLED)',
'open_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (25, 25))',
'close_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (35, 35))',
'body = cv2.morphologyEx(body, cv2.MORPH_OPEN, open_kernel, iterations=1)',
'body = cv2.morphologyEx(body, cv2.MORPH_CLOSE, close_kernel, iterations=1)',
'body = cv2.GaussianBlur(body, (0, 0), 7.0)',
'_, body = cv2.threshold(body, 92, 255, cv2.THRESH_BINARY)',
'body = cv2.GaussianBlur(body, (0, 0), 1.4)',
'_, body = cv2.threshold(body, 64, 255, cv2.THRESH_BINARY)',
'line_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))',
'outer = cv2.dilate(body, line_kernel, iterations=1)',
'inner = cv2.erode(body, line_kernel, iterations=1)',
'stroke = cv2.subtract(outer, inner)',
'stroke = cv2.GaussianBlur(stroke, (0, 0), 0.55)',
'glow = cv2.GaussianBlur(stroke, (0, 0), 2.2)',
'result = np.zeros((mask.shape[0], mask.shape[1], 4), dtype=np.uint8)',
'glow_alpha = np.clip(glow.astype(np.float32) * 0.22, 0, 70).astype(np.uint8)',
'line_alpha = np.clip(stroke.astype(np.float32) * 0.78, 0, 205).astype(np.uint8)',
'result[:, :, 0:3] = 255',
'result[:, :, 3] = np.maximum(glow_alpha, line_alpha)',
'Image.fromarray(result, "RGBA").save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to create thin white character indicator: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function removeCatGuideChromaKey(sourcePath, finalPath) {
const script = [
'from collections import deque',
@@ -1253,6 +1409,50 @@ function scrubChromaFringe(finalPath) {
}
}
function removeCatBodyShoulderDots(sourcePath, finalPath) {
const script = [
'from pathlib import Path',
'import cv2',
'import numpy as np',
'from PIL import Image',
'source, out = Path(__import__("sys").argv[1]), Path(__import__("sys").argv[2])',
'rgba = np.array(Image.open(source).convert("RGBA"))',
'rgb = rgba[:, :, :3].copy()',
'alpha = rgba[:, :, 3]',
'opaque = alpha > 10',
'known = opaque.astype(np.uint8)',
'unknown = (1 - known).astype(np.uint8)',
'_, labels = cv2.distanceTransformWithLabels(unknown, cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL)',
'flat_known_indices = np.flatnonzero(known.reshape(-1))',
'filled_rgb = rgb.copy().reshape(-1, 3)',
'labels_flat = labels.reshape(-1)',
'unknown_flat = unknown.reshape(-1).astype(bool)',
'if flat_known_indices.size > 0 and unknown_flat.any():',
' nearest_known_flat_index = flat_known_indices[np.maximum(labels_flat[unknown_flat] - 1, 0)]',
' filled_rgb[unknown_flat] = filled_rgb[nearest_known_flat_index]',
'filled_rgb = filled_rgb.reshape(rgb.shape)',
'bgr = cv2.cvtColor(filled_rgb, cv2.COLOR_RGB2BGR)',
'mask = np.zeros(alpha.shape, dtype=np.uint8)',
'cv2.ellipse(mask, (383, 763), (23, 26), 0, 0, 360, 255, -1)',
'cv2.ellipse(mask, (648, 762), (23, 26), 0, 0, 360, 255, -1)',
'mask = cv2.bitwise_and(mask, opaque.astype(np.uint8) * 255)',
'repaired = cv2.inpaint(bgr, mask, 7, cv2.INPAINT_TELEA)',
'repaired_rgb = cv2.cvtColor(repaired, cv2.COLOR_BGR2RGB)',
'Image.fromarray(np.dstack([repaired_rgb, alpha]), "RGBA").save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to remove cat body shoulder dots: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function writeOpaquePng(sourcePath, outputPath) {
const result = spawnSync(
'python',
@@ -1291,6 +1491,54 @@ async function generateAsset(asset, env, size, force) {
}
if (args.has('--postprocess-only')) {
if (asset.localPostprocess === 'character-outline-white-thin') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
createWhiteCharacterOutlineIndicator(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (asset.localPostprocess === 'character-outline-only') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
createCharacterOutlineOnlyIndicator(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (asset.localPostprocess === 'remove-cat-body-shoulder-dots') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
removeCatBodyShoulderDots(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (!asset.transparent) {
return {
id: asset.id,
@@ -1328,6 +1576,54 @@ async function generateAsset(asset, env, size, force) {
};
}
if (asset.localPostprocess === 'character-outline-white-thin') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
createWhiteCharacterOutlineIndicator(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (asset.localPostprocess === 'character-outline-only') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
createCharacterOutlineOnlyIndicator(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (asset.localPostprocess === 'remove-cat-body-shoulder-dots') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
removeCatBodyShoulderDots(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
const requestBody = buildRequestBody(asset, size);
const payloadText = await fetchWithTimeout(
buildVectorEngineImagesGenerationUrl(env.baseUrl),
@@ -1427,6 +1723,7 @@ function dryRun(selectedAssets, size) {
? sourceOutputPathFor(asset)
: undefined,
transparent: asset.transparent,
localPostprocess: asset.localPostprocess,
body: {
...body,
image: body.image ? ['<local style reference image>'] : undefined,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+394
View File
@@ -6016,13 +6016,16 @@ fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, heig
let mut background_mask = vec![0u8; pixel_count];
let mut queue = Vec::<usize>::new();
let mut queue_index = 0usize;
let mut transparent_pixel_count = 0usize;
for pixel_index in 0..pixel_count {
let offset = pixel_index * 4;
if pixels[offset + 3] == 0 {
background_mask[pixel_index] = 1;
queue.push(pixel_index);
transparent_pixel_count = transparent_pixel_count.saturating_add(1);
}
}
let has_transparent_background = transparent_pixel_count > pixel_count / 200;
// 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 PNG 外缘;
// 把外缘一段宽度作为去背种子,但只清理绿幕 / 近白 matte,避免误伤贴边主体。
@@ -6136,6 +6139,98 @@ fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, heig
}
}
if has_transparent_background {
let mut visible_mask = vec![0u8; pixel_count];
for pixel_index in 0..pixel_count {
let offset = pixel_index * 4;
if is_match3d_material_visible_pixel([
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
]) {
visible_mask[pixel_index] = 1;
}
}
for _ in 0..2 {
let mut changed_this_round = false;
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if visible_mask[pixel_index] == 0 {
continue;
}
let offset = pixel_index * 4;
let pixel = [
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
];
if !is_match3d_material_green_contaminated_edge_pixel(pixel) {
continue;
}
if !touches_match3d_material_background_mask(
x,
y,
width,
height,
&background_mask,
) {
continue;
}
if is_match3d_material_strong_green_contamination(pixel) {
pixels[offset] = 0;
pixels[offset + 1] = 0;
pixels[offset + 2] = 0;
pixels[offset + 3] = 0;
visible_mask[pixel_index] = 0;
background_mask[pixel_index] = 1;
changed = true;
changed_this_round = true;
continue;
}
let replacement = collect_match3d_material_visible_neighbor_color(
pixels,
width,
height,
x,
y,
&background_mask,
&visible_mask,
)
.unwrap_or((
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
));
let next_red = replacement.0.max(pixels[offset]);
let next_blue = replacement.2.max(pixels[offset + 2]);
let next_green = replacement
.1
.min(next_red.max(next_blue).saturating_add(12));
if next_red != pixels[offset]
|| next_green != pixels[offset + 1]
|| next_blue != pixels[offset + 2]
{
pixels[offset] = next_red;
pixels[offset + 1] = next_green;
pixels[offset + 2] = next_blue;
changed = true;
changed_this_round = true;
}
background_mask[pixel_index] = 1;
}
}
if !changed_this_round {
break;
}
}
}
changed
}
@@ -6167,6 +6262,98 @@ fn is_match3d_material_soft_edge_pixel(pixel: [u8; 4]) -> bool {
&& (red >= 48 || blue >= 96 || pixel[3] < 236)
}
fn is_match3d_material_green_contaminated_edge_pixel(pixel: [u8; 4]) -> bool {
if pixel[3] == 0 {
return false;
}
let red = pixel[0];
let green = pixel[1];
let blue = pixel[2];
green >= 72 && green.saturating_sub(red.max(blue)) >= 18
}
fn is_match3d_material_strong_green_contamination(pixel: [u8; 4]) -> bool {
let red = pixel[0];
let green = pixel[1];
let blue = pixel[2];
green >= 148 && green.saturating_sub(red.max(blue)) >= 34
}
fn collect_match3d_material_visible_neighbor_color(
pixels: &[u8],
width: usize,
height: usize,
x: usize,
y: usize,
background_mask: &[u8],
visible_mask: &[u8],
) -> Option<(u8, u8, u8)> {
let mut total_weight = 0.0f32;
let mut total_red = 0.0f32;
let mut total_green = 0.0f32;
let mut total_blue = 0.0f32;
for offset_y in -3i32..=3 {
for offset_x in -3i32..=3 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 {
continue;
}
let next_pixel_index = next_y as usize * width + next_x as usize;
if background_mask[next_pixel_index] != 0 || visible_mask[next_pixel_index] == 0 {
continue;
}
let next_offset = next_pixel_index * 4;
let next_alpha = pixels[next_offset + 3];
if next_alpha < 96 {
continue;
}
let pixel = [
pixels[next_offset],
pixels[next_offset + 1],
pixels[next_offset + 2],
next_alpha,
];
if is_match3d_material_green_contaminated_edge_pixel(pixel)
|| is_match3d_material_soft_edge_pixel(pixel)
{
continue;
}
let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs();
let weight = (next_alpha as f32 / 255.0)
* if distance <= 1 {
2.0
} else if distance <= 3 {
1.2
} else {
0.7
};
total_weight += weight;
total_red += pixels[next_offset] as f32 * weight;
total_green += pixels[next_offset + 1] as f32 * weight;
total_blue += pixels[next_offset + 2] as f32 * weight;
}
}
if total_weight <= 0.0 {
return None;
}
Some((
(total_red / total_weight).round() as u8,
(total_green / total_weight).round() as u8,
(total_blue / total_weight).round() as u8,
))
}
fn apply_match3d_material_green_screen_alpha(source: image::DynamicImage) -> image::DynamicImage {
let mut image = source.to_rgba8();
let (width, height) = image.dimensions();
@@ -7571,6 +7758,37 @@ mod tests {
);
}
#[test]
fn match3d_material_view_edge_matte_neutralizes_dark_green_contour_pixels() {
let width = 64;
let height = 64;
let mut view = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0]));
for y in 16..48 {
for x in 16..48 {
if x <= 18 || x >= 45 || y <= 18 || y >= 45 {
view.put_pixel(x, y, image::Rgba([42, 118, 36, 255]));
} else {
view.put_pixel(x, y, image::Rgba([174, 92, 72, 255]));
}
}
}
let cleaned =
crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8();
assert!(
cleaned.pixels().all(|pixel| {
let [red, green, blue, alpha] = pixel.0;
alpha == 0 || green <= red.max(blue).saturating_add(18)
}),
"暗绿轮廓污染也必须被透明化或去绿,不能残留可见绿边"
);
assert!(
cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]),
"暗绿轮廓清理不能误删物品主体"
);
}
#[test]
fn match3d_material_sheet_slicing_cleans_white_matte_edge() {
let width = 500;
@@ -8388,6 +8606,7 @@ mod tests {
total_item_count: 36,
publish_ready: false,
blockers: Vec::new(),
generated_item_assets_json: None,
}),
messages: Vec::new(),
last_assistant_reply: None,
@@ -8472,6 +8691,131 @@ mod tests {
);
}
#[test]
fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydration() {
let assets = vec![Match3DGeneratedItemAsset {
item_id: "match3d-item-1".to_string(),
item_name: "草莓".to_string(),
item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()),
image_src: Some(
"/generated-match3d-assets/session/profile/items/strawberry/view-01.png"
.to_string(),
),
image_object_key: Some(
"generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(),
),
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: Some(Match3DGeneratedBackgroundAsset {
prompt: "果园背景".to_string(),
image_src: Some(
"/generated-match3d-assets/session/profile/background/background.png"
.to_string(),
),
image_object_key: Some(
"generated-match3d-assets/session/profile/background/background.png"
.to_string(),
),
container_prompt: Some("果园容器".to_string()),
container_image_src: Some(
"/generated-match3d-assets/session/profile/ui-container/container.png"
.to_string(),
),
container_image_object_key: Some(
"generated-match3d-assets/session/profile/ui-container/container.png"
.to_string(),
),
status: "image_ready".to_string(),
error: None,
}),
status: "image_ready".to_string(),
error: None,
}];
let session = Match3DAgentSessionRecord {
session_id: "match3d-session-1".to_string(),
current_turn: 3,
progress_percent: 100,
stage: "DraftCompiled".to_string(),
anchor_pack: Match3DAnchorPackRecord {
theme: Match3DAnchorItemRecord {
key: "theme".to_string(),
label: "题材主题".to_string(),
value: "水果".to_string(),
status: "confirmed".to_string(),
},
clear_count: Match3DAnchorItemRecord {
key: "clearCount".to_string(),
label: "消除次数".to_string(),
value: "12".to_string(),
status: "confirmed".to_string(),
},
difficulty: Match3DAnchorItemRecord {
key: "difficulty".to_string(),
label: "难度".to_string(),
value: "4".to_string(),
status: "confirmed".to_string(),
},
},
config: None,
draft: Some(Match3DResultDraftRecord {
profile_id: "match3d-profile-1".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary_text: "水果主题".to_string(),
tags: vec!["水果".to_string(), "抓大鹅".to_string()],
cover_image_src: None,
reference_image_src: None,
clear_count: 12,
difficulty: 4,
total_item_count: 36,
publish_ready: false,
blockers: Vec::new(),
generated_item_assets_json: serialize_match3d_generated_item_assets(&assets),
}),
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
updated_at: "2026-05-15T00:00:00.000Z".to_string(),
};
let response = map_match3d_agent_session_response_with_assets(session, &[]);
let draft = response.draft.expect("session draft should exist");
assert_eq!(draft.generated_item_assets.len(), 1);
assert_eq!(
draft.background_image_src.as_deref(),
Some("/generated-match3d-assets/session/profile/background/background.png")
);
assert_eq!(
draft.background_image_object_key.as_deref(),
Some("generated-match3d-assets/session/profile/background/background.png")
);
assert_eq!(
draft
.generated_background_asset
.as_ref()
.and_then(|asset| asset.container_image_src.as_deref()),
Some("/generated-match3d-assets/session/profile/ui-container/container.png")
);
assert_eq!(
draft.generated_item_assets[0]
.background_asset
.as_ref()
.and_then(|asset| asset.image_src.as_deref()),
Some("/generated-match3d-assets/session/profile/background/background.png")
);
}
#[test]
fn match3d_tag_normalization_only_strips_numbered_list_prefix() {
assert_eq!(normalize_match3d_tag("3D素材"), "3D素材");
@@ -8761,9 +9105,59 @@ mod tests {
assert_eq!(response.generated_item_assets.len(), 1);
assert_eq!(response.generated_item_assets[0].item_name, "草莓");
assert_eq!(response.generated_item_assets[0].status, "image_ready");
assert_eq!(response.generation_status.as_deref(), Some("generating"));
assert_eq!(
response.generated_item_assets[0].image_src.as_deref(),
Some("/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png")
);
}
#[test]
fn match3d_work_summary_marks_complete_generated_assets_ready() {
let assets = vec![Match3DGeneratedItemAsset {
background_asset: Some(Match3DGeneratedBackgroundAsset {
prompt: "水果厨房背景".to_string(),
image_src: Some(
"/generated-match3d-assets/session/profile/background.png".to_string(),
),
image_object_key: Some(
"generated-match3d-assets/session/profile/background.png".to_string(),
),
container_prompt: None,
container_image_src: Some(
"/generated-match3d-assets/session/profile/container.png".to_string(),
),
container_image_object_key: Some(
"generated-match3d-assets/session/profile/container.png".to_string(),
),
status: "image_ready".to_string(),
error: None,
}),
..test_match3d_generated_item_asset(1, "草莓")
}];
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {
work_id: "match3d-profile-1".to_string(),
profile_id: "match3d-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("match3d-session-1".to_string()),
author_display_name: "玩家".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary: "水果主题".to_string(),
tags: vec!["水果".to_string()],
cover_image_src: None,
cover_asset_id: None,
reference_image_src: None,
clear_count: 3,
difficulty: 3,
publication_status: "draft".to_string(),
play_count: 0,
updated_at: "2026-05-10T00:00:00.000Z".to_string(),
published_at: None,
publish_ready: false,
generated_item_assets_json: serialize_match3d_generated_item_assets(&assets),
});
assert_eq!(response.generation_status.as_deref(), Some("ready"));
}
}
@@ -32,6 +32,10 @@ pub(super) fn map_match3d_agent_session_response_with_assets(
) -> Match3DAgentSessionSnapshotResponse {
let mut response = map_match3d_agent_session_response(session);
if let Some(draft) = response.draft.as_mut() {
if generated_item_assets.is_empty() {
return response;
}
draft.generated_item_assets = generated_item_assets
.iter()
.cloned()
@@ -129,7 +133,15 @@ pub(super) fn map_match3d_config_response(
pub(super) fn map_match3d_draft_response(
draft: Match3DResultDraftRecord,
) -> Match3DResultDraftResponse {
Match3DResultDraftResponse {
// 中文注释:session draft 自身也可能携带生成素材快照,不能只依赖 work detail 回读补齐 UI 背景和容器图。
let generated_item_assets = parse_match3d_generated_item_assets(
draft.generated_item_assets_json.as_deref(),
)
.into_iter()
.map(Match3DGeneratedItemAsset::from)
.collect::<Vec<_>>();
let background_asset = find_match3d_generated_background_asset(&generated_item_assets);
let mut response = Match3DResultDraftResponse {
profile_id: draft.profile_id,
game_name: draft.game_name,
theme_text: draft.theme_text,
@@ -147,8 +159,24 @@ pub(super) fn map_match3d_draft_response(
background_image_src: None,
background_image_object_key: None,
generated_background_asset: None,
generated_item_assets: Vec::new(),
generated_item_assets: generated_item_assets
.iter()
.cloned()
.map(map_match3d_generated_item_asset_for_agent)
.collect(),
};
if response
.cover_image_src
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty()
{
response.cover_image_src = resolve_match3d_default_cover_image_src(&generated_item_assets);
}
apply_match3d_background_asset_to_agent_draft(&mut response, background_asset);
response
}
pub(super) fn map_match3d_generated_item_asset_for_agent(
@@ -365,6 +393,45 @@ pub(super) fn build_match3d_work_profile_record_with_assets(
item
}
fn match3d_text_present(value: Option<&String>) -> bool {
value.is_some_and(|value| !value.trim().is_empty())
}
fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool {
match3d_text_present(asset.image_src.as_ref())
|| match3d_text_present(asset.image_object_key.as_ref())
|| asset.image_views.iter().any(|view| {
match3d_text_present(view.image_src.as_ref())
|| match3d_text_present(view.image_object_key.as_ref())
})
}
fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool {
match3d_text_present(asset.image_src.as_ref())
|| match3d_text_present(asset.image_object_key.as_ref())
|| match3d_text_present(asset.container_image_src.as_ref())
|| match3d_text_present(asset.container_image_object_key.as_ref())
}
fn resolve_match3d_work_generation_status(
item: &Match3DWorkProfileRecord,
assets: &[Match3DGeneratedItemAssetJson],
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
) -> Option<String> {
if item.publication_status.eq_ignore_ascii_case("published") {
return Some("ready".to_string());
}
if assets.is_empty()
|| !assets.iter().any(match3d_item_asset_has_image)
|| !background_asset.is_some_and(match3d_background_asset_has_image)
{
return Some("generating".to_string());
}
Some("ready".to_string())
}
pub(super) fn map_match3d_message_response(
message: Match3DAgentMessageRecord,
) -> Match3DAgentMessageResponse {
@@ -383,6 +450,11 @@ pub(super) fn map_match3d_work_summary_response(
let generated_item_asset_json =
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
let generation_status = resolve_match3d_work_generation_status(
&item,
&generated_item_asset_json,
background_asset.as_ref(),
);
let generated_background_asset = background_asset
.clone()
.map(map_match3d_background_asset_for_work);
@@ -408,6 +480,7 @@ pub(super) fn map_match3d_work_summary_response(
updated_at: item.updated_at,
published_at: item.published_at,
publish_ready: item.publish_ready,
generation_status,
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
background_image_src: background_asset
.as_ref()
+154 -6
View File
@@ -4030,6 +4030,11 @@ fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError {
error
}
fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|| is_puzzle_request_timeout_message(error.body_text().as_str())
}
async fn generate_puzzle_image_candidates(
state: &AppState,
owner_user_id: &str,
@@ -4111,7 +4116,7 @@ async fn generate_puzzle_image_candidates(
"message": "AI 重绘需要提供参考图。",
}))
})?;
create_puzzle_vector_engine_image_edit(
let edit_result = create_puzzle_vector_engine_image_edit(
&http_client,
&settings,
actual_prompt.as_str(),
@@ -4120,7 +4125,34 @@ async fn generate_puzzle_image_candidates(
count,
reference_image,
)
.await
.await;
match edit_result {
Ok(generated) => Ok(generated),
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
tracing::warn!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
reference_mime = %reference_image.mime_type,
reference_bytes = reference_image.bytes_len,
error = %error,
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
);
create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
resolved_model,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
Some(reference_image),
)
.await
}
Err(error) => Err(error),
}
} else {
create_puzzle_vector_engine_image_generation(
&http_client,
@@ -4130,6 +4162,7 @@ async fn generate_puzzle_image_candidates(
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
None,
)
.await
}
@@ -4263,6 +4296,7 @@ mod tests {
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
4,
None,
);
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
@@ -4278,6 +4312,40 @@ mod tests {
);
}
#[test]
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
let mut cursor = std::io::Cursor::new(Vec::new());
image
.write_to(&mut cursor, ImageFormat::Png)
.expect("test image should encode");
let reference_image = PuzzleResolvedReferenceImage {
mime_type: "image/png".to_string(),
bytes_len: cursor.get_ref().len(),
bytes: cursor.into_inner(),
};
let body = build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::GptImage2,
"参考图里的小猫做成拼图主图。",
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
1,
Some(&reference_image),
);
let images = body["image"]
.as_array()
.expect("fallback generation should include reference image array");
assert_eq!(images.len(), 1);
assert!(
images[0]
.as_str()
.unwrap_or_default()
.starts_with("data:image/png;base64,")
);
}
#[test]
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
let settings = PuzzleVectorEngineSettings {
@@ -4363,6 +4431,39 @@ mod tests {
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() {
let timeout_error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
assert!(should_fallback_puzzle_reference_edit_to_generation(
&timeout_error
));
let auth_error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::UNAUTHORIZED,
r#"{"error":{"message":"invalid api key"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
assert!(!should_fallback_puzzle_reference_edit_to_generation(
&auth_error
));
}
#[test]
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
let error = match reqwest::Client::new().get("http://[::1").build() {
@@ -4834,6 +4935,7 @@ mod tests {
);
assert_eq!(response.levels.len(), 1);
assert_eq!(response.generation_status.as_deref(), Some("ready"));
assert_eq!(
response.levels[0].cover_image_src.as_deref(),
Some("/generated-puzzle-assets/session/cover.png")
@@ -5242,6 +5344,7 @@ async fn create_puzzle_vector_engine_image_generation(
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: Option<&PuzzleResolvedReferenceImage>,
) -> Result<PuzzleGeneratedImages, AppError> {
let request_body = build_puzzle_vector_engine_image_request_body(
image_model,
@@ -5249,6 +5352,7 @@ async fn create_puzzle_vector_engine_image_generation(
negative_prompt,
size,
candidate_count,
reference_image,
);
let request_url = puzzle_vector_engine_images_generation_url(settings);
let request_started_at = Instant::now();
@@ -5277,7 +5381,7 @@ async fn create_puzzle_vector_engine_image_generation(
status = status.as_u16(),
prompt_chars = prompt.chars().count(),
size,
has_reference_image = false,
has_reference_image = reference_image.is_some(),
elapsed_ms = upstream_elapsed_ms,
"拼图 VectorEngine 图片生成 HTTP 返回"
);
@@ -5434,8 +5538,9 @@ fn build_puzzle_vector_engine_image_request_body(
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: Option<&PuzzleResolvedReferenceImage>,
) -> Value {
Value::Object(Map::from_iter([
let mut body = Map::from_iter([
(
"model".to_string(),
Value::String(image_model.request_model_name().to_string()),
@@ -5446,7 +5551,15 @@ fn build_puzzle_vector_engine_image_request_body(
),
("n".to_string(), json!(candidate_count.clamp(1, 1))),
("size".to_string(), Value::String(size.to_string())),
]))
]);
if let Some(reference_image) = reference_image
&& let Some(reference_data_url) =
build_puzzle_generation_reference_image_data_url(reference_image)
{
body.insert("image".to_string(), json!([reference_data_url]));
}
Value::Object(body)
}
fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_image: bool) -> String {
@@ -5465,6 +5578,32 @@ fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_imag
)
}
fn build_puzzle_generation_reference_image_data_url(
image: &PuzzleResolvedReferenceImage,
) -> Option<String> {
let bytes = resize_puzzle_generation_reference_image_bytes(image.bytes.as_slice())
.unwrap_or_else(|| image.bytes.clone());
let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
"image/png"
} else {
image.mime_type.as_str()
};
Some(format!(
"data:{};base64,{}",
normalize_puzzle_downloaded_image_mime_type(mime_type),
BASE64_STANDARD.encode(bytes)
))
}
fn resize_puzzle_generation_reference_image_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
let image = image::load_from_memory(bytes).ok()?;
let resized = image.resize(1024, 1024, image::imageops::FilterType::Triangle);
let mut cursor = std::io::Cursor::new(Vec::new());
resized.write_to(&mut cursor, ImageFormat::Png).ok()?;
Some(cursor.into_inner())
}
fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
reference_image_src
.map(str::trim)
@@ -6185,19 +6324,28 @@ fn map_puzzle_vector_engine_upstream_error(
) -> AppError {
let message = parse_puzzle_api_error_message(raw_text, fallback_message);
let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800);
let is_timeout = is_puzzle_request_timeout_message(message.as_str())
|| is_puzzle_request_timeout_message(raw_excerpt.as_str());
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
upstream_status = upstream_status.as_u16(),
timeout = is_timeout,
message = %message,
raw_excerpt = %raw_excerpt,
"拼图 VectorEngine 上游请求失败"
);
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
AppError::from_status(status).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"upstreamStatus": upstream_status.as_u16(),
"message": message,
"rawExcerpt": raw_excerpt,
"timeout": is_timeout,
}))
}
@@ -278,10 +278,31 @@ pub(super) fn map_puzzle_result_preview_finding_response(
}
}
fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option<String> {
item.levels
.iter()
.map(|level| level.generation_status.trim())
.find(|status| *status == "generating")
.or_else(|| {
item.levels
.iter()
.map(|level| level.generation_status.trim())
.find(|status| *status == "ready")
})
.or_else(|| {
item.levels
.iter()
.map(|level| level.generation_status.trim())
.find(|status| !status.is_empty())
})
.map(str::to_string)
}
pub(super) fn map_puzzle_work_summary_response(
state: &AppState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkSummaryResponse {
let generation_status = resolve_puzzle_work_generation_status(&item);
let author = resolve_work_author_by_user_id(
state,
&item.owner_user_id,
@@ -316,6 +337,7 @@ pub(super) fn map_puzzle_work_summary_response(
.saturating_div(2)
.saturating_sub(item.point_incentive_claimed_points),
publish_ready: item.publish_ready,
generation_status,
levels: Vec::new(),
}
}
@@ -237,7 +237,9 @@ pub fn confirm_click_at(
return Ok(rejected(next, Match3DClickRejectReason::ItemNotClickable));
}
let Some(slot_index) = first_empty_slot_index(&next.tray_slots) else {
let Some(slot_index) =
insert_item_into_tray_after_same_type(&mut next.tray_slots, &mut next.items, item_index)
else {
next = fail_run(next, Match3DFailureReason::TrayFull, client_action_id);
return Ok(rejected(next, Match3DClickRejectReason::TrayFull));
};
@@ -246,7 +248,6 @@ pub fn confirm_click_at(
next.items[item_index].state = Match3DItemState::InTray;
next.items[item_index].clickable = false;
next.items[item_index].tray_slot_index = Some(slot_index);
fill_tray_slot(&mut next.tray_slots, slot_index, &next.items[item_index]);
let cleared_item_instance_ids = clear_first_triple(&mut next, &item_type_id);
compact_tray(&mut next);
@@ -540,12 +541,64 @@ fn first_empty_slot_index(slots: &[Match3DTraySlot]) -> Option<u32> {
.map(|slot| slot.slot_index)
}
fn fill_tray_slot(slots: &mut [Match3DTraySlot], slot_index: u32, item: &Match3DItemSnapshot) {
if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) {
slot.item_instance_id = Some(item.item_instance_id.clone());
slot.item_type_id = Some(item.item_type_id.clone());
slot.visual_key = Some(item.visual_key.clone());
fn insert_item_into_tray_after_same_type(
slots: &mut [Match3DTraySlot],
items: &mut [Match3DItemSnapshot],
item_index: usize,
) -> Option<u32> {
let occupied = slots
.iter()
.filter_map(|slot| {
Some((
slot.item_instance_id.clone()?,
slot.item_type_id.clone()?,
slot.visual_key.clone()?,
))
})
.collect::<Vec<_>>();
if occupied.len() >= slots.len() {
return None;
}
let item = items.get(item_index)?.clone();
let insertion_index = occupied
.iter()
.rposition(|(_, item_type_id, _)| item_type_id == &item.item_type_id)
.map(|index| index + 1)
.unwrap_or(occupied.len());
let mut next_occupied = occupied;
next_occupied.insert(
insertion_index,
(
item.item_instance_id.clone(),
item.item_type_id.clone(),
item.visual_key.clone(),
),
);
for slot in slots.iter_mut() {
slot.item_instance_id = None;
slot.item_type_id = None;
slot.visual_key = None;
}
for (index, (item_instance_id, item_type_id, visual_key)) in
next_occupied.into_iter().enumerate()
{
let slot_index = index as u32;
if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) {
slot.item_instance_id = Some(item_instance_id.clone());
slot.item_type_id = Some(item_type_id);
slot.visual_key = Some(visual_key);
}
if let Some(entry) = items
.iter_mut()
.find(|entry| entry.item_instance_id == item_instance_id)
{
entry.tray_slot_index = Some(slot_index);
}
}
Some(insertion_index as u32)
}
fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec<String> {
@@ -579,6 +632,7 @@ fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec<S
slot.visual_key = None;
}
}
compact_tray(run);
matched_slot_item_ids
}
@@ -1005,8 +1059,16 @@ mod tests {
for item in board_items {
let quadrant = format!(
"{}-{}",
if item.x >= MATCH3D_BOARD_CENTER { "r" } else { "l" },
if item.y >= MATCH3D_BOARD_CENTER { "b" } else { "t" },
if item.x >= MATCH3D_BOARD_CENTER {
"r"
} else {
"l"
},
if item.y >= MATCH3D_BOARD_CENTER {
"b"
} else {
"t"
},
);
*quadrants.entry(quadrant).or_default() += 1;
}
@@ -1108,6 +1170,82 @@ mod tests {
);
}
#[test]
fn clicking_item_inserts_after_same_type_and_shifts_following_slots() {
let mut run = Match3DRunSnapshot {
run_id: "run-insert".to_string(),
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
status: Match3DRunStatus::Running,
started_at_ms: 0,
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
clear_count: 3,
total_item_count: 4,
cleared_item_count: 0,
board_version: 1,
items: vec![
manual_item("apple-3", "apple", None),
manual_item("apple-1", "apple", Some(0)),
manual_item("apple-2", "apple", Some(1)),
manual_item("pear-1", "pear", Some(2)),
],
tray_slots: empty_tray_slots(),
failure_reason: None,
last_confirmed_action_id: None,
};
run.tray_slots[0].item_instance_id = Some("apple-1".to_string());
run.tray_slots[0].item_type_id = Some("apple".to_string());
run.tray_slots[0].visual_key = Some("apple".to_string());
run.tray_slots[1].item_instance_id = Some("apple-2".to_string());
run.tray_slots[1].item_type_id = Some("apple".to_string());
run.tray_slots[1].visual_key = Some("apple".to_string());
run.tray_slots[2].item_instance_id = Some("pear-1".to_string());
run.tray_slots[2].item_type_id = Some("pear".to_string());
run.tray_slots[2].visual_key = Some("pear".to_string());
let confirmed = confirm_click_at(
&run,
&Match3DClickInput {
run_id: run.run_id.clone(),
owner_user_id: run.owner_user_id.clone(),
item_instance_id: "apple-3".to_string(),
client_action_id: "action-insert".to_string(),
snapshot_version: run.board_version,
clicked_at_ms: 1_000,
},
)
.expect("click should confirm");
assert_eq!(confirmed.entered_slot_index, Some(2));
assert_eq!(
confirmed
.run
.tray_slots
.iter()
.map(|slot| slot.item_instance_id.as_deref())
.collect::<Vec<_>>(),
vec![Some("pear-1"), None, None, None, None, None, None]
);
assert_eq!(
confirmed
.run
.items
.iter()
.find(|item| item.item_instance_id == "pear-1")
.and_then(|item| item.tray_slot_index),
Some(0)
);
assert_eq!(
confirmed.cleared_item_instance_ids,
vec![
"apple-1".to_string(),
"apple-2".to_string(),
"apple-3".to_string()
]
);
}
#[test]
fn tray_full_fails_when_no_triple_can_clear() {
let mut run = Match3DRunSnapshot {
+116 -22
View File
@@ -786,6 +786,45 @@ fn first_profile_level(profile: &PuzzleWorkProfile) -> Option<PuzzleDraftLevel>
.next()
}
fn first_profile_ui_background_level(profile: &PuzzleWorkProfile) -> Option<PuzzleDraftLevel> {
normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
.unwrap_or_else(|_| profile.levels.clone())
.into_iter()
.find(|level| {
level
.ui_background_image_src
.as_deref()
.and_then(normalize_required_string)
.is_some()
|| level
.ui_background_image_object_key
.as_deref()
.and_then(normalize_required_string)
.is_some()
})
}
fn resolve_puzzle_runtime_ui_background_fields(
level: Option<&PuzzleDraftLevel>,
fallback_level: Option<&PuzzleDraftLevel>,
) -> (Option<String>, Option<String>) {
for candidate in [level, fallback_level].into_iter().flatten() {
let image_src = candidate
.ui_background_image_src
.as_deref()
.and_then(normalize_required_string);
let object_key = candidate
.ui_background_image_object_key
.as_deref()
.and_then(|value| normalize_required_string(value.trim_start_matches('/')));
if image_src.is_some() || object_key.is_some() {
return (image_src, object_key);
}
}
(None, None)
}
pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 {
let time_limit_ms = if level.time_limit_ms == 0 {
resolve_puzzle_level_time_limit_ms_by_index(level.level_index)
@@ -1047,6 +1086,12 @@ pub fn start_run_with_shuffle_seed_at(
let grid_size = level_config.grid_size;
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
let current_profile_level = first_profile_level(entry_profile);
let ui_background_level = first_profile_ui_background_level(entry_profile);
let (ui_background_image_src, ui_background_image_object_key) =
resolve_puzzle_runtime_ui_background_fields(
current_profile_level.as_ref(),
ui_background_level.as_ref(),
);
Ok(PuzzleRunSnapshot {
run_id: run_id.clone(),
entry_profile_id: entry_profile.profile_id.clone(),
@@ -1067,12 +1112,8 @@ pub fn start_run_with_shuffle_seed_at(
author_display_name: entry_profile.author_display_name.clone(),
theme_tags: entry_profile.theme_tags.clone(),
cover_image_src: entry_profile.cover_image_src.clone(),
ui_background_image_src: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_src.clone()),
ui_background_image_object_key: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_object_key.clone()),
ui_background_image_src,
ui_background_image_object_key,
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
@@ -1326,6 +1367,16 @@ pub fn advance_next_level_at(
let mut played_profile_ids = run.played_profile_ids.clone();
played_profile_ids.push(next_profile.profile_id.clone());
let current_profile_level = first_profile_level(next_profile);
let ui_background_level = first_profile_ui_background_level(next_profile);
let (mut ui_background_image_src, mut ui_background_image_object_key) =
resolve_puzzle_runtime_ui_background_fields(
current_profile_level.as_ref(),
ui_background_level.as_ref(),
);
if ui_background_image_src.is_none() && ui_background_image_object_key.is_none() {
ui_background_image_src = current_level.ui_background_image_src.clone();
ui_background_image_object_key = current_level.ui_background_image_object_key.clone();
}
Ok(PuzzleRunSnapshot {
run_id: run.run_id.clone(),
@@ -1347,12 +1398,8 @@ pub fn advance_next_level_at(
author_display_name: next_profile.author_display_name.clone(),
theme_tags: next_profile.theme_tags.clone(),
cover_image_src: next_profile.cover_image_src.clone(),
ui_background_image_src: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_src.clone()),
ui_background_image_object_key: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_object_key.clone()),
ui_background_image_src,
ui_background_image_object_key,
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
@@ -1408,6 +1455,12 @@ pub fn advance_to_new_work_first_level_at(
played_profile_ids.push(next_profile.profile_id.clone());
}
let current_profile_level = first_profile_level(next_profile);
let ui_background_level = first_profile_ui_background_level(next_profile);
let (ui_background_image_src, ui_background_image_object_key) =
resolve_puzzle_runtime_ui_background_fields(
current_profile_level.as_ref(),
ui_background_level.as_ref(),
);
Ok(PuzzleRunSnapshot {
run_id: run.run_id.clone(),
@@ -1429,12 +1482,8 @@ pub fn advance_to_new_work_first_level_at(
author_display_name: next_profile.author_display_name.clone(),
theme_tags: next_profile.theme_tags.clone(),
cover_image_src: next_profile.cover_image_src.clone(),
ui_background_image_src: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_src.clone()),
ui_background_image_object_key: current_profile_level
.as_ref()
.and_then(|level| level.ui_background_image_object_key.clone()),
ui_background_image_src,
ui_background_image_object_key,
background_music: current_profile_level
.as_ref()
.and_then(|level| level.background_music.clone()),
@@ -3151,8 +3200,7 @@ mod tests {
.background_music
.as_ref()
.map(|music| music.audio_src.as_str()),
Some("/generated-puzzle-assets/background.mp3".to_string())
.as_deref()
Some("/generated-puzzle-assets/background.mp3".to_string()).as_deref()
);
assert_eq!(
current_level.ui_background_image_object_key.as_deref(),
@@ -3175,8 +3223,8 @@ mod tests {
current_level.cleared_at_ms = Some(2_000);
current_level.elapsed_ms = Some(1_000);
let next_run =
advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000).expect("next run");
let next_run = advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000)
.expect("next run");
assert_eq!(
next_run
@@ -3187,6 +3235,52 @@ mod tests {
);
}
#[test]
fn same_work_next_level_inherits_first_available_ui_background() {
let mut profile = build_published_profile("entry", "owner-a", vec!["奇幻"]);
profile.levels[0].ui_background_image_src =
Some("/generated-puzzle-assets/entry-ui.png".to_string());
profile.levels.push(PuzzleDraftLevel {
level_id: "puzzle-level-2".to_string(),
level_name: "第二关".to_string(),
picture_description: "第二关画面".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/level-2.png".to_string()),
cover_asset_id: None,
generation_status: "ready".to_string(),
});
let mut run = start_run("run-same-work-ui".to_string(), &profile, 0).expect("run");
run.cleared_level_count = run.current_level_index;
let current_level = run.current_level.as_mut().expect("level");
current_level.status = PuzzleRuntimeLevelStatus::Cleared;
current_level.cleared_at_ms = Some(2_000);
current_level.elapsed_ms = Some(1_000);
let next_level = selected_profile_level_after_runtime_level(&profile, current_level)
.expect("same work next level");
let mut next_profile = profile.clone();
next_profile.level_name = next_level.level_name.clone();
next_profile.cover_image_src = next_level.cover_image_src.clone();
next_profile.cover_asset_id = next_level.cover_asset_id.clone();
next_profile.levels = vec![next_level];
let next_run = advance_next_level_at(&run, &next_profile, 3_000).expect("next run");
assert_eq!(
next_run
.current_level
.as_ref()
.and_then(|level| level.ui_background_image_src.as_deref()),
Some("/generated-puzzle-assets/entry-ui.png")
);
}
#[test]
fn swap_pieces_marks_cleared_when_back_to_origin() {
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
@@ -151,6 +151,8 @@ pub struct Match3DWorkSummaryResponse {
#[serde(default)]
pub published_at: Option<String>,
pub publish_ready: bool,
#[serde(default)]
pub generation_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -282,4 +284,36 @@ mod tests {
assert_eq!(payload["gameName"], json!("水果抓大鹅"));
assert_eq!(payload["clearCount"], json!(4));
}
#[test]
fn match3d_work_summary_uses_camel_case_generation_status() {
let payload = serde_json::to_value(Match3DWorkSummaryResponse {
work_id: "work-1".to_string(),
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("session-1".to_string()),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary: "水果主题".to_string(),
tags: vec!["水果".to_string()],
cover_image_src: None,
reference_image_src: None,
clear_count: 4,
difficulty: 5,
publication_status: "draft".to_string(),
play_count: 0,
updated_at: "2026-05-01T00:00:00Z".to_string(),
published_at: None,
publish_ready: false,
generation_status: Some("generating".to_string()),
background_prompt: None,
background_image_src: None,
background_image_object_key: None,
generated_background_asset: None,
generated_item_assets: Vec::new(),
})
.expect("payload should serialize");
assert_eq!(payload["generationStatus"], json!("generating"));
}
}
@@ -57,6 +57,8 @@ pub struct PuzzleWorkSummaryResponse {
pub point_incentive_claimable_points: u64,
pub publish_ready: bool,
#[serde(default)]
pub generation_status: Option<String>,
#[serde(default)]
pub levels: Vec<PuzzleDraftLevelResponse>,
}
@@ -91,6 +93,7 @@ mod tests {
point_incentive_total_points: 1.5,
point_incentive_claimable_points: 0,
publish_ready: true,
generation_status: Some("ready".to_string()),
levels: Vec::new(),
})
.expect("payload should serialize");
@@ -99,6 +102,7 @@ mod tests {
assert_eq!(payload["pointIncentiveClaimedPoints"], 1);
assert_eq!(payload["pointIncentiveTotalPoints"], 1.5);
assert_eq!(payload["pointIncentiveClaimablePoints"], 0);
assert_eq!(payload["generationStatus"], "ready");
}
}
@@ -3195,6 +3195,7 @@ fn map_match3d_result_draft(
total_item_count: snapshot.clear_count.saturating_mul(3),
publish_ready: false,
blockers: Vec::new(),
generated_item_assets_json: snapshot.generated_item_assets_json,
}
}
@@ -6398,6 +6399,7 @@ pub struct Match3DResultDraftRecord {
pub total_item_count: u32,
pub publish_ready: bool,
pub blockers: Vec<String>,
pub generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -6541,6 +6543,8 @@ struct Match3DDraftJsonRecord {
tags: Vec<String>,
clear_count: u32,
difficulty: u32,
#[serde(default)]
generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]

Some files were not shown because too many files have changed in this diff Show More