test(codec): 关键点帧跨实现一致性验收——包内 GDScript 解码器 × Web 参考实现(T-WIN-021)

契约 §2 的参考实现是 opendance-web 的 keypoint-codec.ts(其 decodeBinaryFrame
注释就是「Windows 端参考实现」)。Android 已用金标准向量与它对齐;而本端的解码器
此前**只跟自己比过**(自己编码、自己解码),两个方向共用同一份错误假设也能一直绿。

新增:
  * build/integration/frame-vectors.json —— 由参考实现生成并锁定的向量
    (12 用例:i16 最小值 -32768、i16 最大值、±1 全量程、置信度 0/128/255、
    flags 低 3 位组合、frame_id 0xFFFFFFFF 与 0x80000000、取整边界)。
  * build/integration/probe-frame-vectors.gd —— 用**包内** KeypointFrame 解码同一批字节,
    与参考解码结果**逐值精确比对**(坐标/置信度先收敛到 float32 再比精确相等,
    因为本端点存 PackedVector3Array、置信度存 PackedFloat32Array)。
  * build/accept-frame-vectors.sh —— 编排:用参考实现重新生成向量并与锁定文件比对
    (参考实现变了却不同步向量即失败)→ 解包 → 包内解码比对 → 报告核对。

实测(**已发布**的 v0.2.3 资产重新下载后跑):
  ✓ 锁定的向量与参考实现重新生成的结果逐字节一致
  ✓ 解码用例 12/12,逐点比对 396 个,解码差异 0 项
  ✓ 编码器差异 1 项,且全部属于「量化前恰好 .5」的取整边界
  关键点帧跨实现一致性验收全部通过

已测量并归类的一处差异(非缺陷):JS Math.round 与 Kotlin(生产发送方)Math.round
对 -2.5 取 -2(半数朝 +∞),本端 roundi 取 -3(半数远离零)。Windows 是接收方,
encode() 只在自测/协议回归里用,故对产品无影响;脚本硬性要求这类差异只能出现在
取整边界用例上,否则视为新问题。若将来本端要当发送方,把 roundi 换成 floor(x+0.5) 即可。

踩坑(两处「看着通过其实没测到」,都写进任务文档):
  1. 生成器第一版把「喂进编码器的输入」当成 points 写进向量 → 比对变成「比编码量化」,
     靠抽查数值才发现;现在向量同时含 inputPoints / f32InputPoints / f32Hex / points。
  2. 编码器比对一开始用 float64 输入编出的 hex 当基准(正数恰好 .5 那例整例 99 字节不同),
     实际只是输入从 float64 收敛到 float32 的量化差;改用同输入的 f32Hex 基准后,
     差异收敛到唯一一个真正的取整边界用例。教训:跨实现比对必须先统一输入。

package-release.sh 结尾把四条发布前验收一次列全;verify.sh 全绿(774 通过 / 0 失败)。
This commit is contained in:
2026-09-17 05:52:11 +08:00
parent cb1c293dcf
commit 1c5f28cd41
8 changed files with 7776 additions and 2 deletions
+152
View File
@@ -0,0 +1,152 @@
#!/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 "============================================================"