From 5cc8293380f5d4433006fa82e4043db55b1a4725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Sun, 10 May 2026 23:10:24 +0800 Subject: [PATCH] feat: add child motion picture book stage tooling --- .hermes/shared-memory/decision-log.md | 16 +- .hermes/shared-memory/pitfalls.md | 18 +- ...O_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md | 27 +- package.json | 1 + scripts/generate-child-motion-demo-assets.mjs | 261 ++++++++++++++++++ ...gEntryFlowShell.agent.interaction.test.tsx | 163 +++++------ src/index.css | 229 +++++++++++---- src/services/useMocapInput.test.ts | 43 +++ 8 files changed, 609 insertions(+), 149 deletions(-) create mode 100644 scripts/generate-child-motion-demo-assets.mjs diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b4d0f5fa..ce5249ab 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台 + +- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要预留 image-2 真实背景图的固定接入位。 +- 决策:热身舞台统一采用绘本草地视觉语言,真实背景图默认输出到 `public/child-motion-demo/picture-book-grass-stage.webp`,生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`。在缺少 `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` 应能写出默认背景文件。 +- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。 + ## 2026-05-10 运行态输入设备抽象层全项目通用化 - 背景:拼图运行态接入 mocap 后,鼠标/触控和 mocap 各自维护输入逻辑会导致合并大块、拖拽语义和取消会话行为不一致;后续其他玩法也需要复用体感、摇杆、键盘等设备输入。 @@ -24,12 +32,12 @@ - 验证方式:执行 `npm run test -- src\services\input-devices\runtimeDragInputController.test.ts`、`npm run test -- src\components\puzzle-runtime\PuzzleRuntimeShell.test.tsx`、`npm run typecheck` 和编码检查。 - 关联文档:`docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md`、`docs/technical/PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md`。 -## 2026-05-10 儿童动作热身关直接消费 mocap 手势流 +## 2026-05-10 儿童动作热身关直接消费 mocap 数据源 -- 背景:儿童动作 Demo 的挥手、左右手挥动和跳跃阶段不能只依赖键鼠调试输入,否则真实硬件接入后会出现“能看到画面但动作不推进”的卡点。 -- 决策:热身关在 gesture 阶段直接接入 `useMocapInput`,通过本地 mocap WebSocket `/stream` 消费 `actions/action/gesture/gestures/event/name/type` 动作名,以及 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 手部坐标;`wave_greeting`、`wave_left_hand`、`wave_right_hand` 和 `jump_once` 都可以由 mocap 包推进,同时保留键鼠作为本地调试兜底。 +- 背景:儿童动作 Demo 不能只依赖浏览器摄像头状态和键鼠调试输入,否则真实硬件接入后会出现“mocap 在线但页面提示摄像头不可用”或“能看到画面但动作不推进”的卡点。 +- 决策:热身关全流程直接接入 `useMocapInput`,通过本地 mocap WebSocket `/stream` 消费 `general.body.center_norm` 身体中心、`actions/action/gesture/gestures/event/name/type` 动作名,以及 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 手部坐标;位置步骤由身体中心推进,`wave_greeting`、`wave_left_hand`、`wave_right_hand` 和 `jump_once` 由 mocap 手势/轨迹推进。浏览器摄像头只作为背景层,动作数据源状态优先展示,键鼠仍作为本地调试兜底。 - 影响范围:`src/services/useMocapInput.ts`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx`、对应单测与热身关技术文档。 -- 验证方式:执行 `npx vitest run src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts`、`npx eslint ...`、`npm run typecheck`、`npm run check:encoding`,并确认 `http://127.0.0.1:3000/child-motion-demo` 与 `http://127.0.0.1:3100/healthz` 可访问。 +- 验证方式:执行 `npx vitest run src/services/useMocapInput.test.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts`、`npx eslint ...`、`npm run typecheck`、`npm run check:encoding`,并确认 `http://127.0.0.1:8876/stream` WebSocket 可握手、`http://127.0.0.1:3000/child-motion-demo` 可访问。 - 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。 ## 2026-05-09 GPT-image-2 图片生成统一迁移到 VectorEngine diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index fd6d9a61..edb0fdf5 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -43,14 +43,22 @@ - 验证:提交前检查 `git diff -- .hermes`,确认没有密钥、会话记录或个人路径敏感信息。 - 关联:`.hermes/README.md`。 -## 儿童动作 Demo 挥手阶段不推进先查 mocap 消费链路 +## 儿童动作 Demo 卡在摄像头不可用或挥手不推进先查 mocap 消费链路 -- 现象:`/child-motion-demo` 能打开摄像头画面,但到“打个招呼”或左右手挥动阶段时,真实硬件动作无法检测通过,只能用鼠标拖拽或键盘调试继续。 -- 原因:摄像头视频流只是舞台背景;如果热身关没有消费 `useMocapInput` 的动作名和手部坐标,就不会把硬件动作转换成热身状态机完成事件。 -- 处理:确认 `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 在 `step.kind === 'gesture'` 时启用 `useMocapInput`;确认 `src/services/useMocapInput.ts` 能解析 `/stream` 包里的 `actions/action/gesture/gestures/event/name/type`、`hands[]`、`leftHand/rightHand`、`left_hand/right_hand`、左右手标记和 `open_palm/grab` 状态。热身关应由 mocap 推进,键鼠只作为本地调试兜底。 -- 验证:运行 `npx vitest run src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx`,并在本地硬件服务启动后进入 `/child-motion-demo` 实测招手、左右手挥动和跳跃阶段。 +- 现象:`/child-motion-demo` 打开后即使 `http://127.0.0.1:8876/` 已启动,页面仍提示“摄像头暂不可用”,或到“打个招呼”、左右手挥动、站位步骤时真实硬件动作无法检测通过,只能用鼠标拖拽或键盘调试继续。 +- 原因:浏览器摄像头视频流只是舞台背景;如果热身关把 `getUserMedia` 状态当成主动作数据源,或只在 gesture 阶段消费 `useMocapInput`,就会错过 mocap 的身体中心、动作名和手部坐标。 +- 处理:确认 `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 全热身流程启用 `useMocapInput`,页面主提示展示 mocap 动作数据源状态而不是浏览器摄像头状态;确认 `src/services/useMocapInput.ts` 能解析 `/stream` 包里的 `general.body.center_norm`、`actions/action/gesture/gestures/event/name/type`、`hands[]`、`leftHand/rightHand`、`left_hand/right_hand`、左右手标记和 `open_palm/grab` 状态。`/stream` 是 WebSocket,普通 HTTP 访问返回 404 不能当成服务不可用。 +- 验证:运行 `npx vitest run src\services\useMocapInput.test.ts src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx`,并在本地硬件服务启动后进入 `/child-motion-demo` 实测站位、招手、左右手挥动和跳跃阶段。 - 关联:`src/services/useMocapInput.ts`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。 +## 儿童动作 Demo 真实绘本背景图未生成先查 VectorEngine 配置 + +- 现象:`/child-motion-demo` 已经呈现绘本草地风格,但 `public/child-motion-demo/picture-book-grass-stage.webp` 不存在,Network 里该图返回 404,或运行 `npm run assets:child-motion-demo -- --live` 返回缺少 VectorEngine 配置。 +- 原因:儿童动作 Demo 的真实背景图使用 VectorEngine `gpt-image-2-all` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key,缺配置时页面只能使用 CSS 草地绘本兜底。 +- 处理:在本地私密环境补齐 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai` 与 `VECTOR_ENGINE_API_KEY`,不要把 key 写入 Git;先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt,再运行 `npm run assets:child-motion-demo -- --live` 生成默认背景图。 +- 验证:生成后确认 `public/child-motion-demo/picture-book-grass-stage.webp` 存在,重新打开 `/child-motion-demo` 可看到真实绘本草地背景;`npm run check:encoding` 仍通过。 +- 关联:`scripts/generate-child-motion-demo-assets.mjs`、`src/index.css`、`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/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md index bc4cac9b..e45de8b5 100644 --- 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 @@ -21,7 +21,7 @@ 9. 后续关卡使用热身记录的边界进行安全提醒和暂停恢复。 10. 热身结束后进入关卡选择。 -当前阶段先落浏览器本地 Demo。浏览器摄像头视频流已接入舞台背景;热身动作阶段已接入本地 mocap 动作检测接口,通过 `useMocapInput` 连接 `http://127.0.0.1:8876/stream` 对应的 WebSocket 流,消费手势、左右手坐标和跳跃事件推进招手、左右手挥动与原地跳跃步骤。正式语音播报接口继续预留适配层,不阻塞前端热身流程、调试输入和页面表现骨架落地。 +当前阶段先落浏览器本地 Demo。浏览器摄像头视频流仅作为舞台背景;热身动作检测以本地 mocap 动作数据源为准,通过 `useMocapInput` 连接 `http://127.0.0.1:8876/stream` 对应的 WebSocket 流,消费 `general.body.center_norm` 身体中心、手势、左右手坐标和跳跃事件推进站位、招手、左右手挥动与原地跳跃步骤。正式语音播报接口继续预留适配层,不阻塞前端热身流程、调试输入和页面表现骨架落地。 ## 2. 非目标范围 @@ -657,12 +657,14 @@ 当前硬件和动作检测接口接入: 1. 浏览器摄像头视频流已接入舞台背景。 -2. 热身关手势阶段已通过 `src/services/useMocapInput.ts` 接入本地 mocap WebSocket `/stream`。 -3. mocap 包支持从 `actions/action/gesture/gestures/event/name/type` 读取动作名,并支持 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 读取左右手坐标。 -4. `wave_greeting` 可由 `wave/wave_greeting/hand_wave/open_palm` 等动作或 open palm 手势完成。 -5. `wave_left_hand` 和 `wave_right_hand` 优先消费对应左右手动作名;当硬件只持续输出手部坐标时,也可以根据连续手部横向轨迹完成挥手检测。 -6. `jump_once` 消费 `jump/jump_once/hop` 等跳跃动作事件完成。 -7. 键盘 `A/D/Space` 与鼠标左右键拖拽仍保留为本地 Demo 调试兜底,不代表正式硬件口径。 +2. 热身关全流程已通过 `src/services/useMocapInput.ts` 接入本地 mocap WebSocket `/stream`;动作数据源状态优先于浏览器背景摄像头状态展示。 +3. mocap 包支持从 `general.body.center_norm` 读取身体中心,位置类步骤使用该身体中心更新角色剪影横向位置并完成圆环保持检测。 +4. mocap 包支持从 `actions/action/gesture/gestures/event/name/type` 读取动作名,并支持 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 读取左右手坐标。 +5. `hands[].landmarks` 存在时优先用手腕和 MCP 点计算掌心中心;掌心点不足时退回 wrist landmark,再退回 hand 直出坐标。 +6. `wave_greeting` 可由 `wave/wave_greeting/hand_wave/open_palm` 等动作或 open palm 手势完成。 +7. `wave_left_hand` 和 `wave_right_hand` 优先消费对应左右手动作名;当硬件只持续输出手部坐标时,也可以根据连续手部横向轨迹完成挥手检测。 +8. `jump_once` 消费 `jump/jump_once/hop` 等跳跃动作事件完成。 +9. 键盘 `A/D/Space` 与鼠标左右键拖拽仍保留为本地 Demo 调试兜底,不代表正式硬件口径。 当前未接入但已保留边界: @@ -670,6 +672,17 @@ 2. 正式 gpt-image-2 视觉资源暂不接入,当前使用 CSS 占位表达相同位置和状态。 3. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和下一关按钮占位。 +## 16. 当前视觉资产与生图口径补充 + +儿童动作 Demo 的视觉口径已经统一收敛到绘本风格草地舞台: + +1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。 +2. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格重做,未生成真实背景图时由 CSS 兜底。 +3. 真实背景图的默认输出路径固定为 `public/child-motion-demo/picture-book-grass-stage.webp`。 +4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2-all` 调用 VectorEngine `POST /v1/images/generations`。 +5. 当前本机工作区未检测到 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`,因此暂时只能完成 dry-run 或代码层接入,不能直接产出真实 image-2 资产。 +6. 若后续补齐 VectorEngine 私密配置,再运行 live 生成即可把真实绘本背景写入上述固定路径,页面会自动读取。 + 已执行的定向验证命令: ```bash diff --git a/package.json b/package.json index 2a7fdd57..14208738 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "preview": "node scripts/vite-cli.mjs preview", "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", "check:encoding": "node scripts/check-encoding.mjs", + "assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs", "check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs", "check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs", "check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs", diff --git a/scripts/generate-child-motion-demo-assets.mjs b/scripts/generate-child-motion-demo-assets.mjs new file mode 100644 index 00000000..4d783f3a --- /dev/null +++ b/scripts/generate-child-motion-demo-assets.mjs @@ -0,0 +1,261 @@ +import { Buffer } from 'node:buffer'; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..'); +const defaultOut = path.join( + repoRoot, + 'public', + 'child-motion-demo', + 'picture-book-grass-stage.webp', +); +const defaultSize = '1536x1024'; +const defaultTimeoutMs = 180000; + +const prompt = [ + '请生成一张横版儿童动作互动游戏舞台背景图,卡通绘本风格,温暖明亮。', + '画面下半部分必须是开阔柔软的草地地面,适合叠加半透明角色轮廓和地面圆圈指示环。', + '远处有柔和小山坡、树木、天空和浅色云朵,中心和下方前景保持干净开阔。', + '构图需要适配 16:9 横屏游戏舞台,左右和上下边缘可安全裁切,主体信息不要贴边。', + '风格像儿童绘本插画,柔和笔触,清新色彩,轻微纸张纹理,细节适中,不杂乱。', + '不要出现人物、动物、文字、按钮、UI、边框、水印、摄像头画面、真实照片质感。', +].join(''); + +const args = new Map(); +for (let index = 2; index < process.argv.length; index += 1) { + const raw = process.argv[index]; + if (!raw.startsWith('--')) { + continue; + } + const next = process.argv[index + 1]; + if (next && !next.startsWith('--')) { + args.set(raw, next); + index += 1; + } else { + args.set(raw, true); + } +} + +function readDotenv(fileName) { + const filePath = path.join(repoRoot, fileName); + if (!existsSync(filePath)) { + return {}; + } + + const values = {}; + for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed); + if (!match) { + continue; + } + + let value = match[2].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + values[match[1]] = value; + } + return values; +} + +function resolveEnv() { + const loaded = { + ...readDotenv('.env.example'), + ...readDotenv('.env.local'), + ...readDotenv('.env.secrets.local'), + ...process.env, + }; + return { + baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '') + .trim() + .replace(/\/+$/u, ''), + apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(), + timeoutMs: Number.parseInt( + String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + 10, + ), + }; +} + +function buildVectorEngineImagesGenerationUrl(baseUrl) { + return baseUrl.endsWith('/v1') + ? `${baseUrl}/images/generations` + : `${baseUrl}/v1/images/generations`; +} + +function collectStringsByKey(value, targetKey, output) { + if (Array.isArray(value)) { + value.forEach((entry) => collectStringsByKey(entry, targetKey, output)); + return; + } + if (!value || typeof value !== 'object') { + return; + } + + for (const [key, nested] of Object.entries(value)) { + if (key === targetKey) { + if (typeof nested === 'string' && nested.trim()) { + output.push(nested.trim()); + } + if (Array.isArray(nested)) { + nested.forEach((entry) => { + if (typeof entry === 'string' && entry.trim()) { + output.push(entry.trim()); + } + }); + } + } + collectStringsByKey(nested, targetKey, output); + } +} + +function extractImageUrls(payload) { + const urls = []; + collectStringsByKey(payload, 'url', urls); + collectStringsByKey(payload, 'image', urls); + collectStringsByKey(payload, 'image_url', urls); + return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url)); +} + +function extractBase64Images(payload) { + const values = []; + collectStringsByKey(payload, 'b64_json', values); + return values; +} + +async function fetchWithTimeout(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)}`); + } + return text; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +async function downloadImage(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}`); + } + return Buffer.from(await response.arrayBuffer()); + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`Generated image download timed out after ${timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +const size = String(args.get('--size') || defaultSize); +const outPath = path.resolve(String(args.get('--out') || defaultOut)); +const requestBody = { + model: 'gpt-image-2-all', + prompt, + n: 1, + size, +}; + +if (args.has('--dry-run') || !args.has('--live')) { + console.log( + JSON.stringify( + { + mode: 'dry-run', + outPath, + body: requestBody, + }, + null, + 2, + ), + ); + process.exit(0); +} + +const env = resolveEnv(); +if (!env.baseUrl || !env.apiKey) { + console.error( + JSON.stringify({ + ok: false, + error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', + hasBaseUrl: Boolean(env.baseUrl), + hasApiKey: Boolean(env.apiKey), + }), + ); + process.exit(1); +} + +const payloadText = await fetchWithTimeout( + buildVectorEngineImagesGenerationUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + env.timeoutMs, +); + +const payload = JSON.parse(payloadText); +const urls = extractImageUrls(payload); +const base64Images = extractBase64Images(payload); +const imageBytes = urls[0] + ? await downloadImage(urls[0], env.timeoutMs) + : base64Images[0] + ? Buffer.from(base64Images[0], 'base64') + : null; + +if (!imageBytes) { + throw new Error('VectorEngine returned no image'); +} + +mkdirSync(path.dirname(outPath), { recursive: true }); +writeFileSync(outPath, imageBytes); + +console.log( + JSON.stringify( + { + ok: true, + file: outPath, + size, + source: urls[0] ? 'url' : 'b64_json', + }, + null, + 2, + ), +); diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 7dbfbf15..7f2b8bf6 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -2897,6 +2897,86 @@ test('owned public puzzle detail edits original draft instead of remixing', asyn expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1'); expect(remixPuzzleGalleryWork).not.toHaveBeenCalled(); expect(await screen.findByText('拼图结果页')).toBeTruthy(); + const generatingPuzzleDraft: PuzzleResultDraft = { + workTitle: '暖灯猫街作品', + workDescription: '一套雨夜猫街主题拼图。', + levelName: '雨夜猫街', + summary: '屋檐下的猫与暖灯街角。', + themeTags: ['猫咪', '雨夜', '暖灯'], + forbiddenDirectives: [], + creatorIntent: null, + anchorPack: { + themePromise: { + key: 'theme_promise', + label: '主题承诺', + value: '雨夜猫街', + status: 'confirmed', + }, + visualSubject: { + key: 'visual_subject', + label: '视觉主体', + value: '屋檐下的猫', + status: 'confirmed', + }, + visualMood: { + key: 'visual_mood', + label: '视觉气质', + value: '温暖', + status: 'confirmed', + }, + compositionHooks: { + key: 'composition_hooks', + label: '构图钩子', + value: '雨滴与灯牌', + status: 'confirmed', + }, + tagsAndForbidden: { + key: 'tags_and_forbidden', + label: '标签与禁区', + value: '猫咪、雨夜', + status: 'confirmed', + }, + }, + candidates: [ + { + candidateId: 'candidate-1', + imageSrc: '/puzzle/candidate-1.png', + assetId: 'asset-1', + prompt: '雨夜猫咪', + actualPrompt: null, + sourceType: 'generated', + selected: true, + }, + ], + selectedCandidateId: 'candidate-1', + coverImageSrc: '/puzzle/candidate-1.png', + coverAssetId: 'asset-1', + generationStatus: 'generating', + levels: [ + { + levelId: 'puzzle-level-1', + levelName: '雨夜猫街', + pictureDescription: '屋檐下的猫与暖灯街角。', + pictureReference: null, + candidates: [ + { + candidateId: 'candidate-1', + imageSrc: '/puzzle/candidate-1.png', + assetId: 'asset-1', + prompt: '雨夜猫咪', + actualPrompt: null, + sourceType: 'generated', + selected: true, + }, + ], + selectedCandidateId: 'candidate-1', + coverImageSrc: '/puzzle/candidate-1.png', + coverAssetId: 'asset-1', + generationStatus: 'generating', + }, + ], + metadata: null, + }; vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({ operation: { @@ -2944,92 +3024,13 @@ test('owned public puzzle detail edits original draft instead of remixing', asyn status: 'confirmed', }, }, - draft: { - workTitle: '暖灯猫街作品', - workDescription: '一套雨夜猫街主题拼图。', - levelName: '雨夜猫街', - summary: '屋檐下的猫与暖灯街角。', - themeTags: ['猫咪', '雨夜', '暖灯'], - forbiddenDirectives: [], - creatorIntent: null, - anchorPack: { - themePromise: { - key: 'theme_promise', - label: '主题承诺', - value: '雨夜猫街', - status: 'confirmed', - }, - visualSubject: { - key: 'visual_subject', - label: '视觉主体', - value: '屋檐下的猫', - status: 'confirmed', - }, - visualMood: { - key: 'visual_mood', - label: '视觉气质', - value: '温暖', - status: 'confirmed', - }, - compositionHooks: { - key: 'composition_hooks', - label: '构图钩子', - value: '雨滴与灯牌', - status: 'confirmed', - }, - tagsAndForbidden: { - key: 'tags_and_forbidden', - label: '标签与禁区', - value: '猫咪、雨夜', - status: 'confirmed', - }, - }, - candidates: [ - { - candidateId: 'candidate-1', - imageSrc: '/puzzle/candidate-1.png', - assetId: 'asset-1', - prompt: '雨夜猫咪', - actualPrompt: null, - sourceType: 'generated', - selected: true, - }, - ], - selectedCandidateId: 'candidate-1', - coverImageSrc: '/puzzle/candidate-1.png', - coverAssetId: 'asset-1', - generationStatus: 'generating', - levels: [ - { - levelId: 'puzzle-level-1', - levelName: '雨夜猫街', - pictureDescription: '屋檐下的猫与暖灯街角。', - pictureReference: null, - candidates: [ - { - candidateId: 'candidate-1', - imageSrc: '/puzzle/candidate-1.png', - assetId: 'asset-1', - prompt: '雨夜猫咪', - actualPrompt: null, - sourceType: 'generated', - selected: true, - }, - ], - selectedCandidateId: 'candidate-1', - coverImageSrc: '/puzzle/candidate-1.png', - coverAssetId: 'asset-1', - generationStatus: 'generating', - }, - ], - metadata: null, - }, + draft: generatingPuzzleDraft, messages: [], lastAssistantReply: '大鱼结果页草稿已经生成,可以补正式资产。', publishedProfileId: null, suggestedActions: [], resultPreview: { - draft: null, + draft: generatingPuzzleDraft, publishReady: false, blockers: [], qualityFindings: [], diff --git a/src/index.css b/src/index.css index b4cb4e81..87210724 100644 --- a/src/index.css +++ b/src/index.css @@ -5696,13 +5696,18 @@ button { } .child-motion-demo { - --child-motion-bg: #07151c; - --child-motion-panel: rgba(6, 24, 30, 0.64); - --child-motion-panel-border: rgba(178, 239, 220, 0.25); - --child-motion-text: #eefcf7; - --child-motion-soft: rgba(238, 252, 247, 0.7); - --child-motion-green: #5ff08f; - --child-motion-sky: #8fd8ff; + --child-motion-bg: #dff1d6; + --child-motion-sky: #cfefff; + --child-motion-cloud: rgba(255, 255, 255, 0.82); + --child-motion-ground: #78b76a; + --child-motion-ground-deep: #3b7f46; + --child-motion-ground-shadow: rgba(56, 110, 60, 0.3); + --child-motion-panel: rgba(255, 250, 241, 0.76); + --child-motion-panel-border: rgba(98, 132, 88, 0.18); + --child-motion-text: #27412a; + --child-motion-soft: rgba(39, 65, 42, 0.74); + --child-motion-green: #70c16b; + --child-motion-sky-accent: #95d2ff; display: grid; width: 100%; min-width: 0; @@ -5711,9 +5716,9 @@ button { place-items: center; overflow: hidden; background: - radial-gradient(circle at 18% 14%, rgba(143, 216, 255, 0.24), transparent 32%), - radial-gradient(circle at 82% 22%, rgba(95, 240, 143, 0.18), transparent 30%), - linear-gradient(180deg, #092433 0%, var(--child-motion-bg) 54%, #0a1f18 100%); + radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.92), transparent 18%), + radial-gradient(circle at 82% 18%, rgba(255, 255, 255, 0.56), transparent 17%), + linear-gradient(180deg, #f8fcff 0%, #eaf7ff 26%, var(--child-motion-sky) 52%, #dcefd0 70%, #cde3bd 100%); color: var(--child-motion-text); font-family: Inter, ui-sans-serif, system-ui, sans-serif; } @@ -5725,15 +5730,47 @@ button { } } +.child-motion-demo::before, +.child-motion-demo::after { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + content: ''; +} + +.child-motion-demo::before { + background: + radial-gradient(circle at 12% 16%, var(--child-motion-cloud) 0 3.4%, transparent 3.6%), + radial-gradient(circle at 16% 15%, rgba(255, 255, 255, 0.86) 0 2.2%, transparent 2.5%), + radial-gradient(circle at 17.8% 16.2%, rgba(255, 255, 255, 0.9) 0 2.7%, transparent 3%), + radial-gradient(circle at 76% 13%, var(--child-motion-cloud) 0 4.1%, transparent 4.3%), + radial-gradient(circle at 82% 12.6%, rgba(255, 255, 255, 0.88) 0 2.5%, transparent 2.8%), + radial-gradient(circle at 85% 14.2%, rgba(255, 255, 255, 0.82) 0 2.1%, transparent 2.4%), + linear-gradient(180deg, rgba(255, 255, 255, 0) 0 62%, rgba(255, 255, 255, 0.08) 100%); + opacity: 0.9; +} + +.child-motion-demo::after { + background: + radial-gradient(ellipse at 50% 100%, rgba(61, 120, 76, 0.26) 0 32%, transparent 58%), + linear-gradient(180deg, transparent 0 58%, rgba(255, 255, 255, 0.12) 76%, transparent 100%); + mix-blend-mode: soft-light; + opacity: 0.68; +} + .child-motion-stage { position: relative; + z-index: 1; width: min(100vw, calc(100vh * 16 / 9)); height: min(100vh, calc(100vw * 9 / 16)); overflow: hidden; background: - linear-gradient(180deg, rgba(16, 64, 86, 0.86), rgba(9, 42, 39, 0.9)), - var(--child-motion-bg); - box-shadow: 0 30px 100px rgba(0, 0, 0, 0.38); + linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0)), + radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.6), transparent 24%), + linear-gradient(180deg, #f3fbff 0%, #e4f3ff 32%, #d9efc4 56%, #bbdea1 100%); + box-shadow: 0 30px 100px rgba(62, 98, 53, 0.18); + isolation: isolate; touch-action: none; user-select: none; } @@ -5745,18 +5782,48 @@ button { } } +.child-motion-stage::before, +.child-motion-stage::after { + position: absolute; + inset: 0; + pointer-events: none; + content: ''; +} + +.child-motion-stage::before { + z-index: 0; + background-image: url('/child-motion-demo/picture-book-grass-stage.webp'); + background-position: center center; + background-repeat: no-repeat; + background-size: cover; + opacity: 0.88; + filter: saturate(1.02) contrast(0.98) brightness(1.02); +} + +.child-motion-stage::after { + z-index: 1; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, transparent 18%), + radial-gradient(ellipse at 50% 82%, rgba(255, 245, 220, 0.16), transparent 42%), + linear-gradient(180deg, transparent 0 58%, rgba(80, 141, 72, 0.14) 100%); + opacity: 0.95; +} + .child-motion-camera-layer { position: absolute; inset: 0; + z-index: 1; width: 100%; height: 100%; object-fit: cover; background: - radial-gradient(circle at 50% 33%, rgba(255, 255, 255, 0.12), transparent 28%), - linear-gradient(110deg, rgba(255, 255, 255, 0.06) 0 12%, transparent 12% 20%, rgba(255, 255, 255, 0.04) 20% 31%, transparent 31% 100%); - filter: blur(7px) saturate(0.8); - opacity: 0.62; - transform: scale(1.05); + linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.08)), + radial-gradient(circle at 50% 33%, rgba(255, 255, 255, 0.42), transparent 30%), + linear-gradient(120deg, rgba(255, 255, 255, 0.1) 0 11%, transparent 11% 20%, rgba(255, 255, 255, 0.08) 20% 30%, transparent 30% 100%); + filter: blur(8px) saturate(0.92); + opacity: 0.34; + transform: scale(1.04); + mix-blend-mode: soft-light; } .child-motion-camera-state { @@ -5765,14 +5832,19 @@ button { left: 50%; z-index: 7; transform: translateX(-50%); - border: 1px solid rgba(238, 252, 247, 0.2); + border: 1px solid rgba(103, 140, 94, 0.18); border-radius: 999px; - background: rgba(6, 24, 30, 0.52); - color: rgba(238, 252, 247, 0.82); + background: rgba(255, 250, 241, 0.7); + color: rgba(39, 65, 42, 0.88); padding: 0.45rem 0.9rem; font-size: clamp(0.68rem, 1.35vw, 0.84rem); font-weight: 800; backdrop-filter: blur(12px); + box-shadow: 0 10px 28px rgba(90, 120, 82, 0.14); +} + +.child-motion-camera-state--ready { + display: none; } .child-motion-floor { @@ -5780,12 +5852,49 @@ button { right: -8%; bottom: -19%; left: -8%; + z-index: 2; height: 47%; border-radius: 50% 50% 0 0; background: - radial-gradient(ellipse at 50% 8%, rgba(190, 255, 220, 0.22), transparent 36%), - linear-gradient(180deg, rgba(24, 86, 67, 0.84), rgba(7, 43, 34, 0.96)); - box-shadow: inset 0 22px 70px rgba(255, 255, 255, 0.07); + radial-gradient(ellipse at 50% 10%, rgba(255, 255, 255, 0.22), transparent 30%), + radial-gradient(ellipse at 42% 30%, rgba(255, 246, 205, 0.2) 0 8%, transparent 18%), + radial-gradient(ellipse at 70% 25%, rgba(255, 255, 255, 0.18) 0 5%, transparent 14%), + linear-gradient(180deg, rgba(135, 194, 104, 0.92), rgba(69, 145, 76, 0.98)); + box-shadow: + inset 0 26px 70px rgba(255, 255, 255, 0.16), + inset 0 -38px 68px rgba(52, 94, 46, 0.18); +} + +.child-motion-floor::before, +.child-motion-floor::after { + position: absolute; + border-radius: 999px; + content: ''; +} + +.child-motion-floor::before { + inset: 14% 10% auto 16%; + height: 18%; + background: + radial-gradient(circle at 8% 50%, rgba(96, 148, 60, 0.68) 0 12%, transparent 13%), + radial-gradient(circle at 21% 42%, rgba(96, 148, 60, 0.58) 0 9%, transparent 10%), + radial-gradient(circle at 33% 55%, rgba(255, 255, 255, 0.2) 0 7%, transparent 8%), + radial-gradient(circle at 45% 40%, rgba(96, 148, 60, 0.62) 0 11%, transparent 12%), + radial-gradient(circle at 58% 52%, rgba(255, 255, 255, 0.16) 0 6%, transparent 7%), + radial-gradient(circle at 69% 42%, rgba(96, 148, 60, 0.62) 0 10%, transparent 11%), + radial-gradient(circle at 82% 50%, rgba(255, 255, 255, 0.18) 0 7%, transparent 8%); + opacity: 0.78; +} + +.child-motion-floor::after { + inset: auto 6% 10%; + height: 15%; + background: + radial-gradient(circle at 18% 50%, rgba(55, 104, 53, 0.42) 0 10%, transparent 11%), + radial-gradient(circle at 38% 50%, rgba(255, 255, 255, 0.12) 0 6%, transparent 7%), + radial-gradient(circle at 60% 48%, rgba(55, 104, 53, 0.38) 0 11%, transparent 12%), + radial-gradient(circle at 80% 52%, rgba(255, 255, 255, 0.1) 0 5%, transparent 6%); + opacity: 0.68; } .child-motion-hud { @@ -5797,7 +5906,7 @@ button { border: 1px solid var(--child-motion-panel-border); border-radius: clamp(0.75rem, 2vw, 1.25rem); background: var(--child-motion-panel); - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.2); + box-shadow: 0 18px 48px rgba(72, 112, 68, 0.12); backdrop-filter: blur(14px); } @@ -5834,12 +5943,13 @@ button { flex: 0 0 auto; align-items: center; justify-content: center; - border: 1px solid rgba(238, 252, 247, 0.2); + border: 1px solid rgba(112, 143, 97, 0.2); border-radius: 999px; - background: rgba(255, 255, 255, 0.1); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(242, 248, 236, 0.92)); color: var(--child-motion-text); font-size: clamp(0.72rem, 1.45vw, 0.95rem); font-weight: 900; + box-shadow: 0 8px 20px rgba(96, 132, 82, 0.12); } .child-motion-ring { @@ -5848,24 +5958,28 @@ button { z-index: 3; width: clamp(5.8rem, 13vw, 9rem); aspect-ratio: 1; - transform: translateX(-50%) rotateX(62deg); + transform: translateX(-50%) rotateX(66deg); border-radius: 999px; background: conic-gradient( from -90deg, - rgba(255, 255, 255, 0.95) 0 var(--child-motion-ring-progress), - rgba(95, 240, 143, 0.18) var(--child-motion-ring-progress) 360deg + rgba(255, 255, 255, 0.88) 0 var(--child-motion-ring-progress), + rgba(102, 190, 95, 0.22) var(--child-motion-ring-progress) 360deg ); box-shadow: - 0 0 28px rgba(95, 240, 143, 0.42), - inset 0 0 26px rgba(255, 255, 255, 0.18); + 0 0 18px rgba(120, 191, 110, 0.34), + 0 0 0 6px rgba(255, 255, 255, 0.12), + inset 0 0 24px rgba(255, 255, 255, 0.2); } .child-motion-ring::before { position: absolute; inset: 14%; border-radius: inherit; - background: rgba(8, 44, 36, 0.94); + background: + radial-gradient(circle at 50% 45%, rgba(255, 255, 255, 0.1), transparent 40%), + linear-gradient(180deg, rgba(151, 215, 139, 0.82), rgba(73, 151, 74, 0.94)); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.38); content: ''; } @@ -5873,8 +5987,9 @@ button { position: absolute; inset: 34%; border-radius: 999px; - background: var(--child-motion-green); - opacity: 0.28; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(150, 231, 137, 0.86)); + opacity: 0.62; + box-shadow: 0 0 22px rgba(124, 199, 112, 0.44); } .child-motion-ring--active { @@ -5887,7 +6002,7 @@ button { } to { - filter: brightness(1.25); + filter: brightness(1.08); } } @@ -5899,6 +6014,7 @@ button { height: clamp(6rem, 13vw, 10rem); transform: translateX(-50%); transition: left 260ms ease, transform 220ms ease; + filter: drop-shadow(0 6px 14px rgba(56, 92, 55, 0.18)); } .child-motion-avatar--jumping { @@ -5911,8 +6027,13 @@ button { .child-motion-avatar__leg { position: absolute; display: block; - background: rgba(7, 18, 24, 0.82); - box-shadow: 0 0 24px rgba(143, 216, 255, 0.18); + background: + linear-gradient(180deg, rgba(77, 109, 79, 0.44), rgba(41, 65, 44, 0.7)), + rgba(245, 250, 245, 0.1); + opacity: 0.6; + border: 1px solid rgba(239, 249, 235, 0.18); + box-shadow: 0 0 24px rgba(143, 216, 255, 0.12); + backdrop-filter: blur(1px); } .child-motion-avatar__head { @@ -5985,12 +6106,13 @@ button { transform: translate(-50%, -50%); align-items: center; justify-content: center; - border: 2px solid rgba(95, 240, 143, 0.64); + border: 2px solid rgba(117, 186, 92, 0.56); border-radius: 999px; - background: rgba(95, 240, 143, 0.1); + background: rgba(247, 251, 243, 0.18); color: var(--child-motion-text); font-size: clamp(1rem, 2.4vw, 1.55rem); font-weight: 900; + box-shadow: 0 8px 24px rgba(79, 126, 67, 0.12); } .child-motion-gesture-guide__hand { @@ -5998,7 +6120,7 @@ button { top: 28%; width: clamp(4rem, 9vw, 7rem); aspect-ratio: 1; - border: 2px dashed rgba(95, 240, 143, 0.58); + border: 2px dashed rgba(117, 186, 92, 0.5); border-radius: 999px; animation: child-motion-hand-guide 1.1s ease-in-out infinite alternate; } @@ -6027,8 +6149,8 @@ button { height: 0.8rem; transform: translate(-50%, -50%); border-radius: 999px; - background: #b9ffd0; - box-shadow: 0 0 16px rgba(95, 240, 143, 0.56); + background: #f6fff1; + box-shadow: 0 0 16px rgba(119, 194, 111, 0.56); } .child-motion-floating-reward { @@ -6040,7 +6162,7 @@ button { color: #ffffff; font-size: clamp(1.4rem, 4vw, 2.4rem); font-weight: 900; - text-shadow: 0 4px 26px rgba(0, 0, 0, 0.42); + text-shadow: 0 4px 26px rgba(61, 90, 54, 0.42); animation: child-motion-reward-rise 0.72s ease-out forwards; } @@ -6070,6 +6192,7 @@ button { background: var(--child-motion-panel); padding: 0.45rem; backdrop-filter: blur(14px); + box-shadow: 0 14px 32px rgba(82, 124, 72, 0.1); } .child-motion-calibration div { @@ -6078,7 +6201,7 @@ button { gap: 0.08rem; justify-items: center; border-radius: 999px; - background: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.48); padding: 0.36rem 0.55rem; } @@ -6103,11 +6226,11 @@ button { transform: translate(-50%, -50%); align-items: center; gap: 0.85rem; - border: 1px solid rgba(178, 239, 220, 0.32); + border: 1px solid rgba(143, 176, 124, 0.24); border-radius: 1.4rem; - background: rgba(6, 24, 30, 0.7); + background: rgba(255, 250, 241, 0.76); padding: clamp(0.85rem, 2vw, 1.15rem); - box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28); + box-shadow: 0 24px 70px rgba(82, 124, 72, 0.18); backdrop-filter: blur(14px); } @@ -6116,12 +6239,12 @@ button { min-height: clamp(3rem, 7vw, 4.2rem); border: 0; border-radius: 999px; - background: linear-gradient(135deg, #5ff08f, #8fd8ff); - color: #062018; + background: linear-gradient(135deg, #88cf74, #9dd3ff); + color: #214228; font-size: clamp(1rem, 2.5vw, 1.4rem); font-weight: 950; cursor: pointer; - box-shadow: 0 16px 44px rgba(95, 240, 143, 0.28); + box-shadow: 0 16px 44px rgba(124, 182, 98, 0.24); } .child-motion-start-panel span { @@ -6136,7 +6259,9 @@ button { z-index: 30; display: none; place-items: center; - background: #07151c; + background: + radial-gradient(circle at 24% 22%, rgba(255, 255, 255, 0.88), transparent 20%), + linear-gradient(180deg, #f7fcff 0%, #dff3ff 54%, #c9e6b9 100%); color: var(--child-motion-text); font-size: 1.25rem; font-weight: 900; diff --git a/src/services/useMocapInput.test.ts b/src/services/useMocapInput.test.ts index 548bda00..14582e22 100644 --- a/src/services/useMocapInput.test.ts +++ b/src/services/useMocapInput.test.ts @@ -78,4 +78,47 @@ describe('parseMocapPacket', () => { expect.objectContaining({x: 0.9, y: 0.8, source: 'direct'}), ); }); + + test('解析 mocap frame 的身体中心和左右手来源', () => { + const command = parseMocapPacket({ + schema_version: '1.0', + stream: { type: 'mocap.frame' }, + general: { + body: { + center_norm: [0.34, 0.58], + }, + }, + actions: [{ gesture: 'wave-left-hand' }], + hands: [ + { + label: 'Left', + state: 'open_palm', + landmarks: [ + { name: 'wrist', x: 0.21, y: 0.31 }, + { name: 'index_mcp', x: 0.25, y: 0.33 }, + { name: 'middle_mcp', x: 0.27, y: 0.34 }, + { name: 'ring_mcp', x: 0.28, y: 0.35 }, + { name: 'pinky_mcp', x: 0.29, y: 0.36 }, + ], + }, + { + label: 'Right', + state: 'unknown', + x: 0.72, + y: 0.32, + }, + ], + }); + + expect(command.bodyCenter).toEqual({x: 0.34, y: 0.58}); + expect(command.actions).toEqual( + expect.arrayContaining(['wave_left_hand', 'open_palm']), + ); + expect(command.leftHand).toEqual( + expect.objectContaining({side: 'left', source: 'palm_center'}), + ); + expect(command.rightHand).toEqual( + expect.objectContaining({x: 0.72, y: 0.32, side: 'right'}), + ); + }); });