|
| 1 | +"""Public reusable 3D/voice metadata. No scene audio, credentials, or AI imports.""" |
| 2 | +import json |
| 3 | +import math |
| 4 | +import re |
| 5 | +from urllib.parse import urlsplit, parse_qsl |
| 6 | + |
| 7 | + |
| 8 | +def _asset_fields(value): |
| 9 | + if not isinstance(value, dict): |
| 10 | + raise ValueError("A persistent asset reference is required.") |
| 11 | + if set(value) - {"workspaceId", "filename", "url", "assetId"}: |
| 12 | + raise ValueError("Invalid asset fields.") |
| 13 | + for key in ("workspaceId", "filename", "url"): |
| 14 | + if not isinstance(value.get(key), str) or not value[key] or len(value[key]) > 1200: |
| 15 | + raise ValueError("Invalid asset reference.") |
| 16 | + if "assetId" in value and (not isinstance(value["assetId"], str) or len(value["assetId"]) > 240): |
| 17 | + raise ValueError("Invalid asset identity.") |
| 18 | + |
| 19 | + |
| 20 | +def _persistent_url(url): |
| 21 | + parsed = urlsplit(url) |
| 22 | + if not (url.startswith("/") and not url.startswith("//") or parsed.scheme in {"http", "https"} and parsed.netloc): |
| 23 | + raise ValueError("Use a persistent HTTP asset.") |
| 24 | + if parsed.username or parsed.password or any(ord(c) <= 32 or c == "\\" for c in url): |
| 25 | + raise ValueError("Credentials are not asset references.") |
| 26 | + if any(re.search(r"token|key|secret|signature", key, re.I) for key, _ in parse_qsl(parsed.query)): |
| 27 | + raise ValueError("Import the asset instead of saving credential-bearing URLs.") |
| 28 | + |
| 29 | + |
| 30 | +def source_ref(value): |
| 31 | + _asset_fields(value) |
| 32 | + _persistent_url(value["url"]) |
| 33 | + return dict(value) |
| 34 | + |
| 35 | + |
| 36 | +def _vector(raw, length, low, high): |
| 37 | + return isinstance(raw, list) and len(raw) == length and all( |
| 38 | + type(n) in (int, float) and math.isfinite(n) and low <= n <= high for n in raw) |
| 39 | + |
| 40 | + |
| 41 | +def _eye_placement(eyes): |
| 42 | + return (isinstance(eyes, dict) and set(eyes) == {"left", "right", "size", "skinLeft", "skinRight"} |
| 43 | + and all(_vector(eyes.get(k), 3, -10000, 10000) for k in ("left", "right")) |
| 44 | + and _vector(eyes.get("size"), 2, .00001, 10000) |
| 45 | + and all(_vector(eyes.get(k), 3, 0, 1) for k in ("skinLeft", "skinRight"))) |
| 46 | + |
| 47 | + |
| 48 | +def _face_placement(face): |
| 49 | + if (set(face) != {"meshIndex", "center", "size", "skin", "eyes"} |
| 50 | + or type(face["meshIndex"]) is not int or not 0 <= face["meshIndex"] <= 1023 |
| 51 | + or not _eye_placement(face.get("eyes")) |
| 52 | + or not _vector(face.get("center"), 3, -10000, 10000) |
| 53 | + or not _vector(face.get("size"), 2, .00001, 10000) |
| 54 | + or not _vector(face.get("skin"), 3, 0, 1)): |
| 55 | + raise ValueError("Invalid face placement.") |
| 56 | + |
| 57 | + |
| 58 | +def _face_style(value): |
| 59 | + if "atlas" in value: |
| 60 | + source_ref(value["atlas"]) |
| 61 | + for key in ("clean", "blink", "eyes"): |
| 62 | + if key in value and type(value[key]) is not bool: |
| 63 | + raise ValueError("Invalid face switch.") |
| 64 | + if "strength" in value and (type(value["strength"]) not in (int, float) or not 0 <= value["strength"] <= 1.5): |
| 65 | + raise ValueError("Invalid face strength.") |
| 66 | + for key, choices in (("style", {"soft", "toon", "pixel"}), ("expression", {"neutral", "happy", "angry", "worried", "surprised", "sleepy"})): |
| 67 | + if key in value and (not isinstance(value[key], str) or value[key] not in choices): |
| 68 | + raise ValueError("Invalid face style.") |
| 69 | + if "lip" in value and not re.fullmatch(r"#[0-9a-fA-F]{6}", str(value["lip"])): |
| 70 | + raise ValueError("Invalid lip color.") |
| 71 | + |
| 72 | + |
| 73 | +def face_settings(value): |
| 74 | + allowed = {"face", "atlas", "strength", "clean", "style", "lip", "expression", "blink", "eyes"} |
| 75 | + if not isinstance(value, dict) or not isinstance(value.get("face"), dict) or not set(value).issubset(allowed): |
| 76 | + raise ValueError("Only face settings may be stored.") |
| 77 | + _face_placement(value["face"]) |
| 78 | + _face_style(value) |
| 79 | + if len(json.dumps(value, allow_nan=False)) > 24000: |
| 80 | + raise ValueError("Face profile too large.") |
| 81 | + return value |
| 82 | + |
| 83 | + |
| 84 | +def normalize_speech3d(value): |
| 85 | + if not isinstance(value, dict) or set(value) - {"model", "digest", "settings"}: |
| 86 | + raise ValueError("Invalid 3D character definition.") |
| 87 | + model = source_ref(value.get("model")) |
| 88 | + if not model["filename"].lower().endswith(".glb") or not re.fullmatch("[a-f0-9]{64}", str(value.get("digest", ""))): |
| 89 | + raise ValueError("Save a GLB with its content digest.") |
| 90 | + result = {"model": model, "digest": value["digest"]} |
| 91 | + if value.get("settings") is not None: |
| 92 | + result["settings"] = face_settings(value["settings"]) |
| 93 | + return result |
| 94 | + |
| 95 | + |
| 96 | +def normalize_character_voice(value): |
| 97 | + if not isinstance(value, dict) or set(value) - {"provider", "model", "voiceId", "instructions"}: |
| 98 | + raise ValueError("Store voice preferences only, never credentials.") |
| 99 | + if value.get("provider") != "local" or value.get("model") != "qwen3_tts_customvoice": |
| 100 | + raise ValueError("Choose a supported local character voice.") |
| 101 | + if value.get("voiceId") not in {"vivian", "serena", "uncle_fu", "dylan", "eric", "ryan", "aiden", "ono_anna", "sohee"}: |
| 102 | + raise ValueError("Invalid voice preset.") |
| 103 | + if "instructions" in value and (not isinstance(value["instructions"], str) or len(value["instructions"]) > 1000): |
| 104 | + raise ValueError("Voice instructions are too long.") |
| 105 | + return dict(value) |
0 commit comments