feat(win): T-WIN-002 REST 客户端与契约类型
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user