amv-diffusion 2.1.0__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.
- amv_diffusion-2.1.0.dist-info/METADATA +180 -0
- amv_diffusion-2.1.0.dist-info/RECORD +39 -0
- amv_diffusion-2.1.0.dist-info/WHEEL +5 -0
- amv_diffusion-2.1.0.dist-info/entry_points.txt +7 -0
- amv_diffusion-2.1.0.dist-info/licenses/LICENSE +21 -0
- amv_diffusion-2.1.0.dist-info/top_level.txt +1 -0
- termux_diffusion/__init__.py +185 -0
- termux_diffusion/__main__.py +7 -0
- termux_diffusion/_version.py +3 -0
- termux_diffusion/adapter.py +41 -0
- termux_diffusion/canvas.py +364 -0
- termux_diffusion/cli.py +492 -0
- termux_diffusion/cluster.py +340 -0
- termux_diffusion/control/__init__.py +5 -0
- termux_diffusion/control/component.py +309 -0
- termux_diffusion/control/status.py +15 -0
- termux_diffusion/core.py +2936 -0
- termux_diffusion/data/bootstrap-manifest.json +15 -0
- termux_diffusion/data/prompt_cache/108a7149c4e70550.json +10 -0
- termux_diffusion/data/release-public-keys.json +10 -0
- termux_diffusion/data/validated-vulkan-profiles.json +298 -0
- termux_diffusion/detailer.py +283 -0
- termux_diffusion/doctor.py +128 -0
- termux_diffusion/downloader.py +111 -0
- termux_diffusion/engine.py +226 -0
- termux_diffusion/exceptions.py +158 -0
- termux_diffusion/hardware.py +985 -0
- termux_diffusion/hub.py +686 -0
- termux_diffusion/installer.py +499 -0
- termux_diffusion/locking.py +134 -0
- termux_diffusion/manifest.py +128 -0
- termux_diffusion/npu.py +267 -0
- termux_diffusion/platform.py +533 -0
- termux_diffusion/purger.py +145 -0
- termux_diffusion/py.typed +1 -0
- termux_diffusion/safety.py +150 -0
- termux_diffusion/selftest.py +178 -0
- termux_diffusion/web.py +3370 -0
- termux_diffusion/workflow.py +1345 -0
termux_diffusion/hub.py
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
# Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0 OR Commercial-License
|
|
3
|
+
"""Smart Model Hub, preset management, Hugging Face streaming downloader, GGUF validation, and local caching."""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import struct
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from types import MappingProxyType
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
20
|
+
|
|
21
|
+
from .exceptions import ModelDownloadError, ModelNotFoundError
|
|
22
|
+
from .platform import get_default_cache_dir
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("termux_diffusion.hub")
|
|
25
|
+
|
|
26
|
+
# Built-in Samsung Galaxy & Mobile optimized GGUF presets (Immutable MappingProxy)
|
|
27
|
+
DEFAULT_PRESETS = MappingProxyType({
|
|
28
|
+
"z-image-turbo": {
|
|
29
|
+
"repo_id": "leejet/Z-Image-Turbo-GGUF",
|
|
30
|
+
"filename": "z_image_turbo-Q2_K.gguf",
|
|
31
|
+
"alias": "z_image_turbo.gguf",
|
|
32
|
+
"description": "Z-Image Turbo 6B DiT - 4-step Photorealistic DiT Model",
|
|
33
|
+
"size_mb": 2472,
|
|
34
|
+
"default_steps": 4,
|
|
35
|
+
"default_cfg": 1.0,
|
|
36
|
+
"default_guidance": 3.5,
|
|
37
|
+
"default_sampler": "euler",
|
|
38
|
+
"default_schedule": "flux",
|
|
39
|
+
"default_device": "vulkan",
|
|
40
|
+
"default_diffusion_model": "z_image_turbo-Q2_K.gguf",
|
|
41
|
+
"default_llm": "unsloth/Qwen3-4B-Instruct-2507-GGUF/Qwen3-4B-Instruct-2507-Q2_K.gguf",
|
|
42
|
+
"default_vae": "z_image_ae.safetensors",
|
|
43
|
+
"default_taesd": "taef1.safetensors",
|
|
44
|
+
"default_vae_format": "flux",
|
|
45
|
+
"status": "verified"
|
|
46
|
+
},
|
|
47
|
+
"z-image": {
|
|
48
|
+
"alias": "z-image-turbo",
|
|
49
|
+
},
|
|
50
|
+
"turbo-6b": {
|
|
51
|
+
"alias": "z-image-turbo",
|
|
52
|
+
},
|
|
53
|
+
"taef1": {
|
|
54
|
+
"repo_id": "madebyollin/taef1",
|
|
55
|
+
"filename": "diffusion_pytorch_model.safetensors",
|
|
56
|
+
"alias": "taef1.safetensors",
|
|
57
|
+
"description": "Tiny AutoEncoder for FLUX.1 & Z-Image Turbo (~1-3s fast VAE decode)",
|
|
58
|
+
"size_mb": 10,
|
|
59
|
+
"status": "verified"
|
|
60
|
+
},
|
|
61
|
+
"qwen3-4b": {
|
|
62
|
+
"repo_id": "unsloth/Qwen3-4B-Instruct-2507-GGUF",
|
|
63
|
+
"filename": "Qwen3-4B-Instruct-2507-Q2_K.gguf",
|
|
64
|
+
"alias": "Qwen3-4B-Instruct-2507-Q2_K.gguf",
|
|
65
|
+
"description": "Qwen3-4B-Instruct Q2_K Text Encoder LLM for Z-Image Turbo",
|
|
66
|
+
"size_mb": 1560,
|
|
67
|
+
"status": "verified"
|
|
68
|
+
},
|
|
69
|
+
"fast": {
|
|
70
|
+
"repo_id": "concedo/sdxs-512-tinySDdistilled-GGUF",
|
|
71
|
+
"filename": "sdxs-512-tinySDdistilled_Q8_0.gguf",
|
|
72
|
+
"alias": "sdxs.gguf",
|
|
73
|
+
"description": "Fast Preset (SDXS Q8_0) - Ultra-fast 256x256 1-step Vulkan inference (Verified)",
|
|
74
|
+
"size_mb": 651,
|
|
75
|
+
"default_steps": 1,
|
|
76
|
+
"default_cfg": 1.0,
|
|
77
|
+
"default_width": 256,
|
|
78
|
+
"default_height": 256,
|
|
79
|
+
"default_sampler": "euler_a",
|
|
80
|
+
"default_device": "vulkan",
|
|
81
|
+
"status": "verified"
|
|
82
|
+
},
|
|
83
|
+
"balanced": {
|
|
84
|
+
"repo_id": "concedo/sdxs-512-tinySDdistilled-GGUF",
|
|
85
|
+
"filename": "sdxs-512-tinySDdistilled_Q8_0.gguf",
|
|
86
|
+
"alias": "sdxs.gguf",
|
|
87
|
+
"description": "Balanced Preset (SDXS Q8_0) - High-quality 512x512 2-step Vulkan inference (Verified)",
|
|
88
|
+
"size_mb": 651,
|
|
89
|
+
"default_steps": 2,
|
|
90
|
+
"default_cfg": 1.0,
|
|
91
|
+
"default_guidance": 3.5,
|
|
92
|
+
"default_width": 512,
|
|
93
|
+
"default_height": 512,
|
|
94
|
+
"default_sampler": "euler_a",
|
|
95
|
+
"default_device": "vulkan",
|
|
96
|
+
"status": "verified"
|
|
97
|
+
},
|
|
98
|
+
"anime-experimental": {
|
|
99
|
+
"repo_id": "haven-ai-companion/dreamshaper8-lcm-gguf",
|
|
100
|
+
"filename": "DreamShaper8_LCM_q4_0.gguf",
|
|
101
|
+
"alias": "anime.gguf",
|
|
102
|
+
"description": "Anime Experimental (DreamShaper 8 LCM Q4_0) - 512x512 6-step Vulkan inference (Verified Experimental)",
|
|
103
|
+
"size_mb": 1550,
|
|
104
|
+
"default_steps": 6,
|
|
105
|
+
"default_cfg": 1.5,
|
|
106
|
+
"default_width": 512,
|
|
107
|
+
"default_height": 512,
|
|
108
|
+
"default_sampler": "lcm",
|
|
109
|
+
"default_device": "vulkan",
|
|
110
|
+
"default_vae_tiling": True,
|
|
111
|
+
"status": "verified_experimental"
|
|
112
|
+
},
|
|
113
|
+
"sdxs": {
|
|
114
|
+
"repo_id": "concedo/sdxs-512-tinySDdistilled-GGUF",
|
|
115
|
+
"filename": "sdxs-512-tinySDdistilled_Q8_0.gguf",
|
|
116
|
+
"alias": "sdxs.gguf",
|
|
117
|
+
"description": "SDXS 512 Tiny SD Distilled (Q8_0) - Ultra-lightweight 1-2 step distilled model (CFG 1.0, Euler A)",
|
|
118
|
+
"size_mb": 651,
|
|
119
|
+
"default_steps": 2,
|
|
120
|
+
"default_cfg": 1.0,
|
|
121
|
+
"default_guidance": 3.5,
|
|
122
|
+
},
|
|
123
|
+
"anime": {
|
|
124
|
+
"repo_id": "haven-ai-companion/dreamshaper8-lcm-gguf",
|
|
125
|
+
"filename": "DreamShaper8_LCM_q4_0.gguf",
|
|
126
|
+
"alias": "anime.gguf",
|
|
127
|
+
"description": "DreamShaper 8 LCM (Q4_0) - Fast 6-step LCM stylized anime & illustration art (CFG 1.5, LCM)",
|
|
128
|
+
"size_mb": 1550,
|
|
129
|
+
"default_steps": 6,
|
|
130
|
+
"default_cfg": 1.5,
|
|
131
|
+
},
|
|
132
|
+
"realistic": {
|
|
133
|
+
"repo_id": "second-state/Realistic_Vision_V6.0_B1-GGUF",
|
|
134
|
+
"filename": "realisticVisionV60B1_v51HyperVAE-Q4_k.gguf",
|
|
135
|
+
"alias": "realistic.gguf",
|
|
136
|
+
"description": "Realistic Vision V6.0 B1 (Q4_K) - Full SD1.5 photorealistic portraits (Vulkan disabled pending Q4_K runtime instrumentation)",
|
|
137
|
+
"size_mb": 1547,
|
|
138
|
+
"default_steps": 20,
|
|
139
|
+
"default_cfg": 7.0,
|
|
140
|
+
"status": "disabled_pending_instrumentation"
|
|
141
|
+
},
|
|
142
|
+
"speed": {
|
|
143
|
+
"repo_id": "gpustack/stable-diffusion-v1-5-GGUF",
|
|
144
|
+
"filename": "stable-diffusion-v1-5-Q4_1.gguf",
|
|
145
|
+
"alias": "lightning.gguf",
|
|
146
|
+
"description": "Stable Diffusion 1.5 Base (Q4_1) - General-purpose base model (Needs 15-20 steps, CFG 6.0)",
|
|
147
|
+
"size_mb": 1682,
|
|
148
|
+
"default_steps": 20,
|
|
149
|
+
"default_cfg": 6.0,
|
|
150
|
+
},
|
|
151
|
+
"turbo": {
|
|
152
|
+
"repo_id": "second-state/stable-diffusion-v1-5-GGUF",
|
|
153
|
+
"filename": "stable-diffusion-v1-5-pruned-emaonly-Q4_0.gguf",
|
|
154
|
+
"alias": "turbo.gguf",
|
|
155
|
+
"description": "Stable Diffusion 1.5 Pruned (Q4_0) - High-efficiency SD1.5 base model (Needs 15-20 steps, CFG 6.0)",
|
|
156
|
+
"size_mb": 1494,
|
|
157
|
+
"default_steps": 20,
|
|
158
|
+
"default_cfg": 6.0,
|
|
159
|
+
},
|
|
160
|
+
"counterfeit-v3-lcm": {
|
|
161
|
+
"repo_id": "mzwing/Anything-LCM-GGUF",
|
|
162
|
+
"filename": "anything_v30-LCM.q4_0.gguf",
|
|
163
|
+
"alias": "counterfeit-v3-lcm.gguf",
|
|
164
|
+
"description": "Counterfeit / Anime LCM (Q4_0) - High-quality 4~6 step Anime & Illustration generation",
|
|
165
|
+
"size_mb": 1566,
|
|
166
|
+
"default_steps": 6,
|
|
167
|
+
"default_cfg": 1.5,
|
|
168
|
+
"default_sampler": "lcm",
|
|
169
|
+
},
|
|
170
|
+
"anything-v5-lcm": {
|
|
171
|
+
"repo_id": "mzwing/Anything-LCM-GGUF",
|
|
172
|
+
"filename": "anything_v30-LCM.q4_0.gguf",
|
|
173
|
+
"alias": "anything-v5-lcm.gguf",
|
|
174
|
+
"description": "Anything V5 / V3 LCM (Q4_0) - 4~6 step Stylized Anime art",
|
|
175
|
+
"size_mb": 1566,
|
|
176
|
+
"default_steps": 6,
|
|
177
|
+
"default_cfg": 1.5,
|
|
178
|
+
"default_sampler": "lcm",
|
|
179
|
+
},
|
|
180
|
+
"ghostmix-lcm": {
|
|
181
|
+
"repo_id": "void-gryph/ghostmix-GGUF",
|
|
182
|
+
"filename": "ghostmix.Q4_K_M.gguf",
|
|
183
|
+
"alias": "ghostmix.gguf",
|
|
184
|
+
"description": "GhostMix (Q4_K_M) - 2.5D Semi-realistic Anime illustration",
|
|
185
|
+
"size_mb": 484,
|
|
186
|
+
"default_steps": 15,
|
|
187
|
+
"default_cfg": 6.0,
|
|
188
|
+
},
|
|
189
|
+
"cyberrealistic-lcm": {
|
|
190
|
+
"repo_id": "void-gryph/cyberrealistic-GGUF",
|
|
191
|
+
"filename": "cyberrealistic.Q4_K_M.gguf",
|
|
192
|
+
"alias": "cyberrealistic.gguf",
|
|
193
|
+
"description": "CyberRealistic (Q4_K_M) - High-detail photorealistic portraits",
|
|
194
|
+
"size_mb": 484,
|
|
195
|
+
"default_steps": 15,
|
|
196
|
+
"default_cfg": 6.0,
|
|
197
|
+
},
|
|
198
|
+
"bk-sdm-tiny": {
|
|
199
|
+
"repo_id": "concedo/sdxs-512-tinySDdistilled-GGUF",
|
|
200
|
+
"filename": "sdxs-512-tinySDdistilled_Q8_0.gguf",
|
|
201
|
+
"alias": "sdxs.gguf",
|
|
202
|
+
"description": "BK-SDM Tiny Fallback (SDXS Distilled Q8_0) - Ultra-lightweight on-device diffusion",
|
|
203
|
+
"size_mb": 651,
|
|
204
|
+
"default_steps": 1,
|
|
205
|
+
"default_cfg": 1.0,
|
|
206
|
+
},
|
|
207
|
+
"z-image-llm": {
|
|
208
|
+
"repo_id": "unsloth/Qwen3-4B-Instruct-2507-GGUF",
|
|
209
|
+
"filename": "Qwen3-4B-Instruct-2507-Q2_K.gguf",
|
|
210
|
+
"alias": "Qwen3-4B-Instruct-2507-Q2_K.gguf",
|
|
211
|
+
"description": "Qwen3 4B Instruct (Q2_K) - Text Encoder LLM for Z-Image",
|
|
212
|
+
"size_mb": 1592,
|
|
213
|
+
},
|
|
214
|
+
"z-image-vae": {
|
|
215
|
+
"repo_id": "Comfy-Org/z_image_turbo",
|
|
216
|
+
"filename": "split_files/vae/ae.safetensors",
|
|
217
|
+
"alias": "z_image_ae.safetensors",
|
|
218
|
+
"description": "FLUX / Z-Image VAE Decoder",
|
|
219
|
+
"size_mb": 320,
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
_registry_lock = threading.Lock()
|
|
224
|
+
_custom_registry: Dict[str, Dict] = {}
|
|
225
|
+
_active_cache_dir: Optional[Path] = None
|
|
226
|
+
|
|
227
|
+
# GGUF Magic bytes: 0x47 0x47 0x55 0x46 in ASCII ("GGUF")
|
|
228
|
+
GGUF_MAGIC = b"GGUF"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def validate_gguf_file(file_path: Union[str, Path]) -> bool:
|
|
232
|
+
"""Validate whether the file is a genuine GGUF binary format by inspecting the header magic bytes & version."""
|
|
233
|
+
path = Path(os.path.expanduser(str(file_path))).resolve()
|
|
234
|
+
if not path.is_file():
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
if path.stat().st_size < 8:
|
|
239
|
+
return False
|
|
240
|
+
with open(path, "rb") as f:
|
|
241
|
+
header = f.read(4)
|
|
242
|
+
if header != GGUF_MAGIC:
|
|
243
|
+
return False
|
|
244
|
+
version_bytes = f.read(4)
|
|
245
|
+
if len(version_bytes) < 4:
|
|
246
|
+
return False
|
|
247
|
+
version = struct.unpack("<I", version_bytes)[0]
|
|
248
|
+
return version in (1, 2, 3)
|
|
249
|
+
except Exception as e:
|
|
250
|
+
logger.debug("GGUF header validation error on %s: %s", path, e)
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def verify_file_sha256(file_path: Union[str, Path], expected_sha256: str) -> bool:
|
|
255
|
+
"""Compute and verify SHA256 checksum for a downloaded model file."""
|
|
256
|
+
path = Path(os.path.expanduser(str(file_path))).resolve()
|
|
257
|
+
if not path.is_file():
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
sha256_hash = hashlib.sha256()
|
|
261
|
+
try:
|
|
262
|
+
with open(path, "rb") as f:
|
|
263
|
+
for byte_block in iter(lambda: f.read(1024 * 1024), b""):
|
|
264
|
+
sha256_hash.update(byte_block)
|
|
265
|
+
return sha256_hash.hexdigest().lower() == expected_sha256.lower().strip()
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.error("SHA256 verification failed on %s: %s", path, e)
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def set_cache_dir(path: Union[str, Path]) -> Path:
|
|
272
|
+
"""Explicitly set custom storage directory for model weights and caches."""
|
|
273
|
+
global _active_cache_dir
|
|
274
|
+
resolved = Path(os.path.expanduser(str(path))).resolve()
|
|
275
|
+
resolved.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
with _registry_lock:
|
|
277
|
+
_active_cache_dir = resolved
|
|
278
|
+
logger.info("termux-diffusion cache directory set to: %s", resolved)
|
|
279
|
+
return resolved
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def get_cache_dir() -> Path:
|
|
283
|
+
"""Get active model cache directory."""
|
|
284
|
+
with _registry_lock:
|
|
285
|
+
if _active_cache_dir is not None:
|
|
286
|
+
return _active_cache_dir
|
|
287
|
+
return get_default_cache_dir() / "models"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def register_model(
|
|
291
|
+
name: str,
|
|
292
|
+
repo_id: str,
|
|
293
|
+
filename: str,
|
|
294
|
+
alias: Optional[str] = None,
|
|
295
|
+
description: Optional[str] = None,
|
|
296
|
+
default_steps: int = 10,
|
|
297
|
+
default_cfg: float = 4.0,
|
|
298
|
+
sha256: Optional[str] = None,
|
|
299
|
+
size_mb: Optional[int] = None,
|
|
300
|
+
**kwargs,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Register a custom Hugging Face GGUF model into the hub catalog."""
|
|
303
|
+
alias_name = str(alias) if alias else f"{name}.gguf"
|
|
304
|
+
with _registry_lock:
|
|
305
|
+
meta = {
|
|
306
|
+
"repo_id": str(repo_id),
|
|
307
|
+
"filename": str(filename),
|
|
308
|
+
"alias": alias_name,
|
|
309
|
+
"description": description or f"Custom model '{name}' from {repo_id}",
|
|
310
|
+
"default_steps": default_steps,
|
|
311
|
+
"default_cfg": default_cfg,
|
|
312
|
+
"sha256": sha256,
|
|
313
|
+
}
|
|
314
|
+
if size_mb is not None:
|
|
315
|
+
meta["size_mb"] = int(size_mb)
|
|
316
|
+
meta.update(kwargs)
|
|
317
|
+
_custom_registry[name] = meta
|
|
318
|
+
logger.info("Registered custom model '%s' -> %s/%s", name, repo_id, filename)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def list_presets() -> Dict[str, Dict]:
|
|
322
|
+
"""Return dictionary of all available presets (built-in + custom)."""
|
|
323
|
+
with _registry_lock:
|
|
324
|
+
combined = dict(DEFAULT_PRESETS)
|
|
325
|
+
combined.update(_custom_registry)
|
|
326
|
+
return combined
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def is_model_cached(model_name_or_path: str, cache_dir: Optional[Union[str, Path]] = None) -> bool:
|
|
330
|
+
"""Check if a model exists locally as an exact file or cached preset alias."""
|
|
331
|
+
target_dir = Path(cache_dir).resolve() if cache_dir else get_cache_dir()
|
|
332
|
+
|
|
333
|
+
# 1. Direct file path check
|
|
334
|
+
direct_path = Path(os.path.expanduser(model_name_or_path))
|
|
335
|
+
if direct_path.is_file():
|
|
336
|
+
return True
|
|
337
|
+
|
|
338
|
+
# 2. In cache directory direct filename check
|
|
339
|
+
in_cache_path = target_dir / model_name_or_path
|
|
340
|
+
if in_cache_path.is_file():
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
# 3. Preset alias check
|
|
344
|
+
presets = list_presets()
|
|
345
|
+
if model_name_or_path in presets:
|
|
346
|
+
alias = presets[model_name_or_path]["alias"]
|
|
347
|
+
if (target_dir / alias).is_file():
|
|
348
|
+
return True
|
|
349
|
+
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def resolve_model_path(model_name_or_path: str, cache_dir: Optional[Union[str, Path]] = None) -> Path:
|
|
354
|
+
"""Resolve model path to an absolute Path, downloading it if not yet cached."""
|
|
355
|
+
target_dir = Path(cache_dir).resolve() if cache_dir else get_cache_dir()
|
|
356
|
+
|
|
357
|
+
# Direct file exists
|
|
358
|
+
direct_path = Path(os.path.expanduser(model_name_or_path))
|
|
359
|
+
if direct_path.is_file():
|
|
360
|
+
return direct_path.resolve()
|
|
361
|
+
|
|
362
|
+
# Shared common cache (~/.cache/termux-ai/models)
|
|
363
|
+
shared_dir = Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "termux-ai" / "models"
|
|
364
|
+
if shared_dir.is_dir():
|
|
365
|
+
shared_file = shared_dir / model_name_or_path
|
|
366
|
+
if shared_file.is_file():
|
|
367
|
+
return shared_file.resolve()
|
|
368
|
+
|
|
369
|
+
# Direct filename in cache
|
|
370
|
+
in_cache_path = target_dir / model_name_or_path
|
|
371
|
+
if in_cache_path.is_file():
|
|
372
|
+
return in_cache_path.resolve()
|
|
373
|
+
|
|
374
|
+
# Preset alias
|
|
375
|
+
presets = list_presets()
|
|
376
|
+
if model_name_or_path in presets:
|
|
377
|
+
alias = presets[model_name_or_path]["alias"]
|
|
378
|
+
if shared_dir.is_dir() and (shared_dir / alias).is_file():
|
|
379
|
+
return (shared_dir / alias).resolve()
|
|
380
|
+
alias_path = target_dir / alias
|
|
381
|
+
if alias_path.is_file():
|
|
382
|
+
return alias_path.resolve()
|
|
383
|
+
# Automatically download preset if not cached
|
|
384
|
+
return download_model(model_name_or_path, cache_dir=target_dir)
|
|
385
|
+
|
|
386
|
+
# Direct URL or custom HuggingFace repo identifier
|
|
387
|
+
if model_name_or_path.startswith("http://") or model_name_or_path.startswith("https://") or ("/" in model_name_or_path and not Path(model_name_or_path).is_file()):
|
|
388
|
+
return download_model(model_name_or_path, cache_dir=target_dir)
|
|
389
|
+
|
|
390
|
+
raise ModelNotFoundError(
|
|
391
|
+
f"Model '{model_name_or_path}' could not be resolved. "
|
|
392
|
+
f"Available presets: {list(presets.keys())}, or specify a custom repo ('org/repo/file.gguf'), "
|
|
393
|
+
f"a direct URL ('https://.../model.gguf'), or an existing local .gguf file path."
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def download_model(
|
|
398
|
+
model_name_or_url: str,
|
|
399
|
+
cache_dir: Optional[Union[str, Path]] = None,
|
|
400
|
+
force: bool = False,
|
|
401
|
+
progress_callback: Optional[Callable[[int, int], None]] = None,
|
|
402
|
+
expected_sha256: Optional[str] = None,
|
|
403
|
+
) -> Path:
|
|
404
|
+
"""Download GGUF model weights from Hugging Face or direct HTTP URL with progress display, resume capability, and checksum check."""
|
|
405
|
+
target_dir = Path(cache_dir).resolve() if cache_dir else get_cache_dir()
|
|
406
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
407
|
+
|
|
408
|
+
presets = list_presets()
|
|
409
|
+
if model_name_or_url in presets:
|
|
410
|
+
info = presets[model_name_or_url]
|
|
411
|
+
repo_id = info["repo_id"]
|
|
412
|
+
filename = str(info["filename"])
|
|
413
|
+
target_filename = str(info.get("alias") or filename)
|
|
414
|
+
if expected_sha256 is None:
|
|
415
|
+
expected_sha256 = info.get("sha256")
|
|
416
|
+
download_url = f"https://huggingface.co/{repo_id}/resolve/main/{filename}"
|
|
417
|
+
elif model_name_or_url.startswith("http://") or model_name_or_url.startswith("https://"):
|
|
418
|
+
# Direct URL download
|
|
419
|
+
download_url = model_name_or_url
|
|
420
|
+
target_filename = model_name_or_url.split("?")[0].rstrip("/").split("/")[-1]
|
|
421
|
+
if not target_filename.endswith(".gguf"):
|
|
422
|
+
target_filename += ".gguf"
|
|
423
|
+
elif "/" in model_name_or_url:
|
|
424
|
+
# Custom HuggingFace repo identifier (e.g. org/repo/file.gguf)
|
|
425
|
+
parts = model_name_or_url.split("/")
|
|
426
|
+
if len(parts) == 3:
|
|
427
|
+
repo_id = f"{parts[0]}/{parts[1]}"
|
|
428
|
+
filename = parts[2]
|
|
429
|
+
target_filename = filename
|
|
430
|
+
download_url = f"https://huggingface.co/{repo_id}/resolve/main/{filename}"
|
|
431
|
+
elif len(parts) == 2:
|
|
432
|
+
repo_id = f"{parts[0]}/{parts[1]}"
|
|
433
|
+
try:
|
|
434
|
+
api_url = f"https://huggingface.co/api/models/{repo_id}"
|
|
435
|
+
req = urllib.request.Request(api_url, headers={"User-Agent": "termux-diffusion/1.1.0"})
|
|
436
|
+
with urllib.request.urlopen(req, timeout=10.0) as resp:
|
|
437
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
438
|
+
siblings = [s.get("rfilename", "") for s in data.get("siblings", [])]
|
|
439
|
+
gguf_files = [f for f in siblings if f.endswith(".gguf")]
|
|
440
|
+
if gguf_files:
|
|
441
|
+
filename = gguf_files[0]
|
|
442
|
+
target_filename = filename
|
|
443
|
+
download_url = f"https://huggingface.co/{repo_id}/resolve/main/{filename}"
|
|
444
|
+
else:
|
|
445
|
+
raise ModelNotFoundError(f"No .gguf file found in repository '{repo_id}'")
|
|
446
|
+
except Exception as e:
|
|
447
|
+
raise ModelNotFoundError(f"Could not inspect Hugging Face repo '{repo_id}': {e}") from e
|
|
448
|
+
else:
|
|
449
|
+
raise ModelNotFoundError(f"Invalid model reference: '{model_name_or_url}'")
|
|
450
|
+
else:
|
|
451
|
+
raise ModelNotFoundError(f"Unknown model preset: '{model_name_or_url}'. Available: {list(presets.keys())}")
|
|
452
|
+
|
|
453
|
+
final_path = target_dir / target_filename
|
|
454
|
+
if final_path.is_file() and not force:
|
|
455
|
+
logger.info("Model '%s' already cached at: %s", model_name_or_url, final_path)
|
|
456
|
+
return final_path
|
|
457
|
+
|
|
458
|
+
temp_path = target_dir / f"{target_filename}.{os.getpid()}.part"
|
|
459
|
+
logger.info("Downloading '%s' from %s -> %s", model_name_or_url, download_url, final_path)
|
|
460
|
+
print(f"[termux-diffusion] Fetching model '{target_filename}' ({download_url})...")
|
|
461
|
+
|
|
462
|
+
# Attempt download with streaming chunk writer and HTTP Range resume support
|
|
463
|
+
try:
|
|
464
|
+
_stream_download(download_url, temp_path, progress_callback)
|
|
465
|
+
os.replace(temp_path, final_path)
|
|
466
|
+
|
|
467
|
+
# Integrity Check
|
|
468
|
+
if expected_sha256:
|
|
469
|
+
print(f"[termux-diffusion] Verifying SHA256 integrity checksum...")
|
|
470
|
+
if not verify_file_sha256(final_path, expected_sha256):
|
|
471
|
+
final_path.unlink(missing_ok=True)
|
|
472
|
+
raise ModelDownloadError(f"SHA256 checksum mismatch for downloaded model '{target_filename}'")
|
|
473
|
+
|
|
474
|
+
print(f"[termux-diffusion] Successfully cached model at: {final_path}")
|
|
475
|
+
return final_path
|
|
476
|
+
except Exception as exc:
|
|
477
|
+
temp_path.unlink(missing_ok=True)
|
|
478
|
+
raise ModelDownloadError(f"Failed downloading model '{model_name_or_url}' from {download_url}: {exc}") from exc
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _stream_download(
|
|
482
|
+
url: str,
|
|
483
|
+
temp_path: Path,
|
|
484
|
+
progress_callback: Optional[Callable[[int, int], None]] = None,
|
|
485
|
+
) -> None:
|
|
486
|
+
"""Download binary file via HTTP streaming with HTTP Range resume capability and terminal progress."""
|
|
487
|
+
headers = {"User-Agent": "termux-diffusion/1.1.0"}
|
|
488
|
+
existing_bytes = 0
|
|
489
|
+
|
|
490
|
+
if temp_path.exists():
|
|
491
|
+
existing_bytes = temp_path.stat().st_size
|
|
492
|
+
if existing_bytes > 0:
|
|
493
|
+
headers["Range"] = f"bytes={existing_bytes}-"
|
|
494
|
+
logger.info("Resuming partial download from byte offset %d for %s", existing_bytes, temp_path.name)
|
|
495
|
+
|
|
496
|
+
req = urllib.request.Request(url, headers=headers)
|
|
497
|
+
is_resumed = False
|
|
498
|
+
|
|
499
|
+
resp = None
|
|
500
|
+
for attempt in range(3):
|
|
501
|
+
try:
|
|
502
|
+
resp = urllib.request.urlopen(req, timeout=30.0)
|
|
503
|
+
break
|
|
504
|
+
except urllib.error.HTTPError as e:
|
|
505
|
+
if e.code == 416: # Range Not Satisfiable
|
|
506
|
+
logger.debug("HTTP 416 Range Not Satisfiable; resetting temp file.")
|
|
507
|
+
temp_path.unlink(missing_ok=True)
|
|
508
|
+
existing_bytes = 0
|
|
509
|
+
req = urllib.request.Request(url, headers={"User-Agent": "termux-diffusion/1.1.0"})
|
|
510
|
+
continue
|
|
511
|
+
elif e.code in (429, 503) and attempt < 2:
|
|
512
|
+
import random
|
|
513
|
+
retry_after = random.uniform(0.8, 1.6) * (attempt + 1)
|
|
514
|
+
logger.warning("HTTP %d Rate Limited. Retrying in %.2fs with jitter (attempt %d/2)...", e.code, retry_after, attempt + 1)
|
|
515
|
+
time.sleep(retry_after)
|
|
516
|
+
continue
|
|
517
|
+
else:
|
|
518
|
+
raise
|
|
519
|
+
|
|
520
|
+
if resp is None:
|
|
521
|
+
raise ModelDownloadError(f"Failed opening connection to '{url}' after 3 attempts.")
|
|
522
|
+
|
|
523
|
+
with resp:
|
|
524
|
+
status_code = resp.status if hasattr(resp, "status") else 200
|
|
525
|
+
content_len = resp.headers.get("content-length")
|
|
526
|
+
server_total = int(content_len) if content_len and content_len.isdigit() else 0
|
|
527
|
+
|
|
528
|
+
if status_code == 206: # Partial Content
|
|
529
|
+
is_resumed = True
|
|
530
|
+
total_size = existing_bytes + server_total
|
|
531
|
+
file_mode = "ab"
|
|
532
|
+
else:
|
|
533
|
+
is_resumed = False
|
|
534
|
+
total_size = server_total
|
|
535
|
+
file_mode = "wb"
|
|
536
|
+
existing_bytes = 0
|
|
537
|
+
|
|
538
|
+
downloaded = existing_bytes
|
|
539
|
+
chunk_size = 1024 * 1024 # 1MB chunks
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
with open(temp_path, file_mode) as f:
|
|
543
|
+
last_print = time.time()
|
|
544
|
+
while True:
|
|
545
|
+
chunk = resp.read(chunk_size)
|
|
546
|
+
if not chunk:
|
|
547
|
+
break
|
|
548
|
+
f.write(chunk)
|
|
549
|
+
downloaded += len(chunk)
|
|
550
|
+
|
|
551
|
+
if progress_callback:
|
|
552
|
+
progress_callback(downloaded, total_size)
|
|
553
|
+
elif total_size > 0 and (time.time() - last_print > 0.5):
|
|
554
|
+
pct = (downloaded / total_size) * 100
|
|
555
|
+
mb_done = downloaded / (1024 * 1024)
|
|
556
|
+
mb_total = total_size / (1024 * 1024)
|
|
557
|
+
print(f" > Progress: {mb_done:.1f}MB / {mb_total:.1f}MB ({pct:.1f}%) [Resumed: {'Y' if is_resumed else 'N'}]", end="\r", flush=True)
|
|
558
|
+
last_print = time.time()
|
|
559
|
+
|
|
560
|
+
if total_size > 0:
|
|
561
|
+
print(f" > Progress: {total_size / (1024*1024):.1f}MB / {total_size / (1024*1024):.1f}MB (100.0%)")
|
|
562
|
+
except OSError as err:
|
|
563
|
+
import errno
|
|
564
|
+
if err.errno == errno.ENOSPC or "space" in str(err).lower():
|
|
565
|
+
temp_path.unlink(missing_ok=True)
|
|
566
|
+
raise ModelDownloadError(
|
|
567
|
+
f"Insufficient disk space on device while downloading model '{temp_path.name}'. "
|
|
568
|
+
"Please free up at least 2GB of internal storage."
|
|
569
|
+
) from err
|
|
570
|
+
raise
|
|
571
|
+
|
|
572
|
+
print() # newline after completion
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def list_cached_models(cache_dir: Optional[Union[str, Path]] = None) -> List[Dict]:
|
|
576
|
+
"""List all currently downloaded and cached GGUF models on device."""
|
|
577
|
+
target_dir = Path(cache_dir).resolve() if cache_dir else get_cache_dir()
|
|
578
|
+
results = []
|
|
579
|
+
if not target_dir.is_dir():
|
|
580
|
+
return results
|
|
581
|
+
|
|
582
|
+
for item in target_dir.glob("*.gguf"):
|
|
583
|
+
size_mb = item.stat().st_size / (1024 * 1024)
|
|
584
|
+
is_valid = validate_gguf_file(item)
|
|
585
|
+
results.append({
|
|
586
|
+
"name": item.name,
|
|
587
|
+
"path": str(item.resolve()),
|
|
588
|
+
"size_mb": round(size_mb, 2),
|
|
589
|
+
"last_modified": item.stat().st_mtime,
|
|
590
|
+
"is_valid_gguf": is_valid,
|
|
591
|
+
})
|
|
592
|
+
return results
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def clear_cache(cache_dir: Optional[Union[str, Path]] = None, model_name: Optional[str] = None) -> int:
|
|
596
|
+
"""Clear cached models to reclaim storage space. Returns number of files removed."""
|
|
597
|
+
target_dir = Path(cache_dir).resolve() if cache_dir else get_cache_dir()
|
|
598
|
+
if not target_dir.is_dir():
|
|
599
|
+
return 0
|
|
600
|
+
|
|
601
|
+
removed = 0
|
|
602
|
+
if model_name:
|
|
603
|
+
target_file = target_dir / model_name
|
|
604
|
+
if not target_file.name.endswith(".gguf"):
|
|
605
|
+
target_file = target_dir / f"{model_name}.gguf"
|
|
606
|
+
if target_file.is_file():
|
|
607
|
+
target_file.unlink()
|
|
608
|
+
removed += 1
|
|
609
|
+
else:
|
|
610
|
+
for item in target_dir.glob("*.gguf"):
|
|
611
|
+
item.unlink()
|
|
612
|
+
removed += 1
|
|
613
|
+
|
|
614
|
+
logger.info("Cleared %d cached model file(s) from %s", removed, target_dir)
|
|
615
|
+
return removed
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def get_device_model_catalog(cache_dir: Optional[Union[str, Path]] = None) -> Dict[str, Any]:
|
|
619
|
+
"""Scan and categorize all installed model weights (GGUF, Safetensors, CKPT, ONNX) on device."""
|
|
620
|
+
target_dir = Path(cache_dir).resolve() if cache_dir else get_cache_dir()
|
|
621
|
+
|
|
622
|
+
search_dirs = [target_dir]
|
|
623
|
+
alt_dir = Path.home() / ".cache" / "termux-diffusion" / "models"
|
|
624
|
+
if alt_dir.is_dir() and alt_dir.resolve() != target_dir:
|
|
625
|
+
search_dirs.append(alt_dir)
|
|
626
|
+
|
|
627
|
+
exts = {".gguf", ".safetensors", ".ckpt", ".bin", ".pt", ".onnx"}
|
|
628
|
+
found_files: Dict[str, Dict[str, Any]] = {}
|
|
629
|
+
|
|
630
|
+
for s_dir in search_dirs:
|
|
631
|
+
if not s_dir.is_dir():
|
|
632
|
+
continue
|
|
633
|
+
try:
|
|
634
|
+
for item in s_dir.rglob("*"):
|
|
635
|
+
if item.is_file() and item.suffix.lower() in exts:
|
|
636
|
+
if item.name not in found_files:
|
|
637
|
+
try:
|
|
638
|
+
size_mb = item.stat().st_size / (1024 * 1024)
|
|
639
|
+
except OSError:
|
|
640
|
+
size_mb = 0.0
|
|
641
|
+
found_files[item.name] = {
|
|
642
|
+
"name": item.name,
|
|
643
|
+
"path": str(item.resolve()),
|
|
644
|
+
"size_mb": round(size_mb, 1),
|
|
645
|
+
"ext": item.suffix.lower(),
|
|
646
|
+
}
|
|
647
|
+
except Exception as e:
|
|
648
|
+
logger.warning("Error scanning model dir %s: %s", s_dir, e)
|
|
649
|
+
|
|
650
|
+
checkpoints: List[Dict[str, Any]] = []
|
|
651
|
+
vae_models: List[Dict[str, Any]] = []
|
|
652
|
+
loras: List[Dict[str, Any]] = []
|
|
653
|
+
text_encoders: List[Dict[str, Any]] = []
|
|
654
|
+
|
|
655
|
+
for name, info in found_files.items():
|
|
656
|
+
n_low = name.lower()
|
|
657
|
+
rel_path = str(info["path"]).lower()
|
|
658
|
+
if "vae" in n_low or "ae" in n_low or "taef" in n_low or "taesd" in n_low or "/vae/" in rel_path:
|
|
659
|
+
info["category"] = "vae"
|
|
660
|
+
vae_models.append(info)
|
|
661
|
+
elif "lora" in n_low or "/lora" in rel_path:
|
|
662
|
+
info["category"] = "lora"
|
|
663
|
+
loras.append(info)
|
|
664
|
+
elif "clip" in n_low or "qwen" in n_low or "smolvlm" in n_low or "encoder" in n_low or "/clip/" in rel_path:
|
|
665
|
+
info["category"] = "text_encoder"
|
|
666
|
+
text_encoders.append(info)
|
|
667
|
+
else:
|
|
668
|
+
info["category"] = "checkpoint"
|
|
669
|
+
checkpoints.append(info)
|
|
670
|
+
|
|
671
|
+
checkpoints.sort(key=lambda x: x["name"].lower())
|
|
672
|
+
vae_models.sort(key=lambda x: x["name"].lower())
|
|
673
|
+
loras.sort(key=lambda x: x["name"].lower())
|
|
674
|
+
text_encoders.sort(key=lambda x: x["name"].lower())
|
|
675
|
+
|
|
676
|
+
all_list = sorted(list(found_files.values()), key=lambda x: x["name"].lower())
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
"checkpoints": checkpoints,
|
|
680
|
+
"vae": vae_models,
|
|
681
|
+
"loras": loras,
|
|
682
|
+
"text_encoders": text_encoders,
|
|
683
|
+
"all": all_list,
|
|
684
|
+
"total": len(all_list),
|
|
685
|
+
}
|
|
686
|
+
|