Files
opendance-windows/build/accept-frame-vectors.sh
T
zijunchiang 1c5f28cd41 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 失败)。
2026-09-17 05:52:11 +08:00

153 lines
7.0 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 "============================================================"