diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index c8893c4a..d4209973 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -172,7 +172,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`。 @@ -197,7 +197,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 ` 应能写出对应 PNG,并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only ` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 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`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index f80c0903..13539b48 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -83,6 +83,14 @@ - 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。 - 关联:`AGENTS.md`、`npm run check:encoding`。 +## 陶泥儿 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 与本地快照不一致。 @@ -144,10 +152,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 摄像头视角换算 @@ -161,11 +169,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`。 + ## 寓教于乐作品和宝贝识物模板同时消失先查入口种子 - 现象:发现页“寓教于乐”分类下已发布的宝贝识物作品突然消失,同时创作界面模板选项中也看不到或无法正常展示 `宝贝识物`。 @@ -186,10 +210,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 `,不重新请求 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 `,不重新请求 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`。 diff --git a/docs/design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md b/docs/design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md new file mode 100644 index 00000000..759ba781 --- /dev/null +++ b/docs/design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md @@ -0,0 +1,505 @@ +# 儿童动作识别互动玩法 Demo 热身关开发文档 + +> 日期:2026-05-09 +> 适用范围:儿童动作识别互动玩法 Demo 的固定启动热身关 +> 文档性质:玩法 Demo 开发设计文档 +> 说明:本文整理当前已确认的热身关内容、体验、流程和热身数据记录要求。 + +## 1. 热身关定位 + +热身关是 Demo 启动后的固定流程,用于在正式进入后续趣味学习关前完成以下事项: + +- 调用摄像头; +- 识别用户和环境; +- 引导用户来到建议互动位置; +- 教学基础交互方式; +- 确认用户可在互动空间内完成左右移动和挥手; +- 记录用户左右移动距离和挥动手臂空间,作为后续关卡的空间边界与行为坐标; +- 完成后进入关卡选择。 + +热身关不接入创作模块,不作为可配置玩法模板提供给创作者。 + +## 2. 屏幕与设备适配 + +本产品适用于电视屏幕、电脑屏幕等环境。 + +热身关制作表达使用横屏比例。 + +## 3. 画面基础表现 + +用户进入热身关后,摄像头被调用,并开始识别用户和环境。 + +画面基础表现如下: + +1. 在屏幕中央位置的地面生成预设的绿色圆环,作为建议位置的指引。 +2. 将用户的实际位置生成为更细的白色描边小人指示器,作为用户在画面中的标识。 +3. 只对摄像头背景做虚化处理,用于表达对用户隐私的保护、屏蔽周围环境干扰,并营造空间感。 + +## 4. 通用检测与引导规则 + +### 4.1 不允许跳过 + +热身关每个步骤都必须由用户完成,不允许跳过,也不允许系统自动进入下一步。 + +### 4.2 引导动画播放规则 + +每个动作等待 3 秒后可以播放引导动画。 + +当前不设置最长等待时间。 + +### 4.3 绿色圆环完成规则 + +用户到达绿色圆环后,绿色圆环进入 2 秒选中状态。 + +用户需要在绿色圆环内保持停留 2 秒,才算完成该圆环位置检测。 + +### 4.4 左右距离映射规则 + +“约半米”的左右移动距离,技术上以角色剪影移动距离为准。 + +该距离后续会根据实际体验继续调校。 + +### 4.5 手势区分规则 + +招手 / 摆手、挥动左手、挥动右手三类动作需要有动作区分。 + +手势检测仅对肢体进行区分,不对手部细节进行区分。 + +### 4.6 手势引导规则 + +挥动哪只手,就使用对应手的引导。 + +## 5. 热身关完整流程 + +### 5.1 进入热身关 + +#### 画面表现 + +- 摄像头被调用。 +- 系统识别用户和环境。 +- 屏幕中央位置的地面出现预设绿色圆环。 +- 用户实际位置以更细的白色描边小人指示器形式显示。 +- 只对摄像头背景做虚化处理,保留空间感。 + +#### 屏幕文字与语音 + +屏幕中上方浮现文字,同时语音播报: + +```text +欢迎你,小朋友,见到你真开心 +``` + +随后继续播报: + +```text +来圆圈这里和我打个招呼吧 +``` + +首句展示完成后停顿 2 秒,再展示第二句。该步骤不展示“来到圆圈这里”大标题。 + +#### 检测逻辑 + +系统检测用户是否到达屏幕中央绿色圆环位置。 + +用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。 + +#### 完成反馈 + +用户完成中央圆环位置检测后: + +- 播放圆圈消失特效; +- 进入招手手势教学步骤。 + +--- + +### 5.2 招手教学 + +#### 画面表现 + +播放招手的手势引导,引导猫咪整体位于上半屏幕、字幕 UI 下方。 + +若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。 + +#### 检测逻辑 + +系统检测用户是否完成招手 / 摆手手势。 + +该动作与后续挥动左手、挥动右手需要有动作区分,但仅对肢体进行区分,不对手部细节进行区分。 + +#### 完成反馈 + +用户完成招手 / 摆手手势后,进入下一步。 + +--- + +### 5.3 热身说明 + +#### 屏幕文字与语音 + +```text +你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 +``` + +播放完成后进入左右移动热身步骤。 + +--- + +### 5.4 向左一步 + +#### 屏幕文字与语音 + +```text +向左一步 +``` + +#### 画面表现 + +屏幕中心向左一个身位,约半米的地面位置,出现新的绿色圆圈。 + +“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。 + +#### 检测逻辑 + +系统检测用户是否到达该绿色圆圈位置。 + +用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。 + +#### 完成反馈 + +用户完成后播放鼓励语: + +```text +真棒 +``` + +同时记录本次向左移动距离,作为后续关卡中的左侧空间边界参考。 + +完成后进入“回到中间来”。 + +--- + +### 5.5 回到中间来(一) + +#### 屏幕文字与语音 + +```text +回到中间来 +``` + +#### 画面表现 + +场地中心位置出现绿色圆圈。 + +#### 检测逻辑 + +系统检测用户是否到达场地中心绿色圆圈位置。 + +用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。 + +#### 完成反馈 + +用户完成后播放鼓励语: + +```text +真棒 +``` + +完成后进入“向右一步”。 + +--- + +### 5.6 向右一步 + +#### 屏幕文字与语音 + +```text +向右一步 +``` + +#### 画面表现 + +屏幕中心向右一个身位,约半米的地面位置,出现新的绿色圆圈。 + +“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。 + +#### 检测逻辑 + +系统检测用户是否到达该绿色圆圈位置。 + +用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。 + +#### 完成反馈 + +用户完成后播放鼓励语: + +```text +真棒 +``` + +同时记录本次向右移动距离,作为后续关卡中的右侧空间边界参考。 + +完成后进入“回到中间来”。 + +--- + +### 5.7 回到中间来(二) + +#### 屏幕文字与语音 + +```text +回到中间来 +``` + +#### 画面表现 + +场地中心位置出现绿色圆圈。 + +#### 检测逻辑 + +系统检测用户是否到达场地中心绿色圆圈位置。 + +用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。 + +#### 完成反馈 + +用户完成后播放鼓励语: + +```text +真棒 +``` + +完成后进入左手挥动教学。 + +--- + +### 5.8 挥动左手 + +#### 屏幕文字与语音 + +```text +挥动左手 +``` + +#### 画面表现 + +播放伸展手臂挥动左手的手势引导。 + +若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。 + +#### 检测逻辑 + +系统检测用户是否完成挥动左手手势。 + +该手势检测仅对肢体进行区分,不对手部细节进行区分。 + +#### 完成反馈 + +用户完成后播放鼓励语: + +```text +真棒 +``` + +同时记录用户挥动左手的空间,保存为该用户对应的行为坐标。 + +完成后进入右手挥动教学。 + +--- + +### 5.9 挥动右手 + +#### 屏幕文字与语音 + +```text +挥动右手 +``` + +#### 画面表现 + +播放伸展手臂挥动右手的手势引导。 + +若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。 + +#### 检测逻辑 + +系统检测用户是否完成挥动右手手势。 + +该手势检测仅对肢体进行区分,不对手部细节进行区分。 + +#### 完成反馈 + +用户完成后播放鼓励语: + +```text +真棒 +``` + +同时记录用户挥动右手的空间,保存为该用户对应的行为坐标。 + +完成后进入热身结束。 + +--- + +### 5.10 热身结束 + +#### 进入条件 + +用户完成挥动右手后,直接进入热身结束阶段。 + +#### 完成反馈 + +播放热身结束特效、上浮字幕和语音: + +```text +真厉害,你是我见过最聪明的小朋友 +``` + +随后继续播放: + +```text +别走开,现在开始我们的游戏吧 +``` + +热身关结束,进入关卡选择。 + +## 6. 流程状态表 + +| 顺序 | 步骤 | 屏幕文字 / 语音 | 画面表现 | 检测目标 | 完成后反馈 | +|---:|---|---|---|---|---| +| 1 | 进入热身关 | 欢迎你,小朋友,见到你真开心;来圆圈这里和我打个招呼吧 | 中央地面绿色圆环;用户更细白色描边小人指示器;摄像头背景虚化 | 用户到达中央圆环并保持 2 秒 | 圆圈消失特效 | +| 2 | 招手教学 | 同上流程延续 | 招手手势引导;等待 3 秒可播放引导动画 | 招手 / 摆手 | 进入下一步 | +| 3 | 热身说明 | 你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 | 保持热身引导状态 | 无新增动作检测 | 进入移动热身 | +| 4 | 向左一步 | 向左一步 | 左侧约半米处绿色圆圈 | 用户到达左侧圆环并保持 2 秒 | 真棒;记录左侧空间边界 | +| 5 | 回到中间来 | 回到中间来 | 中心位置绿色圆圈 | 用户到达中心圆环并保持 2 秒 | 真棒 | +| 6 | 向右一步 | 向右一步 | 右侧约半米处绿色圆圈 | 用户到达右侧圆环并保持 2 秒 | 真棒;记录右侧空间边界 | +| 7 | 回到中间来 | 回到中间来 | 中心位置绿色圆圈 | 用户到达中心圆环并保持 2 秒 | 真棒 | +| 8 | 挥动左手 | 挥动左手 | 伸展手臂挥动左手手势引导;等待 3 秒可播放引导动画 | 挥动左手 | 真棒;记录左手挥动空间 | +| 9 | 挥动右手 | 挥动右手 | 伸展手臂挥动右手手势引导;等待 3 秒可播放引导动画 | 挥动右手 | 真棒;记录右手挥动空间;进入热身结束 | +| 10 | 热身结束 | 真厉害,你是我见过最聪明的小朋友;别走开,现在开始我们的游戏吧 | 热身结束特效 | 无新增动作检测 | 进入关卡选择 | + +## 7. 固定文案与语音清单 + +以下文案需要作为屏幕中上方浮现文字,并同步语音播报。 + +```text +欢迎你,小朋友,见到你真开心 +来圆圈这里和我打个招呼吧 +你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 +向左一步 +真棒 +回到中间来 +真棒 +向右一步 +真棒 +回到中间来 +真棒 +挥动左手 +真棒 +挥动右手 +真厉害,你是我见过最聪明的小朋友 +别走开,现在开始我们的游戏吧 +``` + +## 8. 需要开发支持的识别能力 + +热身关当前流程需要支持以下识别能力: + +1. 摄像头调用; +2. 用户识别; +3. 环境识别; +4. 用户实际位置识别; +5. 用户是否到达中央绿色圆环位置; +6. 用户是否在绿色圆环内持续保持 2 秒; +7. 用户是否到达左侧约半米绿色圆环位置; +8. 用户是否到达右侧约半米绿色圆环位置; +9. 招手 / 摆手手势识别; +10. 挥动左手识别; +11. 挥动右手识别; +12. 用户左右移动距离记录; +13. 用户挥动手臂空间记录。 + +## 9. 需要开发支持的表现能力 + +热身关当前流程需要支持以下表现能力: + +1. 横屏比例显示; +2. 摄像头背景虚化; +3. 用户位置生成更细的白色描边小人指示器; +4. 屏幕中央地面绿色圆环; +5. 左侧约半米地面绿色圆环; +6. 右侧约半米地面绿色圆环; +7. 绿色圆环 2 秒选中状态; +8. 圆圈消失特效; +9. 招手手势引导; +10. 伸展手臂挥动左手手势引导; +11. 伸展手臂挥动右手手势引导; +12. 热身结束特效; +13. 上浮字幕; +14. 语音播报。 + +## 10. 热身数据记录要求 + +热身关需要记录以下数据,用于后续关卡的空间边界和行为坐标判断。 + +### 10.1 左右空间边界 + +用户完成向左一步后,记录该移动距离,作为后续关卡中的左侧空间边界。 + +用户完成向右一步后,记录该移动距离,作为后续关卡中的右侧空间边界。 + +后续关卡中,当用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。 + +后续关卡中,当用户身体主体超出安全边界线时: + +1. 关卡内容暂停; +2. 屏幕虚化; +3. 屏幕中央地面出现绿色圆圈; +4. 屏幕提示文案: + +```text +小朋友,要注意安全哦 +``` + +5. 用户需要回到中心绿色圆圈并保持 2 秒后,才能继续游戏内容。 + +### 10.2 手臂挥动空间 + +用户完成挥动左手后,记录用户挥动左手的空间,保存为该用户对应的行为坐标。 + +用户完成挥动右手后,记录用户挥动右手的空间,保存为该用户对应的行为坐标。 + +## 11. 热身关完成条件 + +热身关完成条件为用户按顺序完成以下流程: + +1. 到达中央圆环位置并保持 2 秒; +2. 完成招手 / 摆手手势; +3. 到达左侧约半米圆环位置并保持 2 秒; +4. 记录左侧空间边界; +5. 回到中央圆环位置并保持 2 秒; +6. 到达右侧约半米圆环位置并保持 2 秒; +7. 记录右侧空间边界; +8. 回到中央圆环位置并保持 2 秒; +9. 完成挥动左手; +10. 记录左手挥动空间; +11. 完成挥动右手; +12. 记录右手挥动空间; +13. 播放热身结束特效和结束语音; +14. 进入关卡选择。 + +## 12. 数据保存方式 + +左右空间边界和手臂挥动空间仅在当前 Demo 体验会话内保存。 + +这里的“当前 Demo 体验会话”指用户本次打开并体验 Demo 的过程。当用户关闭 Demo、刷新页面、退出当前体验流程、重新进入 Demo,或更换设备后,系统不再沿用上一次热身记录的数据,需要重新完成热身关并重新记录。 + +采用仅当前 Demo 体验会话内保存的原因: + +1. 每名用户的身高、体型、动作幅度不同,安全边界和行为坐标会发生变化。 +2. 当前 Demo 不做特定用户识别,无法确认下一次体验的是否仍是同一名用户。 +3. 用户所处的体验环境可能变化,包括房间大小、摄像头位置、屏幕位置和站立距离。 +4. 为保证安全,每次体验都需要重新对环境和距离进行安全检查。 + +## 13. 后续待确认事项 + +当前暂无待确认事项。 diff --git a/docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md b/docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md new file mode 100644 index 00000000..d2be0b32 --- /dev/null +++ b/docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md @@ -0,0 +1,1680 @@ +# 陶泥儿品牌 Logo 概念稿 + +> 本稿是围绕候选产品名“陶泥儿”的品牌视觉探索,不替代当前已冻结的“百梦”正式命名口径。若后续确认更名,需要另起产品命名、前后端文案和商标检索落地方案。 + +## 1. 品牌定位归纳 + +“陶泥儿”适合承接的不是传统陶艺或儿童黏土,而是“把灵感塑形成可玩内容”的 AI 创作平台隐喻。 + +核心关键词: + +- 精品:作品不是随手糊出来,而是经过 AI 辅助打磨、可被消费和传播的轻精品内容。 +- UGC:用户是主要造物者,平台降低创作门槛。 +- 创作:从一句脑洞、一个梗、一张图,生成小游戏、互动作品或可分享内容。 +- 裂变与梗:名字要支持“开捏”“捏个梗”“捏个小游戏”这类用户语言。 +- 轻度休闲:体验应松弛、即时、好玩,不走硬核生产工具气质。 +- AI:AI 是塑形能力,不是冷冰冰的技术标签。 + +推荐品牌主张: + +```text +把脑洞捏成小游戏 +``` + +备选表达: + +```text +捏个脑洞,马上开玩 +AI 开捏,人人会创作 +随手造梗,随心开玩 +``` + +## 2. 生成原则 + +本稿包含多轮 Logo 概念:早期批次使用仓库 GPT-image-2 / VectorEngine 工作流生成无文字图标,锚点底座与抽象泥胚批次使用确定性矢量脚本生成 SVG / PNG。当前新主线已切换为“抽象泥胚角色”:保留陶泥人 / 陶泥手办 / 吉祥物的生命感和 IP 延展性,但不再直接画人体,而是把它压缩成一个可被记住的几何陶泥主标。 + +原因: + +- AI 生图直接生成中文品牌字容易出现笔画错误,不适合作为正式字标。 +- 当前阶段更适合先确定图形符号方向,再由设计师或前端继续做矢量化、字标搭配和多尺寸适配。 +- 当前主线已停止此前软泥合拍、旋涡、糖果粉绿、锚点底座和具象小人方向,改以“非人形抽象陶泥角色 / 泥胚手办符号”为原型,强调可记住、可延展、可做 IP 的品牌主标。 +- 图标需要优先服务 App icon、平台左上角品牌、分享卡片和加载页,而不是一次性海报图。 + +生成文件: + +```text +public/branding/taonier-logo-v3-concepts/ +├─ taonier-logo-v3-contact-sheet.png +├─ taonier-v3-finger-spark.png +├─ taonier-v3-seed-pop.png +├─ taonier-v3-magic-dot.png +├─ taonier-v3-work-gem.png +└─ taonier-v3-soft-t.png + +public/branding/taonier-logo-magic-dot-concepts/ +├─ taonier-logo-magic-dot-contact-sheet.png +├─ taonier-magic-dot-orbit.png +├─ taonier-magic-dot-seal.png +├─ taonier-magic-dot-squish.png +├─ taonier-magic-dot-mold.png +└─ taonier-magic-dot-bloom.png + +public/branding/taonier-logo-anchor-concepts/ +├─ taonier-logo-anchor-contact-sheet.png +├─ taonier-anchor-core.svg +├─ taonier-anchor-core.png +├─ taonier-anchor-soft-slab.svg +├─ taonier-anchor-soft-slab.png +├─ taonier-anchor-work-stack.svg +├─ taonier-anchor-work-stack.png +├─ taonier-anchor-clay-drop.svg +├─ taonier-anchor-clay-drop.png +├─ taonier-anchor-creation-base.svg +├─ taonier-anchor-creation-base.png +├─ taonier-anchor-app-token.svg +└─ taonier-anchor-app-token.png + +public/branding/taonier-logo-clay-mascot-concepts/ +├─ taonier-logo-clay-mascot-contact-sheet.png +├─ taonier-clay-mascot-little-maker.png +├─ taonier-clay-mascot-figurine-token.png +├─ taonier-clay-mascot-soft-doll.png +├─ taonier-clay-mascot-creator-totem.png +├─ taonier-clay-mascot-idol-mask.png +└─ taonier-clay-mascot-pocket-figure.png + +public/branding/taonier-logo-geometric-concepts/ +├─ taonier-logo-geometric-contact-sheet.png +├─ taonier-geometric-offset-core.svg +├─ taonier-geometric-offset-core.png +├─ taonier-geometric-mold-chip.svg +├─ taonier-geometric-mold-chip.png +├─ taonier-geometric-pinched-tile.svg +├─ taonier-geometric-pinched-tile.png +├─ taonier-geometric-dual-plate.svg +├─ taonier-geometric-dual-plate.png +├─ taonier-geometric-dot-gate.svg +├─ taonier-geometric-dot-gate.png +├─ taonier-geometric-work-knot.svg +└─ taonier-geometric-work-knot.png + +public/branding/taonier-logo-hands-concepts/ +├─ taonier-logo-hands-contact-sheet.png +├─ taonier-hands-v2-cradle.png +├─ taonier-hands-v2-clap.png +├─ taonier-hands-v2-bowl.png +├─ taonier-hands-v2-seal.png +└─ taonier-hands-v2-pop.png + +public/branding/taonier-logo-squish-concepts/ +├─ taonier-logo-squish-contact-sheet.png +├─ taonier-squish-v2-pulse.png +├─ taonier-squish-v2-bounce.png +├─ taonier-squish-v2-spark-gap.png +├─ taonier-squish-v2-comet.png +└─ taonier-squish-v2-token.png + +public/branding/taonier-logo-spiral-reference-concepts/ +├─ taonier-logo-spiral-reference-contact-sheet.png +├─ taonier-spiral-reference.jpg +├─ taonier-spiral-soft-squish.png +├─ taonier-spiral-candy-roll.png +├─ taonier-spiral-star-core.png +├─ taonier-spiral-bouncy-clay.png +├─ taonier-spiral-creation-whirl.png +└─ taonier-spiral-soft-token.png + +public/branding/taonier-logo-broad-concepts/ +├─ taonier-logo-broad-contact-sheet.png +├─ taonier-broad-soft-portal.png +├─ taonier-broad-work-embryo.png +├─ taonier-broad-game-mold.png +├─ taonier-broad-soft-totem.png +└─ taonier-broad-creation-spark.png + +public/branding/taonier-logo-fresh-concepts/ +├─ taonier-logo-fresh-contact-sheet.png +├─ taonier-fresh-wheel-imprint.png +├─ taonier-fresh-mold-window.png +├─ taonier-fresh-dot-dice.png +├─ taonier-fresh-pocket-world.png +├─ taonier-fresh-stage-window.png +└─ taonier-fresh-punch-hole.png + +public/branding/taonier-logo-punch-hole-concepts/ +├─ taonier-logo-punch-hole-contact-sheet.png +├─ taonier-punch-locked-shape.png +├─ taonier-punch-stable-icon.png +├─ taonier-punch-hole-balance.png +├─ taonier-punch-color-inlay.png +├─ taonier-punch-mono-test.png +└─ taonier-punch-app-token.png + +public/branding/taonier-logo-punch04-color-concepts/ +├─ taonier-logo-punch04-color-contact-sheet.png +├─ taonier-punch04-warm-ink-core.png +├─ taonier-punch04-navy-game-core.png +├─ taonier-punch04-cream-window.png +├─ taonier-punch04-clay-gradient-flat.png +├─ taonier-punch04-mint-shadow.png +└─ taonier-punch04-negative-tile.png + +public/branding/taonier-logo-ref04-locked-color-concepts/ +├─ taonier-logo-ref04-locked-color-contact-sheet.png +├─ taonier-ref04-locked-warm-ink.png +├─ taonier-ref04-locked-blue-ink.png +├─ taonier-ref04-locked-plum-ink.png +├─ taonier-ref04-locked-green-ink.png +├─ taonier-ref04-locked-shrink-core.png +└─ taonier-ref04-locked-soft-charcoal.png + +public/branding/taonier-logo-ref04-warm-star-concepts/ +├─ taonier-logo-ref04-warm-star-contact-sheet.png +├─ taonier-ref04-warm-star-terracotta.png +├─ taonier-ref04-warm-star-caramel.png +├─ taonier-ref04-warm-star-cocoa.png +├─ taonier-ref04-warm-star-rust.png +├─ taonier-ref04-warm-star-olive.png +└─ taonier-ref04-warm-star-plum.png + +public/branding/taonier-logo-ref04-warm-sparkle-v2-concepts/ +├─ taonier-logo-ref04-warm-sparkle-v2-contact-sheet.png +├─ taonier-ref04-warm-sparkle-terracotta.png +├─ taonier-ref04-warm-sparkle-rust.png +├─ taonier-ref04-warm-sparkle-caramel.png +├─ taonier-ref04-warm-sparkle-cocoa.png +├─ taonier-ref04-warm-sparkle-clay-quiet.png +└─ taonier-ref04-warm-sparkle-plum.png + +public/branding/taonier-logo-ref04-palette-transfer/ +├─ taonier-logo-ref04-palette-transfer-contact-sheet.png +└─ taonier-ref04-palette-transfer-warm-yellow-sparkle.png + +public/branding/taonier-logo-abstract-mascot-concepts/ +├─ taonier-logo-abstract-mascot-contact-sheet.png +├─ taonier-abstract-mascot-clay-bean.svg +├─ taonier-abstract-mascot-clay-bean.png +├─ taonier-abstract-mascot-mold-baby.svg +├─ taonier-abstract-mascot-mold-baby.png +├─ taonier-abstract-mascot-dot-face.svg +├─ taonier-abstract-mascot-dot-face.png +├─ taonier-abstract-mascot-soft-totem.svg +├─ taonier-abstract-mascot-soft-totem.png +├─ taonier-abstract-mascot-clay-seed.svg +├─ taonier-abstract-mascot-clay-seed.png +├─ taonier-abstract-mascot-work-puppet.svg +└─ taonier-abstract-mascot-work-puppet.png + +public/branding/taonier-logo-abstract-mascot-v2-concepts/ +├─ taonier-logo-abstract-mascot-v2-contact-sheet.png +├─ taonier-abstract-mascot-v2-clay-sprite.svg +├─ taonier-abstract-mascot-v2-clay-sprite.png +├─ taonier-abstract-mascot-v2-pinch-orbit.svg +├─ taonier-abstract-mascot-v2-pinch-orbit.png +├─ taonier-abstract-mascot-v2-seed-totem.svg +├─ taonier-abstract-mascot-v2-seed-totem.png +├─ taonier-abstract-mascot-v2-soft-mold.svg +├─ taonier-abstract-mascot-v2-soft-mold.png +├─ taonier-abstract-mascot-v2-clay-orb.svg +├─ taonier-abstract-mascot-v2-clay-orb.png +├─ taonier-abstract-mascot-v2-work-glyph.svg +└─ taonier-abstract-mascot-v2-work-glyph.png + +public/branding/taonier-logo-abstract-mascot-image2-concepts/ +├─ taonier-logo-abstract-mascot-image2-contact-sheet.png +├─ taonier-image2-clay-spirit-glyph.png +├─ taonier-image2-pinched-seed-mascot.png +├─ taonier-image2-soft-totem-creature.png +├─ taonier-image2-clay-pocket-token.png +├─ taonier-image2-work-core-puppet.png +└─ taonier-image2-mold-blob-companion.png + +public/branding/taonier-logo-abstract-mascot-minimal-concepts/ +├─ taonier-logo-abstract-mascot-minimal-contact-sheet.png +├─ taonier-minimal-clay-core.png +├─ taonier-minimal-clay-token.png +├─ taonier-minimal-seed-glyph.png +└─ taonier-minimal-mold-bud.png + +public/branding/taonier-logo-flat-concepts/ +├─ taonier-logo-flat-contact-sheet.png +├─ taonier-flat-play-clay.png +├─ taonier-flat-spark-clay.png +├─ taonier-flat-meme-smile.png +├─ taonier-flat-loop-mold.png +└─ taonier-flat-seal-blocks.png + +public/branding/taonier-logo-concepts/ +├─ taonier-logo-contact-sheet.png +├─ taonier-clay-spark.png +├─ taonier-play-mold.png +├─ taonier-meme-bubble.png +├─ taonier-creation-loop.png +└─ taonier-premium-seal.png +``` + +生成脚本: + +```text +scripts/generate-taonier-logo-concepts.mjs +scripts/generate-taonier-hands-logo-concepts.mjs +scripts/generate-taonier-squish-logo-concepts.mjs +scripts/generate-taonier-spiral-logo-concepts.mjs +scripts/generate-taonier-spiral-contact-sheet.py +scripts/generate-taonier-anchor-logo-concepts.py +scripts/generate-taonier-clay-mascot-logo-concepts.mjs +scripts/generate-taonier-clay-mascot-contact-sheet.py +scripts/generate-taonier-geometric-logo-concepts.py +scripts/generate-taonier-abstract-mascot-logo-concepts.py +scripts/generate-taonier-abstract-mascot-v2-logo-concepts.py +scripts/generate-taonier-abstract-mascot-image2-logo-concepts.mjs +scripts/generate-taonier-abstract-mascot-image2-contact-sheet.py +scripts/generate-taonier-abstract-mascot-minimal-logo-concepts.mjs +scripts/generate-taonier-abstract-mascot-minimal-contact-sheet.py +``` + +## 当前主线:抽象泥胚角色 + +用户最新反馈明确:仍然以陶泥人、陶泥手办、抽象角色 / 吉祥物为主方向,但设计时不一定使用人体形象,造型要更简单、更几何、更扁平、更有创意。因此本轮把方向校正为“抽象泥胚角色”:不是完整小人,也不是纯几何系统图标,而是一枚像有生命的陶泥作品主标。 + +设计判断: + +- 保留:陶泥手办的亲和力、可爱度、IP 延展、被捏出的生命感。 +- 削弱:头身四肢、复杂五官、头像感、儿童黏土课和插画感。 +- 强化:单一剪影、偏心孔洞、星核、捏痕、少色、扁平矢量和小尺寸识别。 + +### A. 极简抽象泥胚批次 + +这一批使用 VectorEngine `gpt-image-2-all` 生成,prompt 明确约束“不要人形、不要脸、不要手脚”,只保留一个主轮廓、一个孔洞 / 作品核和一个小星点,用于寻找更像主 Logo 的自由轮廓。 + +![陶泥儿 Logo 极简抽象泥胚总览](../../public/branding/taonier-logo-abstract-mascot-minimal-concepts/taonier-logo-abstract-mascot-minimal-contact-sheet.png) + +本批次结论: + +```text +首选:01 泥芯主标 +强备选:03 泥种图符 +可爱但偏通用:02 泥标小偶 +不建议主标:04 模胚小芽 +``` + +`01 泥芯主标` 的优势是陶泥容器感、偏心黑孔和小星点比较集中,既有手捏陶泥的名字联想,也不像头像或人形。风险是口沿让它略像陶罐,后续人工矢量化时应压低“罐口”形态,让外轮廓更像被捏出的泥胚。 + +`03 泥种图符` 更接近“会呼吸的陶泥种子”,白色主体、黑色偏心孔和底部陶土色关系稳定,适合作为主标第二方向。后续应减少渐变和阴影,保留白泥主体、偏心孔、星核和底部陶土捏痕。 + +### B. image-2 抽象角色自由稿 + +这一批继续走 VectorEngine `gpt-image-2-all`,目标是找更有灵气的“非人形陶泥角色”轮廓。它不作为最终矢量稿,而是给后续人工重绘提供轮廓和气质参考。 + +![陶泥儿 Logo image-2 抽象角色总览](../../public/branding/taonier-logo-abstract-mascot-image2-concepts/taonier-logo-abstract-mascot-image2-contact-sheet.png) + +本批次结论: + +```text +灵气参考:01 泥灵符号 +造型参考:04 口袋泥符 +不建议主标:02 捏胚小偶、03 软陶图灵、05 作品泥偶、06 模团伙伴 +``` + +`01 泥灵符号` 最有“被捏出生命感”的味道,卷角和星核有记忆点,但黑色 App icon 底、角标和渐变需要重绘压平。它适合提炼成“软泥主体 + 星核 + 两个陶土捏点”的辅助参考。 + +`04 口袋泥符` 轮廓足够简单,中心星核清楚,但橙色主体过大,陶泥儿的亲和力偏向通用贴纸。它可以作为色彩和 Q 感参考,不建议直接定稿。 + +### C. 确定性矢量抽象泥偶批次 + +这一批由本地脚本直接生成 SVG / PNG,优点是结构可控、可进入后续矢量微调;缺点是灵气弱于 image-2 自由稿。 + +![陶泥儿 Logo 抽象泥偶 V2 总览](../../public/branding/taonier-logo-abstract-mascot-v2-concepts/taonier-logo-abstract-mascot-v2-contact-sheet.png) + +本批次结论: + +```text +可矢量化基准:01 陶泥小灵 +结构备选:04 软模团子 +成熟符号参考:05 泥芯圆偶 +暂不优先:02 捏孔泥偶、03 星胚图腾、06 作品泥符 +``` + +`01 陶泥小灵` 是当前最接近“非人形小陶泥角色”的可控矢量基准:单体轮廓、偏心陶土捏痕、星核和底部压扁站姿都成立。后续应删除单眼或将其改成更抽象的泥点,避免重新回到头像方向。 + +`04 软模团子` 更像一个被捏过的模具符号,品牌主标感强,但角色感稍弱。它适合和 `01 泥芯主标` 结合:保留模具切口和中心作品核,减少底部横条。 + +### D. 第一轮抽象泥偶批次 + +![陶泥儿 Logo 抽象泥偶总览](../../public/branding/taonier-logo-abstract-mascot-concepts/taonier-logo-abstract-mascot-contact-sheet.png) + +这一批验证了“抽象角色”方向,但多数方案仍偏头像、面具或机器人。仅 `01 陶泥豆偶` 和 `06 作品泥灵` 的轮廓关系可保留为参考,其余不建议继续。 + +## 3. 几何抽象历史探索 + +用户一度反馈“陶泥人 / 手办”方向不喜欢,不一定要人形,希望更简单、更几何、更有创意。因此本轮曾转向确定性几何符号:少元素、强轮廓、可注册感、适合 App icon,同时保留“陶泥、捏痕、泥点、模具、作品核”的隐喻。后续用户澄清并不是要放弃陶泥人 / 手办 / 吉祥物精神,而是不要直接画人体;因此本节降级为历史探索和辅助符号库,不再作为当前主线。 + +![陶泥儿 Logo 几何抽象总览](../../public/branding/taonier-logo-geometric-concepts/taonier-logo-geometric-contact-sheet.png) + +生成脚本: + +```text +scripts/generate-taonier-geometric-logo-concepts.py +``` + +生成文件: + +```text +public/branding/taonier-logo-geometric-concepts/ +├─ taonier-geometric-offset-core.svg +├─ taonier-geometric-offset-core.png +├─ taonier-geometric-mold-chip.svg +├─ taonier-geometric-mold-chip.png +├─ taonier-geometric-pinched-tile.svg +├─ taonier-geometric-pinched-tile.png +├─ taonier-geometric-dual-plate.svg +├─ taonier-geometric-dual-plate.png +├─ taonier-geometric-dot-gate.svg +├─ taonier-geometric-dot-gate.png +├─ taonier-geometric-work-knot.svg +├─ taonier-geometric-work-knot.png +└─ taonier-logo-geometric-contact-sheet.png +``` + +### 3.1 偏心泥孔 + +![偏心泥孔](../../public/branding/taonier-logo-geometric-concepts/taonier-geometric-offset-core.png) + +定位:当前几何主线首选。 + +这个方向像一块被冲孔和按压过的软陶牌,偏心大孔和小泥点形成记忆点,整体足够简单,也不像人形、手办或插画。它能表达“作品核 / 泥点 / 可塑形模具”,适合作为成熟 App 主标继续打磨。 + +建议用途:主 Logo 首选、App icon、favicon。 + +### 3.2 模芯切片 + +![模芯切片](../../public/branding/taonier-logo-geometric-concepts/taonier-geometric-mold-chip.png) + +定位:更硬朗的平台符号。 + +这个方向有“模具芯片 / 作品切片”的感觉,平台和工具属性更强,但陶泥软感弱一些。适合做技术感更强的备选。 + +建议用途:平台入口、创作工具标识、主 Logo 备选。 + +### 3.3 捏痕方标 + +![捏痕方标](../../public/branding/taonier-logo-geometric-concepts/taonier-geometric-pinched-tile.png) + +定位:最贴合“被捏过”的几何符号。 + +这个方向两侧被挤压出缺口,中间作品核清楚,和“陶泥被捏塑”关联最强。风险是整体稍像 UI 控件或票券,需要后续让外轮廓更独特。 + +建议用途:主 Logo 强备选、生成按钮、品牌辅助图形。 + +### 3.4 双片合模 + +![双片合模](../../public/branding/taonier-logo-geometric-concepts/taonier-geometric-dual-plate.png) + +定位:表达两片材料合模成型。 + +这个方向动作感强,能看出上下两片材料夹出中心作品核。但红绿双条偏 UI 化,后续需要减少按钮感。 + +建议用途:生成动效、创作成功态,不建议直接做主 Logo。 + +### 3.5 泥点入口 + +![泥点入口](../../public/branding/taonier-logo-geometric-concepts/taonier-geometric-dot-gate.png) + +定位:入口 / 生成门方向。 + +这个方向有“泥点落入入口,作品生成”的隐喻,但略像锁、包或门。适合作为创作入口图标,不建议作为唯一主标。 + +### 3.6 作品结点 + +![作品结点](../../public/branding/taonier-logo-geometric-concepts/taonier-geometric-work-knot.png) + +定位:作品网络和多点共创。 + +这个方向几何清楚,但更像系统模块图标,陶泥儿名字关联弱。适合作为作品关系、模板组合、共创系统的辅助标识。 + +## 4. 陶泥人 / 手办角色历史探索 + +用户明确要求停止此前锚点底座方向,改以“陶泥人、陶泥手办、抽象角色 / 吉祥物”为主线重新设计。新方向的核心不是做复杂 IP 插画,而是把一个小陶泥角色压缩成可当 Logo 使用的品牌符号:轮廓简单、有陶泥手捏感、有一点灵感 / 作品星核,并能继续延展成表情、动效和 IP。 + +![陶泥儿 Logo 陶泥人角色总览](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-logo-clay-mascot-contact-sheet.png) + +> 2026-05-14 用户反馈该批不喜欢,不一定要人形,造型需要更简单和几何;本节降级为历史探索,不再作为当前主线。 + +生成脚本: + +```text +scripts/generate-taonier-clay-mascot-logo-concepts.mjs +scripts/generate-taonier-clay-mascot-contact-sheet.py +``` + +生成文件: + +```text +public/branding/taonier-logo-clay-mascot-concepts/ +├─ taonier-clay-mascot-little-maker.png +├─ taonier-clay-mascot-figurine-token.png +├─ taonier-clay-mascot-soft-doll.png +├─ taonier-clay-mascot-creator-totem.png +├─ taonier-clay-mascot-idol-mask.png +├─ taonier-clay-mascot-pocket-figure.png +└─ taonier-logo-clay-mascot-contact-sheet.png +``` + +### 4.1 陶泥小人 + +![陶泥小人](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-clay-mascot-little-maker.png) + +定位:最直观的陶泥人方向。 + +这个方向轮廓极简、记忆点强,胸前星点能承接“脑洞成型”。风险是剪影略像通用姜饼人,需要进一步把头身比例和手臂做得更像“被捏出的软陶角色”。 + +建议用途:主 Logo 备选、IP 原型、表情包和启动动效参考。 + +### 4.2 陶泥手办 + +![陶泥手办](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-clay-mascot-figurine-token.png) + +定位:最接近完整吉祥物手办。 + +这个方向亲和、可爱、手办感强,但作为主 Logo 略像完整角色插画,细节和体积感偏多。后续若沿它继续,应大幅压缩五官、手臂和底座。 + +建议用途:IP 形象、运营视觉、品牌吉祥物,不建议未经简化直接做主 Logo。 + +### 4.3 软陶团子 + +![软陶团子](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-clay-mascot-soft-doll.png) + +定位:软萌治愈方向。 + +这个方向更像一团软陶团子,亲和感强,但主标识别点偏弱,容易进入普通治愈头像或玩具形象。中心泥点可以保留,外轮廓需要更独特。 + +建议用途:新手引导、空状态、IP 辅助形象。 + +### 4.4 造物泥偶 + +![造物泥偶](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-clay-mascot-creator-totem.png) + +定位:当前角色主线的主 Logo 首选。 + +这个方向最接近“角色 + 品牌图腾”的平衡:剪影简单、黑底识别强、中心星核明确,既有小陶泥人的亲和感,也不会过度像插画或头像。 + +优点: + +- 图腾化程度最高,适合继续矢量化。 +- 中央星核能承接 AI 生成、作品成型和精品内容。 +- 角色感存在,但没有复杂表情和具象服饰。 + +风险: + +- 头部和身体连接处仍需人工优化,让它更像陶泥手捏而不是普通小人。 +- 周围小星点应在正式主标中删减,只保留一个核心星核。 + +建议用途:当前主 Logo 首选、App icon、启动动效、IP 主体基础。 + +### 4.5 陶泥面偶 + +![陶泥面偶](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-clay-mascot-idol-mask.png) + +定位:面偶 / 头像化方向。 + +这个方向做出了陶泥面偶的收藏感,但人物感、服饰感和头像感都偏强,容易变成具体角色头像,而不是平台主标。 + +建议用途:IP 角色探索,不建议作为主 Logo 主线。 + +### 4.6 口袋泥人 + +![口袋泥人](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-clay-mascot-pocket-figure.png) + +定位:最适合 App icon 的小泥人方向。 + +这个方向造型简洁,黑底白形识别快,旁边金色泥点和星点能强化“泥点 / 灵感”记忆。它比 04 更轻快,比 01 更不像普通姜饼人。 + +建议用途:App icon 强备选、移动端启动图标、品牌小形象。 + +## 5. 锚点底座参考图历史探索 + +用户明确要求停止此前软泥合拍、旋涡、糖果粉绿等方向,改以新的黑底白标参考图为原型重做。新参考图的核心不是“可爱软泥”,而是“一个泥点落到作品底座上”:上方圆点代表泥点 / 灵感,中间竖线代表落点 / 生成锚点,下方叠层代表作品、小游戏或创作底座。 + +这一组使用确定性矢量方式生成,优先保留黑底白标的克制感和成熟 App icon 气质,再少量测试金色泥点作为品牌识别。 + +> 2026-05-14 用户已要求停止锚点底座方向,本节降级为历史探索,不再作为当前主线。 + +![陶泥儿 Logo 锚点底座参考图总览](../../public/branding/taonier-logo-anchor-concepts/taonier-logo-anchor-contact-sheet.png) + +生成脚本: + +```text +scripts/generate-taonier-anchor-logo-concepts.py +``` + +生成文件: + +```text +public/branding/taonier-logo-anchor-concepts/ +├─ taonier-anchor-core.svg +├─ taonier-anchor-core.png +├─ taonier-anchor-soft-slab.svg +├─ taonier-anchor-soft-slab.png +├─ taonier-anchor-work-stack.svg +├─ taonier-anchor-work-stack.png +├─ taonier-anchor-clay-drop.svg +├─ taonier-anchor-clay-drop.png +├─ taonier-anchor-creation-base.svg +├─ taonier-anchor-creation-base.png +├─ taonier-anchor-app-token.svg +├─ taonier-anchor-app-token.png +└─ taonier-logo-anchor-contact-sheet.png +``` + +### 5.1 泥点锚标 + +![泥点锚标](../../public/branding/taonier-logo-anchor-concepts/taonier-anchor-core.png) + +定位:最贴近新参考图的主 Logo 首选。 + +这个方向保留“圆点 + 竖向落点 + 菱形作品底座 + 下层托底”的核心结构,整体克制、稳、识别快。它已经不再依赖软萌插画,而是更像一个可长期使用的产品符号。 + +优点: + +- 与新参考图原型一致性最高。 +- 黑底白标小尺寸识别强,适合 App icon、favicon、启动页。 +- “泥点落到作品底座”能承接泥点、AI 生成、作品成型三层语义。 + +风险: + +- 当前底座菱形偏硬,后续可让转角更像被压出的软泥层。 +- 左侧小点需要确认是品牌特征还是噪音,正式版可以保留或删除。 + +建议用途:当前主 Logo 第一候选、品牌顶栏、App icon。 + +### 5.2 软泥层台 + +![软泥层台](../../public/branding/taonier-logo-anchor-concepts/taonier-anchor-soft-slab.png) + +定位:更柔和的底座版本。 + +这个方向把底座做成略带弧度的软泥层,更贴近“陶泥儿”的名字,但轮廓相对没 01 清楚。适合继续测试“更软,但不回到旧软萌路线”的平衡。 + +建议用途:主 Logo 备选,适合做品牌温度版。 + +### 5.3 作品叠层 + +![作品叠层](../../public/branding/taonier-logo-anchor-concepts/taonier-anchor-work-stack.png) + +定位:最强调 UGC 作品库和多玩法承载。 + +这个方向比 01 多一层底座,能表达“一个灵感生成多个作品 / 多个小游戏层”。优势是平台感强,风险是线条略多,小尺寸时比 01 更拥挤。 + +建议用途:平台主标强备选、作品库 / 创作中心辅助标识。 + +### 5.4 泥点落印 + +![泥点落印](../../public/branding/taonier-logo-anchor-concepts/taonier-anchor-clay-drop.png) + +定位:加入品牌色的泥点版本。 + +这个方向把上方泥点和中心落点改成暖黄色,品牌记忆更强,也更贴合“泥点”货币 / 消费单位。但如果主标追求极简高级,金色可能需要降饱和或只保留上方圆点。 + +建议用途:App icon 彩色版、泥点体系标识、会员 / 奖励场景。 + +### 5.5 创作底座 + +![创作底座](../../public/branding/taonier-logo-anchor-concepts/taonier-anchor-creation-base.png) + +定位:更抽象、更像生成台的版本。 + +这个方向弱化了封闭菱形,强调开放式底座和生成落点。它更像“创作工具 / 生成器”符号,但少了 01 的完整徽标感。 + +建议用途:生成入口、创作按钮、工作台辅助图形。 + +### 5.6 泥点应用标 + +![泥点应用标](../../public/branding/taonier-logo-anchor-concepts/taonier-anchor-app-token.png) + +定位:更接近正式 App icon 的双色版本。 + +这个方向在黑蓝底上使用奶白主形和金色泥点,记忆点更强,也更像可直接落地的应用图标。后续需要测试金色小点在 24px / 32px 下是否仍然清楚。 + +建议用途:App icon 彩色候选、移动端启动图标。 + +## 6. V3-03 软泥合拍 v2 + +这一组回到用户认可的“软泥合拍”参考,不再追求具体手感。核心保留上下两团软泥、中央星点、轻快合拍的生动感,同时明确避开手、眼睛、聊天气泡和表情包。 + +![陶泥儿 Logo 软泥合拍 v2 总览](../../public/branding/taonier-logo-squish-concepts/taonier-logo-squish-contact-sheet.png) + +> 2026-05-14 用户已要求停止此前方向,本节及后续软泥合拍、旋涡、V3、V2、V1 均降级为历史探索,不再作为当前主线。 + +### 6.1 软泥合拍 + +![软泥合拍](../../public/branding/taonier-logo-squish-concepts/taonier-squish-v2-pulse.png) + +定位:当前最贴近参考方向的主 Logo 首选。 + +这个方向保留了上下两团软泥和中央星点,整体干净、生动、年轻,没有手的老气问题,也没有播放器或聊天工具联想。 + +优点: + +- 与用户认可的原始参考最接近。 +- 抽象但不冷,亲和且容易做动效。 +- 元素少,小尺寸可继续优化。 + +风险: + +- 形体还可以更有专属轮廓,避免变成通用软块组合。 + +建议用途:主 Logo 优先打磨方向、启动动效、生成按钮。 + +### 6.2 弹力成型 + +![弹力成型](../../public/branding/taonier-logo-squish-concepts/taonier-squish-v2-bounce.png) + +定位:动感和弹性更强的版本。 + +这个方向有轻快的挤压感,但线条和高光更接近动态图标或运营图,正式主 Logo 需要进一步去掉装饰线。 + +建议用途:生成动效、交互反馈、品牌运动语言。 + +### 6.3 星隙合拍 + +![星隙合拍](../../public/branding/taonier-logo-squish-concepts/taonier-squish-v2-spark-gap.png) + +定位:品牌记忆点最强的主标备选。 + +这个方向用中间负形星建立强记忆点,比 01 更像一个可注册的标志。它的优势是“符号性强”,但白色星隙较大,后续需要优化比例,让上下软泥更像合拍而不是被星形切开。 + +建议用途:主 Logo 强备选,适合继续做专业矢量微调。 + +### 6.4 合拍星流 + +![合拍星流](../../public/branding/taonier-logo-squish-concepts/taonier-squish-v2-comet.png) + +定位:传播和裂变感。 + +这个方向的星流表达“梗 / 作品被传播出去”,但短线让画面稍碎,更适合运营动效而不是静态主标。 + +建议用途:分享成功、生成完成、活动视觉。 + +### 6.5 成型软标 + +![成型软标](../../public/branding/taonier-logo-squish-concepts/taonier-squish-v2-token.png) + +定位:成熟 App icon 方向。 + +这个方向最像完整的 App icon,收束度高、成熟度好。但它有一点眼睛 / 观察 / 球形标识的风险,需要通过中心点、上下轮廓和色彩微调降低误读。 + +建议用途:App icon 备选,不建议未经微调直接定稿。 + +## 7. V3-03 上下手感延展 + +这一组顺着用户反馈中“03 更像上下两只手”的方向继续打磨。目标是把“托住灵感、合掌成型”的感觉做成品牌符号,而不是具体手掌插画。 + +用户进一步评审后认为 01 “掌心星核”太具象,手感明显、手形难看且老气。该方向整体降级为历史探索,不建议继续作为主 Logo 主线。 + +![陶泥儿 Logo 上下手感延展总览](../../public/branding/taonier-logo-hands-concepts/taonier-logo-hands-contact-sheet.png) + +### 7.1 掌心星核 + +![掌心星核](../../public/branding/taonier-logo-hands-concepts/taonier-hands-v2-cradle.png) + +定位:上下手感方向的主 Logo 首选。 + +这个方向最直接地保留了“上下两只手托住灵感”的感觉,同时仍是抽象软形。中央星核建立视觉焦点,能够承接“用户把脑洞交给 AI,一起捏成作品”的产品隐喻。 + +优点: + +- “托住 / 合捏 / 成型”的动作最明确。 +- 亲和力强,远离播放器、聊天和表情包联想。 +- 适合做轻微动效:上下软掌合拢,星核亮起。 + +风险: + +- 左侧线条有一点真实手指感,正式矢量化时应减少指节暗示,让它更像软泥托形。 + +建议用途:主 Logo 备选首选、生成按钮、启动动效、AI 共创标识。 + +### 7.2 合掌成型 + +![合掌成型](../../public/branding/taonier-logo-hands-concepts/taonier-hands-v2-clap.png) + +定位:最简洁、最主流的上下手感方案。 + +这个方向用上下两片大软形和中心圆点表达“合掌成型”,小尺寸识别会比 01 更稳。它的问题是整体有一点“眼睛 / 观察”联想,后续需要调整中心圆点和上下弧线比例。 + +建议用途:主 Logo 强备选,适合继续做专业矢量微调。 + +### 7.3 软掌托碗 + +![软掌托碗](../../public/branding/taonier-logo-hands-concepts/taonier-hands-v2-bowl.png) + +定位:创作容器和生成氛围。 + +这个方向更有场景感,像从掌心托出一个创意容器。它亲和、丰富,但装饰星点和喷溅线稍多,主 Logo 使用前需要大幅精简。 + +建议用途:创作页空状态、生成中插画、运营视觉。 + +### 7.4 双掌印记 + +![双掌印记](../../public/branding/taonier-logo-hands-concepts/taonier-hands-v2-seal.png) + +定位:完整、稳重、有泥印感的主标备选。 + +这个方向把上下软形合成一个圆润印记,中间负形有“被捏出”的感觉。它比 01 更不像真实手,也比 02 更有陶泥儿的“印记 / 成型”心智。 + +建议用途:主 Logo 备选;若后续想降低“手”的直观性,可以优先打磨这一版。 + +### 7.5 掌心开捏 + +![掌心开捏](../../public/branding/taonier-logo-hands-concepts/taonier-hands-v2-pop.png) + +定位:传播感和动效感。 + +这个方向更像“掌心弹出灵感”,年轻、轻松,适合做动效,但静态 Logo 里短线和星点稍碎。 + +建议用途:生成成功动效、活动视觉、引导页,不建议直接做主 Logo。 + +## 8. 横向发散造型补充 + +这一组在既有 V3 / 上下手感 / 一捏成型之外继续横向发散,刻意避开上一轮已经降级的播放键、聊天气泡、褐色陶土主色和碎元素。它更关注“平台入口、作品胚、游戏模芯、软体图腾、合捏火花”等造型。 + +![陶泥儿 Logo 横向发散总览](../../public/branding/taonier-logo-broad-concepts/taonier-logo-broad-contact-sheet.png) + +### 8.1 软泥入口 + +![软泥入口](../../public/branding/taonier-logo-broad-concepts/taonier-broad-soft-portal.png) + +定位:最接近“AI 创作入口”的平台符号。 + +这个方向像一枚被捏开的柔软门洞,中心星核留白明确,能承接“打开陶泥儿,进入创作”的心智。它保留了软泥和托举感,但没有真实手指、播放键或聊天气泡联想。 + +建议用途:主 Logo 强备选、创作首页入口、启动页核心动效。 + +### 8.2 作品胚芽 + +![作品胚芽](../../public/branding/taonier-logo-broad-concepts/taonier-broad-work-embryo.png) + +定位:精品作品和内容生长方向。 + +这个方向更像一颗正在成型的作品核,色彩偏青绿,整体高级、温和。它的产品语义更偏“作品孵化 / 创意生长”,和轻休闲小游戏的即时感弱一些。 + +建议用途:作品库、精选内容、创作者中心辅助标识;不建议优先做主 Logo。 + +### 8.3 游戏模芯 + +![游戏模芯](../../public/branding/taonier-logo-broad-concepts/taonier-broad-game-mold.png) + +定位:最强调“可玩”和小游戏平台属性。 + +这个方向把软泥主形和极简方向键 / 方块负形结合,能快速传达“生成出来的是可玩的互动作品”。深色底和强对比适合 App icon,但图形中的游戏控件联想较强,后续需要避免变成泛游戏平台图标。 + +建议用途:App icon 备选、玩法入口、游戏运行态品牌露出。 + +### 8.4 软体图腾 + +![软体图腾](../../public/branding/taonier-logo-broad-concepts/taonier-broad-soft-totem.png) + +定位:Taonier / 陶泥儿首字母感探索。 + +这个方向试图从软体纵向图腾里建立长期品牌记忆。它比普通字母标更柔软,也有捏塑感,但当前轮廓仍略像抽象数字或符号,正式使用前需要人工矢量重构。 + +建议用途:英文辅助标、favicon 探索、品牌图腾备选。 + +### 8.5 开捏火花 + +![开捏火花](../../public/branding/taonier-logo-broad-concepts/taonier-broad-creation-spark.png) + +定位:最稳定的“合捏生成”抽象主标备选。 + +这个方向把上下 / 左右的软形收成一个完整外轮廓,中心星核简洁,既能表达“开捏”,又比原始括号式结构更完整。它是本轮横向发散里最值得继续打磨的方向。 + +建议用途:主 Logo 强备选、生成按钮、AI 成功态和启动动效。 + +### 8.6 本轮未稳定产出的方向 + +`泥点皇冠`、`陶字负形` 和 `作品星轨` 的提示在本地 VectorEngine `gpt-image-2-all` 上多次超过 10 分钟仍未返回。后续若继续探索这些方向,建议先把 prompt 压短到单一造型,再单张运行,不要与批量任务混跑。 + +## 9. V3-03 一捏成型延展 + +这一组专门沿 V3 “一捏成型”继续打磨。目标是保留“两个软形触点 + 中央作品核”的成型瞬间,同时降低括号感、碰撞特效感和功能按钮感。 + +![陶泥儿 Logo 一捏成型延展总览](../../public/branding/taonier-logo-magic-dot-concepts/taonier-logo-magic-dot-contact-sheet.png) + +### 9.1 捏合星核 + +![捏合星核](../../public/branding/taonier-logo-magic-dot-concepts/taonier-magic-dot-orbit.png) + +定位:一捏成型方向的主标首选。 + +这个方向最稳地保留了“左右合拢、中央成型”的核心动作,中心青绿色星核形成了明确焦点,整体比原 V3-03 更完整,也没有明显播放器、聊天或表情联想。 + +优点: + +- 结构清楚,第一眼能看出“合拢生成”。 +- 元素少,小尺寸适配潜力好。 +- 中央星核可以做加载、生成成功、发布完成等动效延展。 + +风险: + +- 左右软形仍有一点括号感,后续矢量化可把外轮廓做得更不对称、更像被捏塑的软泥。 + +建议用途:主 Logo 备选首选、AI 生成按钮、启动动效核心符号。 + +### 9.2 成型印记 + +![成型印记](../../public/branding/taonier-logo-magic-dot-concepts/taonier-magic-dot-seal.png) + +定位:完整主标感最强的延展方向。 + +这个方向把左右触点收成一个更完整的软形图腾,减少了“两个括号”的割裂感。视觉上更像独立品牌符号,但也因此少了一点“捏合动作”的即时感。 + +建议用途:主 Logo 强备选;若选择它,后续应去掉背景底色并强化中心负形星点。 + +### 9.3 软泥合拍 + +![软泥合拍](../../public/branding/taonier-logo-magic-dot-concepts/taonier-magic-dot-squish.png) + +定位:轻松、年轻、动效友好。 + +这个方向的上下软形更活泼,适合表达“啪嗒一下成型”。但静态 Logo 中的黄色星点和短线略像特效贴纸,主标使用前需要继续简化。 + +建议用途:生成中动效、运营图、互动反馈,不建议直接定为主 Logo。 + +### 9.4 灵感模口 + +![灵感模口](../../public/branding/taonier-logo-magic-dot-concepts/taonier-magic-dot-mold.png) + +定位:最有“模口 / 造物容器”意味。 + +这个方向图形独特,和“从软泥模口里生成作品”的隐喻贴合。但外形复杂度比 01、02 更高,边缘细节在小尺寸下可能损失。 + +建议用途:主 Logo 备选探索,适合继续做专业矢量简化。 + +### 9.5 捏开灵感 + +![捏开灵感](../../public/branding/taonier-logo-magic-dot-concepts/taonier-magic-dot-bloom.png) + +定位:温和、包裹、生成容器。 + +这个方向亲和、平衡,但整体像眼睛 / 容器 / 开合结构,陶泥儿的“捏”动作弱一些。 + +建议用途:AI 生成入口、等待态、创作容器辅助图形。 + +## 10. 软泥合拍换色微调 + +这一轮不再改结构,也不改角度,只基于用户最认可的 `03 软泥合拍` 原图做配色和 Q 感微调。目标是保住“上下两团软泥 + 中央星点”的记忆点,只通过更甜、更轻、更亮的色彩关系,让它更像一个主流、亲和、容易记住的品牌符号。 + +![陶泥儿 Logo 软泥合拍换色总览](../../public/branding/taonier-logo-squish-recolor-variants/taonier-squish-recolor-contact-sheet.png) + +生成脚本: + +```text +scripts/generate-taonier-squish-recolor-variants.py +``` + +生成文件: + +```text +public/branding/taonier-logo-squish-recolor-variants/ +├─ taonier-squish-recolor-original-plus.png +├─ taonier-squish-recolor-candy-mint.png +├─ taonier-squish-recolor-peach-jelly.png +├─ taonier-squish-recolor-pop-bright.png +├─ taonier-squish-recolor-coral-soda.png +├─ taonier-squish-recolor-bubble-q.png +└─ taonier-squish-recolor-contact-sheet.png +``` + +推荐结论: + +- `02 糖果薄荷`:最均衡,保留原 03 的识别度,同时更轻、更甜。 +- `06 泡泡Q感`:最软萌,Q 感最强,适合做年轻化主视觉。 +- `04 亮彩出圈`:对比更强,适合传播场景和更醒目的入口图标。 +- `01 原版提亮`:最稳,几乎不改结构,适合作为保守版基线。 + +这一轮的结论是:如果后续只允许做“颜色修改或轻微可爱化”,优先沿原 03 继续做色彩细化,而不是重做造型。这样最能保住用户已经认可的那一下“软泥合拍”感觉。 + +## 11. 螺旋参考图延展 + +这一轮引入一张粗圆头黑白螺旋参考图,提取的是“向心旋转、包裹、揉合”的动势,而不是复刻黑白旋涡本身。生成时继续沿用前面认可的粉红、薄荷青、暖黄星点和软泥合拍语言,避免变成加载图、太极、棒棒糖或通用旋涡。 + +![陶泥儿 Logo 螺旋参考图延展总览](../../public/branding/taonier-logo-spiral-reference-concepts/taonier-logo-spiral-reference-contact-sheet.png) + +生成脚本: + +```text +scripts/generate-taonier-spiral-logo-concepts.mjs +scripts/generate-taonier-spiral-contact-sheet.py +``` + +生成文件: + +```text +public/branding/taonier-logo-spiral-reference-concepts/ +├─ taonier-spiral-reference.jpg +├─ taonier-spiral-soft-squish.png +├─ taonier-spiral-candy-roll.png +├─ taonier-spiral-star-core.png +├─ taonier-spiral-bouncy-clay.png +├─ taonier-spiral-creation-whirl.png +├─ taonier-spiral-soft-token.png +└─ taonier-logo-spiral-reference-contact-sheet.png +``` + +推荐结论: + +- `06 旋合软标`:最接近“原 03 软泥合拍 + 螺旋动势”的融合,整体完整,主标潜力最高。 +- `01 软泥旋合`:保留 Q 感和中心星点,亲和、可爱,适合继续做更扁平的人工矢量化。 +- `04 Q弹泥涡`:软萌感强,风险是中心星点偏贴纸感,适合做运营或启动动效参考。 +- `02 糖果泥卷`、`05 创作星涡`:最贴近参考图,但也最容易被误读成加载图、糖果卷或通用旋涡,主 Logo 优先级低于 01 / 06。 + +这一轮的结论是:螺旋参考图可以增强“AI 把灵感揉合成作品”的生成感,但主标不宜过分旋涡化。后续如果沿这条线继续,应以 `06 旋合软标` 为基准,把螺旋缝隙和上下软泥关系做得更像陶泥儿专属符号。 + +## 12. V3 抽象主标候选 + +V3 根据评审反馈重新避开了五个问题:播放三角、褐色陶土主色、聊天气泡 / 表情包、循环符号,以及过多碎元素。方向转为更抽象、更亮眼、更像长期主 Logo 的符号。 + +![陶泥儿 Logo V3 概念总览](../../public/branding/taonier-logo-v3-concepts/taonier-logo-v3-contact-sheet.png) + +### 12.1 灵感捏痕 + +![灵感捏痕](../../public/branding/taonier-logo-v3-concepts/taonier-v3-finger-spark.png) + +定位:主 Logo 首选。 + +这个方向用醒目的珊瑚红软形、指纹捏痕和星点负形建立记忆点。它不再依赖“陶泥的褐色”,而是用“被捏过的痕迹”表达陶泥儿的核心动作:用户把脑洞捏成作品。 + +优点: + +- 第一眼足够醒目,远离旧版褐色和播放器感。 +- 指纹捏痕有独特性,能承接“人人创作”和“亲手塑形”。 +- 元素少,适合继续矢量化和小尺寸适配。 + +风险: + +- 指纹弧线后续需要进一步简化,避免在 24px 以下变糊。 +- 星点比例要克制,避免变成普通灵感图标。 + +建议用途:主 Logo、App icon、平台顶栏、启动页、生成按钮。 + +### 12.2 脑洞种子 + +![脑洞种子](../../public/branding/taonier-logo-v3-concepts/taonier-v3-seed-pop.png) + +定位:创意生长与新手友好。 + +这个方向从“灵感发芽”切入,比陶泥更偏创造生命力。它亲和、可爱,但容易让用户联想到教育、植物、儿童启蒙或种植类产品。 + +建议用途:新手引导、创作孵化、儿童 / 寓教于乐支线,不建议作为主 Logo。 + +### 12.3 一捏成型 + +![一捏成型](../../public/branding/taonier-logo-v3-concepts/taonier-v3-magic-dot.png) + +定位:AI 把灵感合成为作品的瞬间。 + +这个方向很简洁,用左右两个软形触点和中心星点表达“捏合”。它避开了播放器和聊天气泡,也能做动效,但静态图形目前稍像碰撞特效或括号,需要继续重绘增强独特轮廓。 + +建议用途:生成按钮、AI 施法动效、主 Logo 备选微调方向。 + +### 12.4 作品胶囊 + +![作品胶囊](../../public/branding/taonier-logo-v3-concepts/taonier-v3-work-gem.png) + +定位:精品内容和作品沉淀。 + +这个方向更稳、更精品,青绿色也比褐色更吸睛。但整体像水滴、宝石或通用内容图标,和“捏”这个动作的关系弱。 + +建议用途:精选作品、作品库、创作者中心,不建议优先做主 Logo。 + +### 12.5 软体 T 形 + +![软体 T 形](../../public/branding/taonier-logo-v3-concepts/taonier-v3-soft-t.png) + +定位:英文辅助名 / Taonier 的抽象首字母。 + +这个方向试图做更品牌化的抽象符号,但当前形体还不够自然,也未形成足够强的“陶泥儿”心智。若未来英文名确定为 `Taonier` 或类似形式,可以继续沿这个方向做专业字母标重绘。 + +建议用途:英文标识探索,不作为当前主 Logo 首选。 + +## 13. V2 扁平矢量候选 + +第一批图形偏 3D 和拟物,更适合作为吉祥物、运营图或启动页气氛图,不适合作为长期主 Logo。V2 已把约束收紧为扁平、矢量、少元素、强轮廓和小尺寸可识别。 + +![陶泥儿 Logo 扁平概念总览](../../public/branding/taonier-logo-flat-concepts/taonier-logo-flat-contact-sheet.png) + +### 13.1 扁平开捏 + +![扁平开捏](../../public/branding/taonier-logo-flat-concepts/taonier-flat-play-clay.png) + +定位:最直接的主 Logo 候选。 + +这个方向用一团柔软陶泥承载播放符号,用户一眼能理解“点开玩 / 马上玩”,同时外形保留“捏出来”的不规则软泥感。 + +优点: + +- 识别速度最快,移动端小尺寸也成立。 +- 符合主流 App Logo 语言,亲和、不重、不技术冷。 +- 和“把脑洞捏成小游戏”的主张绑定最强。 + +风险: + +- 播放符号是常见母题,后续矢量化时要通过不规则软泥外轮廓、颜色和字标形成独特资产。 + +建议用途:主 Logo 首选、App icon、平台顶栏、分享卡片角标。 + +### 13.2 灵感泥星 + +![灵感泥星](../../public/branding/taonier-logo-flat-concepts/taonier-flat-spark-clay.png) + +定位:AI 创作与灵感生成。 + +这个方向比“扁平开捏”更品牌化,中心负形星点表达灵感、AI 生成和创意爆发。它没有播放符号那么直白,但更容易和“陶泥儿”的创作平台气质绑定。 + +优点: + +- 图形更简洁,品牌记忆点强。 +- 陶泥心智、AI 灵感和精品感比较平衡。 +- 适合未来扩成字标、启动页和生成态动效。 + +风险: + +- 对“小游戏/马上玩”的表达弱于播放符号。 + +建议用途:主 Logo 强备选、创作首页、AI 生成按钮和品牌主视觉。 + +### 13.3 造梗笑泥 + +![造梗笑泥](../../public/branding/taonier-logo-flat-concepts/taonier-flat-meme-smile.png) + +定位:社交传播和玩梗亲和力。 + +这个方向的气泡与笑脸非常亲和,适合表达“分享快乐”和“造梗”。但它和聊天、社区类产品的通用图形过近,作为主 Logo 可能会让用户误判产品品类。 + +建议用途:社区、评论、分享、活动贴纸,不建议做主 Logo。 + +### 13.4 共创泥环 + +![共创泥环](../../public/branding/taonier-logo-flat-concepts/taonier-flat-loop-mold.png) + +定位:AI 与用户共创闭环。 + +这个方向表达共创与循环,但生成结果带有偏柔和彩虹渐变的视觉倾向,与“陶泥儿”的软泥名称关联不够直观,也不如 01/02 容易记住。 + +建议用途:创作流程、共创能力、生成进度辅助图形。 + +### 13.5 精品泥印 + +![精品泥印](../../public/branding/taonier-logo-flat-concepts/taonier-flat-seal-blocks.png) + +定位:精品作品和内容集合。 + +这个方向像内容平台或作品库入口,能表达图片、用户、游戏等多形态内容。但图形元素较多,主标识别不如 01/02 凝练。 + +建议用途:精选作品、作品集、创作者中心、内容品质标识。 + +## 14. V1 立体探索 + +### 14.1 灵感陶团 + +![灵感陶团](../../public/branding/taonier-logo-concepts/taonier-clay-spark.png) + +定位:AI 共创与灵感造物。 + +这个方向把“陶泥”作为主视觉,内部用发光火花和节点表达 AI 赋能。它最贴“陶泥儿”名字本身,也能说明平台不是普通小游戏集合,而是从灵感生成作品的创作容器。 + +优点: + +- 与“陶泥儿”的名称绑定最强。 +- 有 AI、创作、造物的综合含义。 +- 适合启动页、品牌介绍、创作首页空状态。 + +风险: + +- 小尺寸下细节偏多,需要后续矢量化时压缩节点和纹理。 +- 如果色彩处理不当,会回到手工陶艺联想。 + +建议用途:品牌主视觉备选、官网/启动页、创作入口图形。 + +### 14.2 开玩模具 + +![开玩模具](../../public/branding/taonier-logo-concepts/taonier-play-mold.png) + +定位:把脑洞捏成小游戏。 + +这个方向用软陶捏出播放符号,最直接地连接“创作”和“马上玩”。它比单纯陶泥团更有产品动作,也更适合轻休闲、小游戏、短内容传播。 + +优点: + +- 识别强,小尺寸也清楚。 +- 与轻度休闲小游戏的关系最直接。 +- 适合作为 App icon 和主 Logo 图形。 + +风险: + +- 播放符号相对常见,需要后续在外轮廓、捏痕和色彩上做独特性。 +- 如果三角形过硬,会削弱“陶泥儿”的柔软感。 + +建议用途:主 Logo 首选、App icon、分享卡片角标、加载态图形。 + +### 14.3 造梗气泡 + +![造梗气泡](../../public/branding/taonier-logo-concepts/taonier-meme-bubble.png) + +定位:社交传播、玩梗、裂变。 + +这个方向把陶泥变形成聊天气泡和表情,强调“梗”和“传播”。它最有社交平台感,也适合表情包、活动贴纸和运营视觉。 + +优点: + +- 传播感强,年轻、轻松、容易做 IP 化。 +- 能承接社区、评论、分享和玩梗场景。 +- 比较容易延展成贴纸和表情包。 + +风险: + +- 偏软萌,可能削弱“精品 AI 创作平台”的质感。 +- 作为主 Logo 容易显得像聊天或表情产品。 + +建议用途:社区模块、活动运营、IP 辅助形象,不建议作为唯一主 Logo。 + +### 14.4 共创回路 + +![共创回路](../../public/branding/taonier-logo-concepts/taonier-creation-loop.png) + +定位:AI 与用户共同迭代生成。 + +这个方向用软陶带形成循环和造物轨迹,表达“灵感 -> AI 塑形 -> 用户修改 -> 作品传播”的闭环。它比其他方向更抽象,也更有平台级和工具级气质。 + +优点: + +- 高级、简洁,避免儿童化。 +- 适合表达 AI 共创、迭代和作品循环。 +- 可用于创作者工作台或生成进度标识。 + +风险: + +- 与“陶泥儿”名称的直观关联较弱。 +- 缺少小游戏和玩梗的即时识别。 + +建议用途:创作流程标识、AI 共创能力图标、品牌辅助图形。 + +### 14.5 精品泥印 + +![精品泥印](../../public/branding/taonier-logo-concepts/taonier-premium-seal.png) + +定位:精品内容、作品认证、创作者成果。 + +这个方向像一个被压印的软陶徽章,中间有方块和火花,比较适合表达“作品被打磨成型”。它的内容平台感强于游戏入口感。 + +优点: + +- 精品感和作品库气质较强。 +- 适合作品认证、精选、创作者徽章。 +- 与“陶泥压印”隐喻相对自然。 + +风险: + +- 细节较多,主 Logo 小尺寸可读性不如“开玩模具”。 +- 徽章感偏静态,轻休闲的即时性稍弱。 + +建议用途:精选作品标识、创作者荣誉、内容品质标签。 + +## 15. 推荐结论 + +优先级建议: + +```text +当前主 Logo 首选:抽象泥胚角色 A-01 泥芯主标 +当前主 Logo 强备选:抽象泥胚角色 A-03 泥种图符 +可控矢量基准:抽象泥偶 V2-01 陶泥小灵 +结构辅助参考:抽象泥偶 V2-04 软模团子 +灵气辅助参考:image-2 B-01 泥灵符号 +历史探索保留:几何抽象、具象陶泥人 / 手办、锚点底座、软泥合拍、螺旋参考图延展、V3 / V2 / V1 批次 +``` + +当前应停止继续推进软泥合拍、旋涡、糖果粉绿、锚点底座和具象小人插画路线。新的主线不是放弃陶泥人 / 手办 / 吉祥物,而是把它们消解成“抽象泥胚角色”:用一个像有生命的陶泥主形、一个偏心孔洞 / 作品核和少量星点,形成更成熟、更可注册、更像主 Logo 的符号。 + +后续优先围绕 `A-01 泥芯主标` 做人工矢量重绘:保留陶泥容器 / 泥胚的温度、偏心黑孔和小星点,但弱化陶罐口沿,避免被误读成传统陶艺品牌。若希望更轻、更像会动的小泥种,则以 `A-03 泥种图符` 为第二主线。 + +## 16. 后续落地建议 + +1. 基于 `A-01 泥芯主标` 做专业矢量重绘:保留一个主形、一个偏心孔、一个小星点,删除渐变和拟物口沿,避免过度像陶罐。 +2. 基于 `A-03 泥种图符` 做第二主线:保留白泥主体、偏心黑孔、底部陶土捏痕和星核,压平成纯矢量色块。 +3. 参考 `V2-01 陶泥小灵` 做可控 SVG 版本:把单眼改成泥点或负形捏痕,确保不是头像。 +4. 同步做 24px / 32px / 64px 小尺寸测试,淘汰小尺寸下像陶罐、表情包、头像、UI 控件或通用系统图标的版本。 +5. 输出黑底彩色、白底彩色、纯黑白、favicon 四套应用版,确认主标能脱离 App icon 底色使用。 +6. 字标不要直接使用生图结果,应单独设计“陶泥儿”中文字标,并准备英文辅助名。 +7. 正式应用前做商标近似检索,重点覆盖第 9、35、38、41、42 类。 +8. 若确认替换“百梦”,再更新现有命名规范文档、前端品牌组件、HTML metadata、后台和后端默认文案。 + +## 17. 纯形状发散新批次 + +上一轮“横向发散”仍然被判定不好看,所以本轮不再沿用软手、星核、入口、胚芽等惯性母题,而是转向更硬、更图形化的 App 标志草案:陶轮、模具窗格、骰面、口袋、舞台窗和印模孔洞。 + +![陶泥儿 Logo 纯形状发散总览](../../public/branding/taonier-logo-fresh-concepts/taonier-logo-fresh-contact-sheet.png) + +### 17.1 陶轮印记 + +![陶轮印记](../../public/branding/taonier-logo-fresh-concepts/taonier-fresh-wheel-imprint.png) + +定位:旋转的创作轮盘。 + +这个方向有速度感,也更像成熟消费级 App 主标。它已经远离软手和星核语言,适合继续测试小尺寸识别。 + +### 17.2 模具窗格 + +![模具窗格](../../public/branding/taonier-logo-fresh-concepts/taonier-fresh-mold-window.png) + +定位:多种作品从同一个模具里生成。 + +这个方向的四宫格负形有平台承载感,也能表达拼图、视觉小说、小游戏等多内容形态。但它更像系统入口图标,主 Logo 使用前需要进一步抽象。 + +### 17.3 泥点骰面 + +![泥点骰面](../../public/branding/taonier-logo-fresh-concepts/taonier-fresh-dot-dice.png) + +定位:泥点、玩法和随机脑洞。 + +这个方向游戏化最强,识别速度快,适合作为玩法或泥点体系衍生图标。主 Logo 风险是会被误读成骰子或桌游。 + +### 17.4 口袋世界 + +![口袋世界](../../public/branding/taonier-logo-fresh-concepts/taonier-fresh-pocket-world.png) + +定位:把脑洞装进口袋随手开玩。 + +这个方向亲和、有随身感,但图形稍像小世界插画,主标凝练度弱于陶轮和印模孔洞。 + +### 17.5 叙事舞台窗 + +![叙事舞台窗](../../public/branding/taonier-logo-fresh-concepts/taonier-fresh-stage-window.png) + +定位:视觉小说、RPG 与互动叙事。 + +这一版垂类内容方向很明确,适合叙事模板或视觉小说品牌分支;作为陶泥儿全平台主标会偏窄。 + +### 17.6 印模孔洞 + +![印模孔洞](../../public/branding/taonier-logo-fresh-concepts/taonier-fresh-punch-hole.png) + +定位:极简印模与作品冲孔。 + +这个方向轮廓最干净,是本批次里最适合继续做专业极简图标化的一张。后续可以把黑色主形、孔洞比例和两块彩色辅形继续压缩。 + +### 17.7 未稳定完成 + +`灵感绳结` 在本地 VectorEngine 上超时;`贴纸折角` 请求返回 429,上游提示当前分组负载饱和。后续如果继续追这组,建议把 prompt 再缩短,并改成单张串行跑。 + +## 18. 06 印模孔洞延展 + +这一组只沿 `12.6 印模孔洞` 继续,不再扩散到新母题。生成时把原 06 作为参考图输入,目标是保住“黑色不规则冲孔 + 中央白洞 + 右上珊瑚 / 左下青蓝”的识别关系,同时测试它能不能成为更稳的品牌主标。 + +![陶泥儿 Logo 06 印模孔洞延展总览](../../public/branding/taonier-logo-punch-hole-concepts/taonier-logo-punch-hole-contact-sheet.png) + +### 18.1 原型锁定微调 + +![原型锁定微调](../../public/branding/taonier-logo-punch-hole-concepts/taonier-punch-locked-shape.png) + +定位:不改变 06 基本造型的保守基准。 + +这一张基本保留原 06 的主轮廓、孔洞、右上红块和左下青块,只把边缘和比例做得更顺。它满足“先别动 06 本体”的要求,也适合作为后续人工矢量化时的基准稿。 + +建议用途:06 方向的锁定版、和原图并排做微调比较。 + +### 18.2 稳定主标 + +![稳定主标](../../public/branding/taonier-logo-punch-hole-concepts/taonier-punch-stable-icon.png) + +定位:本批次最值得继续打磨的主标方向。 + +这一张比原 06 更稳,黑色主形更像完整 App 标志,孔洞比例也更利于小尺寸识别。它仍然保留右上红、左下青的双辅形关系,没有偏离 06 的核心记忆点。 + +建议用途:主 Logo 候选、后续 24px / 32px / 64px 小尺寸测试。 + +### 18.3 孔洞比例 + +![孔洞比例](../../public/branding/taonier-logo-punch-hole-concepts/taonier-punch-hole-balance.png) + +定位:负形节奏测试。 + +这一张把黑色环形做得更圆、更符号化,孔洞也更规整。优点是识别干净,风险是个性少了一点,容易变成通用圆环标。它更适合作为比例参考,不建议直接定稿。 + +建议用途:孔洞比例、黑形厚薄和小尺寸识别测试。 + +### 18.4 彩色嵌合 + +![彩色嵌合](../../public/branding/taonier-logo-punch-hole-concepts/taonier-punch-color-inlay.png) + +定位:更年轻、更有活力的彩色版。 + +这一张的红青辅形和黑色主形嵌合得更自然,也更有“从泥板里取出作品碎片”的感觉。问题是彩色面积偏大,主标定稿前需要压低红青比例,否则会削弱黑色冲孔主形的记忆点。 + +建议用途:品牌活力版、运营场景、后续彩色比例压缩参考。 + +### 18.5 单色测试 + +![单色测试](../../public/branding/taonier-logo-punch-hole-concepts/taonier-punch-mono-test.png) + +定位:商标和极简场景测试。 + +这一张去掉了彩色辅形,只剩黑色冲孔和白色孔洞,证明 06 的核心轮廓在单色下仍然成立。但它也少了陶泥儿年轻、轻游戏的平台气质,不建议作为唯一主标。 + +建议用途:单色商标、印刷、极小尺寸兜底。 + +### 18.6 应用图标 + +![应用图标](../../public/branding/taonier-logo-punch-hole-concepts/taonier-punch-app-token.png) + +定位:App icon 图形强化版。 + +这一张黑色主形更饱满,右上红块比较稳定,但中心孔洞趋向圆,个性弱于 01 / 02。它适合拿来测试 icon 外框和启动页,但主标优先级低于 `13.2 稳定主标`。 + +建议用途:App icon 包装测试、启动页核心图形参考。 + +本批次结论: + +```text +06 延展首选:13.2 稳定主标 +不改基本造型基准:13.1 原型锁定微调 +彩色活力参考:13.4 彩色嵌合 +单色兜底参考:13.5 单色测试 +暂不直接定稿:13.3 孔洞比例、13.6 应用图标 +``` + +## 19. 04 彩色嵌合配色与中孔延展 + +这一组继续沿 `13.4 彩色嵌合` 推进。目标不是重新造型,而是在保持“圆润主环 + 右上珊瑚红 + 左下青蓝 + 中央孔洞”的基本结构下,测试两件事:第一,中间原本偏黑的主形是否可以换成更柔和的深色;第二,中央空心区域能否加入作品核、内窗或拼片内容,让它不只是白洞。 + +![陶泥儿 Logo 04 彩色嵌合配色与中孔延展总览](../../public/branding/taonier-logo-punch04-color-concepts/taonier-logo-punch04-color-contact-sheet.png) + +### 19.1 暖墨填芯 + +![暖墨填芯](../../public/branding/taonier-logo-punch04-color-concepts/taonier-punch04-warm-ink-core.png) + +定位:本批次最稳的保守延展。 + +这一张基本保住了 04 的三块嵌合关系,把纯黑主形降到温暖深灰,中间加入奶油色作品核。它的优势是没有大幅改结构,整体也比原 04 没那么硬。风险是灰色主形的品牌冲击力弱于黑色,需要继续测试更深一点的暖墨色。 + +建议用途:04 结构不变的主标微调基准。 + +### 19.2 靛蓝作品核 + +![靛蓝作品核](../../public/branding/taonier-logo-punch04-color-concepts/taonier-punch04-navy-game-core.png) + +定位:互联网 App 感测试。 + +这一张把主形改成模块化靛蓝环,色彩干净,但它已经明显偏离 04 的有机冲孔形态,更像通用系统图标或应用商店图标。中间作品核成立,但整体不建议继续作为 04 主线。 + +建议用途:色彩参考,不作为造型参考。 + +### 19.3 奶油内窗 + +![奶油内窗](../../public/branding/taonier-logo-punch04-color-concepts/taonier-punch04-cream-window.png) + +定位:柔和亲和版。 + +这一张把黑色主形大幅柔化,中央内窗也更像内容容器。优点是亲和、轻,但主形和红青辅形边界太软,品牌主标的冲击力不足。 + +建议用途:可作为启动页、运营图或浅色主题参考。 + +### 19.4 陶盒彩芯 + +![陶盒彩芯](../../public/branding/taonier-logo-punch04-color-concepts/taonier-punch04-clay-gradient-flat.png) + +定位:彩芯方向的失败参考。 + +这一张虽然有中间彩色泥芯,但外轮廓和色块位置已经明显变成新图形,不再像 04。它说明中孔内容不能做太复杂,也不能让彩色辅形绕到主形四周。 + +建议用途:不继续。 + +### 19.5 薄荷深影 + +![薄荷深影](../../public/branding/taonier-logo-punch04-color-concepts/taonier-punch04-mint-shadow.png) + +定位:清爽配色强备选。 + +这一张保留了 04 的动势,但主形换成深青绿后,气质更轻、更年轻。中央奶油小芯也比较克制。问题是左下青块变大后有一点抢主体,后续若继续,应压缩左下辅形,让主形重新占主导。 + +建议用途:04 配色强备选、年轻化品牌色参考。 + +### 19.6 内嵌拼片 + +![内嵌拼片](../../public/branding/taonier-logo-punch04-color-concepts/taonier-punch04-negative-tile.png) + +定位:中孔内容过度设计的参考。 + +这一张中间拼片语义明确,但外形已经变成对称徽章,失去了 04 的柔软不规则感。它也说明“中间内容设计”不宜出现太多模块,否则会像玩法图标而不是品牌 Logo。 + +建议用途:不继续。 + +本批次结论: + +```text +04 延展优先:14.1 暖墨填芯 +04 配色强备选:14.5 薄荷深影 +亲和浅色参考:14.3 奶油内窗 +只作色彩/反例参考:14.2 靛蓝作品核、14.4 陶盒彩芯、14.6 内嵌拼片 +``` + +下一步如果继续沿 04 打磨,建议以 `14.1 暖墨填芯` 的结构为基准,把主形加深到接近墨黑但保留暖度;中间只放一个非常克制的奶油色作品核,不再加入复杂拼片或多色内容。 + +## 20. REF-04 锁形配色与中孔填充 + +这一组不再使用 image-2 重新生成轮廓,而是直接读取 `13.4 彩色嵌合` 的像素轮廓做锁形换色:外轮廓、右上珊瑚红块、左下青蓝块和中央孔洞边界都保持不变,只替换主形颜色,并在中孔内部加入极简内容。因此这一组更适合判断配色和中孔策略,不适合评估新造型。 + +![陶泥儿 Logo REF-04 锁形配色与中孔填充总览](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-logo-ref04-locked-color-contact-sheet.png) + +### 20.1 暖墨作品核 + +![暖墨作品核](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-ref04-locked-warm-ink.png) + +定位:最稳的锁形主标基准。 + +这一版把纯黑主形改成暖墨色,中孔加入单个奶油色作品核。它保留了 REF-04 的结构,同时削弱了原黑色的生硬感。当前最适合继续微调,后续可以把暖墨再压深一点,保留柔和但不丢识别。 + +### 20.2 蓝墨作品核 + +![蓝墨作品核](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-ref04-locked-blue-ink.png) + +定位:年轻互联网感。 + +深蓝主形比黑色更轻,也和青色辅形更协调。问题是蓝色会把陶泥儿的“软泥 / 手作”心智拉向科技或工具感,需要靠字标和品牌色系统补回温度。 + +### 20.3 梅紫双芯 + +![梅紫双芯](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-ref04-locked-plum-ink.png) + +定位:内容生成感测试。 + +梅紫主形有记忆点,中孔双芯能表达多内容生成,但中孔内容稍显图标化。若继续,应把双芯合并成一个更柔软的小作品核。 + +### 20.4 墨绿小芯 + +![墨绿小芯](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-ref04-locked-green-ink.png) + +定位:清爽配色测试。 + +墨绿主形让整体更轻、更亲和,但左下青色和主形色相接近,层次不如暖墨和蓝墨清楚。适合作为品牌辅助色参考,不建议优先做主标。 + +### 20.5 缩孔填芯 + +![缩孔填芯](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-ref04-locked-shrink-core.png) + +定位:中孔缩小和内容填充测试。 + +这一版在不改孔洞边界的前提下,用奶油色内芯视觉上缩小了空洞,并放入红青两点。它证明中孔可以适当填充,但红青两点会把画面带向功能图标,正式版应更克制。 + +### 20.6 柔炭陶珠 + +![柔炭陶珠](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-ref04-locked-soft-charcoal.png) + +定位:温和品牌感。 + +柔炭主形比暖墨更松弛,中孔陶珠也比较亲和。缺点是整体对比变弱,放到 32px 以下可能不如 `15.1 暖墨作品核` 清楚。 + +本批次结论: + +```text +锁形首选:15.1 暖墨作品核 +年轻配色备选:15.2 蓝墨作品核 +中孔缩小参考:15.5 缩孔填芯 +亲和辅助参考:15.6 柔炭陶珠 +暂不优先:15.3 梅紫双芯、15.4 墨绿小芯 +``` + +如果下一轮继续打磨 REF-04,建议保持 `15.1` 的单个奶油作品核,不再增加多点、多拼片或复杂图形;主形颜色在暖墨和蓝墨之间继续找一个更有品牌识别的深色。 + +## 21. REF-04 暖色主形与中孔星星 + +这一组继续锁定 REF-04 原型轮廓,只做两项变化:把中间黑色主形换成温暖色系,并在空心位置绘制一枚星星。右上珊瑚红块、左下青蓝块和整体冲孔结构保持不变。 + +![陶泥儿 Logo REF-04 暖色主形与中孔星星总览](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-logo-ref04-warm-star-contact-sheet.png) + +### 21.1 陶土星 + +![陶土星](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-ref04-warm-star-terracotta.png) + +定位:最贴近“陶泥儿”名字的暖色版。 + +陶土棕主形保留了足够对比,中心浅金星也比较轻,不会把中孔填得太满。它是本批次最值得继续微调的方向。 + +### 21.2 焦糖星 + +![焦糖星](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-ref04-warm-star-caramel.png) + +定位:更亮、更甜的暖色版。 + +焦糖色比陶土棕更年轻,但也更接近食品或糖果联想。若后续品牌想更轻松可继续测试,否则优先级低于 `16.1 陶土星`。 + +### 21.3 可可星章 + +![可可星章](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-ref04-warm-star-cocoa.png) + +定位:星星更明确的版本。 + +这一版在星星外加了奶油底托,识别更清楚,但中孔内容稍重,容易像徽章或按钮。后续如果保留底托,应缩小 15% 到 20%。 + +### 21.4 赤陶星 + +![赤陶星](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-ref04-warm-star-rust.png) + +定位:暖色强备选。 + +赤陶色比 16.1 更红,和右上珊瑚块的关系更统一。整体温暖、亲和,但主形和红块色差变小,正式版需要略微拉开明度。 + +### 21.5 橄榄星 + +![橄榄星](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-ref04-warm-star-olive.png) + +定位:偏自然的配色测试。 + +橄榄色主形与青蓝辅形关系较近,星星用红色后视觉中心偏硬。它更像辅助配色,不建议作为主标优先方向。 + +### 21.6 梅紫星 + +![梅紫星](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-ref04-warm-star-plum.png) + +定位:成熟温暖版测试。 + +梅紫主形有记忆点,但和“陶泥儿”的泥感关联弱一些,中心青星也让色彩关系变复杂。适合保留为备选参考,不建议优先推进。 + +本批次结论: + +```text +首选:16.1 陶土星 +强备选:16.4 赤陶星 +更轻甜参考:16.2 焦糖星 +星星底托参考:16.3 可可星章 +暂不优先:16.5 橄榄星、16.6 梅紫星 +``` + +下一轮建议以 `16.1 陶土星` 为基准,保持星星单独存在,不加复杂底托;主形颜色可以在陶土棕和赤陶棕之间继续找更有品牌记忆的暖色。 + +## 17. REF-04 暖色主形与四角闪光星 + +这一组修正上一批的星形:中孔不再使用五角星,而是使用参考图中那种上下左右尖出的四角闪光星,并保留少量短光芒。REF-04 的外轮廓、红青辅形和中孔边界继续锁定不变。 + +![陶泥儿 Logo REF-04 暖色主形与四角闪光星总览](../../public/branding/taonier-logo-ref04-warm-sparkle-v2-concepts/taonier-logo-ref04-warm-sparkle-v2-contact-sheet.png) + +本批次结论: + +```text +首选:17.1 陶土四角闪光 +强备选:17.2 赤陶四角闪光 +轻甜参考:17.3 焦糖四角闪光 +低干扰参考:17.5 安静闪光 +暂不优先:17.4 可可四角闪光、17.6 梅紫四角闪光 +``` + +其中 `17.1` 最接近“陶泥儿”的暖泥感,星形也更接近参考图的四角闪光;`17.5` 去掉了周围小光芒,更安静,但品牌动感弱一点。后续建议在 `17.1` 的基础上继续微调星星大小和主形陶土色深浅。 + +## 18. REF-04 造型锁定与参考配色迁移 + +这一版按用户指定执行单张定向调整:锁定 `15.1 暖墨填芯` 的造型与分区关系,把参考软泥合拍图的粉红、薄荷青和黄色闪光语言迁移过来。原中间黑色主形改为暖黄色,中心空洞使用参考图中的四角闪光星和短光芒填充。 + +![陶泥儿 Logo REF-04 造型锁定与参考配色迁移](../../public/branding/taonier-logo-ref04-palette-transfer/taonier-logo-ref04-palette-transfer-contact-sheet.png) + +生成文件: + +```text +public/branding/taonier-logo-ref04-palette-transfer/ +├─ taonier-logo-ref04-palette-transfer-contact-sheet.png +└─ taonier-ref04-palette-transfer-warm-yellow-sparkle.png +``` + +结论:这版最严格满足“图一造型不变 + 图二配色迁移 + 暖黄色主形 + 四角闪光填充”的要求。后续若继续打磨,建议只微调暖黄色主形的明度和星星大小,不再改变外轮廓。 + +## 22. REF-04 淡暖黄与四角闪光星 v4 + +这一组使用 image-2 继续修正上一版反馈:中间主形不再使用土黄、脏黄或偏橙的暖黄,而是改为更温暖、低饱和、淡淡的奶油纸黄色;中心星星继续以四角闪光星参考裁剪图为准,重点避免被拉伸成细十字。 + +![陶泥儿 Logo REF-04 淡暖黄与四角闪光星 v4 总览](../../public/branding/taonier-logo-ref04-palette-refine-v4-concepts/taonier-logo-ref04-palette-refine-v4-contact-sheet.png) + +生成文件: + +```text +public/branding/taonier-logo-ref04-palette-refine-v4-concepts/ +├─ taonier-logo-ref04-palette-refine-v4-contact-sheet.png +├─ taonier-ref04-palette-refine-v4-cream-paper.png +├─ taonier-ref04-palette-refine-v4-warm-ivory.png +├─ taonier-ref04-palette-refine-v4-soft-champagne.png +└─ taonier-ref04-palette-refine-v4-pale-butter.png +``` + +本批次判断: + +```text +优先看:22.1 奶油纸淡黄、22.4 淡黄油暖白 +可作为更轻柔参考:22.2 暖象牙淡黄 +色彩高级但识别略弱:22.3 淡香槟暖黄 +不再使用:18 旧版土黄 +``` + +`22.1` 和 `22.4` 的中间黄色已经明显脱离旧版土黄,星星也不再是细长十字;`22.3` 的颜色最淡、最柔和,但粉红和薄荷青辅色也被一起降得偏软,小尺寸品牌识别可能弱一些。image-2 仍会轻微软化 REF-04 的外轮廓,若下一步要完全锁死造型,应以本批次的淡黄色与星星比例为参考,再回到确定性换色脚本或矢量稿做最终收口。 + +## 23. REF-04 填平中孔与中央亮星 v5 + +这一组根据用户给出的修改参考继续使用 image-2 精修 04 图标:补全左侧曲线,让淡黄主形更完整;将中央白色空心区域用同色淡黄填平;最后把四角闪光星改为更明亮的黄色并放在中央。 + +![陶泥儿 Logo REF-04 填平中孔与中央亮星 v5 总览](../../public/branding/taonier-logo-ref04-palette-refine-v5-concepts/taonier-logo-ref04-palette-refine-v5-contact-sheet.png) + +生成文件: + +```text +public/branding/taonier-logo-ref04-palette-refine-v5-concepts/ +├─ taonier-logo-ref04-palette-refine-v5-contact-sheet.png +├─ taonier-ref04-palette-refine-v5-filled-centered-spark.png +├─ taonier-ref04-palette-refine-v5-smooth-left-small-spark.png +├─ taonier-ref04-palette-refine-v5-balanced-bright-spark.png +└─ taonier-ref04-palette-refine-v5-solid-core-no-hole.png +``` + +本批次判断: + +```text +最接近本轮要求:23.1 填心居中亮星、23.3 平衡亮星 +更克制参考:23.2 顺滑左弧小亮星 +星星偏大参考:23.4 实体主形亮星 +``` + +`23.1` 和 `23.3` 都完成了中孔填平和亮黄星居中,左侧曲线也比 v4 更完整;`23.2` 更安静,但星星没有短光芒,视觉记忆点弱一点;`23.4` 的星星更醒目,但比例稍大,后续若作为主标应把星星缩小约 10%。 diff --git a/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md b/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md new file mode 100644 index 00000000..ca2cedf0 --- /dev/null +++ b/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md @@ -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. 运行态开局先完成两个目标物品的居中展示和飞入篮子动画,之后才出现礼物盒并进入首轮随机物品。 diff --git a/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md b/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md new file mode 100644 index 00000000..36aa7c74 --- /dev/null +++ b/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md @@ -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 表目录更新。 diff --git a/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md new file mode 100644 index 00000000..697b7a8f --- /dev/null +++ b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md @@ -0,0 +1,710 @@ +# 儿童动作识别互动玩法 Demo 热身关开发规格文档 + +> 日期:2026-05-09 +> 关联设计文档:[CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md](../design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md) +> 适用范围:儿童动作识别互动玩法 Demo 固定启动热身关 +> 文档性质:开发落地规格 +> 说明:本文只将已确认的热身关设计内容拆解为工程可执行规格,不新增未确认的玩法、文案或视觉设计。 + +## 1. 开发目标 + +热身关作为 Demo 启动后的固定流程,需要完成以下开发目标: + +1. 调用摄像头并识别用户和环境。 +2. 使用横屏比例展示热身关。 +3. 在屏幕中央地面生成绿色圆环,引导用户到达建议位置。 +4. 将用户实际位置生成纯描边小人指示器。 +5. 只对摄像头背景做虚化处理,表达隐私保护、屏蔽环境干扰,并营造空间感。 +6. 按固定步骤完成站位、招手、左右移动、挥动左右手检测。 +7. 记录用户左右移动距离和挥动手臂空间。 +8. 将记录结果仅保存在当前 Demo 体验会话内。 +9. 后续关卡使用热身记录的边界进行安全提醒和暂停恢复。 +10. 热身结束后进入关卡选择。 + +当前阶段先落浏览器本地 Demo。浏览器摄像头视频流仅作为舞台背景;热身动作检测以本地 mocap 动作数据源为准,通过 `useMocapInput` 连接 `http://127.0.0.1:8876/stream` 对应的 WebSocket 流,消费 `general.body.center_norm` 身体中心、手势和左右手坐标推进站位、招手与左右手挥动步骤。正式语音播报接口继续预留适配层,不阻塞前端热身流程、调试输入和页面表现骨架落地。 + +## 2. 非目标范围 + +热身关当前不包含以下内容: + +1. 不接入创作模块。 +2. 不作为可配置玩法模板提供给创作者。 +3. 不允许跳过步骤。 +4. 不允许系统自动进入下一步。 +5. 不设置动作检测最长等待时间。 +6. 不做特定用户识别。 +7. 不跨会话保存左右空间边界和手臂挥动空间。 +8. 不对手部细节进行识别,只对肢体进行区分。 +9. 本阶段不处理无硬件、拒绝摄像头、多人入镜、识别丢失等异常流程;这些问题记录为待决策事项,后续硬件与摄像头方案稳定后再重新设计。 + +## 3. 运行入口与流向 + +### 3.1 入口 + +用户进入 Demo 后,先进入热身关。 + +### 3.2 出口 + +用户完成热身关所有步骤后,进入关卡选择。 + +热身结束后展示“开始游戏”按钮,用户点击后进入宝贝识物首关本地 Demo。该入口只用于热身关后的本地体验验证;正式平台体验仍必须通过“宝贝识物”创作模板发布后,在寓教于乐板块进入。 + +### 3.3 固定流程顺序 + +热身关必须按照以下顺序执行: + +```text +进入热身关 +↓ +到达中央绿色圆环并保持 2 秒 +↓ +招手 / 摆手 +↓ +热身说明 +↓ +向左一步,到达左侧绿色圆环并保持 2 秒 +↓ +回到中间,到达中央绿色圆环并保持 2 秒 +↓ +向右一步,到达右侧绿色圆环并保持 2 秒 +↓ +回到中间,到达中央绿色圆环并保持 2 秒 +↓ +挥动左手 +↓ +挥动右手 +↓ +播放热身结束特效和结束语音 +↓ +进入关卡选择 +``` + +## 4. 页面基础表现规格 + +### 4.1 横屏比例 + +热身关需要使用横屏比例制作和展示,适用于电视屏幕、电脑屏幕等环境。 + +### 4.2 摄像头画面处理 + +用户进入热身关时调用摄像头。 + +摄像头画面处理要求: + +1. 识别用户和环境。 +2. 将用户实际位置生成纯描边小人指示器。 +3. 只对摄像头背景做虚化处理。 +4. 用户纯描边小人指示器用于表达用户在画面中的实际位置。 +5. 背景虚化用于表达对用户隐私的保护、屏蔽周围环境干扰,并营造空间感。 + +### 4.3 绿色圆环 + +绿色圆环用于指引用户到达指定位置。 + +绿色圆环出现位置包括: + +1. 屏幕中央位置的地面。 +2. 屏幕中心向左一个身位,约半米的地面位置。 +3. 屏幕中心向右一个身位,约半米的地面位置。 + +“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。 + +### 4.4 绿色圆环选中状态 + +用户到达绿色圆环后,绿色圆环进入 2 秒选中状态。 + +用户需要在绿色圆环内保持 2 秒,才算完成该位置检测。 + +## 5. 通用交互规则 + +### 5.1 不允许跳过 + +每个步骤都必须由用户完成。 + +系统不提供跳过,也不自动进入下一步。 + +### 5.2 引导动画规则 + +每个动作等待 3 秒后可以播放对应引导动画。 + +当前不设置最长等待时间。 + +### 5.3 手势检测规则 + +招手 / 摆手、挥动左手、挥动右手三类动作需要有动作区分。 + +检测只区分肢体,不识别手部细节。 + +### 5.4 手势引导规则 + +挥动哪只手,就使用对应手的引导。 + +## 6. 状态机规格 + +### 6.1 状态列表 + +热身关至少需要支持以下流程状态: + +| 状态 ID | 状态名称 | 进入条件 | 完成条件 | 下一状态 | +|---|---|---|---|---| +| warmup_enter | 进入热身关 | 用户进入 Demo | 摄像头调用并展示中央绿色圆环 | center_arrive | +| center_arrive | 到达中央圆环 | 中央绿色圆环出现 | 用户到达中央圆环并保持 2 秒 | wave_greeting | +| wave_greeting | 招手教学 | 中央圆环完成并播放圆圈消失特效 | 用户完成招手 / 摆手 | warmup_intro | +| warmup_intro | 热身说明 | 招手 / 摆手完成 | 播放热身说明文案与语音 | move_left | +| move_left | 向左一步 | 热身说明完成 | 用户到达左侧圆环并保持 2 秒 | return_center_1 | +| return_center_1 | 回到中间(一) | 向左一步完成 | 用户到达中央圆环并保持 2 秒 | move_right | +| move_right | 向右一步 | 回到中间(一)完成 | 用户到达右侧圆环并保持 2 秒 | return_center_2 | +| return_center_2 | 回到中间(二) | 向右一步完成 | 用户到达中央圆环并保持 2 秒 | wave_left_hand | +| wave_left_hand | 挥动左手 | 回到中间(二)完成 | 用户完成挥动左手 | wave_right_hand | +| wave_right_hand | 挥动右手 | 挥动左手完成 | 用户完成挥动右手 | warmup_finish | +| warmup_finish | 热身结束 | 挥动右手完成 | 播放热身结束特效和结束语音 | level_select | +| level_select | 关卡选择 | 热身结束 | 进入关卡选择 | - | + +### 6.2 状态推进约束 + +1. 状态必须按顺序推进。 +2. 用户未完成当前状态检测目标时,不进入下一状态。 +3. 位置类状态必须满足“到达绿色圆环并保持 2 秒”。 +4. 动作类状态没有最长等待时间。 +5. 动作类状态等待 3 秒后可以播放对应引导动画。 +6. 每个步骤进入时需要先展示本步骤文字字幕和语音播报入口,约 1 秒后再进入可交互阶段并展示绿色圆环、手势引导等检测提示。 +7. 步骤完成后需要先进入完成停顿阶段,当前停顿约 0.8 秒;停顿期间保留完成反馈位置,后续可在该阶段补充完成特效或音效,再切换到下一步骤。 +8. 入场等待和完成停顿阶段不消费动作完成判定,避免用户上一步残留动作直接触发下一步。 + +### 6.3 开发者调试输入 + +本地 Demo 需要支持开发者调试模式,用于无摄像头和自动化验证场景。 + +调试映射如下: + +1. `A` 键映射用户向左移动。 +2. `D` 键映射用户向右移动。 +3. 鼠标左键按下并拖动映射左手轨迹。 +4. 鼠标右键按下并拖动映射右手轨迹。 +5. 空格键仅映射小人弹起调试动画,不触发流程推进。 + +调试输入只作为本地 Demo 与测试辅助,不代表正式动作识别硬件口径。正式摄像头接入后,位置和手势判断需要按摄像头硬件调教结果重新校准。 + +## 7. 分步骤开发规格 + +### 7.1 进入热身关 + +#### 展示内容 + +- 调用摄像头。 +- 识别用户和环境。 +- 屏幕中央地面显示绿色圆环。 +- 用户实际位置显示为纯描边小人指示器。 +- 只对摄像头背景做虚化。 + +#### 文案与语音 + +```text +欢迎你,小朋友,见到你真开心 +来圆圈这里和我打个招呼吧 +``` + +首个 `center_arrive` 步骤不显示顶部大标题,只显示字幕文案。第一句展示后停顿 2 秒,再切换到第二句;绿色圆环仍按步骤入场节奏约 1 秒后出现。 + +#### 检测目标 + +用户到达中央绿色圆环并保持 2 秒。 + +#### 完成反馈 + +播放圆圈消失特效。 + +--- + +### 7.2 招手教学 + +#### 展示内容 + +播放招手的手势引导。 + +用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。 + +#### 检测目标 + +用户完成招手 / 摆手手势。 + +#### 完成后 + +进入热身说明。 + +--- + +### 7.3 热身说明 + +#### 文案与语音 + +```text +你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 +``` + +#### 完成后 + +进入“向左一步”。 + +--- + +### 7.4 向左一步 + +#### 展示内容 + +屏幕中心向左一个身位,约半米的地面位置出现新的绿色圆圈。 + +#### 文案与语音 + +```text +向左一步 +``` + +#### 检测目标 + +用户到达左侧绿色圆环并保持 2 秒。 + +#### 完成反馈 + +```text +真棒 +``` + +#### 数据记录 + +记录本次向左移动距离,作为后续关卡中的左侧空间边界参考。 + +--- + +### 7.5 回到中间来(一) + +#### 展示内容 + +场地中心位置出现绿色圆圈。 + +#### 文案与语音 + +```text +回到中间来 +``` + +#### 检测目标 + +用户到达中央绿色圆环并保持 2 秒。 + +#### 完成反馈 + +```text +真棒 +``` + +--- + +### 7.6 向右一步 + +#### 展示内容 + +屏幕中心向右一个身位,约半米的地面位置出现新的绿色圆圈。 + +#### 文案与语音 + +```text +向右一步 +``` + +#### 检测目标 + +用户到达右侧绿色圆环并保持 2 秒。 + +#### 完成反馈 + +```text +真棒 +``` + +#### 数据记录 + +记录本次向右移动距离,作为后续关卡中的右侧空间边界参考。 + +--- + +### 7.7 回到中间来(二) + +#### 展示内容 + +场地中心位置出现绿色圆圈。 + +#### 文案与语音 + +```text +回到中间来 +``` + +#### 检测目标 + +用户到达中央绿色圆环并保持 2 秒。 + +#### 完成反馈 + +```text +真棒 +``` + +--- + +### 7.8 挥动左手 + +#### 展示内容 + +播放伸展手臂挥动左手的手势引导。 + +用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。 + +#### 文案与语音 + +```text +挥动左手 +``` + +#### 检测目标 + +用户完成挥动左手。 + +当前本地 mocap 的 handedness 按摄像头视角输出,热身关内需要先换算成用户身体视角再判断:摄像头右侧手对应用户左手。挥动左手不是普通横向轨迹检测,而是用于确认现实环境中用户左侧手臂打开空间足够和安全。 + +完成条件必须同时满足: + +1. 使用用户身体左手轨迹。 +2. 手腕在左肩外侧达到最小外展距离。 +3. 手腕不能处于自然下垂低位。 +4. 最近连续有效帧中,手臂存在足够上下摆动幅度。 +5. 最近连续有效帧中,肩膀到手腕向量的角度变化达到阈值。 +6. 至少出现一次上下摆动方向变化。 + +#### 完成反馈 + +```text +真棒 +``` + +#### 数据记录 + +记录用户挥动左手的轨迹、空间包络、角度范围和最大外展距离,保存为该用户对应的行为坐标。 + +--- + +### 7.9 挥动右手 + +#### 展示内容 + +播放伸展手臂挥动右手的手势引导。 + +用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。 + +#### 文案与语音 + +```text +挥动右手 +``` + +#### 检测目标 + +用户完成挥动右手。 + +当前本地 mocap 的 handedness 按摄像头视角输出,热身关内需要先换算成用户身体视角再判断:摄像头左侧手对应用户右手。挥动右手不是普通横向轨迹检测,而是用于确认现实环境中用户右侧手臂打开空间足够和安全。 + +完成条件必须同时满足: + +1. 使用用户身体右手轨迹。 +2. 手腕在右肩外侧达到最小外展距离。 +3. 手腕不能处于自然下垂低位。 +4. 最近连续有效帧中,手臂存在足够上下摆动幅度。 +5. 最近连续有效帧中,肩膀到手腕向量的角度变化达到阈值。 +6. 至少出现一次上下摆动方向变化。 + +#### 完成反馈 + +```text +真棒 +``` + +#### 数据记录 + +记录用户挥动右手的轨迹、空间包络、角度范围和最大外展距离,保存为该用户对应的行为坐标。 + +--- + +### 7.10 热身结束 + +#### 进入条件 + +用户完成挥动右手后,直接进入热身结束阶段。 + +#### 完成反馈 + +播放热身结束特效、上浮字幕和语音: + +```text +真厉害,你是我见过最聪明的小朋友 +别走开,现在开始我们的游戏吧 +``` + +#### 完成后 + +进入关卡选择。 + +## 8. 当前 Demo 体验会话数据 + +### 8.1 保存范围 + +以下数据仅在当前 Demo 体验会话内保存: + +1. 左侧空间边界。 +2. 右侧空间边界。 +3. 左手挥动空间。 +4. 右手挥动空间。 + +当前 Demo 体验会话数据需要满足: + +1. 用户刷新产品或退出产品后失效。 +2. 用户只关闭当前游戏关卡并重新进入时,可以直接来到开始游戏界面,不强制重复热身。 +3. 首版可使用前端运行时内存或同等生命周期容器保存;不得跨产品刷新持久化保存。 + +### 8.2 当前 Demo 体验会话定义 + +“当前 Demo 体验会话”指用户本次打开并体验 Demo 的过程。 + +当用户关闭 Demo、刷新页面、退出当前体验流程、重新进入 Demo,或更换设备后,系统不再沿用上一次热身记录的数据,需要重新完成热身关并重新记录。 + +### 8.3 仅会话内保存原因 + +采用仅当前 Demo 体验会话内保存的原因: + +1. 每名用户的身高、体型、动作幅度不同,安全边界和行为坐标会发生变化。 +2. 当前 Demo 不做特定用户识别,无法确认下一次体验的是否仍是同一名用户。 +3. 用户所处的体验环境可能变化,包括房间大小、摄像头位置、屏幕位置和站立距离。 +4. 为保证安全,每次体验都需要重新对环境和距离进行安全检查。 + +## 9. 后续关卡安全边界使用规则 + +后续关卡需要使用热身关记录的左右空间边界进行安全判断。 + +### 9.1 覆盖安全边界线 + +当用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。 + +### 9.2 超出安全边界线 + +当用户身体主体超出安全边界线时: + +1. 关卡内容暂停。 +2. 屏幕虚化。 +3. 屏幕中央地面出现绿色圆圈。 +4. 屏幕提示文案: + +```text +小朋友,要注意安全哦 +``` + +5. 用户需要回到中心绿色圆圈并保持 2 秒后,才能继续游戏内容。 + +## 10. 识别能力清单 + +热身关需要接入或实现以下识别能力: + +1. 摄像头调用。 +2. 用户识别。 +3. 环境识别。 +4. 用户实际位置识别。 +5. 用户是否到达中央绿色圆环位置。 +6. 用户是否在绿色圆环内持续保持 2 秒。 +7. 用户是否到达左侧约半米绿色圆环位置。 +8. 用户是否到达右侧约半米绿色圆环位置。 +9. 招手 / 摆手手势识别。 +10. 挥动左手识别。 +11. 挥动右手识别。 +12. 用户左右移动距离记录。 +13. 用户挥动手臂空间记录。 +14. 用户身体主体覆盖安全边界线判断。 +15. 用户身体主体超出安全边界线判断。 +16. 用户回到中心绿色圆环并保持 2 秒判断。 + +## 11. 表现能力清单 + +热身关需要实现以下表现能力: + +1. 横屏比例显示。 +2. 摄像头背景虚化。 +3. 用户位置生成纯描边小人指示器。 +4. 屏幕中央地面绿色圆环。 +5. 左侧约半米地面绿色圆环。 +6. 右侧约半米地面绿色圆环。 +7. 绿色圆环 2 秒选中状态。 +8. 圆圈消失特效。 +9. 招手手势引导。 +10. 伸展手臂挥动左手手势引导。 +11. 伸展手臂挥动右手手势引导。 +12. 热身结束特效。 +13. 上浮字幕。 +14. 语音播报。 +15. 安全边界虚影提醒。 +16. 关卡暂停时屏幕虚化。 +17. 关卡暂停时屏幕中央地面绿色圆圈。 +18. 关卡暂停提示文案。 + +角色剪影、绿色圆环、虚影提醒、圆圈消失特效、手势引导动画和热身结束特效的正式视觉资源将通过 gpt-image-2 设计和生成。本地 Demo 阶段可以先使用 CSS、Canvas 或临时占位资源实现相同交互位置与状态,不把占位资源写死为正式资产。 + +## 12. 固定文案与语音清单 + +以下文案需要作为屏幕中上方浮现文字,并同步语音播报。 + +正式语音播报后续接入语音播报功能接口。本地 Demo 阶段保留播报适配层与调用点,可先只展示文字,不强制生成或播放正式语音资产。 + +```text +欢迎你,小朋友,见到你真开心 +来圆圈这里和我打个招呼吧 +你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 +向左一步 +真棒 +回到中间来 +真棒 +向右一步 +真棒 +回到中间来 +真棒 +挥动左手 +真棒 +挥动右手 +真厉害,你是我见过最聪明的小朋友 +别走开,现在开始我们的游戏吧 +小朋友,要注意安全哦 +``` + +## 13. 开发验收标准 + +### 13.1 热身流程验收 + +1. 用户进入 Demo 后先进入热身关。 +2. 热身关使用横屏比例展示。 +3. 摄像头被调用。 +4. 用户位置显示为纯描边小人指示器。 +5. 摄像头背景被虚化。 +6. 中央、左侧、右侧绿色圆环可以按流程出现。 +7. 用户到达每个绿色圆环后,需要保持 2 秒才算完成。 +8. 每个步骤未完成时不能跳过,也不能自动进入下一步。 +9. 动作等待 3 秒后可以播放对应引导动画。 +10. 所有固定文案可以展示并语音播报。 +11. 完成全部热身步骤后进入关卡选择。 + +### 13.2 数据记录验收 + +1. 完成向左一步后,可以记录左侧空间边界。 +2. 完成向右一步后,可以记录右侧空间边界。 +3. 完成挥动左手后,可以记录左手挥动空间。 +4. 完成挥动右手后,可以记录右手挥动空间。 +5. 以上数据仅在当前 Demo 体验会话内保存。 +6. 重新进入 Demo 后,不沿用上一次热身记录,需要重新完成热身关。 + +### 13.3 后续关卡安全边界验收 + +1. 用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。 +2. 用户身体主体超出安全边界线时,关卡内容暂停。 +3. 关卡暂停时,屏幕虚化。 +4. 关卡暂停时,屏幕中央地面出现绿色圆圈。 +5. 关卡暂停时,展示提示文案: + +```text +小朋友,要注意安全哦 +``` + +6. 用户回到中心绿色圆圈并保持 2 秒后,游戏内容继续。 + +## 14. 不确定项与补充确认 + +当前需求已明确本文所需的热身关开发规格。 + +以下内容作为待决策事项保留,后续硬件、摄像头和正式关卡设计稳定后再补充: + +1. 具体接入的动作识别 SDK、硬件接口和摄像头接口。 +2. 无硬件、摄像头拒绝授权、多人入镜、识别不到用户、跟踪丢失等异常流程。 +3. 小人指示器、圆环、虚影提醒、特效、手势引导动画的正式资源文件命名。 +4. 绿色圆环、小人指示器、安全边界在线性空间或屏幕坐标中的正式计算公式。 +5. 正式关卡选择页与后续游戏关卡的具体页面结构。 + +## 15. 第 3 项本地 Demo 落地记录 + +本地浏览器 Demo 入口已落在: + +```text +/child-motion-demo +``` + +当前实现范围: + +1. `src/ChildMotionDemoApp.tsx` 挂载独立 Demo 应用壳。 +2. `src/components/child-motion-demo/childMotionWarmupModel.ts` 维护热身步骤、圆环目标、2 秒保持判定、热身校准记录和当前运行时会话完成标记。 +3. `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 实现横屏舞台、背景虚化占位层、角色剪影、绿色圆环、手势引导、热身记录面板、热身完成后的“开始游戏”按钮,并复用宝贝识物运行态进入首关本地 Demo。 +4. `src/services/child-motion-demo/childMotionDebugInput.ts` 保留开发者调试输入适配层,后续可被正式动作识别 SDK 适配层替换或并行接入。 +5. `src/routing/appRoutes.tsx` 新增 `/child-motion-demo` 独立路由,并复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时不允许通过该直达路径进入 Demo。 + +当前调试输入: + +1. `A` 键映射用户向左移动,松开后回到中心。 +2. `D` 键映射用户向右移动,松开后回到中心。 +3. 鼠标左键按下并拖动映射左手轨迹。 +4. 鼠标右键按下并拖动映射右手轨迹。 +5. 空格键仅映射小人弹起调试动画,不触发流程推进。 +6. 调试输入只在步骤可交互阶段触发步骤完成;步骤入场字幕阶段和完成停顿阶段会忽略完成判定,便于观察节奏和后续补充特效。 + +当前硬件和动作检测接口接入: + +1. 浏览器摄像头视频流已接入舞台背景。 +2. 热身关全流程已通过 `src/services/useMocapInput.ts` 接入本地 mocap WebSocket `/stream`;动作数据源状态优先于浏览器背景摄像头状态展示。 +3. mocap 包支持从 `general.body.center_norm` 读取身体中心,位置类步骤使用该身体中心更新小人指示器横向位置并完成圆环保持检测。 +4. 身体中心横向坐标进入小人指示器前必须做输入稳定化处理:先 clamp 到 `0..1`,再使用小幅死区、低通阻尼和单包最大步长限制,避免硬件噪声造成角色左右误判、画面抽搐或视觉上的忽大忽小。当前实现参数为死区 `0.012`、阻尼系数 `0.28`、单包最大步长 `0.035`;位置保持检测使用稳定化后的角色坐标。 +5. 小人指示器渲染需要把水平位移和跳跃表现拆开:外层只负责横向定位,内层资源只负责轮廓图和跳跃位移,避免 `left` 与 `transform` 同时抢占导致资源重采样抖动。 +6. mocap 包支持从 `actions/action/gesture/gestures/event/name/type` 读取动作名,并支持 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 读取左右手坐标。 +7. `hands[].landmarks` 存在时优先用手腕和 MCP 点计算掌心中心;掌心点不足时退回 wrist landmark,再退回 hand 直出坐标。 +8. 热身舞台需要复用宝贝识物运行态的左右手指示器资源与样式,显示用户当前左右手位置;mocap 显示同样按摄像头视角换算成用户身体视角,用户左手使用 camera-right,用户右手使用 camera-left。手部指示器优先使用 `general.limb_nodes` / `limb_nodes` 中换算后同侧的 `right_wrist` / `left_wrist` 骨架手腕节点,骨架手腕缺失时再回退到手部 landmark 的 `wrist`,最后回退到 hand 直出坐标,避免手掌识别不稳时指示器跟随掌心抽搐。鼠标左键 / 右键调试时也同步显示同款左手 / 右手指示器。 +9. `wave_greeting` 只消费左手、右手或未知单手的连续横向挥手轨迹,不再使用 `wave`、`hand_wave`、`open_palm`、张手状态或动作名直接完成判定;进入轨迹判定前必须先满足抬手有效区:优先使用 `hands[].landmarks.wrist` 与 `general.limb_nodes` 的同侧 `*_elbow` / `*_shoulder` 判断,当前阈值为 `wrist.y <= elbow.y + 0.04`,缺少肘部时使用 `wrist.y <= shoulder.y + 0.08`;缺少同侧肘部和肩膀参考时不允许招呼通过,不再使用身体中心兜底判断抬手。轨迹阈值为至少 5 个连续抬手点,横向 `x` 范围差值不小于 `0.075`,且至少出现 1 次横向方向变化,避免“手刚露出画面”或“手自然下垂抖动”被误判为招手。 +10. `wave_greeting` 完成后直接进入 `warmup_intro` 的“准备热身 / 你好呀小朋友...”字幕节奏,不显示“真棒”完成飘字;后续位置移动、左右手挥动等正式热身步骤仍保留“真棒”反馈。 +11. `wave_left_hand` 和 `wave_right_hand` 只消费用户身体侧对应手的连续坐标轨迹,不再使用动作名、张手状态或 primary hand 兜底完成判定;本地 mocap handedness 当前按摄像头视角输出,因此用户左手使用 camera-right,用户右手使用 camera-left。完成判定必须同时满足对应肩肘腕外展、手腕非自然下垂、连续有效帧、横向范围、上下摆动范围、肩腕角度范围和上下方向变化,当前阈值为连续外展点不少于 5 个、横向 `x` 范围不小于 `0.055`、垂直 `y` 范围不小于 `0.08`、肩腕角度范围不小于 `28°`、外展距离不小于 `0.12`、手腕相对肩膀外侧距离不小于 `0.1`;后续以真实体验结果继续调参。 +12. 挥动右手完成后直接进入 `warmup_finish`,不再要求原地跳跃检测或记录跳跃空间。 +13. 键盘 `A/D/Space` 与鼠标左右键拖拽仍保留为本地 Demo 调试兜底,不代表正式硬件口径;其中 `Space` 只播放小人弹起调试动画,不推进热身流程。 + +当前未接入但已保留边界: + +1. 正式语音播报接口暂不接入,当前先展示热身文案。 +2. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和宝贝识物首关本地 Demo 衔接。 + +## 16. 当前视觉资产与生图口径补充 + +儿童动作 Demo 的视觉口径已经统一收敛到绘本风格草地舞台: + +1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。 +2. 该卡通绘本草地风格是儿童动作 Demo 后续场景、物品、UI 资源的全局风格要求;新增资源不得切回暗色科技风、真实照片风或后台面板风。 +3. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格接入真实资源;资源加载失败时保留 CSS 兜底。 +4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2-all` 调用 VectorEngine `POST /v1/images/generations`,透明资源先生成品红底源图,再在本地移除色键,源图写入 `tmp/child-motion-demo-assets/`。 +5. 当前已生成并接入以下正式 Demo 资源: + - `public/child-motion-demo/picture-book-grass-stage.png`:默认草地舞台背景。 + - `public/child-motion-demo/picture-book-foreground-grass-v2.png`:底部前景草坪条,只覆盖舞台下沿,不作为整块地板拉伸。 + - `public/child-motion-demo/picture-book-ground-ring-v3.png`:已按透视绘制的浅蓝与暖黄色地面椭圆指示环,和草地材质做明显区分,CSS 只等比缩放。 + - `public/child-motion-demo/picture-book-character-outline-v4.png`:用户位置小人指示器,基于 v2 本地后处理为更细的白色描边样式,中间完全透明,耳朵、手指、脚趾等细节已弱化;页面显示尺寸相对上一版放大 50%。 + - `public/child-motion-demo/picture-book-hud-strip-v2.png`:顶部 HUD 细长软纸条。 + - `public/child-motion-demo/picture-book-calibration-strip-v2.png`:右下角五格热身状态条。 + - `public/child-motion-demo/picture-book-start-panel-v2.png`:开始按钮背后的轻盈托盘。 + - `public/child-motion-demo/picture-book-ui-button-v2.png`:开始按钮绘本风按钮底图。 + - `public/child-motion-demo/picture-book-wave-cat-body-guide-v7.png`:招手阶段中央猫咪身体底座资源,按可动纸偶结构只包含猫头和短身体;v7 基于 v6 局部去除了身体左右两侧不协调的小圆点,不再和旧猫头、胸口或猫爪资源叠加。 + - `public/child-motion-demo/picture-book-wave-cat-arm-guide-v7.png`:招手阶段左右独立手臂资源,也用于左右手阶段单手提示;网页用同一拆件承接挥手摆动动画,但左手阶段使用 `picture-book-wave-cat-paw-left-v1.png`,右手阶段使用 `picture-book-wave-cat-paw-right-v1.png`,不再依赖同图镜像猜方向。v7 重点修正猫爪掌面朝向,末端圆猫爪必须正面对玩家,避免看起来朝内或朝向角色自己。 +6. v2 资源按最终用途拆分,CSS 必须按资源原始比例、`aspect-ratio` 或 `background-size: contain / auto` 等方式等比使用;禁止把方形面板强行拉伸为 HUD、状态条或地板,也禁止把底部草坪扩展成覆盖角色脚下的大色块。 +7. 猫咪招手引导拆件必须由 `.child-motion-gesture-guide__wave-cat` 父级统一承接上下浮动;身体层不再单独 bob,左右手臂只在同一父级坐标系内围绕肩部挂点旋转,并且手臂层级必须位于身体层前方。招手阶段使用独立全屏定位容器,猫咪整体放在上半屏幕、顶部字幕 UI 下方,避免压到地面小人指示器和圆环。当前 v7 资源的手臂贴近身体外缘摆放,左右侧距为 `12%`,左臂使用原图层与 `60% 78%` 旋转轴,右臂使用镜像图层与 `40% 78%` 旋转轴;左右手臂同步摆动,挥手动画周期为 `0.47s`,相对上一版约提速 50%,避免身体和手臂在动画过程中产生相对位移或压住胸口主体。8/11 挥动左手和 9/11 挥动右手阶段的单手猫猫手臂提示需要与打招呼双臂区分:不再使用左右招手式摆动,而是显示单侧外展安全弧线,并让猫爪沿外侧弧线做上下摆动,和“手臂外展、上下摆动幅度、角度变化、方向变化”的判定规则保持一致。 +8. 猫咪招手引导资源使用 `cat-guide` 透明后处理:先由 image-2 生成品红底源图,再通过边缘背景连通区域去背,避免把浅粉、淡橘和暖棕主体误删。源图只保存在 `tmp/child-motion-demo-assets/`,正式页面只引用 `public/child-motion-demo/` 下的最终 PNG。 +9. 若后续补充或重绘资源,应先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt 和输出路径,再使用 `--live --only ` 小批量生成;仅调整透明去背、裁切、画布归一、品红边缘、`character-outline-only-v3` / `character-outline-white-v4` 或 `wave-cat-body-guide-v7` 这种基于正式资源的局部后处理时,可用 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only ` 复用本地源图,不额外请求 image-2;不得把 `VECTOR_ENGINE_API_KEY`、源图或中间预览图提交到仓库。 + +已执行的定向验证命令: + +```bash +npx eslint src/components/child-motion-demo/ChildMotionWarmupDemo.tsx src/components/child-motion-demo/childMotionWarmupModel.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/services/child-motion-demo/index.ts src/ChildMotionDemoApp.tsx src/routing/appRoutes.tsx src/routing/appRoutes.test.ts --ext .ts,.tsx --max-warnings 0 +npx vitest run src/components/child-motion-demo/childMotionWarmupModel.test.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts +npm run check:encoding +``` diff --git a/packages/shared/src/contracts/edutainmentBabyObject.ts b/packages/shared/src/contracts/edutainmentBabyObject.ts index f1612c4e..d1ba0687 100644 --- a/packages/shared/src/contracts/edutainmentBabyObject.ts +++ b/packages/shared/src/contracts/edutainmentBabyObject.ts @@ -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; diff --git a/scripts/generate-child-motion-demo-assets.mjs b/scripts/generate-child-motion-demo-assets.mjs index 9f1a6690..e3a81fbb 100644 --- a/scripts/generate-child-motion-demo-assets.mjs +++ b/scripts/generate-child-motion-demo-assets.mjs @@ -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 ? [''] : undefined, diff --git a/scripts/generate-taonier-logo-concepts.mjs b/scripts/generate-taonier-logo-concepts.mjs index cdc2899f..2faf51b9 100644 --- a/scripts/generate-taonier-logo-concepts.mjs +++ b/scripts/generate-taonier-logo-concepts.mjs @@ -1,5 +1,7 @@ import { Buffer } from 'node:buffer'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; import path from 'node:path'; const repoRoot = process.cwd(); @@ -156,6 +158,12 @@ const handsConcepts = [ prompt: '围绕“陶泥儿”Logo 方向 03 的“上下两只手”感觉做延展。设计一个无文字扁平矢量主标:上下一对抽象软手 / 软泥掌从两侧微微合捏,中间形成一颗小圆珠或作品核。图形要像品牌符号,不像手势教学图;保留托举与成型的温柔感。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、褐色主色、真实手指、复杂掌纹、碎小图标。风格:flat vector, minimal, mainstream app logo, high contrast, iconic, friendly。配色:莓红、奶白、薄荷青、少量深墨,最多 3 色。', }, + { + id: 'taonier-hands-cradle-v2', + title: '托星软掌', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。图形是上下两片圆润软托,托住中央一颗小星,像把灵感轻轻捏成作品。不要画具体手指,只保留抽象软掌感觉。适合 App icon,简单、亲和、醒目、小尺寸清楚。配色:珊瑚红、薄荷青、奶油白,最多三色。不要播放三角、聊天气泡、笑脸、眼睛、花朵、褐色、文字、字母、3D、碎元素。', + }, { id: 'taonier-hands-soft-bowl', title: '创意托碗', @@ -176,6 +184,464 @@ const handsConcepts = [ }, ]; +const broadConcepts = [ + { + id: 'taonier-broad-clay-dot-crown', + title: '泥点皇冠', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品是 AI UGC 创作与轻休闲互动内容平台,用户用“泥点”驱动 AI,把一句脑洞、一张图或一个梗捏成小游戏和可分享作品。本方向把“泥点”做成核心品牌符号:3 到 5 个圆润泥点自然聚合,形成一个像皇冠、火苗、作品星核之间的抽象主轮廓,表达很多灵感汇聚成精品作品。整体必须像成熟 App 主标,亲和、明亮、可注册感强,小尺寸清楚。避免播放三角、聊天气泡、笑脸、真实陶艺、褐色陶土主色、人物、手、复杂碎点。风格:flat vector logo, bold simple silhouette, modern consumer app, warm, memorable, scalable, solid colors。配色:珊瑚红、奶油白、青绿色、少量金色,最多 4 色。无文字、无字母、无水印、无 3D、无厚阴影、无玻璃高光。', + }, + { + id: 'taonier-broad-soft-portal', + title: '软泥入口', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品把 AI 创作、UGC、小游戏、视觉小说、拼图和轻互动作品放在同一平台内,核心感觉是“打开一个软软的创作入口,进去就能造作品”。图形主体是一枚被捏开的柔软入口/门洞,外轮廓像软泥被拉开,中心留出干净负形作品核或小星点。图形要完整、抽象、主流,不像播放器、不像聊天框、不像眼睛。风格:flat vector brand mark, simple, iconic, friendly premium, strong silhouette, app icon ready。配色使用亮珊瑚、薄荷青、奶油白、深墨中的 3 色。禁止中文字、英文字母、真实门、真实陶土、3D、复杂纹理、碎小装饰、UI 按钮。', + }, + { + id: 'taonier-broad-work-embryo', + title: '作品胚芽', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。品牌隐喻不是传统陶艺,而是“灵感胚胎被 AI 塑形成可玩的作品”。图形主体是一颗圆润的作品胚芽:外形像软泥种子、游戏棋子和小宇宙的结合,内部只有一条柔软切面和一个小星点负形。整体高级、温柔、年轻,适合平台主 Logo 和 App icon。避免植物叶子过强、教育儿童感、播放按钮、聊天气泡、笑脸、循环箭头、褐色主色。风格:flat vector, premium friendly app logo, minimal, bold, clear at 32px, solid colors。配色:湖蓝或青绿主色,珊瑚橙点缀,奶白负形,最多 3 色。无文字、无字母、无水印、无 3D、无照片质感。', + }, + { + id: 'taonier-broad-game-mold', + title: '游戏模芯', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品不是工具后台,而是能把脑洞生成拼图、抓大鹅、视觉小说、文字游戏等互动作品的平台。本方向用“游戏模芯”做符号:一个圆润软泥主形中嵌入极简十字方向键或小方块负形,但不要画传统手柄,不要出现播放三角。图形要表达可玩、轻休闲、低门槛创作,同时保持品牌主标感。风格:flat vector logo, simple geometric, friendly, playful but mature, app icon, high contrast。配色:珊瑚红、青绿、奶油白、深墨,最多 4 色。禁止文字、字母、水印、3D、复杂按钮、真实手柄、聊天气泡、笑脸、儿童玩具感。', + }, + { + id: 'taonier-broad-tao-negative', + title: '陶字负形', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。尝试从“陶”的结构提炼抽象负形,但不要直接写汉字,也不要让模型生成可读文字。图形主体是一枚圆润软泥徽标,内部用两到三块负形构成类似陶器开口、耳部、土块和作品核的抽象关系,让熟悉中文的人隐约感到“陶”,但第一眼仍是现代 App 标志。风格:flat vector brand symbol, abstract Chinese-inspired, clean, iconic, friendly premium, scalable。配色:深墨或莓红主形,奶油白负形,青绿小点缀。禁止真实汉字、书法、篆刻、传统印章、褐色陶艺、播放按钮、聊天气泡、人物、3D、水印。', + }, + { + id: 'taonier-broad-soft-totem', + title: '软体图腾', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。基于 Taonier / 陶泥儿 的品牌声母感觉做一个抽象软体图腾,但不要直接画英文字母 T,也不要生成任何文字。图形由一笔连续的圆润软泥带形成稳定的竖向图腾,顶部像被轻捏出的小角,中心有一颗作品星核负形,表达“捏、造、发布”。整体要比普通字母标更独特,适合 App icon、favicon 和平台顶栏。风格:flat vector logo, bold, simple, modern, friendly, memorable, solid colors。配色:珊瑚红主形、奶油白负形、薄荷青小面积辅助。禁止文字、字母直出、播放三角、聊天气泡、笑脸、无限循环、褐色陶土、3D、复杂纹理。', + }, + { + id: 'taonier-broad-creation-spark', + title: '开捏火花', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。核心动作为“开捏”:用户输入灵感,AI 立刻生成可玩的作品。图形不要画真实手,用两块极简软形挤压出中心火花,火花不是爆炸特效,而是一个稳定的四角作品星核。外轮廓要比上一轮左右括号更完整,像一个独立品牌图腾。风格:flat vector logo, iconic, minimal, high contrast, friendly, youthful, app icon ready。配色:莓红或珊瑚红主形,奶油白负形,青绿中心点缀,最多 3 色。禁止文字、字母、水印、播放三角、聊天气泡、笑脸、眼睛、真实手指、碎粒、3D、厚阴影。', + }, + { + id: 'taonier-broad-content-orbit', + title: '作品星轨', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品承载多种互动内容:RPG、拼图、抓大鹅、视觉小说、文字游戏、儿童寓教于乐。图形用一个软泥圆核和两条极简短弧形成“作品星轨”,表达一个灵感生成多个作品形态;但整体必须是一个凝聚的主标,不是天文图标。风格:flat vector brand mark, simple, premium friendly, clean geometry, app icon, scalable。配色:青绿主核、珊瑚红弧线、奶油白负形、深墨小轮廓可选。禁止文字、字母、水印、真实星球、复杂轨道、科技冷硬、播放键、聊天气泡、循环箭头、3D。', + }, +]; + +const freshConcepts = [ + { + id: 'taonier-fresh-wheel-imprint', + title: '陶轮印记', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:俯视一个正在旋转的创作轮盘,圆环被轻轻压出一处缺口,像把灵感旋成作品。成熟消费级 App 主标,几何、干净、有速度感。配色:钴蓝、奶白、珊瑚红、少量深墨。不要软手、星核、聊天气泡、播放键、笑脸、真实陶艺、褐色、文字、字母、3D。', + }, + { + id: 'taonier-fresh-mold-window', + title: '模具窗格', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个圆角模具窗口,内部是 2x2 的不规则负形窗格,像多种小游戏和互动作品从同一个模具里生成。主流、简洁、品牌感强、小尺寸清楚。配色:深墨主形、奶油白负形、亮青绿和珊瑚小点缀。不要软手、星星、播放键、聊天气泡、脸、真实陶土、文字、字母、3D。', + }, + { + id: 'taonier-fresh-dot-dice', + title: '泥点骰面', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一枚圆润方形骰面或游戏牌面,5 个泥点孔组成独特节奏,表达泥点、玩法和随机脑洞。不要画立体骰子,只要正面抽象符号。潮流、轻游戏、可注册。配色:象牙白底、黑色主形、荧光青、珊瑚红。不要播放键、聊天气泡、笑脸、星星、软手、褐色、文字、字母、3D。', + }, + { + id: 'taonier-fresh-pinwheel', + title: '灵感风车', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:抽象纸风车,由四片圆润色块围成旋转中心,表达简单、轻松、人人能造内容。它要像品牌主标,不像儿童玩具。配色:莓红、天蓝、薄荷、奶白、深墨。不要软泥团、手、星核、播放键、聊天气泡、笑脸、花朵、文字、字母、3D、复杂渐变。', + }, + { + id: 'taonier-fresh-pocket-world', + title: '口袋世界', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个抽象口袋形徽标,口袋里露出一小块世界切片或舞台切片,表示把脑洞装进口袋随手开玩。现代、亲和、平台感强。配色:青绿色主形、奶白负形、珊瑚红小块、深墨轮廓。不要软手、星核、播放键、聊天气泡、笑脸、地图图钉、真实口袋、文字、字母、3D。', + }, + { + id: 'taonier-fresh-builder-blocks', + title: '创作积木', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:三块圆角积木以不对称方式咬合,形成一个稳定主轮廓,表达 UGC 搭建、模板生成和小游戏创作。不要儿童玩具感,要成熟、潮流、清晰。配色:黑色或深紫主轮廓,珊瑚、青绿、奶白填色。不要软手、星星、播放键、聊天气泡、笑脸、褐色、文字、字母、3D。', + }, + { + id: 'taonier-fresh-stage-window', + title: '叙事舞台窗', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个极简舞台窗或小剧场窗口,左右两片抽象幕布形成负形中心,代表视觉小说、RPG 和互动叙事。它要是 App icon 主标,不是插画。配色:深墨、珊瑚红、奶油白、少量湖蓝。不要播放键、聊天气泡、笑脸、软手、星核、真实舞台、文字、字母、3D。', + }, + { + id: 'taonier-fresh-ribbon-knot', + title: '灵感绳结', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一条圆润彩色泥条打成简洁绳结,像把多个创意线索系成一个作品。形状必须凝聚成单个主标,不能散。配色:珊瑚、钴蓝、薄荷、奶白,边缘干净。不要无限符号、软手、星核、播放键、聊天气泡、笑脸、褐色陶土、文字、字母、3D。', + }, + { + id: 'taonier-fresh-folded-sticker', + title: '贴纸折角', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一张圆角贴纸或作品卡片,右上角轻轻折起,负形像一个小入口。表达 UGC、作品发布、随手开玩。成熟、潮流、极简。配色:奶白、黑、珊瑚、青绿。不要播放键、聊天气泡、笑脸、手、星星、褐色、文字、字母、3D。', + }, + { + id: 'taonier-fresh-punch-hole', + title: '印模孔洞', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个圆润印模形状,中间被冲出一个不规则圆孔,像从泥板里取出作品。抽象、强轮廓、可注册、小尺寸清楚。配色:黑色主形、奶白负形、荧光青小块、珊瑚红。不要播放键、聊天气泡、笑脸、手、星星、陶罐、文字、字母、3D。', + }, +]; + +const punchReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-fresh-concepts', + 'taonier-fresh-punch-hole.png', +); +const punch04ReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-punch-hole-concepts', + 'taonier-punch-color-inlay.png', +); +const paletteRefineReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-transfer', + 'taonier-ref04-palette-transfer-warm-yellow-sparkle.png', +); +const paletteShapeReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-locked-color-concepts', + 'taonier-ref04-locked-warm-ink.png', +); +const sparkleRefineReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-warm-sparkle-v2-concepts', + 'taonier-ref04-warm-sparkle-terracotta.png', +); +const sparkleCropReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-concepts', + 'taonier-sparkle-reference-crop.png', +); +const paletteRefineV2ReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-v2-concepts', + 'taonier-ref04-palette-refine-v2-pale-cream.png', +); +const paletteRefineV4PaleButterReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-v4-concepts', + 'taonier-ref04-palette-refine-v4-pale-butter.png', +); + +const punchConcepts = [ + { + id: 'taonier-punch-locked-shape', + title: '原型锁定微调', + referenceImages: [punchReferencePath], + prompt: + '为“陶泥儿”继续打磨参考图 06 印模孔洞 logo。必须保持参考图基本造型不变:黑色圆润不规则环形主形、中央白色不规则孔洞、右上珊瑚红辅形、左下青蓝辅形。只优化比例、边缘、留白和小尺寸识别,让它更像成熟 App icon。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch-stable-icon', + title: '稳定主标', + referenceImages: [punchReferencePath], + prompt: + '基于参考图 06 印模孔洞,为“陶泥儿”做无文字扁平矢量 logo 延展。保留黑色冲孔主形和中央不规则白洞,但让外轮廓更稳定、更像长期品牌主标。右上珊瑚红和左下青蓝辅形更克制,白底,强轮廓,小尺寸清楚。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch-hole-balance', + title: '孔洞比例', + referenceImages: [punchReferencePath], + prompt: + '基于参考图 06 印模孔洞,为“陶泥儿”延展一个更干净的无文字 logo。核心仍是黑色圆润印模环和中央不规则白色孔洞,重点调整孔洞大小、厚薄关系和负形节奏,让黑形更有张力。珊瑚红、青蓝只作为小面积辅形。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch-color-inlay', + title: '彩色嵌合', + referenceImages: [punchReferencePath], + prompt: + '基于参考图 06 印模孔洞,为“陶泥儿”做彩色嵌合版 logo。黑色主环保持冲孔感,右上珊瑚红和左下青蓝两块辅形与主形更自然嵌合,像从泥板里取出的两片作品碎片。造型简洁、可注册、App icon 友好。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch-mono-test', + title: '单色测试', + referenceImages: [punchReferencePath], + prompt: + '基于参考图 06 印模孔洞,为“陶泥儿”做单色极简版 logo。只保留黑色圆润冲孔主形和中央白色不规则孔洞,去掉彩色辅形。强调强轮廓、可注册、小尺寸识别和品牌符号感。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch-app-token', + title: '应用图标', + referenceImages: [punchReferencePath], + prompt: + '基于参考图 06 印模孔洞,为“陶泥儿”延展一个更完整的 App icon 核心图形。黑色不规则冲孔主形更饱满,中央白洞更清晰,珊瑚红与青蓝辅形保持年轻感但不抢主体。整体像可长期使用的品牌符号,不像插画。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, +]; + +const punch04Concepts = [ + { + id: 'taonier-punch04-warm-ink-core', + title: '暖墨填芯', + referenceImages: [punch04ReferencePath], + prompt: + '基于参考图“04 彩色嵌合”为“陶泥儿”继续做 logo 延展。保持原有基本结构不变:一个圆润不规则环形主形,右上珊瑚红嵌合块,左下青蓝嵌合块,中央不规则孔洞。重点调整配色:中间黑色主形改为温暖深墨灰,不要纯黑;中央孔洞内部加入一枚很简洁的奶油色软泥种子/作品核填充,不要填满,保留留白呼吸。扁平矢量、品牌主标、小尺寸清楚。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch04-navy-game-core', + title: '靛蓝作品核', + referenceImages: [punch04ReferencePath], + prompt: + '基于参考图“04 彩色嵌合”为“陶泥儿”设计一版配色延展。保持黑环、右上红块、左下青块的基本结构和嵌合关系,但把主形从黑色改为深靛蓝或蓝黑色,整体更年轻、更像互联网 App。中央空心区域加入一个极简浅色作品核:小圆角方块或软形小岛,不能像播放键、不能像字母。白底,扁平矢量,干净可注册。无文字、无字母、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch04-cream-window', + title: '奶油内窗', + referenceImages: [punch04ReferencePath], + prompt: + '基于参考图“04 彩色嵌合”为“陶泥儿”做一版更柔和的 logo。基本结构不变:主环、右上珊瑚红、左下青蓝、中央孔洞都保留。把原黑色主环调整为柔和深紫灰或墨绿色,降低硬度。中央孔洞不再是纯空白,设计成奶油色内窗,里面有两块极简小色面,表达多个作品从同一模具生成。整体仍然极简,不要复杂插画。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch04-clay-gradient-flat', + title: '陶盒彩芯', + referenceImages: [punch04ReferencePath], + prompt: + '基于参考图“04 彩色嵌合”为“陶泥儿”做配色与中孔设计。保持 04 的基本轮廓和红青嵌合块位置。主形不要纯黑,改成深陶紫、莓紫或炭灰紫,仍保持强轮廓。中央孔洞加入一个扁平的彩色泥芯,由珊瑚、青蓝、奶白三块圆润小面组成,像作品被捏出来的内核。不要渐变高光,不要立体,不要复杂细节。无文字、无字母、无播放键、无聊天气泡、无手、无星星。', + }, + { + id: 'taonier-punch04-mint-shadow', + title: '薄荷深影', + referenceImages: [punch04ReferencePath], + prompt: + '基于参考图“04 彩色嵌合”为“陶泥儿”做一版更清爽的品牌 logo。保持 04 的三块嵌合结构不变。把中间黑色主形改成深青绿/墨绿,右上红块更偏珊瑚,左下青块更偏亮薄荷。中央空心处加入一枚小小的浅黄色或奶白圆角形,像可玩的作品胚,不要过大。整体强识别、轻休闲、App icon 友好。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch04-negative-tile', + title: '内嵌拼片', + referenceImages: [punch04ReferencePath], + prompt: + '基于参考图“04 彩色嵌合”为“陶泥儿”做一版中间内容更明确的 logo。保持外部基本结构和红青嵌合块位置不变。主形从纯黑改为深墨蓝灰。中央不规则孔洞内部放入一个极简拼片/圆角模块组合,表示拼图、小游戏、互动作品,但必须非常简洁,不能像 UI 图标堆叠。白底,扁平矢量,主标感强。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, +]; + +const paletteRefineConcepts = [ + { + id: 'taonier-ref04-palette-refine-butter', + title: '淡黄黄油', + referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath], + prompt: + '为“陶泥儿”继续调整 REF-04 配色迁移版。必须锁定参考图一的外轮廓和分区:主形、右上红块、左下青块和中间孔洞都保持不变;把中间主形改成温暖、低饱和、很淡的黄油黄或奶油黄,不要脏黄、土黄、芥末黄或偏橙黄。中间的星星必须保持参考图二的原样:四角闪光星,带短小光芒,不能拉伸成细长十字,不能变成五角星,不能加厚底托。整体要像成熟、干净、轻松的品牌 logo。无文字、无字母、无播放键、无聊天气泡、无手、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-cream', + title: '奶油淡黄', + referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath], + prompt: + '基于 REF-04 造型锁定版和四角闪光星参考图,生成一版更高级的暖黄配色。保持图一的造型完全不变,只把中间主形改成低饱和奶油淡黄,颜色要轻、透、干净,避免脏、沉、厚。中心星星完全沿用参考图二的四角闪光样式和短光芒,不要拉伸,不要变形,不要变成五角星。红块和青块保持现有位置与比例。白底、扁平、品牌标志感。无文字、无字母、无手、无播放键、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-biscuit', + title: '饼干淡黄', + referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath], + prompt: + '继续基于 REF-04 造型锁定版做色彩优化。外轮廓、红青辅形、中孔边界全部锁住不变;中间主形换成更淡的饼干黄、奶油黄或浅麦黄,必须低饱和、暖而不脏。中心填充严格使用参考图二的四角闪光星和短光芒,保持原样,不许被拉长,也不许改成几何五角星。整体要简洁、轻盈、专业。无文字、无字母、无聊天气泡、无3D、无复杂阴影。', + }, + { + id: 'taonier-ref04-palette-refine-milk', + title: '牛奶暖黄', + referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath], + prompt: + '在 REF-04 锁形轮廓上做最后一轮暖黄微调。只改中间主形的颜色,把它变成接近牛奶、黄油、奶霜的浅暖黄,低饱和、柔和、干净,不要土气,不要发灰。中间星星必须保持参考图二的四角闪光星原型和短光芒,不能被拉伸,不能变瘦,不能加底托。红青两块辅形位置不动。白底,极简 logo。无文字、无字母、无手、无播放键、无3D。', + }, +]; + +const paletteRefineV2Concepts = [ + { + id: 'taonier-ref04-palette-refine-v2-soft-butter', + title: '柔和奶黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineReferencePath, + sparkleCropReferencePath, + ], + prompt: + '为“陶泥儿”修正 REF-04 配色迁移版。严格锁定参考图一的造型和分区:不改变外轮廓、不改变右上辅形、不改变左下辅形、不改变中央孔洞边界。只做两处调整:1)把中间主形改成温暖、低饱和、淡淡的奶油黄/黄油黄,颜色要高级、轻、干净,绝对不要土黄、脏黄、芥末黄、焦糖黄、偏橙黄;2)中心空洞里的星星必须使用参考图三的原始四角闪光星和短光芒,保持饱满菱形闪光,不要拉伸成十字,不要变成五角星,不要加底托。保持白底和扁平 logo。无文字、无字母、无手、无播放键、无聊天气泡、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v2-pale-cream', + title: '浅奶油黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineReferencePath, + sparkleCropReferencePath, + ], + prompt: + '基于三张参考图生成一版修正版 logo:参考图一只用于锁定 REF-04 造型;参考图二只用于当前粉红与薄荷青位置;参考图三用于中心星星样式。中间主形颜色改为低饱和浅奶油黄,接近柔和奶霜,不要土气、不要脏、不要高饱和。中心星星必须照参考图三,四角闪光星带短光芒,比例自然饱满,不能被压扁或拉长。外轮廓和孔洞边界不变。白底、干净、成熟品牌 logo。无文字、无字母、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v2-light-vanilla', + title: '香草淡黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineReferencePath, + sparkleCropReferencePath, + ], + prompt: + '继续优化 REF-04 造型锁定 logo。必须保持参考图一的所有轮廓位置,只把中间原黑色区域换成温暖低饱和的香草淡黄,颜色像轻柔黄油、奶油纸、浅米黄,不能像陶土、咖啡、焦糖或芥末。中心空洞填入参考图三的星星:圆润四角闪光、短小光芒、自然比例,不要变瘦,不要拉伸,不要五角星。粉红和薄荷青辅形沿用参考图二的气质。无文字、无字母、无播放键、无聊天气泡、无手、无3D。', + }, +]; + +const paletteRefineV3Concepts = [ + { + id: 'taonier-ref04-palette-refine-v3-butter-soft', + title: '淡奶油黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineV2ReferencePath, + sparkleCropReferencePath, + ], + prompt: + '为“陶泥儿”继续修正 REF-04 的配色迁移版。锁定参考图一的外轮廓、红块、青块和孔洞边界不动;把中间主形调成更高级的淡奶油黄、黄油白黄或柔软黄米色,颜色要更淡一点、更轻一点、更透一点,不要土黄、脏黄、焦糖黄、芥末黄,也不要偏橙偏褐。中心空洞使用参考图三的星星:必须是饱满的四角闪光星,带短小光芒,不能被拉长成细十字,不能变成五角星,也不能出现厚底托。整体保持白底、扁平、品牌 logo 感。无文字、无字母、无3D、无聊天气泡。', + }, + { + id: 'taonier-ref04-palette-refine-v3-milk-cream', + title: '奶霜淡黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineV2ReferencePath, + sparkleCropReferencePath, + ], + prompt: + '基于三张参考图输出一版更轻的 REF-04 logo。第一张参考只负责锁定原始造型;第二张参考只负责当前配色关系;第三张参考只负责中心闪光星的样子。中间主形改成低饱和的奶霜淡黄,颜色要轻柔、通透、像淡淡的黄油和牛奶混合,不要土、不要厚、不要脏。星星保持参考图三的四角闪光星和短光芒,不许拉伸,不许变形,不许五角星化。红块和青块位置固定。无文字、无字母、无手、无播放键、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v3-soft-vanilla', + title: '香草奶黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineV2ReferencePath, + sparkleCropReferencePath, + ], + prompt: + '继续保持 REF-04 的造型锁定,做一次更安静的暖黄修正。中间主形变成香草奶黄或浅奶油黄,必须是低饱和、柔和、高级的淡黄,不要像土黄、咖喱黄、焦糖黄或偏橙黄。中心填充沿用参考图三的四角闪光星,星体要圆润饱满,旁边的短光芒保留,但不能夸张,不能拉长。外轮廓完全不动。白底、logo 感、扁平。无文字、无字母、无聊天气泡、无3D。', + }, +]; + +const paletteRefineV4Concepts = [ + { + id: 'taonier-ref04-palette-refine-v4-cream-paper', + title: '奶油纸淡黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineReferencePath, + sparkleCropReferencePath, + ], + prompt: + '继续用 image-2 修正“陶泥儿” REF-04 logo。参考图一只用于锁定 REF-04 原型:外轮廓、右上粉红块、左下薄荷青块、中央孔洞边界都不要重新设计;参考图二只说明当前需要修正的版本;参考图三只用于中心星星。把中间原本土黄/脏黄的主形改成温暖、低饱和、淡淡的奶油纸黄色,接近 #F3E5B4 或 #F6E9C5,颜色要轻、干净、高级,不要陶土黄、芥末黄、咖喱黄、焦糖黄、橙黄、棕黄。中心孔洞里的星星必须保持参考图三原本的四角闪光星比例:上下左右四个圆润尖角,宽高自然,不能被横向或纵向拉伸,不能变成细十字,不能变成五角星,旁边短光芒也保持短小。白底、扁平品牌 logo。无文字、无字母、无播放键、无聊天气泡、无手、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v4-warm-ivory', + title: '暖象牙淡黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineReferencePath, + sparkleCropReferencePath, + ], + prompt: + '基于三张参考图输出一版 REF-04 精修 logo。第一张参考图的形状和分区必须优先:主形轮廓不改、粉红块和薄荷青块位置不改、中间白色孔洞不改;只把中间主形从现在偏土的黄改成暖象牙淡黄,像很淡的黄油白、奶油米白、暖白纸,低饱和、柔和、通透,不要厚重和脏感。第三张参考图的四角闪光星需要原样放进中心:星体不能被压扁、不能拉长、不能瘦成十字,短光芒不要变多。整体保持成熟、干净、可做 App icon 的扁平 logo。无文字、无字母、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v4-soft-champagne', + title: '淡香槟暖黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineV2ReferencePath, + sparkleCropReferencePath, + ], + prompt: + '为“陶泥儿”做一版更高级的 REF-04 暖黄精修。参考图一锁定基本造型,不允许改成播放按钮、三角形、气泡或新图标;参考图二只参考淡黄的轻盈程度;参考图三锁定星星。中间主形使用低饱和淡香槟黄/奶霜黄,颜色要非常淡、温暖、干净,不能像泥土、咖喱、焦糖、芥末或橙棕。中心星星必须是参考图三那种饱满四角闪光,保留短光芒,按原始宽高比例绘制,不能拉伸、不能变形、不能五角星化。粉红和薄荷青辅形保持克制。白底、扁平、品牌主标感。无文字、无字母、无3D、无复杂阴影。', + }, + { + id: 'taonier-ref04-palette-refine-v4-pale-butter', + title: '淡黄油暖白', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineV2ReferencePath, + sparkleCropReferencePath, + ], + prompt: + '继续调整 REF-04 配色版本,只修正颜色和中心星星,不重画 logo。外轮廓、三块嵌合关系、中央孔洞边界以参考图一为准;中间主形换成淡黄油暖白,像轻薄奶油、温暖米白、浅黄纸,低饱和、不土、不脏、不橙、不褐。中心孔洞填入参考图三原样的四角闪光星:星星要圆润饱满,四个尖角长度均衡,短光芒短而自然,不能拉伸成细长十字。保留白底和扁平矢量 logo 气质。禁止文字、字母、五角星、播放键、聊天气泡、手、3D。', + }, +]; + +const paletteRefineV5Concepts = [ + { + id: 'taonier-ref04-palette-refine-v5-filled-centered-spark', + title: '填心居中亮星', + referenceImages: [ + paletteRefineV4PaleButterReferencePath, + paletteShapeReferencePath, + sparkleCropReferencePath, + ], + prompt: + '根据参考图修改“陶泥儿”04 图标,保留右上粉红块、左下薄荷青块和整体软泥圆润气质。重点做三处修改:1)补全左侧外轮廓曲线,让左侧从上到下形成连续、顺滑、饱满的弧线,不能有缺口、锯齿、截断或不自然凹陷;2)把中央白色空心孔洞完全用主体同色的温暖低饱和淡奶油黄填平,不能再出现白色中孔、白色环或内窗;3)把参考星星改成明亮的黄色四角闪光星,放在整个淡黄主体的视觉中央,星星清晰、圆润、比例自然,不要五角星,不要拉伸成十字。白底、扁平品牌 logo、干净高级。无文字、无字母、无播放键、无聊天气泡、无手、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v5-smooth-left-small-spark', + title: '顺滑左弧小亮星', + referenceImages: [ + paletteRefineV4PaleButterReferencePath, + paletteShapeReferencePath, + sparkleCropReferencePath, + ], + prompt: + '继续精修“陶泥儿”04 图标。以参考图一的 04 配色和比例为基础,但不要保留中央白洞。左侧外边缘需要补成更完整、更协调的连续曲线,像一整块柔软陶泥的自然外轮廓;中间原空心区域必须填成和主形一致的淡奶油黄色,与主体融为一体。中心放一枚明亮黄色四角闪光星,星星略小、居中、干净,不带复杂底托,不是五角星,不是细长十字。粉红块和薄荷青块仍然分离在右上和左下,白色间隔保持干净。无文字、无字母、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v5-balanced-bright-spark', + title: '平衡亮星', + referenceImages: [ + paletteRefineV4PaleButterReferencePath, + paletteShapeReferencePath, + sparkleCropReferencePath, + ], + prompt: + '为“陶泥儿”输出一版更协调的 04 图标修改稿。主形是温暖、低饱和、淡淡的奶油黄色;请补齐左侧曲线,让左边外轮廓更圆润完整,整体重心更稳。中央空心区域不再留白,必须填平为同样的淡黄色主形。把四角闪光星改成更明亮、更清楚的黄色,准确放在图标中央,星体饱满,四个尖角均衡,可以有很短的小光芒但不要抢主体。保持扁平 logo 感和白底。禁止文字、字母、五角星、播放键、聊天气泡、手、3D。', + }, + { + id: 'taonier-ref04-palette-refine-v5-solid-core-no-hole', + title: '实体主形亮星', + referenceImages: [ + paletteRefineV4PaleButterReferencePath, + paletteShapeReferencePath, + sparkleCropReferencePath, + ], + prompt: + '按用户参考图修改 04 logo:把淡黄主形做成一个更完整的实体软泥形。左侧曲线补全并顺滑化,外轮廓不要破碎;原中央白色孔洞完全消失,改成与主形同色的淡奶油黄实体面;在实体面的正中央放一枚明亮黄色四角闪光星,星星比主体颜色更亮,有明确识别但不幼稚。保持右上粉红块和左下薄荷青块的年轻配色,整体干净、轻盈、品牌主标感。无文字、无字母、无内孔、无白色中窗、无五角星、无播放键、无3D。', + }, +]; + const args = new Map(); for (let index = 2; index < process.argv.length; index += 1) { const raw = process.argv[index]; @@ -201,6 +667,24 @@ const concepts = ? magicDotConcepts : style === 'hands' ? handsConcepts + : style === 'broad' + ? broadConcepts + : style === 'fresh' + ? freshConcepts + : style === 'punch' + ? punchConcepts + : style === 'punch04' + ? punch04Concepts + : style === 'palette-refine' + ? paletteRefineConcepts + : style === 'palette-refine-v2' + ? paletteRefineV2Concepts + : style === 'palette-refine-v3' + ? paletteRefineV3Concepts + : style === 'palette-refine-v4' + ? paletteRefineV4Concepts + : style === 'palette-refine-v5' + ? paletteRefineV5Concepts : dimensionalConcepts; const selectedOutputDir = style === 'flat' @@ -221,6 +705,69 @@ const selectedOutputDir = 'branding', 'taonier-logo-hands-concepts', ) + : style === 'broad' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-broad-concepts', + ) + : style === 'fresh' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-fresh-concepts', + ) + : style === 'punch' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-punch-hole-concepts', + ) + : style === 'punch04' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-punch04-color-concepts', + ) + : style === 'palette-refine' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-concepts', + ) + : style === 'palette-refine-v2' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-v2-concepts', + ) + : style === 'palette-refine-v3' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-v3-concepts', + ) + : style === 'palette-refine-v4' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-v4-concepts', + ) + : style === 'palette-refine-v5' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-v5-concepts', + ) : outputDir; function readDotenv(fileName) { @@ -276,6 +823,82 @@ function buildUrl(baseUrl) { : `${baseUrl}/v1/images/generations`; } +function hasHeader(headers, targetName) { + return Object.keys(headers).some( + (name) => name.toLowerCase() === targetName.toLowerCase(), + ); +} + +async function requestBuffer(url, options, timeoutMs, redirectCount = 0) { + const body = + typeof options.body === 'string' + ? Buffer.from(options.body) + : options.body || null; + const headers = { ...(options.headers || {}) }; + if (body && !hasHeader(headers, 'content-length')) { + headers['Content-Length'] = String(body.length); + } + + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const transport = parsedUrl.protocol === 'http:' ? http : https; + const request = transport.request( + parsedUrl, + { + method: options.method || 'GET', + headers, + }, + (response) => { + const statusCode = response.statusCode || 0; + const location = response.headers.location; + if ( + statusCode >= 300 && + statusCode < 400 && + location && + redirectCount < 5 + ) { + response.resume(); + const redirectedUrl = new URL(location, parsedUrl).toString(); + const preserveBody = statusCode === 307 || statusCode === 308; + requestBuffer( + redirectedUrl, + preserveBody + ? options + : { + method: 'GET', + headers: options.headers, + }, + timeoutMs, + redirectCount + 1, + ) + .then(resolve) + .catch(reject); + return; + } + + const chunks = []; + response.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + response.on('end', () => + resolve({ + statusCode, + headers: response.headers, + bytes: Buffer.concat(chunks), + }), + ); + }, + ); + + request.setTimeout(timeoutMs, () => { + request.destroy(new Error(`request timed out after ${timeoutMs}ms`)); + }); + request.on('error', reject); + if (body) { + request.write(body); + } + request.end(); + }); +} + function collectStringsByKey(value, targetKey, output) { if (Array.isArray(value)) { value.forEach((entry) => collectStringsByKey(entry, targetKey, output)); @@ -331,45 +954,58 @@ function inferExtensionFromBytes(bytes) { return 'png'; } +function imagePathToDataUrl(imagePath) { + if (!existsSync(imagePath)) { + throw new Error(`Reference image not found: ${imagePath}`); + } + + const bytes = readFileSync(imagePath); + const extension = path.extname(imagePath).toLowerCase(); + const mimeType = + extension === '.jpg' || extension === '.jpeg' + ? 'image/jpeg' + : extension === '.webp' + ? 'image/webp' + : 'image/png'; + return `data:${mimeType};base64,${bytes.toString('base64')}`; +} + async function fetchJson(url, options, timeoutMs) { - const abortController = new AbortController(); - const timer = setTimeout(() => abortController.abort(), timeoutMs); try { - const response = await fetch(url, { - ...options, - signal: abortController.signal, - }); - const text = await response.text(); - if (!response.ok) { - throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); + const response = await requestBuffer(url, options, timeoutMs); + const text = response.bytes.toString('utf8'); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error( + `VectorEngine ${response.statusCode}: ${text.slice(0, 600)}`, + ); } return JSON.parse(text); } catch (error) { - if (error?.name === 'AbortError') { - throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + if (String(error?.message || '').includes('timed out')) { + throw new Error( + `VectorEngine request timed out after ${timeoutMs}ms`, + { cause: error }, + ); } throw error; - } finally { - clearTimeout(timer); } } async function downloadUrl(url, timeoutMs) { - const abortController = new AbortController(); - const timer = setTimeout(() => abortController.abort(), timeoutMs); try { - const response = await fetch(url, { signal: abortController.signal }); - if (!response.ok) { - throw new Error(`download ${response.status}`); + const response = await requestBuffer(url, { method: 'GET' }, timeoutMs); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error(`download ${response.statusCode}`); } - return Buffer.from(await response.arrayBuffer()); + return response.bytes; } catch (error) { - if (error?.name === 'AbortError') { - throw new Error(`Generated image download timed out after ${timeoutMs}ms`); + if (String(error?.message || '').includes('timed out')) { + throw new Error( + `Generated image download timed out after ${timeoutMs}ms`, + { cause: error }, + ); } throw error; - } finally { - clearTimeout(timer); } } @@ -380,6 +1016,9 @@ async function generateConcept(env, concept) { n: 1, size: '1024x1024', }; + if (concept.referenceImages?.length) { + requestBody.image = concept.referenceImages.map(imagePathToDataUrl); + } const payload = await fetchJson( buildUrl(env.baseUrl), { @@ -438,6 +1077,13 @@ if (dryRun) { prompt: concept.prompt, n: 1, size: '1024x1024', + ...(concept.referenceImages?.length + ? { + image: concept.referenceImages.map((imagePath) => + path.relative(repoRoot, imagePath), + ), + } + : {}), }, })), }, diff --git a/server-rs/crates/api-server/src/edutainment_baby_object.rs b/server-rs/crates/api-server/src/edutainment_baby_object.rs index cbd0682f..0458a4e6 100644 --- a/server-rs/crates/api-server/src/edutainment_baby_object.rs +++ b/server-rs/crates/api-server/src/edutainment_baby_object.rs @@ -7,14 +7,12 @@ use axum::{ response::Response, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use futures_util::{StreamExt, stream::FuturesUnordered}; -use image::{ColorType, ImageEncoder, codecs::png::PngEncoder}; +use image::{ColorType, GenericImageView, ImageEncoder, ImageFormat, codecs::png::PngEncoder}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::{ api_response::json_success_body, - character_visual_assets::try_apply_background_alpha_to_png, config::DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, http_error::AppError, openai_image_generation::{ @@ -28,6 +26,7 @@ use crate::{ const BABY_OBJECT_MATCH_PROVIDER: &str = "vector-engine-gpt-image-2"; const BABY_OBJECT_MATCH_IMAGE_SIZE: &str = "1024x1024"; const BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE: &str = "1536x1024"; +const BABY_OBJECT_MATCH_SHEET_GRID_SIZE: u32 = 2; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -56,53 +55,58 @@ struct BabyObjectMatchItemAssetPayload { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum BabyObjectMatchVisualAssetKind { Background, - UiFrame, GiftBox, Basket, - SmokePuff, } impl BabyObjectMatchVisualAssetKind { fn asset_id(self) -> &'static str { match self { Self::Background => "baby-object-visual-background", - Self::UiFrame => "baby-object-visual-ui-frame", Self::GiftBox => "baby-object-visual-gift-box", Self::Basket => "baby-object-visual-basket", - Self::SmokePuff => "baby-object-visual-smoke-puff", } } fn contract_kind(self) -> &'static str { match self { Self::Background => "background", - Self::UiFrame => "ui-frame", Self::GiftBox => "gift-box", Self::Basket => "basket", - Self::SmokePuff => "smoke-puff", - } - } - - fn requires_transparency(self) -> bool { - !matches!(self, Self::Background) - } - - fn image_size(self) -> &'static str { - match self { - Self::Background => BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE, - Self::UiFrame | Self::GiftBox | Self::Basket | Self::SmokePuff => { - BABY_OBJECT_MATCH_IMAGE_SIZE - } } } fn failure_context(self) -> &'static str { match self { Self::Background => "宝贝识物背景环境图片生成失败", - Self::UiFrame => "宝贝识物 UI 装饰图片生成失败", Self::GiftBox => "宝贝识物礼物盒图片生成失败", Self::Basket => "宝贝识物篮子图片生成失败", - Self::SmokePuff => "宝贝识物烟雾特效图片生成失败", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BabyObjectMatchSheetSlot { + ItemA, + ItemB, + Basket, + GiftBox, +} + +impl BabyObjectMatchSheetSlot { + const ALL: [Self; 4] = [Self::ItemA, Self::ItemB, Self::Basket, Self::GiftBox]; + + fn row(self) -> u32 { + match self { + Self::ItemA | Self::ItemB => 0, + Self::Basket | Self::GiftBox => 1, + } + } + + fn col(self) -> u32 { + match self { + Self::ItemA | Self::Basket => 0, + Self::ItemB | Self::GiftBox => 1, } } } @@ -125,6 +129,21 @@ struct BabyObjectMatchVisualAssetPayload { prompt: String, } +#[derive(Debug)] +struct BabyObjectMatchSheetAssets { + items: Vec, + basket: BabyObjectMatchVisualAssetPayload, + gift_box: BabyObjectMatchVisualAssetPayload, +} + +#[derive(Clone, Copy, Debug)] +struct BabyObjectMatchSheetCellBounds { + x0: u32, + y0: u32, + x1: u32, + y1: u32, +} + pub async fn generate_baby_object_match_assets( State(state): State, Extension(request_context): Extension, @@ -153,16 +172,21 @@ pub async fn generate_baby_object_match_assets( item_count = item_names.len(), "宝贝识物 image-2 资源生成开始" ); - let (assets, visual_package) = tokio::try_join!( - build_baby_object_match_item_assets(&http_client, &settings, item_names.as_slice()), - build_baby_object_match_visual_package(&http_client, &settings, item_names.as_slice()), - ) - .map_err(|error| baby_object_match_error_response(&request_context, error))?; + let (sheet_assets, background_asset, theme_prompt) = + build_baby_object_match_optimized_assets(&http_client, &settings, item_names.as_slice()) + .await + .map_err(|error| baby_object_match_error_response(&request_context, error))?; tracing::info!( elapsed_ms = request_started_at.elapsed().as_millis() as u64, "宝贝识物 image-2 资源生成完成" ); + let assets = sheet_assets.items; + let visual_package = BabyObjectMatchVisualPackagePayload { + theme_prompt, + assets: vec![background_asset, sheet_assets.gift_box, sheet_assets.basket], + }; + Ok(json_success_body( Some(&request_context), GenerateBabyObjectMatchAssetsResponse { @@ -190,18 +214,58 @@ fn normalize_item_names(item_names: Vec) -> Result, AppError Ok(normalized) } -fn build_baby_object_match_item_prompt(item_name: &str) -> String { +async fn build_baby_object_match_optimized_assets( + http_client: &reqwest::Client, + settings: &OpenAiImageSettings, + item_names: &[String], +) -> Result< + ( + BabyObjectMatchSheetAssets, + BabyObjectMatchVisualAssetPayload, + String, + ), + AppError, +> { + let theme_prompt = build_baby_object_match_visual_theme_prompt(item_names); + let sheet_prompt = build_baby_object_match_sheet_prompt(item_names, theme_prompt.as_str()); + let background_prompt = build_baby_object_match_visual_asset_prompt( + BabyObjectMatchVisualAssetKind::Background, + item_names, + theme_prompt.as_str(), + ); + + let (sheet_assets, background_asset) = tokio::try_join!( + build_baby_object_match_sheet_assets( + http_client, + settings, + item_names, + sheet_prompt.as_str() + ), + build_baby_object_match_background_asset(http_client, settings, background_prompt.as_str()), + )?; + + Ok((sheet_assets, background_asset, theme_prompt)) +} + +fn build_baby_object_match_sheet_prompt(item_names: &[String], theme_prompt: &str) -> String { + let item_a = item_names.first().map(String::as_str).unwrap_or_default(); + let item_b = item_names.get(1).map(String::as_str).unwrap_or_default(); + format!( - "为儿童动作 Demo 玩法“宝贝识物”生成物品素材。关键词:{item_name}。\n\ - 风格必须与寓教于乐板块统一:明亮、温暖、卡通绘本质感,适合 4-8 岁儿童,物体边缘清晰,色彩干净,能自然放在草地舞台插画中。\n\ - 画面只允许出现一个围绕关键词“{item_name}”的单一物品主体,不要生成组合物、多个物体、人物、手、篮子、礼物盒或玩法 UI。\n\ - 不要生成背景、场景、氛围渲染、阴影地面、文字、水印、边框或按钮。背景必须是纯白或直接透明,便于服务端做透明抠图。\n\ - 输出为居中完整物品,留少量透明安全边距,最终素材将作为透明 PNG 进入游戏。" + "{theme_prompt}\n\ + 生成一张 1024x1024 的 2x2 游戏素材 sheet,严格均匀分成四格,但画面中不要绘制网格线、文字、标签或编号。\n\ + 四格内容必须固定为:左上格是单一物品“{item_a}”;右上格是单一物品“{item_b}”;左下格是游戏左右两侧复用的大号篮子;右下格是游戏中央使用的大号礼物盒。\n\ + 风格必须与寓教于乐板块统一:明亮、温暖、卡通绘本质感,适合 4-8 岁儿童,边缘清晰,色彩干净,能自然放在同一套游戏场景中。\n\ + 物品格只允许出现围绕对应关键词的单一主体,不能出现组合物、多个物体、人物、手、篮子、礼物盒、文字、水印或 UI。\n\ + 篮子格只生成一个主体饱满、开口清晰、可读性高的大号篮子,不能放入待分类物品,不能出现文字、人物、手或礼物盒,手柄和篮口镂空处不要留下白底描边或毛边。\n\ + 礼物盒格只生成一个主体饱满、中心构图的大号礼物盒,不能出现篮子、待分类物品、人物、手或文字。\n\ + 每格背景必须是统一纯白或接近纯白的干净背景,无场景、无阴影地面、无氛围渲染、无按钮、无边框,便于服务端将背景抠成透明 PNG。\n\ + 每个主体必须完整居中,四周保留安全留白,不得跨格、贴边或越界。" ) } -fn build_baby_object_match_negative_prompt() -> &'static str { - "背景,场景,草地,天空,房间,光效氛围,多个物品,组合套装,人物,手,篮子,礼物盒,包装文字,标签文字,水印,Logo,UI,按钮,边框,真实照片风,复杂投影" +fn build_baby_object_match_sheet_negative_prompt() -> &'static str { + "文字,数字,水印,Logo,网格线,标签,按钮,UI,人物,手,复杂背景,场景,草地,天空,房间,光效氛围,多个物品,组合套装,物体跨格,贴边,越界,真实照片风,复杂投影" } fn with_baby_object_match_image_timeout(mut settings: OpenAiImageSettings) -> OpenAiImageSettings { @@ -211,155 +275,133 @@ fn with_baby_object_match_image_timeout(mut settings: OpenAiImageSettings) -> Op settings } -async fn build_baby_object_match_item_assets( +async fn build_baby_object_match_sheet_assets( http_client: &reqwest::Client, settings: &OpenAiImageSettings, item_names: &[String], -) -> Result, AppError> { - let mut pending = FuturesUnordered::new(); + prompt: &str, +) -> Result { + let asset_started_at = Instant::now(); + tracing::info!("宝贝识物 image-2 2x2 素材 sheet 生成开始"); + let generated = create_openai_image_generation( + http_client, + settings, + prompt, + Some(build_baby_object_match_sheet_negative_prompt()), + BABY_OBJECT_MATCH_IMAGE_SIZE, + 1, + &[], + "宝贝识物 2x2 素材 sheet 生成失败", + ) + .await?; + let generated_image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "宝贝识物 2x2 素材 sheet 生成没有返回图片。", + })) + })?; + let sliced = slice_baby_object_match_sheet(&generated_image)?; + tracing::info!( + elapsed_ms = asset_started_at.elapsed().as_millis() as u64, + "宝贝识物 image-2 2x2 素材 sheet 生成完成" + ); - // 中文注释:两个物品图互不依赖,并发生成可缩短创作等待时间。 - for (index, item_name) in item_names.iter().cloned().enumerate() { - let prompt = build_baby_object_match_item_prompt(item_name.as_str()); - pending.push(async move { - let asset_started_at = Instant::now(); - tracing::info!( - asset_kind = "item", - item_index = index + 1, - item_name = %item_name, - "宝贝识物 image-2 物品资源生成开始" - ); - let generated = create_openai_image_generation( - http_client, - settings, - prompt.as_str(), - Some(build_baby_object_match_negative_prompt()), - BABY_OBJECT_MATCH_IMAGE_SIZE, - 1, - &[], - "宝贝识物物品图片生成失败", - ) - .await?; - let generated_image = generated.images.into_iter().next().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "宝贝识物物品图片生成没有返回图片。", - })) - })?; - let image_src = build_transparent_png_data_url(generated_image)?; - tracing::info!( - asset_kind = "item", - item_index = index + 1, - item_name = %item_name, - elapsed_ms = asset_started_at.elapsed().as_millis() as u64, - "宝贝识物 image-2 物品资源生成完成" - ); - - Ok::<_, AppError>(BabyObjectMatchItemAssetPayload { - item_id: format!("baby-object-item-{}", index + 1), - item_name, - image_src, + Ok(BabyObjectMatchSheetAssets { + items: vec![ + BabyObjectMatchItemAssetPayload { + item_id: "baby-object-item-1".to_string(), + item_name: item_names.first().cloned().unwrap_or_default(), + image_src: sliced + .slot_data_url(BabyObjectMatchSheetSlot::ItemA) + .to_string(), asset_object_id: None, generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(), - prompt, - }) - }); - } - - let mut assets = Vec::with_capacity(item_names.len()); - while let Some(result) = pending.next().await { - assets.push(result?); - } - assets.sort_by_key(|asset| asset.item_id.clone()); - - Ok(assets) + prompt: prompt.to_string(), + }, + BabyObjectMatchItemAssetPayload { + item_id: "baby-object-item-2".to_string(), + item_name: item_names.get(1).cloned().unwrap_or_default(), + image_src: sliced + .slot_data_url(BabyObjectMatchSheetSlot::ItemB) + .to_string(), + asset_object_id: None, + generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(), + prompt: prompt.to_string(), + }, + ], + basket: BabyObjectMatchVisualAssetPayload { + asset_id: BabyObjectMatchVisualAssetKind::Basket + .asset_id() + .to_string(), + asset_kind: BabyObjectMatchVisualAssetKind::Basket + .contract_kind() + .to_string(), + image_src: sliced + .slot_data_url(BabyObjectMatchSheetSlot::Basket) + .to_string(), + asset_object_id: None, + generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(), + prompt: prompt.to_string(), + }, + gift_box: BabyObjectMatchVisualAssetPayload { + asset_id: BabyObjectMatchVisualAssetKind::GiftBox + .asset_id() + .to_string(), + asset_kind: BabyObjectMatchVisualAssetKind::GiftBox + .contract_kind() + .to_string(), + image_src: sliced + .slot_data_url(BabyObjectMatchSheetSlot::GiftBox) + .to_string(), + asset_object_id: None, + generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(), + prompt: prompt.to_string(), + }, + }) } -async fn build_baby_object_match_visual_package( +async fn build_baby_object_match_background_asset( http_client: &reqwest::Client, settings: &OpenAiImageSettings, - item_names: &[String], -) -> Result { - let package_started_at = Instant::now(); - let theme_prompt = build_baby_object_match_visual_theme_prompt(item_names); - let kinds = [ - BabyObjectMatchVisualAssetKind::Background, - BabyObjectMatchVisualAssetKind::UiFrame, - BabyObjectMatchVisualAssetKind::GiftBox, - BabyObjectMatchVisualAssetKind::Basket, - BabyObjectMatchVisualAssetKind::SmokePuff, - ]; - let mut pending = FuturesUnordered::new(); + prompt: &str, +) -> Result { + let asset_started_at = Instant::now(); + let kind = BabyObjectMatchVisualAssetKind::Background; tracing::info!( - asset_count = kinds.len(), - "宝贝识物 image-2 视觉主题包生成开始" + asset_kind = kind.contract_kind(), + "宝贝识物 image-2 场景资源生成开始" + ); + let generated = create_openai_image_generation( + http_client, + settings, + prompt, + Some(build_baby_object_match_visual_negative_prompt(kind)), + BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE, + 1, + &[], + kind.failure_context(), + ) + .await?; + let generated_image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": format!("{}:VectorEngine 没有返回图片。", kind.failure_context()), + })) + })?; + let image_src = build_png_data_url(generated_image)?; + tracing::info!( + asset_kind = kind.contract_kind(), + elapsed_ms = asset_started_at.elapsed().as_millis() as u64, + "宝贝识物 image-2 场景资源生成完成" ); - for kind in kinds.iter().copied() { - let prompt = build_baby_object_match_visual_asset_prompt(kind, item_names, &theme_prompt); - pending.push(async move { - let asset_started_at = Instant::now(); - let asset_kind = kind.contract_kind(); - tracing::info!(asset_kind, "宝贝识物 image-2 视觉资源生成开始"); - let generated = create_openai_image_generation( - http_client, - settings, - prompt.as_str(), - Some(build_baby_object_match_visual_negative_prompt(kind)), - kind.image_size(), - 1, - &[], - kind.failure_context(), - ) - .await?; - let generated_image = generated.images.into_iter().next().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": format!("{}:VectorEngine 没有返回图片。", kind.failure_context()), - })) - })?; - let image_src = if kind.requires_transparency() { - build_transparent_png_data_url(generated_image)? - } else { - build_png_data_url(generated_image)? - }; - tracing::info!( - asset_kind, - elapsed_ms = asset_started_at.elapsed().as_millis() as u64, - "宝贝识物 image-2 视觉资源生成完成" - ); - - Ok::<_, AppError>(BabyObjectMatchVisualAssetPayload { - asset_id: kind.asset_id().to_string(), - asset_kind: asset_kind.to_string(), - image_src, - asset_object_id: None, - generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(), - prompt, - }) - }); - } - - let mut assets = Vec::with_capacity(kinds.len()); - while let Some(result) = pending.next().await { - assets.push(result?); - } - assets.sort_by_key(|asset| match asset.asset_kind.as_str() { - "background" => 0, - "ui-frame" => 1, - "gift-box" => 2, - "basket" => 3, - "smoke-puff" => 4, - _ => 5, - }); - tracing::info!( - elapsed_ms = package_started_at.elapsed().as_millis() as u64, - "宝贝识物 image-2 视觉主题包生成完成" - ); - - Ok(BabyObjectMatchVisualPackagePayload { - theme_prompt, - assets, + Ok(BabyObjectMatchVisualAssetPayload { + asset_id: kind.asset_id().to_string(), + asset_kind: kind.contract_kind().to_string(), + image_src, + asset_object_id: None, + generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(), + prompt: prompt.to_string(), }) } @@ -453,11 +495,6 @@ fn build_baby_object_match_visual_asset_prompt( 生成游戏背景环境图。背景需要根据关键词主题匹配环境,例如水果可偏果园自然,动漫角色或玩具可偏动漫玩具主题。\n\ 保持中间、屏幕中下方和底部左右篮子区域清爽,给放大后的礼物盒、中央物品和左右大篮子预留足够空间,不能画入礼物盒、篮子、物品、人物、文字或操作 UI。" ), - BabyObjectMatchVisualAssetKind::UiFrame => format!( - "{base}\n\ - 生成透明 PNG 的 UI 装饰框资源,用于字幕条和计数器的风格化包装。\n\ - 只生成柔和装饰边框、贴纸感边缘和少量主题点缀,不生成任何文字、数字、按钮、图标说明或大面积背景。背景需要纯白或透明友好,便于抠图。" - ), BabyObjectMatchVisualAssetKind::GiftBox => format!( "{base}\n\ 生成透明 PNG 的大号礼物盒资源。礼物盒会在游戏中以约 2 倍视觉尺寸展示,需要主体饱满、轮廓清晰、中心构图、边缘安全留白少,打开动画时可被烟雾遮罩后移除。\n\ @@ -466,12 +503,7 @@ fn build_baby_object_match_visual_asset_prompt( BabyObjectMatchVisualAssetKind::Basket => format!( "{base}\n\ 生成透明 PNG 的大号篮子资源,游戏左右两侧会复用同一个篮子造型并以约 1.5 倍视觉尺寸展示。篮子主体要饱满、开口清晰、可读性高、边缘安全留白少。\n\ - 篮子要与关键词主题匹配,可以有主题色和贴纸感边缘,但不能出现任何文字、礼物盒、人物、手或待分类物品。背景需要纯白或透明友好,便于抠图。" - ), - BabyObjectMatchVisualAssetKind::SmokePuff => format!( - "{base}\n\ - 生成透明 PNG 的烟雾弹出特效资源,用于礼物盒打开瞬间。画面只允许出现一团柔和、圆润、儿童绘本风的云朵烟雾和少量主题色星点,不要生成礼物盒、篮子、物品、人物、手、文字或 UI。\n\ - 烟雾需要中心构图、边缘柔和、透明边界干净,适合覆盖礼物盒打开区域并衬托中央物品弹出。背景需要纯白或透明友好,便于抠图。" + 篮子要与关键词主题匹配,可以有主题色和贴纸感边缘,但不能出现任何文字、礼物盒、人物、手或待分类物品。背景需要纯白或透明友好,便于抠图,手柄和篮口边缘不要留下白底描边或毛边。" ), } } @@ -483,29 +515,372 @@ fn build_baby_object_match_visual_negative_prompt( BabyObjectMatchVisualAssetKind::Background => { "文字,数字,水印,Logo,按钮,说明面板,人物,手,礼物盒,篮子,中心物品,复杂前景遮挡,真实照片风,暗黑风" } - BabyObjectMatchVisualAssetKind::UiFrame => { - "文字,数字,水印,Logo,按钮,复杂面板,大面积实心背景,人物,手,礼物盒,篮子,物品主体,真实照片风" - } BabyObjectMatchVisualAssetKind::GiftBox => { "文字,数字,水印,Logo,人物,手,篮子,待分类物品,大面积背景,场景,真实照片风" } BabyObjectMatchVisualAssetKind::Basket => { "文字,数字,水印,Logo,人物,手,礼物盒,待分类物品,大面积背景,场景,真实照片风" } - BabyObjectMatchVisualAssetKind::SmokePuff => { - "文字,数字,水印,Logo,人物,手,礼物盒,篮子,待分类物品,大面积背景,场景,真实照片风,硬边爆炸,火焰" + } +} + +struct BabyObjectMatchSlicedSheet { + item_a: String, + item_b: String, + basket: String, + gift_box: String, +} + +impl BabyObjectMatchSlicedSheet { + fn slot_data_url(&self, slot: BabyObjectMatchSheetSlot) -> &str { + match slot { + BabyObjectMatchSheetSlot::ItemA => self.item_a.as_str(), + BabyObjectMatchSheetSlot::ItemB => self.item_b.as_str(), + BabyObjectMatchSheetSlot::Basket => self.basket.as_str(), + BabyObjectMatchSheetSlot::GiftBox => self.gift_box.as_str(), } } } -fn build_transparent_png_data_url(image: DownloadedOpenAiImage) -> Result { +fn slice_baby_object_match_sheet( + image: &DownloadedOpenAiImage, +) -> Result { let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?; - let transparent_png_bytes = - try_apply_background_alpha_to_png(png_bytes.as_slice()).unwrap_or(png_bytes); - Ok(format!( - "data:image/png;base64,{}", - BASE64_STANDARD.encode(transparent_png_bytes) - )) + let source = image::load_from_memory_with_format(png_bytes.as_slice(), ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": format!("解析宝贝识物 2x2 素材 sheet 失败:{error}"), + })) + })?; + let source = apply_baby_object_match_sheet_background_alpha(source); + let (width, height) = source.dimensions(); + if width < BABY_OBJECT_MATCH_SHEET_GRID_SIZE || height < BABY_OBJECT_MATCH_SHEET_GRID_SIZE { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "宝贝识物 2x2 素材 sheet 尺寸过小,无法切割。", + })), + ); + } + + let mut data_urls = Vec::with_capacity(BabyObjectMatchSheetSlot::ALL.len()); + for slot in BabyObjectMatchSheetSlot::ALL { + let (crop_x, crop_y, crop_width, crop_height) = + resolve_baby_object_match_sheet_cell_crop(&source, slot); + let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height); + data_urls.push(encode_baby_object_match_dynamic_image_data_url( + cropped, + slot == BabyObjectMatchSheetSlot::Basket, + )?); + } + + Ok(BabyObjectMatchSlicedSheet { + item_a: data_urls.remove(0), + item_b: data_urls.remove(0), + basket: data_urls.remove(0), + gift_box: data_urls.remove(0), + }) +} + +fn resolve_baby_object_match_sheet_cell_crop( + source: &image::DynamicImage, + slot: BabyObjectMatchSheetSlot, +) -> (u32, u32, u32, u32) { + let (image_width, image_height) = source.dimensions(); + let cell = resolve_baby_object_match_sheet_cell_bounds( + image_width, + image_height, + slot.row(), + slot.col(), + ); + let Some(foreground) = detect_baby_object_match_sheet_foreground_bounds(source, cell) else { + return cell.to_crop_tuple(); + }; + + let pad_x = (cell.width() / 14).clamp(6, 24); + let pad_y = (cell.height() / 14).clamp(6, 24); + BabyObjectMatchSheetCellBounds { + x0: foreground.x0.saturating_sub(pad_x).max(cell.x0), + y0: foreground.y0.saturating_sub(pad_y).max(cell.y0), + x1: foreground.x1.saturating_add(pad_x).min(cell.x1), + y1: foreground.y1.saturating_add(pad_y).min(cell.y1), + } + .to_crop_tuple() +} + +fn resolve_baby_object_match_sheet_cell_bounds( + image_width: u32, + image_height: u32, + row: u32, + col: u32, +) -> BabyObjectMatchSheetCellBounds { + let cell_x0 = col.saturating_mul(image_width) / BABY_OBJECT_MATCH_SHEET_GRID_SIZE; + let cell_x1 = + (col.saturating_add(1)).saturating_mul(image_width) / BABY_OBJECT_MATCH_SHEET_GRID_SIZE; + let cell_y0 = row.saturating_mul(image_height) / BABY_OBJECT_MATCH_SHEET_GRID_SIZE; + let cell_y1 = + (row.saturating_add(1)).saturating_mul(image_height) / BABY_OBJECT_MATCH_SHEET_GRID_SIZE; + + BabyObjectMatchSheetCellBounds { + x0: cell_x0.min(image_width.saturating_sub(1)), + y0: cell_y0.min(image_height.saturating_sub(1)), + x1: cell_x1.clamp(cell_x0.saturating_add(1), image_width), + y1: cell_y1.clamp(cell_y0.saturating_add(1), image_height), + } +} + +fn detect_baby_object_match_sheet_foreground_bounds( + source: &image::DynamicImage, + cell: BabyObjectMatchSheetCellBounds, +) -> Option { + let mut foreground: Option = None; + let mut foreground_pixels = 0u32; + + for y in cell.y0..cell.y1 { + for x in cell.x0..cell.x1 { + let pixel = source.get_pixel(x, y).0; + if pixel[3] <= 24 { + continue; + } + foreground_pixels = foreground_pixels.saturating_add(1); + foreground = Some(match foreground { + Some(bounds) => BabyObjectMatchSheetCellBounds { + x0: bounds.x0.min(x), + y0: bounds.y0.min(y), + x1: bounds.x1.max(x.saturating_add(1)), + y1: bounds.y1.max(y.saturating_add(1)), + }, + None => BabyObjectMatchSheetCellBounds { + x0: x, + y0: y, + x1: x.saturating_add(1), + y1: y.saturating_add(1), + }, + }); + } + } + + let min_foreground_pixels = (cell.area() / 360).clamp(16, 240); + foreground.filter(|bounds| { + foreground_pixels >= min_foreground_pixels && bounds.width() > 2 && bounds.height() > 2 + }) +} + +fn apply_baby_object_match_sheet_background_alpha( + source: image::DynamicImage, +) -> image::DynamicImage { + let mut image = source.to_rgba8(); + let (width, height) = image.dimensions(); + if width == 0 || height == 0 { + return image::DynamicImage::ImageRgba8(image); + } + + for slot in BabyObjectMatchSheetSlot::ALL { + let cell = + resolve_baby_object_match_sheet_cell_bounds(width, height, slot.row(), slot.col()); + remove_baby_object_match_sheet_cell_background( + image.as_mut(), + width as usize, + height as usize, + cell, + ); + } + + image::DynamicImage::ImageRgba8(image) +} + +fn remove_baby_object_match_sheet_cell_background( + pixels: &mut [u8], + image_width: usize, + image_height: usize, + cell: BabyObjectMatchSheetCellBounds, +) -> bool { + let pixel_count = image_width.saturating_mul(image_height); + if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { + return false; + } + + let background = + sample_baby_object_match_sheet_cell_background(pixels, image_width, image_height, cell); + let mut background_mask = vec![0u8; pixel_count]; + let mut queue = Vec::::new(); + let mut queue_index = 0usize; + let mut changed = false; + + let mut seed_background_pixel = |x: u32, y: u32| { + let pixel_index = y as usize * image_width + x as usize; + if background_mask[pixel_index] != 0 { + return; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_baby_object_match_sheet_background_pixel(pixel, background) { + return; + } + background_mask[pixel_index] = 1; + queue.push(pixel_index); + }; + + for x in cell.x0..cell.x1 { + seed_background_pixel(x, cell.y0); + seed_background_pixel(x, cell.y1.saturating_sub(1)); + } + for y in cell.y0.saturating_add(1)..cell.y1.saturating_sub(1) { + seed_background_pixel(cell.x0, y); + seed_background_pixel(cell.x1.saturating_sub(1), y); + } + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + + let x = (pixel_index % image_width) as u32; + let y = (pixel_index / image_width) as u32; + let neighbors = [ + (x.saturating_sub(1), y, x > cell.x0), + (x.saturating_add(1), y, x + 1 < cell.x1), + (x, y.saturating_sub(1), y > cell.y0), + (x, y.saturating_add(1), y + 1 < cell.y1), + ]; + + for (next_x, next_y, within_cell) in neighbors { + if !within_cell || next_x as usize >= image_width || next_y as usize >= image_height { + continue; + } + let next_pixel_index = next_y as usize * image_width + next_x as usize; + if background_mask[next_pixel_index] != 0 { + continue; + } + let offset = next_pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_baby_object_match_sheet_background_pixel(pixel, background) { + continue; + } + background_mask[next_pixel_index] = 1; + queue.push(next_pixel_index); + } + } + + for y in cell.y0..cell.y1 { + for x in cell.x0..cell.x1 { + let pixel_index = y as usize * image_width + x as usize; + if background_mask[pixel_index] == 0 { + continue; + } + let alpha_offset = pixel_index * 4 + 3; + if pixels[alpha_offset] != 0 { + pixels[alpha_offset] = 0; + changed = true; + } + } + } + + changed +} + +fn sample_baby_object_match_sheet_cell_background( + pixels: &[u8], + image_width: usize, + image_height: usize, + cell: BabyObjectMatchSheetCellBounds, +) -> [u8; 4] { + let sample_size = (cell.width().min(cell.height()) / 12).clamp(2, 8); + let sample_points = [ + (cell.x0, cell.y0), + (cell.x1.saturating_sub(sample_size), cell.y0), + (cell.x0, cell.y1.saturating_sub(sample_size)), + ( + cell.x1.saturating_sub(sample_size), + cell.y1.saturating_sub(sample_size), + ), + ]; + let mut samples = Vec::new(); + + for (start_x, start_y) in sample_points { + let mut totals = [0u32; 4]; + let mut count = 0u32; + for y in start_y..start_y.saturating_add(sample_size).min(cell.y1) { + if y as usize >= image_height { + continue; + } + for x in start_x..start_x.saturating_add(sample_size).min(cell.x1) { + if x as usize >= image_width { + continue; + } + let offset = (y as usize * image_width + x as usize) * 4; + totals[0] = totals[0].saturating_add(pixels[offset] as u32); + totals[1] = totals[1].saturating_add(pixels[offset + 1] as u32); + totals[2] = totals[2].saturating_add(pixels[offset + 2] as u32); + totals[3] = totals[3].saturating_add(pixels[offset + 3] as u32); + count = count.saturating_add(1); + } + } + if count > 0 { + samples.push([ + (totals[0] / count) as u8, + (totals[1] / count) as u8, + (totals[2] / count) as u8, + (totals[3] / count) as u8, + ]); + } + } + + samples + .into_iter() + .min_by_key(|sample| { + let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16; + (sample[3] as u16, u16::MAX.saturating_sub(luminance)) + }) + .unwrap_or([255, 255, 255, 255]) +} + +fn is_baby_object_match_sheet_background_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool { + if pixel[3] <= 32 { + return true; + } + if background[3] <= 32 { + return pixel[3] <= 48; + } + + let color_diff = (pixel[0] as i32 - background[0] as i32).abs() + + (pixel[1] as i32 - background[1] as i32).abs() + + (pixel[2] as i32 - background[2] as i32).abs(); + if color_diff <= 58 { + return true; + } + + let background_luminance = background[0] as u16 + background[1] as u16 + background[2] as u16; + let pixel_luminance = pixel[0] as u16 + pixel[1] as u16 + pixel[2] as u16; + background_luminance >= 720 && pixel_luminance >= 720 && color_diff <= 96 +} + +impl BabyObjectMatchSheetCellBounds { + fn width(self) -> u32 { + self.x1.saturating_sub(self.x0).max(1) + } + + fn height(self) -> u32 { + self.y1.saturating_sub(self.y0).max(1) + } + + fn area(self) -> u32 { + self.width().saturating_mul(self.height()) + } + + fn to_crop_tuple(self) -> (u32, u32, u32, u32) { + (self.x0, self.y0, self.width(), self.height()) + } } fn build_png_data_url(image: DownloadedOpenAiImage) -> Result { @@ -540,6 +915,61 @@ fn normalize_generated_image_to_png(source: &[u8]) -> Result, AppError> Ok(encoded) } +fn encode_baby_object_match_dynamic_image_data_url( + image: image::DynamicImage, + clean_white_edge_matte: bool, +) -> Result { + let mut rgba_image = image.to_rgba8(); + if clean_white_edge_matte { + remove_baby_object_match_basket_white_matte(&mut rgba_image); + } + let (width, height) = rgba_image.dimensions(); + let mut encoded = Vec::new(); + let encoder = PngEncoder::new(&mut encoded); + encoder + .write_image(rgba_image.as_raw(), width, height, ColorType::Rgba8.into()) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": format!("编码宝贝识物 2x2 素材切图失败:{error}"), + })) + })?; + Ok(format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(encoded) + )) +} + +fn remove_baby_object_match_basket_white_matte(image: &mut image::RgbaImage) -> bool { + let mut changed = false; + for pixel in image.pixels_mut() { + let [red, green, blue, alpha] = pixel.0; + if alpha <= 24 { + continue; + } + + let max_channel = red.max(green).max(blue); + let min_channel = red.min(green).min(blue); + let luminance = red as u16 + green as u16 + blue as u16; + let channel_spread = max_channel.saturating_sub(min_channel); + if luminance < 690 || channel_spread > 42 { + continue; + } + + let next_alpha = if luminance >= 735 && channel_spread <= 30 { + 0 + } else { + ((alpha as f32) * 0.18).round() as u8 + }; + if next_alpha != alpha { + pixel.0[3] = next_alpha; + changed = true; + } + } + + changed +} + fn baby_object_match_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } @@ -549,15 +979,21 @@ mod tests { use super::*; #[test] - fn prompt_locks_single_transparent_object_constraints() { - let prompt = build_baby_object_match_item_prompt("苹果"); + fn sheet_prompt_locks_two_by_two_asset_layout() { + let names = vec!["苹果".to_string(), "香蕉".to_string()]; + let theme_prompt = build_baby_object_match_visual_theme_prompt(names.as_slice()); + let prompt = build_baby_object_match_sheet_prompt(names.as_slice(), theme_prompt.as_str()); assert!(prompt.contains("苹果")); + assert!(prompt.contains("香蕉")); + assert!(prompt.contains("2x2")); + assert!(prompt.contains("左上格")); + assert!(prompt.contains("右上格")); + assert!(prompt.contains("左下格")); + assert!(prompt.contains("右下格")); assert!(prompt.contains("卡通绘本")); - assert!(prompt.contains("单一物品")); - assert!(prompt.contains("不要生成背景")); - assert!(prompt.contains("透明 PNG")); - assert!(prompt.contains("纯白或直接透明")); + assert!(prompt.contains("白底描边")); + assert!(prompt.contains("纯白")); } #[test] @@ -622,21 +1058,82 @@ mod tests { } #[test] - fn normalizes_png_to_transparent_png_data_url() { - let mut source = Vec::new(); - let pixels = vec![255u8; 4 * 2 * 2]; - let encoder = PngEncoder::new(&mut source); + fn slices_two_by_two_sheet_into_transparent_asset_data_urls() { + let width = 96; + let height = 96; + let mut sheet = + image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255])); + let slots = [ + (8..40, 8..40, [220, 32, 48, 255]), + (56..88, 8..40, [250, 210, 70, 255]), + (8..40, 56..88, [42, 142, 92, 255]), + (56..88, 56..88, [92, 120, 230, 255]), + ]; + for (xs, ys, color) in slots { + for y in ys { + for x in xs.clone() { + sheet.put_pixel(x, y, image::Rgba(color)); + } + } + } + let mut bytes = Vec::new(); + let encoder = PngEncoder::new(&mut bytes); encoder - .write_image(pixels.as_slice(), 2, 2, ColorType::Rgba8.into()) - .expect("test png should encode"); + .write_image(sheet.as_raw(), width, height, ColorType::Rgba8.into()) + .expect("test sheet should encode"); - let image_src = build_transparent_png_data_url(DownloadedOpenAiImage { - bytes: source, + let sliced = slice_baby_object_match_sheet(&DownloadedOpenAiImage { + bytes, mime_type: "image/png".to_string(), extension: "png".to_string(), }) - .expect("test png should normalize"); + .expect("sheet should slice"); - assert!(image_src.starts_with("data:image/png;base64,")); + for slot in BabyObjectMatchSheetSlot::ALL { + let data_url = sliced.slot_data_url(slot); + assert!(data_url.starts_with("data:image/png;base64,")); + let payload = data_url + .strip_prefix("data:image/png;base64,") + .expect("data url should include png prefix"); + let png_bytes = BASE64_STANDARD + .decode(payload) + .expect("data url should decode"); + let decoded = image::load_from_memory(png_bytes.as_slice()) + .expect("slice should decode") + .to_rgba8(); + assert!( + decoded.pixels().any(|pixel| pixel[3] == 0), + "sheet white background should become transparent" + ); + assert!( + decoded.pixels().any(|pixel| pixel[3] > 200), + "slice should keep foreground pixels" + ); + } + } + + #[test] + fn basket_white_matte_cleanup_removes_enclosed_white_handle_fill() { + let mut basket = image::RgbaImage::from_pixel(48, 48, image::Rgba([0, 0, 0, 0])); + for y in 12..36 { + for x in 8..40 { + basket.put_pixel(x, y, image::Rgba([186, 92, 24, 255])); + } + } + for y in 4..14 { + for x in 12..20 { + basket.put_pixel(x, y, image::Rgba([252, 251, 246, 255])); + } + } + for y in 4..14 { + for x in 28..36 { + basket.put_pixel(x, y, image::Rgba([249, 248, 242, 255])); + } + } + + assert!(remove_baby_object_match_basket_white_matte(&mut basket)); + assert_eq!(basket.get_pixel(16, 8).0[3], 0); + assert_eq!(basket.get_pixel(32, 8).0[3], 0); + assert_eq!(basket.get_pixel(20, 20).0[3], 255); } } diff --git a/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx b/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx index f545f117..110b67e3 100644 --- a/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx +++ b/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx @@ -48,6 +48,8 @@ const mocapMock = vi.hoisted(() => ({ rightShoulder?: { x: number; y: number } | null; leftElbow?: { x: number; y: number } | null; rightElbow?: { x: number; y: number } | null; + leftWrist?: { x: number; y: number } | null; + rightWrist?: { x: number; y: number } | null; }; }, receivedAtMs: 1, @@ -242,16 +244,18 @@ test('renders the warmup stage and starts with the center ring step', () => { render(); expect(screen.getByTestId('child-motion-demo')).toBeTruthy(); - expect(screen.getByText('来到圆圈这里')).toBeTruthy(); + expect(screen.queryByRole('heading', { name: '来到圆圈这里' })).toBeNull(); + expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy(); expect(screen.queryByLabelText('绿色圆环')).toBeNull(); expect(screen.getByText('请横屏体验')).toBeTruthy(); }); -test('shows narration first before revealing the step cue', async () => { +test('shows the first subtitle before revealing the step cue', async () => { vi.useFakeTimers(); render(); - expect(screen.getByText('来到圆圈这里')).toBeTruthy(); + expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy(); + expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull(); expect(screen.queryByLabelText('绿色圆环')).toBeNull(); expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('intro'); @@ -261,6 +265,25 @@ test('shows narration first before revealing the step cue', async () => { expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('active'); }); +test('switches the center step subtitle to the second line after a two second pause', async () => { + vi.useFakeTimers(); + render(); + + expect(screen.queryByRole('heading', { name: '来到圆圈这里' })).toBeNull(); + expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy(); + expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull(); + + await advanceWarmupTime(1999); + + expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy(); + expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull(); + + await advanceWarmupTime(1); + + expect(screen.queryByText('欢迎你,小朋友,见到你真开心')).toBeNull(); + expect(screen.getByText('来圆圈这里和我打个招呼吧')).toBeTruthy(); +}); + test('re-entering within the same runtime session opens the start button', () => { markChildMotionWarmupCompletedInRuntime(); @@ -299,6 +322,50 @@ test('developer keyboard input moves the avatar and triggers jump state', () => expect(avatar.className).toContain('child-motion-avatar--jumping'); }); +test('developer pointer input renders baby object hand indicators in warmup', async () => { + vi.useFakeTimers(); + render(); + + const stage = screen.getByTestId('child-motion-stage'); + vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + width: 1000, + height: 500, + top: 0, + right: 1000, + bottom: 500, + left: 0, + toJSON: () => ({}), + }); + + await revealCurrentStepCue(); + const pointerDownEvent = new Event('pointerdown', { + bubbles: true, + cancelable: true, + }); + Object.defineProperties(pointerDownEvent, { + button: { value: 0 }, + buttons: { value: 1 }, + clientX: { value: 250 }, + clientY: { value: 150 }, + pointerId: { value: 1 }, + }); + await act(async () => { + stage.dispatchEvent(pointerDownEvent); + }); + + const leftHand = screen.getByTestId('child-motion-left-hand-indicator'); + expect(leftHand.className).toContain('baby-object-runtime__hand--left'); + expect(leftHand.getAttribute('style')).toContain( + '--baby-object-hand-x: 25%', + ); + expect(leftHand.getAttribute('style')).toContain( + '--baby-object-hand-y: 30%', + ); + vi.useRealTimers(); +}); + test('mocap body center dampens small jitter before moving the avatar', async () => { setMocapBodyCenter(0.5); const { rerender } = render(); @@ -325,6 +392,68 @@ test('mocap body center dampens small jitter before moving the avatar', async () expect(style).not.toContain('left: 34%'); }); +test('mocap hand positions render with baby object hand indicators in body-side mapping', async () => { + setMocapCameraHandTrackPoint({ cameraSide: 'right', x: 0.24, y: 0.36 }); + const { rerender } = render(); + + await act(async () => { + rerender(); + }); + + const leftHand = await screen.findByTestId( + 'child-motion-left-hand-indicator', + ); + expect(leftHand.className).toContain('baby-object-runtime__hand--left'); + expect(leftHand.getAttribute('style')).toContain( + '--baby-object-hand-x: 24%', + ); + expect(leftHand.getAttribute('style')).toContain( + '--baby-object-hand-y: 36%', + ); + expect(screen.queryByTestId('child-motion-right-hand-indicator')).toBeNull(); +}); + +test('mocap hand indicators prefer skeleton wrist nodes in warmup', async () => { + const cameraRightHand = { + x: 0.24, + y: 0.36, + state: 'unknown', + side: 'right', + wrist: { x: 0.27, y: 0.39 }, + }; + mocapMock.command = { + actions: [], + bodyCenter: { x: 0.5, y: 0.7 }, + bodyJoints: { + leftShoulder: { x: 0.62, y: 0.48 }, + leftElbow: { x: 0.7, y: 0.5 }, + rightShoulder: { x: 0.38, y: 0.48 }, + rightElbow: { x: 0.3, y: 0.5 }, + rightWrist: { x: 0.64, y: 0.25 }, + }, + hands: [cameraRightHand], + primaryHand: cameraRightHand, + leftHand: null, + rightHand: cameraRightHand, + }; + mocapMock.receivedAtMs += 1; + const { rerender } = render(); + + await act(async () => { + rerender(); + }); + + const leftHand = await screen.findByTestId( + 'child-motion-left-hand-indicator', + ); + expect(leftHand.getAttribute('style')).toContain( + '--baby-object-hand-x: 64%', + ); + expect(leftHand.getAttribute('style')).toContain( + '--baby-object-hand-y: 25%', + ); +}); + test('mocap body center keeps the warmup flow on the motion data source', async () => { vi.useFakeTimers(); setMocapBodyCenter(0.5); @@ -440,6 +569,33 @@ test('mocap greeting requires a real horizontal wave track', async () => { vi.useRealTimers(); }); +test('greeting completion goes to warmup intro without praise float text', async () => { + vi.useFakeTimers(); + const { rerender, unmount } = render(); + + await revealCurrentStepCue(); + await completeCurrentPositionStepByHold(); + await vi.waitFor(() => { + expect(screen.getByText('打个招呼')).toBeTruthy(); + }); + + await revealCurrentStepCue(); + await completeGreetingByWaveTrack(rerender); + + expect(screen.queryByText('真棒')).toBeNull(); + + await advanceWarmupTime(900); + await vi.waitFor(() => { + expect(screen.getByText('准备热身')).toBeTruthy(); + }); + expect(screen.queryByText('真棒')).toBeNull(); + + await act(async () => { + unmount(); + }); + vi.useRealTimers(); +}); + test('mocap arm swing steps require body-side mapping and vertical open arm motion', async () => { vi.useFakeTimers(); const { rerender, unmount } = render(); @@ -477,6 +633,14 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti }); await revealCurrentStepCue(); + expect(screen.getByTestId('child-motion-arm-swing-guide-left')).toBeTruthy(); + expect(screen.queryByTestId('child-motion-arm-swing-guide-right')).toBeNull(); + expect( + screen + .getByTestId('child-motion-arm-swing-guide-left') + .querySelector('.child-motion-gesture-guide__arm-swing-paw-asset'), + ).toBeTruthy(); + await sendMocapCameraHandTrack(rerender, 'left', [ { x: 0.78, y: 0.5 }, { x: 0.86, y: 0.5 }, @@ -505,6 +669,14 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti }); await revealCurrentStepCue(); + expect(screen.getByTestId('child-motion-arm-swing-guide-right')).toBeTruthy(); + expect(screen.queryByTestId('child-motion-arm-swing-guide-left')).toBeNull(); + expect( + screen + .getByTestId('child-motion-arm-swing-guide-right') + .querySelector('.child-motion-gesture-guide__arm-swing-paw-asset'), + ).toBeTruthy(); + await sendMocapCameraHandTrack(rerender, 'right', [ { x: 0.2, y: 0.5 }, { x: 0.16, y: 0.42 }, @@ -519,8 +691,9 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti await advanceWarmupTime(900); await vi.waitFor(() => { - expect(screen.getByRole('heading', { name: '原地跳一下' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: '热身完成' })).toBeTruthy(); }); + expect(screen.queryByRole('heading', { name: '原地跳一下' })).toBeNull(); await advanceWarmupTime(720); await act(async () => { unmount(); diff --git a/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx b/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx index 719f805b..a493d4fd 100644 --- a/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx +++ b/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx @@ -1,5 +1,5 @@ import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { @@ -40,9 +40,9 @@ type WarmupStepPhase = 'intro' | 'active' | 'complete'; type WarmupMocapGestureIntent = | 'greeting' | 'left-hand' - | 'right-hand' - | 'jump'; + | 'right-hand'; type WarmupBodyHandSide = 'left' | 'right'; +type WarmupHandIndicators = Record; const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = { draftId: 'child-motion-demo-baby-object-draft', @@ -94,7 +94,9 @@ const WARMUP_GREETING_WAVE_DIRECTION_EPSILON = 0.008; const WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN = 0.04; const WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN = 0.08; const WARMUP_STEP_INTRO_DELAY_MS = 1000; +const WARMUP_SUBTITLE_LINE_DELAY_MS = 2000; const WARMUP_STEP_COMPLETE_PAUSE_MS = 820; +const WARMUP_TOTAL_STEPS = 11; const AVATAR_MOCAP_DEAD_ZONE = 0.012; const AVATAR_MOCAP_SMOOTHING = 0.28; const AVATAR_MOCAP_MAX_STEP = 0.035; @@ -128,6 +130,13 @@ function formatAvatarLeftPercent(value: number) { return `${Math.round(clampMotionUnit(value) * 1000) / 10}%`; } +function createEmptyWarmupHandIndicators(): WarmupHandIndicators { + return { + left: null, + right: null, + }; +} + function resolveMocapHandWithBodySide( command: MocapInputCommand, side: WarmupBodyHandSide, @@ -136,6 +145,29 @@ function resolveMocapHandWithBodySide( return side === 'left' ? command.rightHand : command.leftHand; } +function resolveMocapSkeletonWristWithBodySide( + command: MocapInputCommand, + side: WarmupBodyHandSide, +) { + const joints = command.bodyJoints; + return side === 'left' ? joints?.rightWrist : joints?.leftWrist; +} + +function resolveWarmupHandIndicatorsFromMocap( + command: MocapInputCommand, +): WarmupHandIndicators { + return { + left: mocapHandToWarmupIndicatorPoint( + resolveMocapHandWithBodySide(command, 'left'), + resolveMocapSkeletonWristWithBodySide(command, 'left'), + ), + right: mocapHandToWarmupIndicatorPoint( + resolveMocapHandWithBodySide(command, 'right'), + resolveMocapSkeletonWristWithBodySide(command, 'right'), + ), + }; +} + function resolveMocapJointWithBodySide( command: MocapInputCommand, side: WarmupBodyHandSide, @@ -175,6 +207,22 @@ function mocapHandToChildMotionPoint( }; } +function mocapHandToWarmupIndicatorPoint( + hand: MocapHandInput | null | undefined, + skeletonWrist: MocapPointInput | null | undefined, +): ChildMotionPoint | null { + // 骨架手腕节点比手掌识别结果更稳定;热身指示器优先跟随骨架手腕。 + const point = skeletonWrist ?? hand?.wrist ?? hand; + if (!point) { + return null; + } + + return { + x: clampMotionUnit(point.x), + y: clampMotionUnit(point.y), + }; +} + function appendWarmupMocapPoint( points: ChildMotionPoint[], point: ChildMotionPoint, @@ -218,13 +266,6 @@ function getMotionSourceText(state: MotionSourceState) { return '正在连接动作数据'; } -function hasWarmupMocapAction( - command: MocapInputCommand, - expectedActions: string[], -) { - return command.actions.some((action) => expectedActions.includes(action)); -} - function countWarmupVerticalDirectionChanges(points: ChildMotionPoint[]) { let previousDirection = 0; let directionChanges = 0; @@ -403,7 +444,6 @@ function resolveDampedAvatarX(current: number, target: number) { function resolveWarmupMocapGestureIntent( stepId: ChildMotionWarmupStepId, - command: MocapInputCommand, paths: { leftHandPath: ChildMotionPoint[]; rightHandPath: ChildMotionPoint[]; @@ -434,19 +474,6 @@ function resolveWarmupMocapGestureIntent( return 'right-hand'; } - if ( - stepId === 'jump_once' && - hasWarmupMocapAction(command, [ - 'jump', - 'jump_once', - 'hop', - '跳跃', - '原地跳', - ]) - ) { - return 'jump'; - } - return null; } @@ -475,7 +502,6 @@ function getStepIndex(stepId: ChildMotionWarmupStepId) { 'return_center_2', 'wave_left_hand', 'wave_right_hand', - 'jump_once', 'warmup_finish', 'level_select', ]; @@ -546,11 +572,15 @@ function ChildMotionGestureGuide({ const isLeft = stepId === 'wave_left_hand'; const isRight = stepId === 'wave_right_hand'; const isGreeting = stepId === 'wave_greeting'; - const isJump = stepId === 'jump_once'; const activePath = isLeft ? leftHandPath : isRight ? rightHandPath : []; return ( - ); } @@ -630,11 +693,15 @@ export function ChildMotionWarmupDemo() { const [nowMs, setNowMs] = useState(() => Date.now()); const [leftHandPath, setLeftHandPath] = useState([]); const [rightHandPath, setRightHandPath] = useState([]); + const [handIndicators, setHandIndicators] = useState( + createEmptyWarmupHandIndicators, + ); const [activeHand, setActiveHand] = useState(null); const [isJumping, setIsJumping] = useState(false); const [justCompletedText, setJustCompletedText] = useState( null, ); + const [subtitleLineIndex, setSubtitleLineIndex] = useState(0); const [cameraAccessState, setCameraAccessState] = useState( () => typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia @@ -657,7 +724,9 @@ export function ChildMotionWarmupDemo() { step.kind === 'finish', }); const stepIndex = getStepIndex(stepId); - const progressPercent = Math.round((stepIndex / 12) * 100); + const progressPercent = Math.round( + (stepIndex / (WARMUP_TOTAL_STEPS - 1)) * 100, + ); const holdProgress = getHoldProgress(stepId, avatarX, holdStartedAt, nowMs); const isStepActive = stepPhase === 'active'; const shouldShowStepCues = stepPhase !== 'intro'; @@ -681,12 +750,14 @@ export function ChildMotionWarmupDemo() { ); const nextStep = resolveNextChildMotionWarmupStep(stepId); - if (stepId === 'jump_once') { + if (stepId === 'warmup_finish') { markChildMotionWarmupCompletedInRuntime(); } const completionText = - stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒'; + stepId === 'wave_greeting' || stepId === 'warmup_finish' + ? null + : '真棒'; setJustCompletedText(completionText); setStepPhase('complete'); setHoldStartedAt(null); @@ -803,6 +874,7 @@ export function ChildMotionWarmupDemo() { setHoldStartedAt(null); setLeftHandPath([]); setRightHandPath([]); + setSubtitleLineIndex(0); handledMocapPacketKeyRef.current = null; if (step.kind === 'levelSelect') { @@ -819,6 +891,20 @@ export function ChildMotionWarmupDemo() { return () => window.clearTimeout(timeout); }, [step.kind, stepId]); + useEffect(() => { + if (step.spokenLines.length <= 1) { + return; + } + + setSubtitleLineIndex(0); + const timeout = window.setTimeout(() => { + setSubtitleLineIndex((current) => + Math.min(current + 1, step.spokenLines.length - 1), + ); + }, WARMUP_SUBTITLE_LINE_DELAY_MS); + return () => window.clearTimeout(timeout); + }, [step.spokenLines, stepId]); + useEffect(() => { if (step.kind !== 'position' || !isStepActive) { return; @@ -925,7 +1011,7 @@ export function ChildMotionWarmupDemo() { setRightHandPath(nextRightHandPath); } - const intent = resolveWarmupMocapGestureIntent(stepId, command, { + const intent = resolveWarmupMocapGestureIntent(stepId, { leftHandPath: nextLeftHandPath, rightHandPath: nextRightHandPath, primaryHandPath: nextPrimaryHandPath, @@ -934,13 +1020,6 @@ export function ChildMotionWarmupDemo() { return; } - if (intent === 'jump') { - setIsJumping(true); - window.setTimeout(() => setIsJumping(false), 360); - completeStep({ type: 'jump', jumpSpace: 0.14 }); - return; - } - if (intent === 'right-hand') { const path = [...nextRightHandPath, rightPoint].filter( (point): point is ChildMotionPoint => Boolean(point), @@ -965,6 +1044,21 @@ export function ChildMotionWarmupDemo() { stepId, ]); + useEffect(() => { + if (stepPhase === 'complete' || !mocapInput.latestCommand) { + return; + } + + setHandIndicators( + resolveWarmupHandIndicatorsFromMocap(mocapInput.latestCommand), + ); + }, [ + mocapInput.latestCommand, + mocapInput.rawPacketPreview?.receivedAtMs, + mocapInput.rawPacketPreview?.text, + stepPhase, + ]); + useEffect(() => { if (stepPhase === 'complete' || !mocapInput.latestCommand) { return; @@ -1008,15 +1102,12 @@ export function ChildMotionWarmupDemo() { event.preventDefault(); setIsJumping(true); window.setTimeout(() => setIsJumping(false), 360); - if (stepId === 'jump_once' && isStepActive) { - completeStep({ type: 'jump', jumpSpace: 0.14 }); - } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [completeStep, isStepActive, stepId, stepPhase]); + }, [stepPhase]); useEffect(() => { const handleKeyUp = (event: KeyboardEvent) => { @@ -1040,20 +1131,29 @@ export function ChildMotionWarmupDemo() { return; } - if (event.button !== 0 && event.button !== 2) { + if ( + event.button !== 0 && + event.button !== 2 && + event.buttons !== 1 && + event.buttons !== 2 + ) { return; } event.preventDefault(); - const nextHand: DragHand = event.button === 2 ? 'right' : 'left'; + const nextHand: DragHand = + event.button === 2 || event.buttons === 2 ? 'right' : 'left'; setActiveHand(nextHand); const point = normalizePointerPoint(event, event.currentTarget); + setHandIndicators((current) => ({ ...current, [nextHand]: point })); if (nextHand === 'left') { setLeftHandPath([point]); } else { setRightHandPath([point]); } - event.currentTarget.setPointerCapture(event.pointerId); + if (typeof event.currentTarget.setPointerCapture === 'function') { + event.currentTarget.setPointerCapture(event.pointerId); + } }; const handleStagePointerMove = (event: ReactPointerEvent) => { @@ -1062,6 +1162,7 @@ export function ChildMotionWarmupDemo() { } const point = normalizePointerPoint(event, event.currentTarget); + setHandIndicators((current) => ({ ...current, [activeHand]: point })); const appendPoint = (points: ChildMotionPoint[]) => [...points, point].slice(-16); if (activeHand === 'left') { @@ -1076,7 +1177,10 @@ export function ChildMotionWarmupDemo() { return; } - if (event.currentTarget.hasPointerCapture(event.pointerId)) { + if ( + typeof event.currentTarget.hasPointerCapture === 'function' && + event.currentTarget.hasPointerCapture(event.pointerId) + ) { event.currentTarget.releasePointerCapture(event.pointerId); } const hand = activeHand; @@ -1110,10 +1214,8 @@ export function ChildMotionWarmupDemo() { setIsBabyObjectRuntimeOpen(true); }; - const lineText = useMemo( - () => step.spokenLines.join(','), - [step.spokenLines], - ); + const shouldHideStepTitle = stepId === 'center_arrive'; + const subtitleText = step.spokenLines[subtitleLineIndex] ?? step.spokenLines[0]; if (isBabyObjectRuntimeOpen) { return ( @@ -1170,6 +1272,7 @@ export function ChildMotionWarmupDemo() { rightHandPath={rightHandPath} /> ) : null} + {justCompletedText ? (
@@ -1178,10 +1281,16 @@ export function ChildMotionWarmupDemo() { ) : null}
- {`${Math.min(stepIndex + 1, 12)}/12`} -
-

{step.title}

-

{lineText}

+ {`${Math.min(stepIndex + 1, WARMUP_TOTAL_STEPS)}/${WARMUP_TOTAL_STEPS}`} +
+ {shouldHideStepTitle ? null :

{step.title}

} +

{subtitleText}

{progressPercent}%
diff --git a/src/components/child-motion-demo/childMotionWarmupModel.test.ts b/src/components/child-motion-demo/childMotionWarmupModel.test.ts index e5193de6..eb52ca20 100644 --- a/src/components/child-motion-demo/childMotionWarmupModel.test.ts +++ b/src/components/child-motion-demo/childMotionWarmupModel.test.ts @@ -22,7 +22,6 @@ describe('childMotionWarmupModel', () => { 'return_center_2', 'wave_left_hand', 'wave_right_hand', - 'jump_once', 'warmup_finish', 'level_select', ]); @@ -76,19 +75,11 @@ describe('childMotionWarmupModel', () => { ], }, ); - const completed = applyChildMotionWarmupCompletion( - 'jump_once', - withRightHand, - { - type: 'jump', - jumpSpace: 0.14, - }, - ); - expect(completed.leftBoundary).toBeCloseTo(0.16); - expect(completed.rightBoundary).toBeCloseTo(0.16); - expect(completed.leftHandPath).toHaveLength(2); - expect(completed.leftHandSpace).toEqual({ + expect(withRightHand.leftBoundary).toBeCloseTo(0.16); + expect(withRightHand.rightBoundary).toBeCloseTo(0.16); + expect(withRightHand.leftHandPath).toHaveLength(2); + expect(withRightHand.leftHandSpace).toEqual({ minX: 0.3, maxX: 0.34, minY: 0.32, @@ -97,7 +88,6 @@ describe('childMotionWarmupModel', () => { maxAngleDeg: 44, maxReach: 0.28, }); - expect(completed.rightHandSpace?.maxReach).toBe(0.31); - expect(completed.jumpSpace).toBe(0.14); + expect(withRightHand.rightHandSpace?.maxReach).toBe(0.31); }); }); diff --git a/src/components/child-motion-demo/childMotionWarmupModel.ts b/src/components/child-motion-demo/childMotionWarmupModel.ts index efcc591f..a54d5d13 100644 --- a/src/components/child-motion-demo/childMotionWarmupModel.ts +++ b/src/components/child-motion-demo/childMotionWarmupModel.ts @@ -8,7 +8,6 @@ export type ChildMotionWarmupStepId = | 'return_center_2' | 'wave_left_hand' | 'wave_right_hand' - | 'jump_once' | 'warmup_finish' | 'level_select'; @@ -55,7 +54,6 @@ export type ChildMotionWarmupCalibration = { rightHandPath: ChildMotionPoint[]; leftHandSpace: ChildMotionHandSpace | null; rightHandSpace: ChildMotionHandSpace | null; - jumpSpace: number | null; }; export type ChildMotionWarmupCompletion = @@ -71,10 +69,6 @@ export type ChildMotionWarmupCompletion = type: 'right-hand'; path: ChildMotionPoint[]; } - | { - type: 'jump'; - jumpSpace: number; - } | { type: 'narration'; }; @@ -92,14 +86,14 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [ id: 'center_arrive', kind: 'position', title: '来到圆圈这里', - spokenLines: ['欢迎你,小朋友,见到你真开心', '请你来到圆圈这里和我打个招呼吧'], + spokenLines: ['欢迎你,小朋友,见到你真开心', '来圆圈这里和我打个招呼吧'], target: 'center', }, { id: 'wave_greeting', kind: 'gesture', title: '打个招呼', - spokenLines: ['请你来到圆圈这里和我打个招呼吧'], + spokenLines: ['来圆圈这里和我打个招呼吧'], }, { id: 'warmup_intro', @@ -147,12 +141,6 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [ title: '挥动右手', spokenLines: ['挥动右手'], }, - { - id: 'jump_once', - kind: 'gesture', - title: '原地跳一下', - spokenLines: ['原地跳一下'], - }, { id: 'warmup_finish', kind: 'finish', @@ -224,7 +212,6 @@ export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibratio rightHandPath: [], leftHandSpace: null, rightHandSpace: null, - jumpSpace: null, }; } @@ -290,13 +277,6 @@ export function applyChildMotionWarmupCompletion( }; } - if (stepId === 'jump_once' && completion.type === 'jump') { - return { - ...calibration, - jumpSpace: completion.jumpSpace, - }; - } - return calibration; } diff --git a/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx b/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx index 8932272e..cef47414 100644 --- a/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx +++ b/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx @@ -94,14 +94,6 @@ function createGeneratedDraft() { generationProvider: 'vector-engine-gpt-image-2', prompt: 'background', }, - { - assetId: 'baby-object-visual-ui-frame', - assetKind: 'ui-frame', - imageSrc: 'data:image/png;base64,ui', - assetObjectId: null, - generationProvider: 'vector-engine-gpt-image-2', - prompt: 'ui', - }, { assetId: 'baby-object-visual-gift-box', assetKind: 'gift-box', @@ -118,14 +110,6 @@ function createGeneratedDraft() { generationProvider: 'vector-engine-gpt-image-2', prompt: 'basket', }, - { - assetId: 'baby-object-visual-smoke-puff', - assetKind: 'smoke-puff', - imageSrc: 'data:image/png;base64,smoke', - assetObjectId: null, - generationProvider: 'vector-engine-gpt-image-2', - prompt: 'smoke', - }, ], }, }); diff --git a/src/components/edutainment-result/BabyObjectMatchResultView.tsx b/src/components/edutainment-result/BabyObjectMatchResultView.tsx index 964617a1..5c3e8bd0 100644 --- a/src/components/edutainment-result/BabyObjectMatchResultView.tsx +++ b/src/components/edutainment-result/BabyObjectMatchResultView.tsx @@ -38,10 +38,8 @@ function normalizeDraftForAction(draft: BabyObjectMatchDraft) { const REQUIRED_VISUAL_ASSET_KINDS = [ 'background', - 'ui-frame', 'gift-box', 'basket', - 'smoke-puff', ] as const; export function BabyObjectMatchResultView({ diff --git a/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx b/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx index 0865c503..7f9150f7 100644 --- a/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx +++ b/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx @@ -157,6 +157,7 @@ function dispatchPointerEvent( options: { pointerId: number; button?: number; + buttons?: number; clientX: number; clientY: number; }, @@ -164,9 +165,10 @@ function dispatchPointerEvent( const event = new Event(type, { bubbles: true, cancelable: true }); Object.assign(event, options); target.dispatchEvent(event); + return event; } -function dragHand(stage: HTMLElement, button: 0 | 2) { +function setStageRect(stage: HTMLElement) { Object.defineProperty(stage, 'getBoundingClientRect', { configurable: true, value: () => ({ @@ -181,34 +183,67 @@ function dragHand(stage: HTMLElement, button: 0 | 2) { toJSON: () => ({}), }), }); +} + +function dragItemWithHand(stage: HTMLElement, button: 0 | 2, targetX: number) { + setStageRect(stage); act(() => { dispatchPointerEvent(stage, 'pointerdown', { pointerId: button + 1, button, - clientX: 20, - clientY: 140, + buttons: button === 2 ? 2 : 1, + clientX: 160, + clientY: 89, }); }); act(() => { dispatchPointerEvent(stage, 'pointermove', { pointerId: button + 1, button, - clientX: 120, - clientY: 140, + buttons: button === 2 ? 2 : 1, + clientX: targetX, + clientY: 190, }); }); act(() => { dispatchPointerEvent(stage, 'pointerup', { pointerId: button + 1, button, - clientX: 120, - clientY: 140, + buttons: 0, + clientX: targetX, + clientY: 190, }); }); } async function advanceRoundIntro() { + await advanceInitialTargetPreview(); + await advanceGiftIntro(); +} + +async function advanceInitialTargetPreview() { + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(720); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(720); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); +} + +async function advanceGiftIntro() { await act(async () => { await vi.advanceTimersByTimeAsync(620); }); @@ -236,6 +271,7 @@ test('shows the first gift item after gift and item animations', async () => { ); expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy(); + expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy(); expect(screen.getByTestId('baby-object-current-item').textContent).toBe(''); await advanceRoundIntro(); @@ -246,6 +282,56 @@ test('shows the first gift item after gift and item animations', async () => { vi.useRealTimers(); }); +test('previews both target items before the first gift box round', async () => { + vi.useFakeTimers(); + render( + , + ); + + expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy(); + expect( + within(screen.getByTestId('baby-object-intro-item')).getByAltText('苹果'), + ).toBeTruthy(); + expect(screen.queryByLabelText('礼物盒')).toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + expect(screen.getByTestId('baby-object-intro-item').className).toContain( + 'baby-object-runtime__intro-item--flying', + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(720); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy(); + expect( + within(screen.getByTestId('baby-object-intro-item')).getByAltText('香蕉'), + ).toBeTruthy(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(720); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(screen.queryByTestId('baby-object-intro-item')).toBeNull(); + expect(screen.getByLabelText('礼物盒')).toBeTruthy(); + vi.useRealTimers(); +}); + test('applies generated visual package to stage, gift box, baskets, smoke and hud', async () => { vi.useFakeTimers(); const { container } = render( @@ -270,6 +356,8 @@ test('applies generated visual package to stage, gift box, baskets, smoke and hu expect(stage.style.getPropertyValue('--baby-object-smoke-image')).toContain( 'smoke', ); + await advanceInitialTargetPreview(); + expect(screen.getByAltText('礼物盒')).toBeTruthy(); expect( container.querySelector('.baby-object-runtime__basket-shell-image'), @@ -283,6 +371,80 @@ test('applies generated visual package to stage, gift box, baskets, smoke and hu vi.useRealTimers(); }); +test('uses default runtime hand indicators instead of per-draft generated hand assets', async () => { + vi.useFakeTimers(); + const random = createRandomSequence([0, 0]); + const draftWithLegacyHandAssets: BabyObjectMatchDraft = { + ...createVisualPackageDraft(), + visualPackage: { + ...createVisualPackageDraft().visualPackage!, + assets: [ + ...createVisualPackageDraft().visualPackage!.assets, + { + assetId: 'legacy-left-hand', + assetKind: 'left-hand', + imageSrc: 'data:image/png;base64,legacy-left-hand', + assetObjectId: null, + generationProvider: 'vector-engine-gpt-image-2', + prompt: '旧左手', + }, + { + assetId: 'legacy-right-hand', + assetKind: 'right-hand', + imageSrc: 'data:image/png;base64,legacy-right-hand', + assetObjectId: null, + generationProvider: 'vector-engine-gpt-image-2', + prompt: '旧右手', + }, + ], + }, + }; + const { container, rerender } = render( + , + ); + + await advanceRoundIntro(); + + rerender( + , + ); + + expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy(); + const stage = container.querySelector('.baby-object-runtime__stage'); + expect(stage).toBeInstanceOf(HTMLElement); + expect( + (stage as HTMLElement).style.getPropertyValue( + '--baby-object-left-hand-image', + ), + ).toBe(''); + expect( + (stage as HTMLElement).style.getPropertyValue( + '--baby-object-right-hand-image', + ), + ).toBe(''); + vi.useRealTimers(); +}); + test('removes the gift box after smoke releases the current item', async () => { vi.useFakeTimers(); render( @@ -292,6 +454,8 @@ test('removes the gift box after smoke releases the current item', async () => { />, ); + await advanceInitialTargetPreview(); + expect(screen.getByLabelText('礼物盒')).toBeTruthy(); await act(async () => { @@ -335,10 +499,16 @@ test('keeps left and right baskets fixed while only the gift item is random', as ).toBeTruthy(); expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy(); expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy(); + expect( + within(screen.getByLabelText('左侧篮子 苹果')).getByText('苹果'), + ).toBeTruthy(); + expect( + within(screen.getByLabelText('右侧篮子 香蕉')).getByText('香蕉'), + ).toBeTruthy(); vi.useRealTimers(); }); -test('mocap camera-right hand movement sends the player left hand item into the left basket', async () => { +test('mocap hand must touch the current item before dropping it into a basket', async () => { vi.useFakeTimers(); const random = createRandomSequence([0, 0]); const { rerender } = render( @@ -358,13 +528,211 @@ test('mocap camera-right hand movement sends the player left hand item into the mocapInput={createMocapInput({ latestCommand: { actions: [], - hands: [{ x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }], - primaryHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }, + hands: [{ x: 0.24, y: 0.72, state: 'open_palm', side: 'right' }], + primaryHand: { x: 0.24, y: 0.72, state: 'open_palm', side: 'right' }, leftHand: null, - rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }, + rightHand: { x: 0.24, y: 0.72, state: 'open_palm', side: 'right' }, }, rawPacketPreview: { - text: 'camera-right-horizontal-1', + text: 'drop-without-grab', + receivedAtMs: 1, + }, + })} + />, + ); + + expect(screen.queryByText('真棒')).toBeNull(); + expect(screen.queryByText('再想一想吧')).toBeNull(); + + rerender( + , + ); + + expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy(); + expect(screen.queryByText('真棒')).toBeNull(); + + rerender( + , + ); + + expect(screen.getByText('真棒')).toBeTruthy(); + expect(screen.getByLabelText('成功次数').textContent).toBe('1/20'); + vi.useRealTimers(); +}); + +test('mocap hand uses skeleton wrist before hand landmark points in baby object runtime', async () => { + vi.useFakeTimers(); + const random = createRandomSequence([0, 0]); + const { rerender } = render( + , + ); + + await advanceRoundIntro(); + + rerender( + , + ); + + expect(screen.queryByTestId('baby-object-left-hand')).toBeTruthy(); + expect(screen.queryByText('真棒')).toBeNull(); + + rerender( + , + ); + + expect(screen.getByTestId('baby-object-left-hand').className).toContain( + 'baby-object-runtime__hand--holding-left-corner', + ); + vi.useRealTimers(); +}); + +test('basket judgement accepts the enlarged basket edge while keeping center gap safe', async () => { + vi.useFakeTimers(); + const random = createRandomSequence([0, 0]); + const { rerender } = render( + , + ); + + await advanceRoundIntro(); + + rerender( + , ); - rerender( - , - ); - expect(screen.queryByText('真棒')).toBeNull(); + expect(screen.queryByText('再想一想吧')).toBeNull(); + expect(screen.getByLabelText('成功次数').textContent).toBe('0/20'); rerender( , @@ -438,7 +788,7 @@ test('mocap camera-right hand movement sends the player left hand item into the vi.useRealTimers(); }); -test('mocap camera-left hand movement sends the player right hand item into the right basket', async () => { +test('either mocap hand can drag the current item into either basket', async () => { vi.useFakeTimers(); const random = createRandomSequence([0, 0]); const { rerender } = render( @@ -458,12 +808,12 @@ test('mocap camera-left hand movement sends the player right hand item into the mocapInput={createMocapInput({ latestCommand: { actions: [], - hands: [{ x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }], - primaryHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }, - leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }, + hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }], + primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }, + leftHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }, rightHand: null, }, - rawPacketPreview: { text: 'camera-left-horizontal-1', receivedAtMs: 1 }, + rawPacketPreview: { text: 'right-hand-touch-item', receivedAtMs: 1 }, })} />, ); @@ -475,48 +825,12 @@ test('mocap camera-left hand movement sends the player right hand item into the mocapInput={createMocapInput({ latestCommand: { actions: [], - hands: [{ x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }], - primaryHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }, - leftHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }, + hands: [{ x: 0.78, y: 0.78, state: 'open_palm', side: 'left' }], + primaryHand: { x: 0.78, y: 0.78, state: 'open_palm', side: 'left' }, + leftHand: { x: 0.78, y: 0.78, state: 'open_palm', side: 'left' }, rightHand: null, }, - rawPacketPreview: { text: 'camera-left-horizontal-2', receivedAtMs: 2 }, - })} - />, - ); - - rerender( - , - ); - - expect(screen.queryByText('再想一想吧')).toBeNull(); - - rerender( - , ); @@ -526,7 +840,84 @@ test('mocap camera-left hand movement sends the player right hand item into the vi.useRealTimers(); }); -test('mocap action names do not select a basket without horizontal hand movement', async () => { +test('holding hand indicator anchors to the lower item corner by hand side', async () => { + vi.useFakeTimers(); + const leftHandRandom = createRandomSequence([0, 0]); + const leftHandRuntime = render( + , + ); + + await advanceRoundIntro(); + + leftHandRuntime.rerender( + , + ); + + expect(screen.getByTestId('baby-object-left-hand').className).toContain( + 'baby-object-runtime__hand--holding-left-corner', + ); + + leftHandRuntime.unmount(); + + const rightHandRandom = createRandomSequence([0, 0]); + const rightHandRuntime = render( + , + ); + + await advanceRoundIntro(); + + rightHandRuntime.rerender( + , + ); + + expect(screen.getByTestId('baby-object-right-hand').className).toContain( + 'baby-object-runtime__hand--holding-right-corner', + ); + rightHandRuntime.unmount(); + vi.useRealTimers(); +}); + +test('mocap action names do not select a basket without touching and dragging item', async () => { vi.useFakeTimers(); const random = createRandomSequence([0, 0]); const { rerender } = render( @@ -564,7 +955,7 @@ test('mocap action names do not select a basket without horizontal hand movement vi.useRealTimers(); }); -test('mocap unknown hand horizontal movement does not select a basket', async () => { +test('mocap unknown hand movement does not grab or select a basket', async () => { vi.useFakeTimers(); const random = createRandomSequence([0, 0]); const { rerender } = render( @@ -578,7 +969,8 @@ test('mocap unknown hand horizontal movement does not select a basket', async () await advanceRoundIntro(); for (let index = 0; index < 4; index += 1) { - const x = [0.22, 0.24, 0.22, 0.31][index] ?? 0.22; + const x = [0.5, 0.5, 0.22, 0.22][index] ?? 0.5; + const y = [0.37, 0.78, 0.78, 0.37][index] ?? 0.37; rerender( { +test('left mouse hand drags a correct item into the left basket', async () => { vi.useFakeTimers(); const { container } = render( { throw new Error('Missing baby object runtime stage'); } - dragHand(stage, 0); + dragItemWithHand(stage, 0, 70); expect(screen.queryByText('真棒')).toBeNull(); expect(screen.getByLabelText('成功次数').textContent).toBe('0/20'); await advanceRoundIntro(); - dragHand(stage, 0); + dragItemWithHand(stage, 0, 70); expect(screen.getByText('真棒')).toBeTruthy(); expect(screen.getByLabelText('成功次数').textContent).toBe('1/20'); vi.useRealTimers(); }); +test('keeps the back button outside active gameplay pointer input', async () => { + vi.useFakeTimers(); + const onBack = vi.fn(); + render( + , + ); + + await advanceRoundIntro(); + + const backButton = screen.getByRole('button', { name: '返回' }); + let pointerDownEvent!: Event; + act(() => { + pointerDownEvent = dispatchPointerEvent(backButton, 'pointerdown', { + pointerId: 9, + button: 0, + buttons: 1, + clientX: 16, + clientY: 16, + }); + }); + + expect(pointerDownEvent.defaultPrevented).toBe(false); + expect(screen.queryByTestId('baby-object-left-hand')).toBeNull(); + + act(() => { + backButton.click(); + }); + + expect(onBack).toHaveBeenCalledTimes(1); + vi.useRealTimers(); +}); + test('correct placement automatically shows the next gift item', async () => { vi.useFakeTimers(); const { container } = render( @@ -691,7 +1119,7 @@ test('correct placement automatically shows the next gift item', async () => { within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'), ).toBeTruthy(); - dragHand(stage, 0); + dragItemWithHand(stage, 0, 70); expect(screen.getByText('真棒')).toBeTruthy(); @@ -722,7 +1150,7 @@ test('wrong basket keeps the item active after feedback', async () => { } await advanceRoundIntro(); - dragHand(stage, 2); + dragItemWithHand(stage, 2, 250); expect(screen.getByText('再想一想吧')).toBeTruthy(); expect(screen.getByLabelText('成功次数').textContent).toBe('0/20'); @@ -752,7 +1180,7 @@ test('twenty correct placements completes the level', async () => { for (let index = 0; index < 20; index += 1) { await advanceRoundIntro(); - dragHand(stage, 0); + dragItemWithHand(stage, 0, 70); await advanceFeedback(); } diff --git a/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx b/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx index 4dffe18b..41409be1 100644 --- a/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx +++ b/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx @@ -33,8 +33,15 @@ const BABY_OBJECT_MATCH_GIFT_APPEAR_DURATION_MS = 620; const BABY_OBJECT_MATCH_GIFT_OPEN_DURATION_MS = 640; const BABY_OBJECT_MATCH_ITEM_APPEAR_DURATION_MS = 620; const BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS = 1180; -const BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE = 0.05; -const BABY_OBJECT_MATCH_HAND_PATH_LIMIT = 16; +const BABY_OBJECT_MATCH_INTRO_SHOW_DURATION_MS = 2000; +const BABY_OBJECT_MATCH_INTRO_FLY_DURATION_MS = 720; +const BABY_OBJECT_MATCH_INTRO_READY_PAUSE_MS = 1000; +const BABY_OBJECT_MATCH_ITEM_CENTER: RuntimeHandPoint = { x: 0.5, y: 0.37 }; +const BABY_OBJECT_MATCH_ITEM_GRAB_RADIUS = 0.14; +// 篮子仍只认主体附近,但在上一版核心区基础上扩大约 50%,避免贴近篮子后仍难以命中。 +const BABY_OBJECT_MATCH_BASKET_DROP_Y = 0.62; +const BABY_OBJECT_MATCH_LEFT_BASKET_MAX_X = 0.36; +const BABY_OBJECT_MATCH_RIGHT_BASKET_MIN_X = 0.64; type BabyObjectMatchRuntimeShellProps = { draft: BabyObjectMatchDraft; @@ -48,6 +55,12 @@ type BabyObjectMatchRuntimeShellProps = { type BasketSide = 'left' | 'right'; type RuntimePhase = + | 'intro-left-showing' + | 'intro-left-flying' + | 'intro-left-ready' + | 'intro-right-showing' + | 'intro-right-flying' + | 'intro-right-ready' | 'gift-entering' | 'gift-opening' | 'item-appearing' @@ -61,10 +74,10 @@ type RuntimeRound = { baskets: Record; }; -type DragState = { +type RuntimeIntroShowcase = { side: BasketSide; - startX: number; - lastX: number; + item: BabyObjectMatchItemAsset; + isFlying: boolean; }; type RuntimeHandPoint = { @@ -72,9 +85,12 @@ type RuntimeHandPoint = { y: number; }; -type RuntimeMocapHandPaths = { - left: RuntimeHandPoint[]; - right: RuntimeHandPoint[]; +type RuntimeHandRole = 'left' | 'right'; + +type RuntimeHands = Record; + +type HeldItemState = { + hand: RuntimeHandRole; }; type BabyObjectMatchRandom = () => number; @@ -113,74 +129,72 @@ function buildRuntimeRound( }; } -function isHorizontalDrag(dragState: DragState) { - return ( - Math.abs(dragState.lastX - dragState.startX) >= - BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE - ); -} - function mocapHandToRuntimePoint( hand: MocapHandInput | null | undefined, + skeletonWrist: RuntimeHandPoint | null | undefined, ): RuntimeHandPoint | null { + if (skeletonWrist) { + return clampRuntimePoint(skeletonWrist); + } + if (!hand) { return null; } - return { x: hand.x, y: hand.y }; + // 骨架 wrist 缺失时再回退到手部 landmarks 的 wrist,最后才使用手部派生点。 + const point = hand.wrist ?? hand; + return clampRuntimePoint({ x: point.x, y: point.y }); } -function appendRuntimeHandPoint( - points: RuntimeHandPoint[], - point: RuntimeHandPoint, -) { - return [...points, point].slice(-BABY_OBJECT_MATCH_HAND_PATH_LIMIT); +function clampRuntimePoint(point: RuntimeHandPoint): RuntimeHandPoint { + return { + x: Math.max(0, Math.min(1, point.x)), + y: Math.max(0, Math.min(1, point.y)), + }; } -function hasRuntimeHorizontalMovePath(points: RuntimeHandPoint[]) { - if (points.length < 3) { - return false; - } +function isRuntimePointTouchingItem(point: RuntimeHandPoint) { + const dx = point.x - BABY_OBJECT_MATCH_ITEM_CENTER.x; + const dy = point.y - BABY_OBJECT_MATCH_ITEM_CENTER.y; + return Math.sqrt(dx * dx + dy * dy) <= BABY_OBJECT_MATCH_ITEM_GRAB_RADIUS; +} - const xValues = points.map((point) => point.x); +function isRuntimeControlPointerTarget(target: EventTarget | null) { return ( - Math.max(...xValues) - Math.min(...xValues) >= - BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE + target instanceof Element && + target.closest( + 'button, a, input, select, textarea, [role="button"], [data-baby-object-runtime-control="true"]', + ) !== null ); } -function resolveMocapHandPaths( - command: MocapInputCommand, - currentPaths: RuntimeMocapHandPaths, -) { - // 本地 mocap 当前按摄像头视角输出 handedness,这里换回用户身体视角再选篮。 - const leftPoint = mocapHandToRuntimePoint(command.rightHand); - const rightPoint = mocapHandToRuntimePoint(command.leftHand); - - return { - left: leftPoint - ? appendRuntimeHandPoint(currentPaths.left, leftPoint) - : currentPaths.left, - right: rightPoint - ? appendRuntimeHandPoint(currentPaths.right, rightPoint) - : currentPaths.right, - } satisfies RuntimeMocapHandPaths; -} - -function resolveMocapHorizontalMoveSide( - paths: RuntimeMocapHandPaths, -): BasketSide | null { - if (hasRuntimeHorizontalMovePath(paths.left)) { +function resolveBasketSideForPoint(point: RuntimeHandPoint): BasketSide | null { + if (point.y < BABY_OBJECT_MATCH_BASKET_DROP_Y) { + return null; + } + if (point.x <= BABY_OBJECT_MATCH_LEFT_BASKET_MAX_X) { return 'left'; } - - if (hasRuntimeHorizontalMovePath(paths.right)) { + if (point.x >= BABY_OBJECT_MATCH_RIGHT_BASKET_MIN_X) { return 'right'; } - return null; } +function resolveMocapRuntimeHands(command: MocapInputCommand): RuntimeHands { + // 本地 mocap 当前按摄像头视角输出 handedness,这里换回用户身体视角用于显示双手。 + return { + left: mocapHandToRuntimePoint( + command.rightHand, + command.bodyJoints?.rightWrist, + ), + right: mocapHandToRuntimePoint( + command.leftHand, + command.bodyJoints?.leftWrist, + ), + }; +} + function buildMocapPacketKey( command: MocapInputCommand, rawPacketPreview: UseMocapInputResult['rawPacketPreview'], @@ -204,6 +218,44 @@ function buildCssImageValue(src: string) { return `url("${src.replace(/"/gu, '\\"')}")`; } +function resolveIntroShowcase( + phase: RuntimePhase, + draft: BabyObjectMatchDraft, +): RuntimeIntroShowcase | null { + if (phase === 'intro-left-showing' || phase === 'intro-left-flying') { + const item = draft.itemAssets[0]; + return item + ? { side: 'left', item, isFlying: phase === 'intro-left-flying' } + : null; + } + + if (phase === 'intro-right-showing' || phase === 'intro-right-flying') { + const item = draft.itemAssets[1]; + return item + ? { side: 'right', item, isFlying: phase === 'intro-right-flying' } + : null; + } + + return null; +} + +function isBasketOptionReadyInIntro(side: BasketSide, phase: RuntimePhase) { + if (!phase.startsWith('intro-')) { + return true; + } + + if (side === 'left') { + return ( + phase === 'intro-left-ready' || + phase === 'intro-right-showing' || + phase === 'intro-right-flying' || + phase === 'intro-right-ready' + ); + } + + return phase === 'intro-right-ready'; +} + export function BabyObjectMatchRuntimeShell({ draft, embedded = false, @@ -218,20 +270,20 @@ export function BabyObjectMatchRuntimeShell({ ); const introTimerRef = useRef(null); const feedbackTimerRef = useRef(null); - const dragStateRef = useRef(null); const handledMocapPacketKeyRef = useRef(null); const latestMocapPacketKeyRef = useRef(null); - const mocapHandPathsRef = useRef({ - left: [], - right: [], - }); - const [phase, setPhase] = useState('gift-entering'); + const [phase, setPhase] = useState('intro-left-showing'); const [successCount, setSuccessCount] = useState(0); const [round, setRound] = useState(() => buildRuntimeRound(draft, randomRef.current), ); const [feedbackText, setFeedbackText] = useState(null); const [lastTargetSide, setLastTargetSide] = useState(null); + const [runtimeHands, setRuntimeHands] = useState({ + left: null, + right: null, + }); + const [heldItem, setHeldItem] = useState(null); const liveMocapInput = useMocapInput({ enabled: enableMocapInput && !mocapInput, }); @@ -276,6 +328,8 @@ export function BabyObjectMatchRuntimeShell({ const isComplete = phase === 'complete'; const currentItem = round?.item ?? null; const isJudgementOpen = phase === 'active'; + const introShowcase = resolveIntroShowcase(phase, draft); + const heldPoint = heldItem ? runtimeHands[heldItem.hand] : null; const shouldShowCurrentItem = currentItem && (phase === 'item-appearing' || @@ -314,14 +368,61 @@ export function BabyObjectMatchRuntimeShell({ }, []); const resetInputPaths = useCallback(() => { - dragStateRef.current = null; handledMocapPacketKeyRef.current = null; - mocapHandPathsRef.current = { left: [], right: [] }; + setHeldItem(null); }, []); useEffect(() => { clearIntroTimer(); + if (phase === 'intro-left-showing') { + introTimerRef.current = window.setTimeout(() => { + introTimerRef.current = null; + setPhase('intro-left-flying'); + }, BABY_OBJECT_MATCH_INTRO_SHOW_DURATION_MS); + return clearIntroTimer; + } + + if (phase === 'intro-left-flying') { + introTimerRef.current = window.setTimeout(() => { + introTimerRef.current = null; + setPhase('intro-left-ready'); + }, BABY_OBJECT_MATCH_INTRO_FLY_DURATION_MS); + return clearIntroTimer; + } + + if (phase === 'intro-left-ready') { + introTimerRef.current = window.setTimeout(() => { + introTimerRef.current = null; + setPhase('intro-right-showing'); + }, BABY_OBJECT_MATCH_INTRO_READY_PAUSE_MS); + return clearIntroTimer; + } + + if (phase === 'intro-right-showing') { + introTimerRef.current = window.setTimeout(() => { + introTimerRef.current = null; + setPhase('intro-right-flying'); + }, BABY_OBJECT_MATCH_INTRO_SHOW_DURATION_MS); + return clearIntroTimer; + } + + if (phase === 'intro-right-flying') { + introTimerRef.current = window.setTimeout(() => { + introTimerRef.current = null; + setPhase('intro-right-ready'); + }, BABY_OBJECT_MATCH_INTRO_FLY_DURATION_MS); + return clearIntroTimer; + } + + if (phase === 'intro-right-ready') { + introTimerRef.current = window.setTimeout(() => { + introTimerRef.current = null; + setPhase('gift-entering'); + }, BABY_OBJECT_MATCH_INTRO_READY_PAUSE_MS); + return clearIntroTimer; + } + if (phase === 'gift-entering') { introTimerRef.current = window.setTimeout(() => { introTimerRef.current = null; @@ -359,7 +460,7 @@ export function BabyObjectMatchRuntimeShell({ setRound(buildRuntimeRound(draft, randomRef.current)); setFeedbackText(null); setLastTargetSide(null); - setPhase('gift-entering'); + setPhase('intro-left-showing'); }, [clearFeedbackTimer, clearIntroTimer, draft, resetInputPaths]); const finishFeedback = useCallback( @@ -440,20 +541,38 @@ export function BabyObjectMatchRuntimeShell({ } handledMocapPacketKeyRef.current = packetKey; + const nextHands = resolveMocapRuntimeHands(command); + setRuntimeHands(nextHands); + if (!isJudgementOpen) { resetInputPaths(); return; } - const nextPaths = resolveMocapHandPaths(command, mocapHandPathsRef.current); - mocapHandPathsRef.current = nextPaths; - - const targetSide = resolveMocapHorizontalMoveSide(nextPaths); - if (targetSide) { + const currentHeldItem = heldItem; + if (currentHeldItem) { + const heldHandPoint = nextHands[currentHeldItem.hand]; + const targetSide = heldHandPoint + ? resolveBasketSideForPoint(heldHandPoint) + : null; + if (!targetSide) { + return; + } sendItemToBasket(targetSide); resetInputPaths(); + return; + } + + for (const hand of ['left', 'right'] as const) { + const point = nextHands[hand]; + if (!point || !isRuntimePointTouchingItem(point)) { + continue; + } + setHeldItem({ hand }); + return; } }, [ + heldItem, isComplete, isJudgementOpen, resetInputPaths, @@ -462,16 +581,24 @@ export function BabyObjectMatchRuntimeShell({ sendItemToBasket, ]); - const getPointerUnitX = ( + const getPointerUnitPoint = ( event: ReactPointerEvent, element: HTMLElement, - ) => { + ): RuntimeHandPoint => { const rect = element.getBoundingClientRect(); const width = rect.width || 1; - return Math.max(0, Math.min(1, (event.clientX - rect.left) / width)); + const height = rect.height || 1; + return clampRuntimePoint({ + x: (event.clientX - rect.left) / width, + y: (event.clientY - rect.top) / height, + }); }; const handlePointerDown = (event: ReactPointerEvent) => { + if (isRuntimeControlPointerTarget(event.target)) { + return; + } + if (!isJudgementOpen) { return; } @@ -480,13 +607,12 @@ export function BabyObjectMatchRuntimeShell({ return; } - const side: BasketSide = event.button === 2 ? 'right' : 'left'; - const pointerX = getPointerUnitX(event, event.currentTarget); - dragStateRef.current = { - side, - startX: pointerX, - lastX: pointerX, - }; + const hand: RuntimeHandRole = event.button === 2 ? 'right' : 'left'; + const point = getPointerUnitPoint(event, event.currentTarget); + setRuntimeHands((current) => ({ ...current, [hand]: point })); + if (isRuntimePointTouchingItem(point)) { + setHeldItem({ hand }); + } event.preventDefault(); if (typeof event.currentTarget.setPointerCapture === 'function') { event.currentTarget.setPointerCapture(event.pointerId); @@ -494,36 +620,44 @@ export function BabyObjectMatchRuntimeShell({ }; const handlePointerMove = (event: ReactPointerEvent) => { + if (isRuntimeControlPointerTarget(event.target)) { + return; + } + if (!isJudgementOpen) { - dragStateRef.current = null; return; } - if (!dragStateRef.current) { + if (event.buttons !== 1 && event.buttons !== 2) { return; } - dragStateRef.current = { - ...dragStateRef.current, - lastX: getPointerUnitX(event, event.currentTarget), - }; + const hand: RuntimeHandRole = event.buttons === 2 ? 'right' : 'left'; + const point = getPointerUnitPoint(event, event.currentTarget); + setRuntimeHands((current) => ({ ...current, [hand]: point })); + if (!heldItem && isRuntimePointTouchingItem(point)) { + setHeldItem({ hand }); + return; + } + + if (!heldItem || heldItem.hand !== hand) { + return; + } + + const targetSide = resolveBasketSideForPoint(point); + if (targetSide) { + sendItemToBasket(targetSide); + resetInputPaths(); + } }; const handlePointerUp = (event: ReactPointerEvent) => { - const dragState = dragStateRef.current; - dragStateRef.current = null; if ( typeof event.currentTarget.hasPointerCapture === 'function' && event.currentTarget.hasPointerCapture(event.pointerId) ) { event.currentTarget.releasePointerCapture(event.pointerId); } - - if (!dragState || !isHorizontalDrag(dragState)) { - return; - } - - sendItemToBasket(dragState.side); }; return ( @@ -556,6 +690,7 @@ export function BabyObjectMatchRuntimeShell({