#!/usr/bin/env bash # ============================================================ # OpenDance Studio Windows 客户端 —— **关键点帧跨实现一致性验收**(T-WIN-021) # # opendance-web 的 TypeScript 参考实现(契约 §2)× 发布包里的 GDScript 解码器 # # 为什么需要它:`web-server/src/preprocess/keypoint-codec.ts` 是契约 §2 的 # **参考实现**(其 `decodeBinaryFrame` 的注释就是「Windows 端参考实现(T-WIN-004)」)。 # Android 的 Kotlin 实现已用「金标准字节向量」与它对齐;而**本端的 GDScript 解码器 # 此前只跟自己比过**(自己编码、自己解码)。本脚本把这条缺失的边补上: # # 1. 用**参考实现**重新生成向量,与仓库里锁定的 `build/integration/frame-vectors.json` # 比对 —— 参考实现一旦变化,这里必须失败(否则"锁定"就没有意义); # 2. 用**发布包里的 pck** 解码同一批字节,与参考实现的解码结果**逐值精确比对** # (float32 表示精度内); # 3. 附带比对两边**编码器**的字节:本端不是发送方(`encode()` 只用于自测), # 因此只归类记录 —— 但差异必须**只出现在量化前恰好落在 .5 的用例**上。 # # 用法: # GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh dist-release/opendance-windows-v0.2.3.zip # ... --skip-regen # 不重新生成向量(没有 web 仓 / 没装 node_modules 时) # # 环境变量: # WEB_REPO opendance-web 仓库路径(默认 ../opendance-web) # WORK_DIR 工作目录(默认 mktemp -d) # ============================================================ set -uo pipefail PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$PROJECT_DIR" TARGET="${1:-}" SKIP_REGEN="" for arg in "$@"; do case "$arg" in --skip-regen) SKIP_REGEN=1 ;; "$TARGET") : ;; *) echo "✗ 未知参数:$arg(只支持 --skip-regen)" >&2; exit 2 ;; esac done WEB_REPO="${WEB_REPO:-$PROJECT_DIR/../opendance-web}" WORK_DIR="${WORK_DIR:-$(mktemp -d)}" mkdir -p "$WORK_DIR" VECTORS="$PROJECT_DIR/build/integration/frame-vectors.json" REPORT="$WORK_DIR/frame-vectors-report.json" FAILED=0 log() { printf '\033[1;34m[accept-fv]\033[0m %s\n' "$*"; } fail() { printf '\033[1;31m[accept-fv] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; } if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then echo "用法:GODOT_BIN=... bash build/accept-frame-vectors.sh <发布包 zip 或已解压目录> [--skip-regen]" >&2 exit 2 fi [ -f "$VECTORS" ] || { fail "找不到锁定的向量文件:$VECTORS"; exit 1; } # ---- 1. 用参考实现重新生成并比对(确保向量仍与参考实现一致)---- log "参考实现:$WEB_REPO/web-server/src/preprocess/keypoint-codec.ts" if [ -n "$SKIP_REGEN" ]; then log "跳过重新生成(--skip-regen)" elif [ ! -f "$WEB_REPO/web-server/scripts/gen-frame-vectors.mts" ]; then fail "找不到生成脚本($WEB_REPO/web-server/scripts/gen-frame-vectors.mts)—— 用 WEB_REPO 指定仓库,或 --skip-regen" elif [ ! -d "$WEB_REPO/web-server/node_modules" ]; then fail "web 仓没有 node_modules,无法运行参考实现(先在那边 npm install,或 --skip-regen)" else log "用参考实现重新生成向量并比对…" REGEN="$WORK_DIR/frame-vectors.regen.json" if ( cd "$WEB_REPO/web-server" && timeout 300 npx vite-node scripts/gen-frame-vectors.mts -- --out "$REGEN" >"$WORK_DIR/regen.log" 2>&1 ); then if diff -q "$VECTORS" "$REGEN" >/dev/null; then echo " ✓ 锁定的向量与参考实现重新生成的结果逐字节一致" else fail "锁定的向量已与参考实现不一致(参考实现变了却没同步向量)" diff "$VECTORS" "$REGEN" | head -20 | sed 's/^/ /' fi else fail "参考实现生成失败(见 $WORK_DIR/regen.log)" tail -10 "$WORK_DIR/regen.log" | sed 's/^/ /' fi fi # ---- 2. 取包内 pck ---- if [ -f "$TARGET" ]; then log "解压发布包…" if ! unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null || { fail "解压失败"; exit 1; } fi PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)" else PACKAGE_DIR="$TARGET" fi PCK="$PACKAGE_DIR/OpenDance.pck" [ -f "$PCK" ] || { fail "找不到包内 pck:$PCK"; exit 1; } log "包内 pck:$PCK" GODOT_BIN="${GODOT_BIN:-}" if [ -z "$GODOT_BIN" ]; then for candidate in godot godot4 Godot; do command -v "$candidate" >/dev/null 2>&1 && { GODOT_BIN="$candidate"; break; } done fi [ -n "$GODOT_BIN" ] || { fail "未找到 Godot(用 GODOT_BIN=<路径> 指定,需 4.7.x)"; exit 1; } # ---- 3. 用包内解码器逐值比对 ---- log "包内解码器 × 参考向量…" PROBE_LOG="$WORK_DIR/probe.log" rm -f "$REPORT" if timeout 300 "$GODOT_BIN" --headless --main-pack "$PCK" \ --script "$PROJECT_DIR/build/integration/probe-frame-vectors.gd" \ -- --vectors "$VECTORS" --report "$REPORT" >"$PROBE_LOG" 2>&1; then PROBE_OK=1 else PROBE_OK=0 fi grep -vE "^Godot Engine|^$" "$PROBE_LOG" | sed 's/^/ /' [ "$PROBE_OK" = 1 ] || fail "解码比对未通过" # ---- 4. 报告核对 ---- if [ ! -f "$REPORT" ]; then fail "探针没有产出报告($REPORT)" exit 1 fi log "报告核对:" node -e ' const fs = require("node:fs"); const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); let bad = 0; const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (!ok) bad = 1; }; say(r.cases > 0 && r.decodedCases === r.cases, `解码用例 ${r.decodedCases}/${r.cases}`); say(r.pointsCompared === r.cases * 33, `逐点比对 ${r.pointsCompared} 个(期望 ${r.cases * 33})`); say(Array.isArray(r.decodeFailures) && r.decodeFailures.length === 0, `解码差异 ${r.decodeFailures?.length ?? "?"} 项(必须为 0)`); if (r.decodeFailures?.length) for (const f of r.decodeFailures.slice(0, 5)) console.log(` · ${f.case}:${f.problem}`); // 编码器差异:本端不是发送方(只用于自测),但差异必须只出现在「量化前恰好 .5」的用例上 const diffs = r.encodeDifferences ?? []; const unexpected = diffs.filter((d) => !d.halfValueInput); say(unexpected.length === 0, `编码器差异 ${diffs.length} 项,全部属于「量化前恰好 .5」的取整边界:${diffs.map((d) => d.case + "(" + d.diffBytes + "B)").join("、") || "无"}`); if (unexpected.length) for (const d of unexpected) console.log(` · 非预期差异:${d.case}(${d.diffBytes} 字节)`); process.exit(bad); ' "$REPORT" || fail "报告核对未通过" echo "" if [ "$FAILED" -ne 0 ]; then echo "============================================================" echo " 关键点帧跨实现一致性验收失败(工作目录:$WORK_DIR)" echo "============================================================" exit 1 fi echo "============================================================" echo " 关键点帧跨实现一致性验收全部通过(工作目录:$WORK_DIR)" echo "============================================================"