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.
Files changed (39) hide show
  1. amv_diffusion-2.1.0.dist-info/METADATA +180 -0
  2. amv_diffusion-2.1.0.dist-info/RECORD +39 -0
  3. amv_diffusion-2.1.0.dist-info/WHEEL +5 -0
  4. amv_diffusion-2.1.0.dist-info/entry_points.txt +7 -0
  5. amv_diffusion-2.1.0.dist-info/licenses/LICENSE +21 -0
  6. amv_diffusion-2.1.0.dist-info/top_level.txt +1 -0
  7. termux_diffusion/__init__.py +185 -0
  8. termux_diffusion/__main__.py +7 -0
  9. termux_diffusion/_version.py +3 -0
  10. termux_diffusion/adapter.py +41 -0
  11. termux_diffusion/canvas.py +364 -0
  12. termux_diffusion/cli.py +492 -0
  13. termux_diffusion/cluster.py +340 -0
  14. termux_diffusion/control/__init__.py +5 -0
  15. termux_diffusion/control/component.py +309 -0
  16. termux_diffusion/control/status.py +15 -0
  17. termux_diffusion/core.py +2936 -0
  18. termux_diffusion/data/bootstrap-manifest.json +15 -0
  19. termux_diffusion/data/prompt_cache/108a7149c4e70550.json +10 -0
  20. termux_diffusion/data/release-public-keys.json +10 -0
  21. termux_diffusion/data/validated-vulkan-profiles.json +298 -0
  22. termux_diffusion/detailer.py +283 -0
  23. termux_diffusion/doctor.py +128 -0
  24. termux_diffusion/downloader.py +111 -0
  25. termux_diffusion/engine.py +226 -0
  26. termux_diffusion/exceptions.py +158 -0
  27. termux_diffusion/hardware.py +985 -0
  28. termux_diffusion/hub.py +686 -0
  29. termux_diffusion/installer.py +499 -0
  30. termux_diffusion/locking.py +134 -0
  31. termux_diffusion/manifest.py +128 -0
  32. termux_diffusion/npu.py +267 -0
  33. termux_diffusion/platform.py +533 -0
  34. termux_diffusion/purger.py +145 -0
  35. termux_diffusion/py.typed +1 -0
  36. termux_diffusion/safety.py +150 -0
  37. termux_diffusion/selftest.py +178 -0
  38. termux_diffusion/web.py +3370 -0
  39. termux_diffusion/workflow.py +1345 -0
@@ -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
+