diff --git a/src/net/api_client.gd b/src/net/api_client.gd new file mode 100644 index 0000000..ed8ebbf --- /dev/null +++ b/src/net/api_client.gd @@ -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 diff --git a/src/net/catalog_types.gd b/src/net/catalog_types.gd new file mode 100644 index 0000000..4bfc0c6 --- /dev/null +++ b/src/net/catalog_types.gd @@ -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) diff --git a/src/net/keypoint_sequence.gd b/src/net/keypoint_sequence.gd new file mode 100644 index 0000000..737fa57 --- /dev/null +++ b/src/net/keypoint_sequence.gd @@ -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}