feat(win): T-WIN-002 REST 客户端与契约类型

This commit is contained in:
2026-09-14 20:04:54 +08:00
parent ad62929f59
commit 787f1c0cd8
3 changed files with 534 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
class_name ApiClient
extends Node
## T-WIN-002:Web 服务端 REST 客户端
##
## 只消费 opendance-web 冻结的契约(contracts/rest-api.yaml v0.1.0)。
## 所有方法返回 { ok: bool, status: int, data: Variant, error: String },
## 不抛异常,调用方统一判 ok。
signal request_completed(path: String, ok: bool, status: int)
const DEFAULT_TIMEOUT_SECONDS := 20.0
var base_url: String = "http://127.0.0.1:3000"
var timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
func _init(api_base_url: String = "") -> void:
if api_base_url != "":
base_url = api_base_url
## 规范化基地址:去掉尾部斜杠
func set_base_url(value: String) -> void:
base_url = value.strip_edges().rstrip("/")
static func join_url(base: String, path: String) -> String:
var b := base.rstrip("/")
var p := path if path.begins_with("/") else "/" + path
return b + p
func get_json(path: String) -> Dictionary:
return await _request(HTTPClient.METHOD_GET, path)
func get_text(path: String) -> Dictionary:
return await _request(HTTPClient.METHOD_GET, path, {}, true)
## 下载二进制资源(模型文件等)
func get_binary(path: String) -> Dictionary:
return await _request(HTTPClient.METHOD_GET, path, {}, false, true)
func post_json(path: String, body: Dictionary = {}) -> Dictionary:
return await _request(HTTPClient.METHOD_POST, path, body)
## 健康检查:契约 GET /health
func health() -> Dictionary:
return await get_json("/health")
## 契约版本 + 目录指纹:GET /api/catalog/version
func catalog_version() -> Dictionary:
return await get_json("/api/catalog/version")
## 客户端一次性拉取已发布目录:GET /api/catalog/export
func catalog_export() -> Dictionary:
return await get_json("/api/catalog/export")
## 关键点序列(NDJSON):GET /api/motions/{id}/keypoints
func motion_keypoints(motion_id: int) -> Dictionary:
return await get_text("/api/motions/%d/keypoints" % motion_id)
func _request(method: int, path: String, body: Dictionary = {}, as_text: bool = false, as_binary: bool = false) -> Dictionary:
var outcome := {
"ok": false,
"status": 0,
"data": null,
"error": "",
}
var http := HTTPRequest.new()
http.timeout = timeout_seconds
add_child(http)
var url := join_url(base_url, path)
var headers := PackedStringArray(["Accept: application/json"])
var payload := ""
if method != HTTPClient.METHOD_GET:
payload = JSON.stringify(body)
headers.append("Content-Type: application/json")
var err := http.request(url, headers, method, payload)
if err != OK:
outcome["error"] = "无法发起请求(错误码 %d):%s" % [err, url]
http.queue_free()
request_completed.emit(path, false, 0)
return outcome
var response: Array = await http.request_completed
http.queue_free()
var result_code: int = response[0]
var status: int = response[1]
var raw: PackedByteArray = response[3]
outcome["status"] = status
if result_code != HTTPRequest.RESULT_SUCCESS:
outcome["error"] = _describe_result(result_code)
request_completed.emit(path, false, status)
return outcome
var text := raw.get_string_from_utf8()
if status < 200 or status >= 300:
outcome["error"] = "服务端返回 %d:%s" % [status, _extract_error_message(text)]
request_completed.emit(path, false, status)
return outcome
if as_binary:
outcome["ok"] = true
outcome["data"] = raw
request_completed.emit(path, true, status)
return outcome
if as_text:
outcome["ok"] = true
outcome["data"] = text
request_completed.emit(path, true, status)
return outcome
var parsed: Variant = JSON.parse_string(text)
if parsed == null and text.strip_edges() != "null":
outcome["error"] = "响应不是合法 JSON"
request_completed.emit(path, false, status)
return outcome
outcome["ok"] = true
outcome["data"] = parsed
request_completed.emit(path, true, status)
return outcome
## 解析契约统一错误体 { statusCode, message, error }
static func _extract_error_message(text: String) -> String:
var parsed: Variant = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY:
return text.strip_edges().left(200)
var dict: Dictionary = parsed
if not dict.has("message"):
return text.strip_edges().left(200)
var message: Variant = dict["message"]
if typeof(message) == TYPE_ARRAY:
var parts := PackedStringArray()
for item in message:
parts.append(str(item))
return "; ".join(parts)
return str(message)
static func _describe_result(code: int) -> String:
match code:
HTTPRequest.RESULT_CANT_CONNECT:
return "无法连接到服务端(请确认 Web 服务端已启动且地址正确)"
HTTPRequest.RESULT_CANT_RESOLVE:
return "无法解析服务端地址"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "连接中断"
HTTPRequest.RESULT_TIMEOUT:
return "请求超时"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS 握手失败"
_:
return "请求失败(结果码 %d)" % code
+235
View File
@@ -0,0 +1,235 @@
class_name CatalogTypes
extends RefCounted
## 契约资源类型 —— contracts/data-models.md v0.1.0
## 字段名与 JSON 完全一致(camelCase),解析时做类型收敛。
## 来源(出处)五元组 —— 契约 §1
class SourceInfo extends RefCounted:
var source_type: String = ""
var source_name: String = ""
var source_url: String = ""
var license: String = ""
var author: String = ""
static func from_dict(d: Dictionary) -> SourceInfo:
var out := SourceInfo.new()
out.source_type = str(d.get("sourceType", ""))
out.source_name = str(d.get("sourceName", ""))
out.source_url = str(d.get("sourceUrl", ""))
out.license = str(d.get("license", ""))
out.author = str(d.get("author", ""))
return out
## 来源类型的中文标签(管理/展示用)
func type_label() -> String:
match source_type:
"original": return "原创"
"licensed": return "已获授权"
"cc": return "Creative Commons"
"public_domain": return "公共领域"
"user_upload": return "用户上传"
"other": return "其他"
return source_type
## 是否五项齐备 —— 契约要求任何资产都必须完整
func is_complete() -> bool:
return (
source_type != "" and source_name != "" and source_url != ""
and license != "" and author != ""
)
class Song extends RefCounted:
var id: int = 0
var title: String = ""
var artist: String = ""
var album: String = ""
var language: String = ""
var duration_ms: int = 0
var bpm: float = 0.0
var difficulty: int = 0
var tags: PackedStringArray = PackedStringArray()
var cover_url: String = ""
var audio_url: String = ""
var source: SourceInfo = SourceInfo.new()
var status: String = ""
static func from_dict(d: Dictionary) -> Song:
var out := Song.new()
out.id = int(d.get("id", 0))
out.title = str(d.get("title", ""))
out.artist = str(d.get("artist", ""))
out.album = str(d.get("album", "") if d.get("album") != null else "")
out.language = str(d.get("language", "") if d.get("language") != null else "")
out.duration_ms = int(d.get("durationMs", 0) if d.get("durationMs") != null else 0)
out.bpm = float(d.get("bpm", 0.0) if d.get("bpm") != null else 0.0)
out.difficulty = int(d.get("difficulty", 0) if d.get("difficulty") != null else 0)
out.tags = CatalogTypes._strings(d.get("tags", []))
out.cover_url = CatalogTypes._nullable_string(d.get("coverUrl"))
out.audio_url = CatalogTypes._nullable_string(d.get("audioUrl"))
out.source = SourceInfo.from_dict(d)
out.status = str(d.get("status", ""))
return out
func duration_label() -> String:
if duration_ms <= 0:
return "--:--"
var total := int(duration_ms / 1000.0)
return "%d:%02d" % [total / 60, total % 60]
class Motion extends RefCounted:
var id: int = 0
var song_id: int = -1
var name: String = ""
var format: String = ""
var file_url: String = ""
var file_size: int = 0
var fps: float = 0.0
var frame_count: int = 0
var duration_ms: int = 0
var keypoint_format: String = ""
var keypoint_url: String = ""
var keypoint_frames: int = 0
var preprocess_state: String = "none"
var preprocess_error: String = ""
var source: SourceInfo = SourceInfo.new()
var status: String = ""
static func from_dict(d: Dictionary) -> Motion:
var out := Motion.new()
out.id = int(d.get("id", 0))
out.song_id = int(d.get("songId", -1) if d.get("songId") != null else -1)
out.name = str(d.get("name", ""))
out.format = str(d.get("format", ""))
out.file_url = CatalogTypes._nullable_string(d.get("fileUrl"))
out.file_size = int(d.get("fileSize", 0) if d.get("fileSize") != null else 0)
out.fps = float(d.get("fps", 0.0) if d.get("fps") != null else 0.0)
out.frame_count = int(d.get("frameCount", 0) if d.get("frameCount") != null else 0)
out.duration_ms = int(d.get("durationMs", 0) if d.get("durationMs") != null else 0)
out.keypoint_format = CatalogTypes._nullable_string(d.get("keypointFormat"))
out.keypoint_url = CatalogTypes._nullable_string(d.get("keypointUrl"))
out.keypoint_frames = int(d.get("keypointFrames", 0) if d.get("keypointFrames") != null else 0)
out.preprocess_state = str(d.get("preprocessState", "none"))
out.preprocess_error = CatalogTypes._nullable_string(d.get("preprocessError"))
out.source = SourceInfo.from_dict(d)
out.status = str(d.get("status", ""))
return out
## 是否可用于 DTW 参考序列
func is_scorable() -> bool:
return preprocess_state == "done" and keypoint_frames > 0
func preprocess_label() -> String:
match preprocess_state:
"none": return "未处理"
"running": return "处理中"
"done": return "已完成"
"failed": return "失败"
return preprocess_state
class Model extends RefCounted:
var id: int = 0
var name: String = ""
var format: String = ""
var vrm_version: String = ""
var is_primary: bool = false
var file_url: String = ""
var file_size: int = 0
var thumbnail_url: String = ""
var source: SourceInfo = SourceInfo.new()
var status: String = ""
static func from_dict(d: Dictionary) -> Model:
var out := Model.new()
out.id = int(d.get("id", 0))
out.name = str(d.get("name", ""))
out.format = str(d.get("format", ""))
out.vrm_version = CatalogTypes._nullable_string(d.get("vrmVersion"))
out.is_primary = bool(d.get("isPrimary", false))
out.file_url = CatalogTypes._nullable_string(d.get("fileUrl"))
out.file_size = int(d.get("fileSize", 0) if d.get("fileSize") != null else 0)
out.thumbnail_url = CatalogTypes._nullable_string(d.get("thumbnailUrl"))
out.source = SourceInfo.from_dict(d)
out.status = str(d.get("status", ""))
return out
func is_vrm() -> bool:
return format == "vrm"
## 契约 §6:客户端一次性拉取的目录
class CatalogBundle extends RefCounted:
var songs: Array = []
var motions: Array = []
var models: Array = []
var default_model_id: int = -1
var contract_version: Dictionary = {}
var generated_at: String = ""
static func from_dict(d: Dictionary) -> CatalogBundle:
var out := CatalogBundle.new()
for item in d.get("songs", []):
out.songs.append(Song.from_dict(item))
for item in d.get("motions", []):
out.motions.append(Motion.from_dict(item))
for item in d.get("models", []):
out.models.append(Model.from_dict(item))
var default_id: Variant = d.get("defaultModelId")
out.default_model_id = int(default_id) if default_id != null else -1
out.contract_version = d.get("contractVersion", {})
out.generated_at = str(d.get("generatedAt", ""))
return out
func find_song(id: int) -> Song:
for song in songs:
if song.id == id:
return song
return null
func find_motion(id: int) -> Motion:
for motion in motions:
if motion.id == id:
return motion
return null
func find_model(id: int) -> Model:
for model in models:
if model.id == id:
return model
return null
func motions_for_song(song_id: int) -> Array:
var out: Array = []
for motion in motions:
if motion.song_id == song_id:
out.append(motion)
return out
## 契约 §4.1:默认模型由服务端解析好,客户端直接用 defaultModelId
func default_model() -> Model:
var model := find_model(default_model_id)
if model != null:
return model
for candidate in models:
if candidate.is_vrm():
return candidate
return models[0] if models.size() > 0 else null
# ---------------- 解析辅助 ----------------
static func _strings(value: Variant) -> PackedStringArray:
var out := PackedStringArray()
if typeof(value) == TYPE_ARRAY:
for item in value:
out.append(str(item))
return out
static func _nullable_string(value: Variant) -> String:
if value == null:
return ""
return str(value)
+129
View File
@@ -0,0 +1,129 @@
class_name KeypointSequence
extends RefCounted
## 参考关键点序列 —— 契约 contracts/model-formats.md §2.1
##
## 服务端 T-WEB-006 预处理产出的 NDJSON,每行一帧:
## {"frame":0,"timeMs":0.0,"points":[[x,y,z,conf] × 33],"angles":[9 个余弦]}
##
## 本类把它解析为与实时链路**同构**的 PoseFeatures.FeatureVector 序列,
## 使 DTW 引擎只有一条输入通路(契约 skeleton-topology.md §5 的设计意图)。
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
const PoseFeatures = preload("res://src/score/pose_features.gd")
## 服务端产出的 keypointFormat 标识,必须完全匹配
const EXPECTED_FORMAT := "mediapipe33@0.1.0"
class ParsedSequence extends RefCounted:
## Array[KeypointFrame.PoseFrame]
var frames: Array = []
## Array[PoseFeatures.FeatureVector]
var features: Array = []
## Array[PackedFloat32Array],服务端算好的 9 个关节角(用于交叉校验)
var server_angles: Array = []
var times_ms: PackedFloat32Array = PackedFloat32Array()
var skipped_lines: int = 0
func frame_count() -> int:
return frames.size()
func duration_ms() -> float:
if times_ms.is_empty():
return 0.0
return times_ms[times_ms.size() - 1]
## 依据时间戳推导源帧率
func estimated_fps() -> float:
if times_ms.size() < 2:
return 30.0
var span := times_ms[times_ms.size() - 1] - times_ms[0]
if span <= 0.0:
return 30.0
return float(times_ms.size() - 1) / (span / 1000.0)
## 解析 NDJSON;失败时返回 frames 为空的序列,并把原因写入 error
static func parse_ndjson(text: String) -> ParsedSequence:
var out := ParsedSequence.new()
if text.strip_edges() == "":
return out
var line_number := 0
for raw_line in text.split("\n"):
line_number += 1
var line := raw_line.strip_edges()
if line == "":
continue
var parsed: Variant = JSON.parse_string(line)
if typeof(parsed) != TYPE_DICTIONARY:
out.skipped_lines += 1
continue
var frame := _frame_from_dict(parsed)
if frame == null:
out.skipped_lines += 1
continue
out.frames.append(frame)
out.features.append(PoseFeatures.build(frame))
out.server_angles.append(_angles_from_dict(parsed))
out.times_ms.append(float(parsed.get("timeMs", 0.0)))
return out
static func _frame_from_dict(data: Dictionary) -> KeypointFrame.PoseFrame:
var raw_points: Array = data.get("points", [])
if raw_points.size() != KeypointFrame.KEYPOINT_COUNT:
return null
var frame := KeypointFrame.PoseFrame.new()
frame.frame_id = int(data.get("frame", 0))
frame.points.resize(KeypointFrame.KEYPOINT_COUNT)
frame.confidence.resize(KeypointFrame.KEYPOINT_COUNT)
for i in KeypointFrame.KEYPOINT_COUNT:
var entry: Variant = raw_points[i]
if typeof(entry) != TYPE_ARRAY or (entry as Array).size() != 4:
return null
var point: Array = entry
frame.points[i] = Vector3(float(point[0]), float(point[1]), float(point[2]))
frame.confidence[i] = float(point[3])
return frame
static func _angles_from_dict(data: Dictionary) -> PackedFloat32Array:
var out := PackedFloat32Array()
var raw: Variant = data.get("angles", [])
if typeof(raw) == TYPE_ARRAY:
for value in raw:
out.append(float(value))
return out
## 校验服务端 angles 与本端 PoseFeatures 计算结果是否一致(跨实现交叉校验)
## 返回 { max_delta, mismatched_frames, checked }
static func compare_angles(sequence: ParsedSequence, tolerance: float = 1e-3) -> Dictionary:
var max_delta := 0.0
var mismatched := 0
var checked := 0
for i in sequence.features.size():
var server: PackedFloat32Array = sequence.server_angles[i]
if server.size() != PoseFeatures.ANGLE_COUNT:
continue
var local: PoseFeatures.FeatureVector = sequence.features[i]
var frame_ok := true
for a in PoseFeatures.ANGLE_COUNT:
var local_value: float = local.values[PoseFeatures.KEYPOINT_COUNT * 3 + a]
var delta := absf(local_value - server[a])
max_delta = maxf(max_delta, delta)
if delta > tolerance:
frame_ok = false
checked += 1
if not frame_ok:
mismatched += 1
return {"max_delta": max_delta, "mismatched_frames": mismatched, "checked": checked}