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.
- bithuman/__init__.py +13 -0
- bithuman/_version.py +1 -0
- bithuman/api.py +164 -0
- bithuman/audio/__init__.py +19 -0
- bithuman/audio/audio.py +396 -0
- bithuman/audio/hparams.py +108 -0
- bithuman/audio/utils.py +255 -0
- bithuman/config.py +88 -0
- bithuman/engine/__init__.py +15 -0
- bithuman/engine/auth.py +335 -0
- bithuman/engine/compression.py +257 -0
- bithuman/engine/enums.py +16 -0
- bithuman/engine/image_ops.py +192 -0
- bithuman/engine/inference.py +108 -0
- bithuman/engine/knn.py +58 -0
- bithuman/engine/video_data.py +391 -0
- bithuman/engine/video_reader.py +168 -0
- bithuman/lib/__init__.py +1 -0
- bithuman/lib/audio_encoder.onnx +45631 -28
- bithuman/lib/generator.py +763 -0
- bithuman/lib/pth2h5.py +106 -0
- bithuman/plugins/__init__.py +0 -0
- bithuman/plugins/stt.py +185 -0
- bithuman/runtime.py +1004 -0
- bithuman/runtime_async.py +469 -0
- bithuman/service/__init__.py +9 -0
- bithuman/service/client.py +788 -0
- bithuman/service/messages.py +210 -0
- bithuman/service/server.py +759 -0
- bithuman/utils/__init__.py +43 -0
- bithuman/utils/agent.py +359 -0
- bithuman/utils/fps_controller.py +90 -0
- bithuman/utils/image.py +41 -0
- bithuman/utils/unzip.py +38 -0
- bithuman/video_graph/__init__.py +16 -0
- bithuman/video_graph/action_trigger.py +83 -0
- bithuman/video_graph/driver_video.py +482 -0
- bithuman/video_graph/navigator.py +736 -0
- bithuman/video_graph/trigger.py +90 -0
- bithuman/video_graph/video_script.py +344 -0
- bithuman-1.0.2.dist-info/METADATA +37 -0
- bithuman-1.0.2.dist-info/RECORD +44 -0
- bithuman-1.0.2.dist-info/WHEEL +5 -0
- bithuman-1.0.2.dist-info/top_level.txt +1 -0
bithuman/audio/utils.py
ADDED
|
@@ -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"]
|
bithuman/engine/auth.py
ADDED
|
@@ -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())
|