bithuman 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of bithuman might be problematic. Click here for more details.

Files changed (44) hide show
  1. bithuman/__init__.py +13 -0
  2. bithuman/_version.py +1 -0
  3. bithuman/api.py +164 -0
  4. bithuman/audio/__init__.py +19 -0
  5. bithuman/audio/audio.py +396 -0
  6. bithuman/audio/hparams.py +108 -0
  7. bithuman/audio/utils.py +255 -0
  8. bithuman/config.py +88 -0
  9. bithuman/engine/__init__.py +15 -0
  10. bithuman/engine/auth.py +335 -0
  11. bithuman/engine/compression.py +257 -0
  12. bithuman/engine/enums.py +16 -0
  13. bithuman/engine/image_ops.py +192 -0
  14. bithuman/engine/inference.py +108 -0
  15. bithuman/engine/knn.py +58 -0
  16. bithuman/engine/video_data.py +391 -0
  17. bithuman/engine/video_reader.py +168 -0
  18. bithuman/lib/__init__.py +1 -0
  19. bithuman/lib/audio_encoder.onnx +45631 -28
  20. bithuman/lib/generator.py +763 -0
  21. bithuman/lib/pth2h5.py +106 -0
  22. bithuman/plugins/__init__.py +0 -0
  23. bithuman/plugins/stt.py +185 -0
  24. bithuman/runtime.py +1004 -0
  25. bithuman/runtime_async.py +469 -0
  26. bithuman/service/__init__.py +9 -0
  27. bithuman/service/client.py +788 -0
  28. bithuman/service/messages.py +210 -0
  29. bithuman/service/server.py +759 -0
  30. bithuman/utils/__init__.py +43 -0
  31. bithuman/utils/agent.py +359 -0
  32. bithuman/utils/fps_controller.py +90 -0
  33. bithuman/utils/image.py +41 -0
  34. bithuman/utils/unzip.py +38 -0
  35. bithuman/video_graph/__init__.py +16 -0
  36. bithuman/video_graph/action_trigger.py +83 -0
  37. bithuman/video_graph/driver_video.py +482 -0
  38. bithuman/video_graph/navigator.py +736 -0
  39. bithuman/video_graph/trigger.py +90 -0
  40. bithuman/video_graph/video_script.py +344 -0
  41. bithuman-1.0.2.dist-info/METADATA +37 -0
  42. bithuman-1.0.2.dist-info/RECORD +44 -0
  43. bithuman-1.0.2.dist-info/WHEEL +5 -0
  44. bithuman-1.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,255 @@
1
+ """Utilities for audio resample."""
2
+ from __future__ import annotations
3
+
4
+ import tempfile
5
+ from functools import cached_property
6
+ from pathlib import Path
7
+ from typing import Iterable, Optional, Tuple
8
+
9
+ import numpy as np
10
+ import soundfile
11
+ import soxr
12
+
13
+ from ..api import AudioChunk
14
+
15
+ INT16_MAX = 2**15 - 1 # 32767
16
+
17
+
18
+ def load_audio(
19
+ audio_path: str, target_sr: Optional[int] = None
20
+ ) -> Tuple[np.ndarray, int]:
21
+ """
22
+ Load an audio file and resample it to the target sample rate.
23
+
24
+ Args:
25
+ audio_path (str): Path to the audio file.
26
+ target_sr (int): Target sample rate for resampling.
27
+
28
+ Returns:
29
+ np.array: Resampled audio buffer.
30
+ """
31
+ audio_np, sr = soundfile.read(audio_path, dtype=np.float32)
32
+ # convert multichannel to single channel
33
+ if len(audio_np.shape) > 1:
34
+ audio_np = np.mean(audio_np, axis=0)
35
+
36
+ if target_sr is not None and sr != target_sr:
37
+ audio_np = soxr.resample(audio_np, sr, target_sr)
38
+ sr = target_sr
39
+
40
+ return audio_np, sr
41
+
42
+
43
+ def float32_to_int16(x: np.ndarray) -> np.ndarray:
44
+ """
45
+ Converts an array of float32 values to int16.
46
+
47
+ Args:
48
+ x (np.array): Array of float32 values.
49
+
50
+ Returns:
51
+ np.array: Array of int16 values.
52
+ """
53
+ return (x * INT16_MAX).astype(np.int16)
54
+
55
+
56
+ def int16_to_float32(x: np.ndarray) -> np.ndarray:
57
+ """
58
+ Converts an array of int16 values to float32.
59
+
60
+ Args:
61
+ x (np.array): Array of int16 values.
62
+
63
+ Returns:
64
+ np.array: Array of float32 values.
65
+ """
66
+ return x.astype(np.float32) / INT16_MAX
67
+
68
+
69
+ def resample(audio_buffer: np.ndarray, origin_sr: int, target_sr: int) -> np.ndarray:
70
+ """
71
+ Resamples an audio buffer.
72
+
73
+ Args:
74
+ audio_buffer (np.array): Array of audio samples, only support int16 and float32.
75
+ origin_sr (int): Original sample rate of the audio buffer.
76
+ target_sr (int): Target sample rate for resampling.
77
+
78
+ Returns:
79
+ np.array: Resampled audio buffer.
80
+ """
81
+
82
+ return soxr.resample(audio_buffer, origin_sr, target_sr)
83
+
84
+
85
+ class AudioStreamBatcher:
86
+ """
87
+ A class to batch audio streams into chunks suitable for video processing.
88
+
89
+ This class takes in a stream of AudioFrame objects and outputs batched AudioFrame
90
+ objects that are suitable for synchronization with video frames.
91
+
92
+ Attributes:
93
+ pre_pad (int): Number of samples to pad before the audio data.
94
+ post_pad (int): Number of samples to pad after the audio data.
95
+ hop_size (int): Number of samples to hop between audio data.
96
+ min_video_frames (int): Minimum number of video frames to process.
97
+ expected_video_frames (int): Expected number of video frames to process.
98
+ fps (int): Frames per second of the video.
99
+ output_sample_rate (int): Sample rate of the output audio data.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ *,
105
+ pre_pad: int = 400,
106
+ post_pad: int = 200 * 13,
107
+ hop_size: int = 200,
108
+ min_video_frames: int = 2,
109
+ expected_video_frames: int = 10,
110
+ fps: int = 25,
111
+ output_sample_rate: int = 16000,
112
+ ) -> None:
113
+ """
114
+ Initialize the AudioStreamBatcher.
115
+
116
+ Args:
117
+ pre_pad (int): Number of samples to pad before the audio data.
118
+ post_pad (int): Number of samples to pad after the audio data.
119
+ hop_size (int): Number of samples to hop between audio data.
120
+ min_video_frames (int): Minimum number of video frames to process.
121
+ expected_video_frames (int): Expected number of video frames to process.
122
+ fps (int): Frames per second of the video.
123
+ output_sample_rate (int): Sample rate of the output audio data.
124
+ """
125
+ self.pre_pad = pre_pad
126
+ self.post_pad = post_pad
127
+ self.hop_size = hop_size
128
+ self.min_video_frames = min_video_frames
129
+ self.expected_video_frames = expected_video_frames
130
+ self.fps = fps
131
+ self.output_sample_rate = output_sample_rate
132
+
133
+ self.bytes_per_sample = 2 # int16
134
+ self.min_n_samples = int(
135
+ output_sample_rate / fps * min_video_frames + pre_pad + post_pad
136
+ )
137
+ self.expect_n_samples = int(
138
+ output_sample_rate / fps * expected_video_frames + pre_pad + post_pad
139
+ )
140
+ self.reset()
141
+
142
+ def reset(self) -> None:
143
+ """Reset the batcher to the initial state."""
144
+ self._buffer = bytearray(np.zeros(self.pre_pad, dtype=np.int16).tobytes())
145
+ self._target_length = self.min_n_samples * self.bytes_per_sample
146
+ self._resampler: soxr.ResampleStream | None = None
147
+
148
+ def push(self, data: AudioChunk | None) -> Iterable[AudioChunk]:
149
+ """
150
+ Process an incoming AudioChunk object and yield properly padded AudioChunk objects.
151
+ Args:
152
+ data (AudioChunk): The incoming audio data to process.
153
+
154
+ Yields:
155
+ AudioChunk: A padded AudioChunk.
156
+ """
157
+ # add the audio data to the buffer if provided
158
+ if data is not None:
159
+ audio_array = data.array
160
+ if data.sample_rate != self.output_sample_rate and self._resampler is None:
161
+ self._resampler = soxr.ResampleStream(
162
+ data.sample_rate, self.output_sample_rate, 1, dtype="int16"
163
+ )
164
+
165
+ if self._resampler is not None:
166
+ audio_array = self._resampler.resample_chunk(
167
+ audio_array, last=data.last_chunk
168
+ )
169
+ self._buffer.extend(audio_array.tobytes())
170
+
171
+ if data is None or data.last_chunk:
172
+ last_chunk = self.flush()
173
+ if last_chunk:
174
+ yield last_chunk
175
+ return
176
+
177
+ while len(self._buffer) >= self._target_length:
178
+ new_chunk = AudioChunk.from_bytes(
179
+ bytes(self._buffer[: self._target_length]),
180
+ self.output_sample_rate,
181
+ last_chunk=data.last_chunk,
182
+ )
183
+ yield new_chunk
184
+
185
+ rest = len(self._buffer) - self._target_length
186
+ keep_from = len(self._buffer) - rest - (self.pre_pad + self.post_pad) * self.bytes_per_sample
187
+ self._buffer = bytearray(self._buffer[keep_from:])
188
+ # increase the batch size after the first chunk until the next flush
189
+ self._target_length = self.expect_n_samples * self.bytes_per_sample
190
+
191
+ def flush(self) -> AudioChunk | None:
192
+ """Flush the audio buffer and yield the remaining audio data."""
193
+ n_samples = len(self._buffer) // self.bytes_per_sample
194
+ if n_samples <= self.pre_pad:
195
+ return None
196
+
197
+ # make sure the samples is n x hop_size
198
+ end_pad = (self.hop_size - n_samples % self.hop_size) % self.hop_size
199
+ self._buffer.extend(np.zeros(end_pad + self.post_pad, dtype=np.int16).tobytes())
200
+ chunk = AudioChunk.from_bytes(
201
+ bytes(self._buffer),
202
+ self.output_sample_rate,
203
+ last_chunk=True,
204
+ )
205
+ self.reset()
206
+ return chunk
207
+
208
+ def unpad(self, audio_array: np.ndarray) -> np.ndarray:
209
+ audio_array = audio_array[self.pre_pad : -self.post_pad]
210
+ return audio_array
211
+
212
+ @cached_property
213
+ def pre_pad_video_frames(self) -> int:
214
+ return self.pre_pad // 200
215
+
216
+
217
+ def write_video_with_audio(
218
+ output_path: str | Path,
219
+ frames: list,
220
+ audio_np: np.ndarray,
221
+ sample_rate: int,
222
+ fps: int = 25,
223
+ ) -> None:
224
+ """Write frames and audio numpy array to a video file.
225
+
226
+ Args:
227
+ output_path: Path to save the video
228
+ frames: List of RGB frames as numpy arrays
229
+ audio_np: Audio as numpy array
230
+ sample_rate: Audio sample rate in Hz
231
+ fps: Video frame rate (default: 25)
232
+ """
233
+ from moviepy.editor import AudioFileClip, ImageSequenceClip
234
+
235
+ # Create video clip from frames
236
+ video_clip = ImageSequenceClip(frames, fps=fps)
237
+
238
+ # Write audio to temp file
239
+ with tempfile.NamedTemporaryFile(suffix=".wav") as temp_audio:
240
+ soundfile.write(temp_audio.name, audio_np, sample_rate)
241
+ # Create audio clip from temp file
242
+ audio_clip = AudioFileClip(temp_audio.name)
243
+ video_clip = video_clip.set_audio(audio_clip)
244
+
245
+ # Write output video
246
+ video_clip.write_videofile(
247
+ str(output_path),
248
+ codec="libx264",
249
+ audio_codec="aac",
250
+ fps=fps,
251
+ )
252
+
253
+ # Close clips
254
+ video_clip.close()
255
+ audio_clip.close()
bithuman/config.py ADDED
@@ -0,0 +1,88 @@
1
+ """Configuration settings for the bithuman runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Literal
7
+
8
+ from pydantic import ConfigDict, Field
9
+ from pydantic_settings import BaseSettings
10
+
11
+ _THIS_DIR = Path(__file__).parent
12
+
13
+
14
+ class Settings(BaseSettings):
15
+ """Settings for the bithuman runtime."""
16
+
17
+ model_config = ConfigDict(extra="ignore")
18
+
19
+ # Video settings
20
+ ALLOW_VIDEO_SCRIPT_UPDATE: bool = Field(
21
+ True, description="Whether to allow the video script to update the settings."
22
+ )
23
+ OUTPUT_WIDTH: int = Field(
24
+ 1280,
25
+ description="The size of the output video for the longest side.",
26
+ )
27
+ FPS: int = Field(25, description="The frames per second for the video.")
28
+ COMPRESS_METHOD: Literal["NONE", "JPEG", "TEMP_FILE"] = Field(
29
+ "JPEG", description="The method to compress the image."
30
+ )
31
+ LOADING_MODE: Literal["SYNC", "ASYNC", "ON_DEMAND"] = Field(
32
+ "ASYNC", description="The mode to load the video."
33
+ )
34
+ INPUT_SAMPLE_RATE: int = Field(
35
+ 16_000, frozen=True, description="The sample rate of the input audio."
36
+ )
37
+ AUDIO_ENCODER_PATH: Path = Field(
38
+ str(_THIS_DIR / "lib" / "audio_encoder.onnx"),
39
+ description="The path to the audio encoder model.",
40
+ )
41
+ EXTRACT_WORKSPACE_TO_LOCAL: bool = Field(
42
+ False, description="Whether to extract the workspace to local."
43
+ )
44
+ PROCESS_IDLE_VIDEO: bool = Field(True, description="Whether to process idle video.")
45
+
46
+ # LIVA
47
+ LIVA_IDEL_VIDEO_ENABLED: bool = Field(
48
+ True, description="Whether to enable idle video."
49
+ )
50
+ LIVA_AUTO_SAY_HI: bool = Field(
51
+ False, description="Whether to automatically say hi in the video."
52
+ )
53
+
54
+ # Video triggers
55
+ KEYWORD_VIDEO_TRIGGERS_ENABLED: Annotated[
56
+ bool, Field(description="Whether to enable keyword video triggers.")
57
+ ] = True
58
+ KEYWORD_VIDEO_TRIGGERS_JSON: Annotated[
59
+ str,
60
+ Field(
61
+ description="JSON string containing keyword trigger configurations",
62
+ default="[]",
63
+ ),
64
+ ] = "[]"
65
+
66
+
67
+ _settings = None
68
+
69
+
70
+ def load_settings(force_reload: bool = False) -> Settings:
71
+ """Load the settings for the bithuman runtime.
72
+
73
+ Args:
74
+ force_reload: Whether to force a reload of the settings. Defaults to False
75
+
76
+ Returns:
77
+ The settings for the bithuman runtime
78
+ """
79
+ global _settings
80
+
81
+ if force_reload:
82
+ _settings = None
83
+
84
+ if not _settings:
85
+ # level: environ > .env file > default
86
+ _settings = Settings(_env_file=".env", _env_file_encoding="utf-8")
87
+
88
+ return _settings
@@ -0,0 +1,15 @@
1
+ """Pure Python engine replacing the C++ _bithuman_py extension."""
2
+
3
+ import gc as _gc
4
+
5
+ from .enums import CompressionType, LoadingMode
6
+
7
+ RUNTIME_VERSION = "1.0.1"
8
+
9
+ # Tune GC to reduce pause frequency during frame processing.
10
+ # Default thresholds are (700, 10, 10). Raising gen0 to 50000 means
11
+ # gen0 collections happen ~70x less often, reducing tail latency spikes.
12
+ # Gen1/gen2 ratios stay at 10 (unchanged from default).
13
+ _gc.set_threshold(50000, 10, 10)
14
+
15
+ __all__ = ["CompressionType", "LoadingMode", "RUNTIME_VERSION"]
@@ -0,0 +1,335 @@
1
+ """JWT validation, hardware fingerprinting, token refresh — replaces
2
+ jwt.cpp, fingerprint.cpp, token_request.cpp, hash.cpp.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import platform
11
+ import socket
12
+ import sys
13
+ import threading
14
+ import time
15
+ import uuid
16
+ from datetime import datetime
17
+ from typing import Dict, List, Optional, Set
18
+
19
+ from loguru import logger
20
+
21
+ from . import RUNTIME_VERSION
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # The JWT signing secret — matches getSecretKey() in generator.cpp.
25
+ # The C++ code uses compile-time XOR obfuscation, but the XOR+un-XOR
26
+ # in getSecretKey() is a round-trip that returns the original plaintext.
27
+ # ---------------------------------------------------------------------------
28
+ _SECRET_KEY = "myxVr8FBASSOyPet2Jf2VQBbe3Pbeliv"
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Hashing — replaces hash.cpp
33
+ # ---------------------------------------------------------------------------
34
+ def calculate_md5(data: str) -> str:
35
+ """MD5 hex digest of a string. Matches hash.cpp calculateMD5."""
36
+ return hashlib.md5(data.encode("utf-8")).hexdigest()
37
+
38
+
39
+ def calculate_sha256(data: str) -> str:
40
+ """SHA-256 hex digest of a string. Matches hash.cpp calculateSHA256."""
41
+ return hashlib.sha256(data.encode("utf-8")).hexdigest()
42
+
43
+
44
+ def calculate_file_md5(file_path: str) -> str:
45
+ """MD5 hex digest of file contents. Matches hash.cpp calculateFileMD5."""
46
+ md5 = hashlib.md5()
47
+ try:
48
+ with open(file_path, "rb") as f:
49
+ while True:
50
+ chunk = f.read(8192)
51
+ if not chunk:
52
+ break
53
+ md5.update(chunk)
54
+ return md5.hexdigest()
55
+ except OSError:
56
+ return ""
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Hardware fingerprint — replaces fingerprint.cpp
61
+ # ---------------------------------------------------------------------------
62
+ def generate_hardware_fingerprint() -> str:
63
+ """Generate MD5 hash of machine-id + hostname + timestamp.
64
+
65
+ Matches fingerprint.cpp generateHardwareFingerprint (lines 22-108):
66
+ Linux: /etc/machine-id + "-" + hostname + "-" + YYYYMMDDHHMM → MD5
67
+ macOS: hostname + "-" + platform UUID + "-" + YYYYMMDDHHMM → MD5
68
+ Windows: computer name + "-" + volume serial + "-" + YYYYMMDDHHMM → MD5
69
+ """
70
+ parts: List[str] = []
71
+
72
+ if sys.platform == "win32":
73
+ parts.append(platform.node())
74
+ # Volume serial approximation
75
+ parts.append(str(uuid.getnode()))
76
+ elif sys.platform == "darwin":
77
+ parts.append(socket.gethostname())
78
+ # macOS hardware UUID via ioreg
79
+ try:
80
+ import subprocess
81
+
82
+ result = subprocess.run(
83
+ ["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
84
+ capture_output=True,
85
+ text=True,
86
+ )
87
+ for line in result.stdout.split("\n"):
88
+ if "IOPlatformUUID" in line:
89
+ hw_uuid = line.split('"')[-2]
90
+ parts.append(hw_uuid)
91
+ break
92
+ except Exception:
93
+ pass
94
+ else:
95
+ # Linux
96
+ try:
97
+ with open("/etc/machine-id") as f:
98
+ parts.append(f.read().strip())
99
+ except FileNotFoundError:
100
+ pass
101
+ parts.append(socket.gethostname())
102
+
103
+ # Add timestamp (YYYYMMDDHHMM) — matches C++ strftime "%Y%m%d%H%M"
104
+ now = datetime.now()
105
+ parts.append(now.strftime("%Y%m%d%H%M"))
106
+
107
+ fingerprint = "-".join(parts)
108
+ return calculate_md5(fingerprint)
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # JWT Validator — replaces JWTValidator class in jwt.cpp
113
+ # ---------------------------------------------------------------------------
114
+ class JWTValidator:
115
+ """Validates JWT tokens with HS256 signing.
116
+
117
+ Matches jwt.cpp JWTValidator class: verifies signature, extracts claims
118
+ (iss, sub, aud, exp, nbf, videos, models, instance_id, model_hash_type,
119
+ ref.runtime_model_hash), manages allowed hash sets.
120
+ """
121
+
122
+ def __init__(self, secret_key: str = _SECRET_KEY) -> None:
123
+ self._secret_key = secret_key
124
+ self._is_valid = False
125
+ self._expiration_time: Optional[float] = None # Unix timestamp
126
+ self._issuer = ""
127
+ self._subject = ""
128
+ self._audience = ""
129
+ self._not_before: Optional[int] = None
130
+ self._allowed_video_hashes: Set[str] = set()
131
+ self._allowed_model_hashes: Set[str] = set()
132
+ self._use_raw_model_hash = False
133
+ self._instance_id = ""
134
+ self._runtime_model_hash = ""
135
+
136
+ def validate_token(self, token: str, verbose: bool = True) -> bool:
137
+ """Validate JWT token. Matches jwt.cpp validateToken (lines 29-456).
138
+
139
+ Uses PyJWT for HS256 verification with 300s leeway.
140
+ """
141
+ try:
142
+ import jwt as pyjwt
143
+
144
+ payload = pyjwt.decode(
145
+ token,
146
+ self._secret_key,
147
+ algorithms=["HS256"],
148
+ options={
149
+ "verify_exp": True,
150
+ "verify_aud": False, # C++ jwt-cpp doesn't verify aud
151
+ "require": ["exp"],
152
+ },
153
+ leeway=300, # 5 min clock skew — matches C++
154
+ )
155
+
156
+ # Extract standard claims
157
+ self._issuer = payload.get("iss", "")
158
+ self._subject = payload.get("sub", "")
159
+ self._audience = payload.get("aud", "")
160
+ self._not_before = payload.get("nbf")
161
+
162
+ # Expiration
163
+ exp = payload.get("exp")
164
+ if exp is not None:
165
+ self._expiration_time = float(exp)
166
+ if time.time() > self._expiration_time:
167
+ if verbose:
168
+ logger.error("Token has expired!")
169
+ return False
170
+
171
+ # Video hashes
172
+ videos = payload.get("videos", [])
173
+ if isinstance(videos, list):
174
+ self._allowed_video_hashes = {h for h in videos if isinstance(h, str)}
175
+
176
+ # Model hashes
177
+ models = payload.get("models", [])
178
+ if isinstance(models, list):
179
+ self._allowed_model_hashes = {h for h in models if isinstance(h, str)}
180
+
181
+ # Instance ID
182
+ self._instance_id = payload.get("instance_id", "")
183
+
184
+ # Model hash type
185
+ hash_type = payload.get("model_hash_type", "")
186
+ self._use_raw_model_hash = hash_type == "md5sum"
187
+
188
+ # Runtime model hash from ref claim
189
+ ref = payload.get("ref", {})
190
+ if isinstance(ref, dict):
191
+ self._runtime_model_hash = ref.get("runtime_model_hash", "")
192
+
193
+ self._is_valid = True
194
+ return True
195
+
196
+ except Exception as e:
197
+ if verbose:
198
+ logger.error(f"Token validation failed: {e}")
199
+ return False
200
+
201
+ def is_validated(self) -> bool:
202
+ return self._is_valid
203
+
204
+ def has_expired(self) -> bool:
205
+ """Check if token has expired. Matches jwt.cpp hasExpired."""
206
+ if not self._is_valid:
207
+ return True
208
+ if self._expiration_time is None:
209
+ return False
210
+ return time.time() > self._expiration_time
211
+
212
+ def get_expiration_time(self) -> Optional[float]:
213
+ if not self._is_valid or self._expiration_time is None:
214
+ return None
215
+ return self._expiration_time
216
+
217
+ def get_issuer(self) -> str:
218
+ return self._issuer
219
+
220
+ def get_audience(self) -> str:
221
+ return self._audience
222
+
223
+ def get_not_before(self) -> Optional[int]:
224
+ return self._not_before
225
+
226
+ def get_runtime_model_hash(self) -> str:
227
+ return self._runtime_model_hash
228
+
229
+ def is_video_hash_allowed(self, video_hash: str) -> bool:
230
+ """Matches jwt.cpp isVideoHashAllowed (lines 458-470)."""
231
+ if not self._is_valid:
232
+ return False
233
+ if not self._allowed_video_hashes:
234
+ return True
235
+ return video_hash in self._allowed_video_hashes
236
+
237
+ def is_model_hash_allowed(
238
+ self, raw_model_hash: str, encrypted_model_hash: str = ""
239
+ ) -> bool:
240
+ """Matches jwt.cpp isModelHashAllowed (lines 472-498)."""
241
+ if not self._is_valid:
242
+ return False
243
+ if not self._allowed_model_hashes:
244
+ return True
245
+ if self._use_raw_model_hash:
246
+ return raw_model_hash in self._allowed_model_hashes
247
+ hash_to_check = (
248
+ encrypted_model_hash if encrypted_model_hash else self.encrypt_model_hash(raw_model_hash)
249
+ )
250
+ return hash_to_check in self._allowed_model_hashes
251
+
252
+ def encrypt_model_hash(self, model_hash: str) -> str:
253
+ """Matches jwt.cpp encryptModelHash (lines 500-510):
254
+ SHA256(model_hash + SHA256(secret_key))
255
+ """
256
+ secret_hash = calculate_sha256(self._secret_key)
257
+ combined = model_hash + secret_hash
258
+ return calculate_sha256(combined)
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Token request — replaces token_request.cpp
263
+ # ---------------------------------------------------------------------------
264
+ def request_token(
265
+ api_url: str,
266
+ api_secret: str,
267
+ fingerprint: str,
268
+ runtime_model_hash: Optional[str] = None,
269
+ tags: Optional[str] = None,
270
+ transaction_id: Optional[str] = None,
271
+ insecure: bool = False,
272
+ timeout: float = 30.0,
273
+ ) -> str:
274
+ """HTTP POST to auth API, returns JWT token string.
275
+
276
+ Matches token_request.cpp requestToken (lines 26-134):
277
+ POST with JSON body and api-secret header.
278
+ """
279
+ import requests
280
+
281
+ body: Dict[str, str] = {
282
+ "fingerprint": fingerprint,
283
+ "runtime_version": RUNTIME_VERSION,
284
+ }
285
+ if runtime_model_hash:
286
+ body["runtime_model_hash"] = runtime_model_hash
287
+ if tags:
288
+ body["tags"] = tags
289
+ if transaction_id:
290
+ body["transaction_id"] = transaction_id
291
+
292
+ headers = {
293
+ "Content-Type": "application/json",
294
+ "api-secret": api_secret,
295
+ }
296
+
297
+ resp = requests.post(
298
+ api_url,
299
+ json=body,
300
+ headers=headers,
301
+ timeout=timeout,
302
+ verify=not insecure,
303
+ )
304
+
305
+ if resp.status_code == 200:
306
+ data = resp.json()
307
+ # API wraps response in {"data": {"token": ...}, "status": ...}
308
+ token = data.get("token", "") or data.get("data", {}).get("token", "")
309
+ if not token:
310
+ raise RuntimeError("Failed to parse token from response")
311
+ return token
312
+ elif resp.status_code == 402:
313
+ raise RuntimeError(
314
+ "Payment Required (402): Your quota has been exceeded or "
315
+ "payment is required. Runtime terminated."
316
+ )
317
+ elif resp.status_code == 403:
318
+ raise RuntimeError("Forbidden (403): Access denied. Runtime terminated.")
319
+ elif resp.status_code == 400:
320
+ raise RuntimeError(
321
+ "Bad Request (400): Runtime version is too old or invalid. "
322
+ "Please upgrade. Runtime terminated."
323
+ )
324
+ else:
325
+ raise RuntimeError(
326
+ f"Token request failed with status code: {resp.status_code}"
327
+ )
328
+
329
+
330
+ # ---------------------------------------------------------------------------
331
+ # UUID generation — replaces jwt.cpp generateUUID
332
+ # ---------------------------------------------------------------------------
333
+ def generate_uuid() -> str:
334
+ """Generate a UUID4 string. Matches jwt.cpp generateUUID."""
335
+ return str(uuid.uuid4())