termux-diffusion 1.3.1__tar.gz → 1.3.2__tar.gz

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 (38) hide show
  1. {termux_diffusion-1.3.1/termux_diffusion.egg-info → termux_diffusion-1.3.2}/PKG-INFO +10 -7
  2. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/README.md +8 -6
  3. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/pyproject.toml +7 -1
  4. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/setup.py +5 -3
  5. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/cli.py +6 -4
  6. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/core.py +109 -33
  7. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/downloader.py +2 -52
  8. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/exceptions.py +6 -0
  9. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/hardware.py +172 -30
  10. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/installer.py +33 -40
  11. termux_diffusion-1.3.2/termux_diffusion/locking.py +96 -0
  12. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/npu.py +5 -5
  13. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/platform.py +15 -1
  14. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/selftest.py +20 -7
  15. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2/termux_diffusion.egg-info}/PKG-INFO +10 -7
  16. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion.egg-info/SOURCES.txt +1 -0
  17. termux_diffusion-1.3.2/termux_diffusion.egg-info/requires.txt +1 -0
  18. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/tests/test_core.py +1 -0
  19. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/tests/test_presets.py +17 -0
  20. termux_diffusion-1.3.1/termux_diffusion/locking.py +0 -50
  21. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/LICENSE +0 -0
  22. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/setup.cfg +0 -0
  23. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/__init__.py +0 -0
  24. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/_version.py +0 -0
  25. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/data/bootstrap-manifest.json +0 -0
  26. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/data/release-public-keys.json +0 -0
  27. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/data/validated-vulkan-profiles.json +0 -0
  28. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/hub.py +0 -0
  29. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/manifest.py +0 -0
  30. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion/py.typed +0 -0
  31. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion.egg-info/dependency_links.txt +0 -0
  32. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion.egg-info/entry_points.txt +0 -0
  33. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/termux_diffusion.egg-info/top_level.txt +0 -0
  34. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/tests/test_hardware.py +0 -0
  35. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/tests/test_hub.py +0 -0
  36. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/tests/test_installer.py +0 -0
  37. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/tests/test_npu.py +0 -0
  38. {termux_diffusion-1.3.1 → termux_diffusion-1.3.2}/tests/test_platform.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termux-diffusion
3
- Version: 1.3.1
3
+ Version: 1.3.2
4
4
  Summary: Production On-Device AI Image Generation Framework for Android Termux & Samsung Galaxy (Dual-Engine Python & Node.js)
5
5
  Home-page: https://github.com/uno-km/termux-diffusion
6
6
  Author: uno-km (AMEVA Foundation)
@@ -28,6 +28,7 @@ Classifier: Topic :: Multimedia :: Graphics
28
28
  Requires-Python: >=3.8
29
29
  Description-Content-Type: text/markdown
30
30
  License-File: LICENSE
31
+ Requires-Dist: ameva-vulkan-runtime>=1.0.0
31
32
  Dynamic: author
32
33
  Dynamic: classifier
33
34
  Dynamic: home-page
@@ -75,7 +76,7 @@ termux-setup-storage
75
76
 
76
77
  # 2. Install Toolchains & Provision Native Engine
77
78
  pkg update -y && pkg install python clang make cmake git termux-api wget vulkan-loader vulkan-headers vulkan-tools -y
78
- pip install termux-diffusion && termux-diffusion-install
79
+ pip install termux-diffusion && termux-diffusion install
79
80
  ```
80
81
 
81
82
  #### [Node.js] Node.js / TypeScript:
@@ -88,6 +89,11 @@ pkg update -y && pkg install nodejs-lts clang make cmake git termux-api wget vul
88
89
  npm install -g termux-diffusion && npx termux-diffusion install
89
90
  ```
90
91
 
92
+ #### [Universal] One-Line Automated Bootstrap (curl):
93
+ ```bash
94
+ curl -sL https://raw.githubusercontent.com/uno-km/termux-diffusion/main/scripts/install.sh | bash
95
+ ```
96
+
91
97
  ---
92
98
 
93
99
  ### [Instant] Scenario 2: Instant Generation (Ready to Run)
@@ -416,12 +422,9 @@ adb shell "/system/bin/device_config put activity_manager max_phantom_processes
416
422
 
417
423
  ---
418
424
 
419
- ## 9. Disclaimer (면책 조항)
425
+ ## 9. Disclaimer
420
426
 
421
- > **Disclaimer:**
422
- > *Termux-Diffusion is an independent open-source project developed for the Android Termux environment and is not officially affiliated with, endorsed by, or sponsored by the Termux project.*
423
- >
424
- > *(본 프로젝트는 안드로이드 Termux 환경을 위해 개발된 독립적인 오픈소스 라이브러리이며, Termux 공식 프로젝트와 직접적인 제휴 관계가 아닙니다.)*
427
+ > *This project is an independent open-source library developed for the Android Termux environment. It is not officially affiliated with, endorsed by, or sponsored by the Termux project, stability.ai, or Google LLC.*
425
428
 
426
429
  ---
427
430
 
@@ -39,7 +39,7 @@ termux-setup-storage
39
39
 
40
40
  # 2. Install Toolchains & Provision Native Engine
41
41
  pkg update -y && pkg install python clang make cmake git termux-api wget vulkan-loader vulkan-headers vulkan-tools -y
42
- pip install termux-diffusion && termux-diffusion-install
42
+ pip install termux-diffusion && termux-diffusion install
43
43
  ```
44
44
 
45
45
  #### [Node.js] Node.js / TypeScript:
@@ -52,6 +52,11 @@ pkg update -y && pkg install nodejs-lts clang make cmake git termux-api wget vul
52
52
  npm install -g termux-diffusion && npx termux-diffusion install
53
53
  ```
54
54
 
55
+ #### [Universal] One-Line Automated Bootstrap (curl):
56
+ ```bash
57
+ curl -sL https://raw.githubusercontent.com/uno-km/termux-diffusion/main/scripts/install.sh | bash
58
+ ```
59
+
55
60
  ---
56
61
 
57
62
  ### [Instant] Scenario 2: Instant Generation (Ready to Run)
@@ -380,12 +385,9 @@ adb shell "/system/bin/device_config put activity_manager max_phantom_processes
380
385
 
381
386
  ---
382
387
 
383
- ## 9. Disclaimer (면책 조항)
388
+ ## 9. Disclaimer
384
389
 
385
- > **Disclaimer:**
386
- > *Termux-Diffusion is an independent open-source project developed for the Android Termux environment and is not officially affiliated with, endorsed by, or sponsored by the Termux project.*
387
- >
388
- > *(본 프로젝트는 안드로이드 Termux 환경을 위해 개발된 독립적인 오픈소스 라이브러리이며, Termux 공식 프로젝트와 직접적인 제휴 관계가 아닙니다.)*
390
+ > *This project is an independent open-source library developed for the Android Termux environment. It is not officially affiliated with, endorsed by, or sponsored by the Termux project, stability.ai, or Google LLC.*
389
391
 
390
392
  ---
391
393
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "termux-diffusion"
7
- version = "1.3.1"
7
+ version = "1.3.2"
8
8
  description = "Production On-Device AI Image Generation Framework for Android Termux & Samsung Galaxy (Dual-Engine Python & Node.js)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -40,6 +40,9 @@ keywords = [
40
40
  "webui-alternative"
41
41
  ]
42
42
  dynamic = ["classifiers"]
43
+ dependencies = [
44
+ "ameva-vulkan-runtime>=1.0.0",
45
+ ]
43
46
 
44
47
  [project.urls]
45
48
  Homepage = "https://github.com/uno-km/termux-diffusion"
@@ -58,3 +61,6 @@ termux_diffusion = [
58
61
  "data/*.json",
59
62
  "data/*.pub"
60
63
  ]
64
+
65
+ [tool.pytest.ini_options]
66
+ testpaths = ["tests"]
@@ -11,7 +11,7 @@ if os.path.exists(readme_path):
11
11
 
12
12
  setup(
13
13
  name="termux-diffusion",
14
- version="1.3.1",
14
+ version="1.3.2",
15
15
  description="Production On-Device AI Image Generation Framework for Android Termux & Samsung Galaxy (Dual-Engine Python & Node.js)",
16
16
  long_description=long_description,
17
17
  long_description_content_type="text/markdown",
@@ -33,11 +33,13 @@ setup(
33
33
  ],
34
34
  packages=find_packages(),
35
35
  package_data={
36
- "termux_diffusion": ["py.typed"],
36
+ "termux_diffusion": ["py.typed", "data/*.json", "data/*.pub"],
37
37
  },
38
38
  include_package_data=True,
39
39
  python_requires=">=3.8",
40
- install_requires=[],
40
+ install_requires=[
41
+ "ameva-vulkan-runtime>=1.0.0",
42
+ ],
41
43
  entry_points={
42
44
  "console_scripts": [
43
45
  "termux-diffusion = termux_diffusion.cli:main",
@@ -4,16 +4,18 @@ import argparse
4
4
  import sys
5
5
  from typing import List, Optional
6
6
 
7
+ import logging as _logging
8
+
7
9
  if hasattr(sys.stdout, "reconfigure"):
8
10
  try:
9
11
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
10
- except Exception:
11
- pass
12
+ except Exception as _e:
13
+ _logging.getLogger(__name__).debug("stdout reconfigure failed: %s", _e)
12
14
  if hasattr(sys.stderr, "reconfigure"):
13
15
  try:
14
16
  sys.stderr.reconfigure(encoding="utf-8", errors="replace")
15
- except Exception:
16
- pass
17
+ except Exception as _e:
18
+ _logging.getLogger(__name__).debug("stderr reconfigure failed: %s", _e)
17
19
 
18
20
  from .core import generate
19
21
  from .hub import clear_cache, download_model, list_cached_models, list_presets
@@ -68,13 +68,15 @@ class GenerationResult:
68
68
  import signal
69
69
 
70
70
  def _safe_kill_process(proc: Optional[subprocess.Popen], timeout: float = 2.0) -> None:
71
- """Safely terminate and reap child process groups to prevent zombie handles."""
71
+ """Safely terminate and reap child process groups with granular exception handling and zero zombie leak."""
72
72
  if proc is None:
73
73
  return
74
+
74
75
  try:
75
76
  if proc.poll() is not None:
76
77
  return
77
- except Exception:
78
+ except (OSError, ValueError) as poll_err:
79
+ logger.debug("Process poll inspection note: %s", poll_err)
78
80
  return
79
81
 
80
82
  pid = getattr(proc, "pid", None)
@@ -83,12 +85,17 @@ def _safe_kill_process(proc: Optional[subprocess.Popen], timeout: float = 2.0) -
83
85
 
84
86
  logger.warning("Initiating child process termination (PID: %s)...", pid)
85
87
 
86
- # Step 1: Attempt graceful SIGTERM to process group (POSIX) or single process (Windows)
88
+ # Step 1: Graceful termination attempt (SIGTERM / proc.terminate)
87
89
  try:
88
90
  if sys.platform != "win32":
89
91
  try:
90
- os.killpg(os.getpgid(pid), signal.SIGTERM)
91
- except OSError:
92
+ pgid = os.getpgid(pid)
93
+ os.killpg(pgid, signal.SIGTERM)
94
+ except ProcessLookupError:
95
+ logger.debug("Process group (PGID: %s) already exited.", pid)
96
+ return
97
+ except (OSError, PermissionError) as pg_err:
98
+ logger.debug("killpg SIGTERM note (PGID: %s): %s, falling back to proc.terminate()", pid, pg_err)
92
99
  proc.terminate()
93
100
  else:
94
101
  proc.terminate()
@@ -103,15 +110,25 @@ def _safe_kill_process(proc: Optional[subprocess.Popen], timeout: float = 2.0) -
103
110
  pid,
104
111
  timeout,
105
112
  )
106
- except Exception as e:
107
- logger.debug("SIGTERM dispatch note for PID %s: %s", pid, e)
113
+ except ProcessLookupError:
114
+ logger.debug("Child process (PID: %s) already terminated before SIGTERM dispatch.", pid)
115
+ return
116
+ except PermissionError as perm_err:
117
+ logger.warning("Permission denied while sending SIGTERM to PID %s: %s", pid, perm_err)
118
+ except OSError as os_err:
119
+ logger.warning("OS error during SIGTERM dispatch to PID %s: %s", pid, os_err)
108
120
 
109
- # Step 2: Forceful SIGKILL to process group if still alive
121
+ # Step 2: Forceful termination (SIGKILL / proc.kill)
110
122
  try:
111
123
  if sys.platform != "win32":
112
124
  try:
113
- os.killpg(os.getpgid(pid), signal.SIGKILL)
114
- except OSError:
125
+ pgid = os.getpgid(pid)
126
+ os.killpg(pgid, signal.SIGKILL)
127
+ except ProcessLookupError:
128
+ logger.debug("Process group (PGID: %s) already terminated.", pid)
129
+ return
130
+ except (OSError, PermissionError) as pg_err:
131
+ logger.debug("killpg SIGKILL note (PGID: %s): %s, falling back to proc.kill()", pid, pg_err)
115
132
  proc.kill()
116
133
  else:
117
134
  proc.kill()
@@ -121,8 +138,12 @@ def _safe_kill_process(proc: Optional[subprocess.Popen], timeout: float = 2.0) -
121
138
  logger.debug("Child process (PID: %s) forcefully terminated and reaped.", pid)
122
139
  except subprocess.TimeoutExpired:
123
140
  logger.error("Child process (PID: %s) did not exit after SIGKILL (kernel D-state or pending I/O).", pid)
124
- except Exception as exc:
125
- logger.error("Error during child process termination (PID: %s): %s", pid, exc)
141
+ except ProcessLookupError:
142
+ logger.debug("Child process (PID: %s) already reaped before SIGKILL.", pid)
143
+ except PermissionError as perm_err:
144
+ logger.error("Permission denied while sending SIGKILL to PID %s: %s", pid, perm_err)
145
+ except OSError as os_err:
146
+ logger.error("OS error during SIGKILL dispatch to PID %s: %s", pid, os_err)
126
147
 
127
148
 
128
149
  DEFAULT_QUALITY_GUARD_NEGATIVE_PROMPT = "lowres, bad quality, blur, deformed, distorted, extra limbs, artifacts"
@@ -159,7 +180,7 @@ def generate(
159
180
  prompt: str,
160
181
  model: str = "realistic",
161
182
  negative_prompt: Optional[str] = None,
162
- device: str = "cpu",
183
+ device: str = "auto",
163
184
  steps: Optional[int] = None,
164
185
  cfg_scale: Optional[float] = None,
165
186
  width: int = 512,
@@ -193,7 +214,7 @@ def generate(
193
214
  prompt: Detailed text description of the desired image.
194
215
  model: Preset keyword ('realistic', 'speed', 'sdxs', 'turbo', 'anime'), custom repo ('org/repo/file.gguf'), direct URL, or path to .gguf file.
195
216
  negative_prompt: Optional negative text guidance describing elements to avoid (default: None).
196
- device: Computing device ('cpu', 'gpu', 'opencl', 'vulkan', 'auto'). Default is 'cpu'.
217
+ device: Computing device ('auto', 'gpu', 'vulkan', 'cpu', 'opencl', 'npu', 'tpu'). Default is 'auto'.
197
218
  steps: Number of denoising steps (default determined by preset, e.g. 10).
198
219
  cfg_scale: Classifier-Free Guidance scale (default determined by preset, e.g. 4.0).
199
220
  width: Output image width in pixels (default: 512).
@@ -224,14 +245,23 @@ def generate(
224
245
  if not prompt or not str(prompt).strip():
225
246
  raise ValueError("Prompt must not be empty.")
226
247
 
227
- device_mode = device.lower().strip()
248
+ device_mode = str(device or "auto").lower().strip()
228
249
  if device_mode not in ("cpu", "gpu", "opencl", "vulkan", "npu", "tpu", "auto"):
229
- raise ValueError(f"Invalid device '{device}'. Options: 'cpu', 'gpu', 'opencl', 'vulkan', 'npu', 'tpu', 'auto'.")
250
+ raise ValueError(f"Invalid device '{device}'. Options: 'auto', 'gpu', 'vulkan', 'cpu', 'opencl', 'npu', 'tpu'.")
230
251
 
231
252
  # Resolve device to actual available backend using hardware probing
232
- from .hardware import resolve_device_backend, get_sd_cli_gpu_args
253
+ from .hardware import resolve_device_backend, get_sd_cli_gpu_args, get_profile_gating_manager
233
254
  effective_device, ngl_layers = resolve_device_backend(device_mode)
234
255
 
256
+ # 0. Runtime Profile & Model Gating Validation (Zero Test Illusion)
257
+ gating_mgr = get_profile_gating_manager()
258
+ is_safe, gate_reason = gating_mgr.validate_execution(model, effective_device)
259
+ if not is_safe:
260
+ from .exceptions import PlatformNotSupportedError
261
+ raise PlatformNotSupportedError(
262
+ f"[termux-diffusion] [GATING-BLOCKED] {gate_reason}"
263
+ )
264
+
235
265
  # 1. Pre-flight Memory Safety Inspection (Informational only, non-blocking)
236
266
  if low_ram_guard:
237
267
  safe, msg = check_memory_safety(required_mb=1200)
@@ -450,8 +480,9 @@ def generate(
450
480
  )
451
481
  cmd.extend(["--taesd", str(effective_taesd_path)])
452
482
 
453
- # 5.1 Configure Environment with companion library search paths
483
+ # 5.1 Configure Environment with companion library search paths (Bionic priority on Android)
454
484
  env = os.environ.copy()
485
+ bionic_sys_dirs = ["/system/lib64", "/vendor/lib64", "/vendor/lib64/egl"]
455
486
  lib_dirs = [
456
487
  str(sd_cli.parent),
457
488
  str(sd_cli.parent.parent / "lib"),
@@ -460,10 +491,45 @@ def generate(
460
491
  str(Path.home() / ".cache" / "termux-diffusion" / "staging" / "lib"),
461
492
  ]
462
493
  cur_ld = env.get("LD_LIBRARY_PATH", "")
463
- valid_dirs = [d for d in lib_dirs if Path(d).is_dir()]
494
+ valid_dirs = [d for d in bionic_sys_dirs + lib_dirs if Path(d).is_dir()]
464
495
  if valid_dirs:
465
496
  env["LD_LIBRARY_PATH"] = ":".join(valid_dirs + ([cur_ld] if cur_ld else []))
466
497
 
498
+ # 5.2 ameva-vulkan-runtime DiffusionAdapter Binding (Vulkan / GPU mode)
499
+ if effective_device in ("vulkan", "gpu") or device_mode in ("vulkan", "gpu"):
500
+ try:
501
+ import ameva_vulkan_runtime as avr
502
+ from ameva_vulkan_runtime.adapters import DiffusionAdapter
503
+
504
+ avr_ctx = avr.get_or_create_context(device_mode)
505
+ report = getattr(avr_ctx, "doctor", avr.Doctor()).run_self_test(verbose=False)
506
+
507
+ class EngineProxy:
508
+ def __init__(self, hw):
509
+ self.hw_profile = hw
510
+
511
+ from .hardware import detect_hardware_profile
512
+ engine_proxy = EngineProxy(detect_hardware_profile())
513
+ binding = DiffusionAdapter.bind(engine_proxy, report)
514
+
515
+ cfg = getattr(binding, "config", {})
516
+ if cfg.get("unet_tiling") and "--vae-tiling" not in cmd:
517
+ cmd.append("--vae-tiling")
518
+
519
+ logger.info(
520
+ "[termux-diffusion] DiffusionAdapter bound: backend=%s, status=%s",
521
+ cfg.get("backend", "vulkan"),
522
+ getattr(binding.status, "name", str(binding.status))
523
+ )
524
+ except Exception as e:
525
+ if device_mode in ("gpu", "vulkan") or strict_vulkan:
526
+ from .exceptions import PlatformNotSupportedError
527
+ raise PlatformNotSupportedError(
528
+ f"[termux-diffusion] [FAIL-FAST] Vulkan execution requested (device='{device_mode}'), "
529
+ f"but hardware validation failed: {e}"
530
+ ) from e
531
+ avr_ctx = None
532
+
467
533
  logger.info("Executing diffusion inference: %s", " ".join(cmd[:6]) + " ...")
468
534
  print(f"[termux-diffusion] Processing inference with model='{model}' (steps={steps}, threads={threads}, device={device_mode})...")
469
535
 
@@ -489,23 +555,33 @@ def generate(
489
555
  _safe_kill_process(process)
490
556
  raise TermuxDiffusionError("Inference cancelled by caller.")
491
557
 
558
+ init_logs = []
492
559
  recent_logs = deque(maxlen=20)
493
- # Stream real-time progress to terminal
560
+ # Stream real-time progress to terminal with FD safety
494
561
  if process.stdout:
495
- for line in process.stdout:
496
- if _cancel_event and _cancel_event.is_set():
497
- _safe_kill_process(process)
498
- raise TermuxDiffusionError("Inference cancelled by caller.")
499
- line_str = line.strip()
500
- if line_str:
501
- recent_logs.append(line_str)
502
- if "step" in line_str.lower() or "%" in line_str or "sampling" in line_str.lower():
503
- print(f" > {line_str}")
504
- else:
505
- logger.debug("sd-cli: %s", line_str)
562
+ try:
563
+ for line in process.stdout:
564
+ if _cancel_event and _cancel_event.is_set():
565
+ _safe_kill_process(process)
566
+ raise TermuxDiffusionError("Inference cancelled by caller.")
567
+ line_str = line.strip()
568
+ if line_str:
569
+ if len(init_logs) < 50 or "ggml_vulkan" in line_str or "error" in line_str.lower() or "failed" in line_str.lower():
570
+ init_logs.append(line_str)
571
+ recent_logs.append(line_str)
572
+ if "step" in line_str.lower() or "%" in line_str or "sampling" in line_str.lower():
573
+ print(f" > {line_str}")
574
+ else:
575
+ logger.debug("sd-cli: %s", line_str)
576
+ finally:
577
+ try:
578
+ process.stdout.close()
579
+ except Exception:
580
+ pass
506
581
 
507
582
  process.wait(timeout=timeout)
508
- if (strict_vulkan or device_mode in ("vulkan", "gpu")) and any("ggml_vulkan: No devices found" in l for l in recent_logs):
583
+ all_critical_logs = init_logs + list(recent_logs)
584
+ if (strict_vulkan or device_mode in ("vulkan", "gpu")) and any("ggml_vulkan: No devices found" in l for l in all_critical_logs):
509
585
  raise TermuxDiffusionError(
510
586
  "Strict Vulkan execution mode requested (--strict-vulkan or device='vulkan'), but Vulkan physical device discovery failed: 'ggml_vulkan: No devices found'."
511
587
  )
@@ -582,7 +658,7 @@ def generate(
582
658
  prompt=prompt,
583
659
  negative_prompt=negative_prompt,
584
660
  model=model,
585
- device=device_mode,
661
+ device=effective_device,
586
662
  steps=steps,
587
663
  cfg_scale=cfg_scale,
588
664
  width=width,
@@ -8,63 +8,13 @@ import logging
8
8
  from pathlib import Path
9
9
  from typing import Optional
10
10
 
11
- from .exceptions import TermuxDiffusionError
11
+ from .exceptions import DownloadError, InstallLockError, TermuxDiffusionError
12
+ from .locking import InstallLock
12
13
  from .manifest import verify_file_sha256
13
14
 
14
15
  logger = logging.getLogger("termux_diffusion.downloader")
15
16
 
16
17
 
17
- class InstallLockError(TermuxDiffusionError):
18
- """Raised when install lock cannot be acquired due to concurrent process."""
19
- pass
20
-
21
-
22
- class DownloadError(TermuxDiffusionError):
23
- """Raised when file download, size, or SHA-256 verification fails."""
24
- pass
25
-
26
-
27
- class InstallLock:
28
- """File lock manager to prevent concurrent installation processes."""
29
- def __init__(self, lock_file: Path, timeout_sec: float = 5.0):
30
- self.lock_file = lock_file
31
- self.timeout_sec = timeout_sec
32
- self._acquired = False
33
-
34
- def acquire(self):
35
- self.lock_file.parent.mkdir(parents=True, exist_ok=True)
36
- start = time.time()
37
- while time.time() - start < self.timeout_sec:
38
- try:
39
- # O_CREAT | O_EXCL atomic creation
40
- fd = os.open(str(self.lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
41
- os.write(fd, f"pid={os.getpid()}\n".encode("utf-8"))
42
- os.close(fd)
43
- self._acquired = True
44
- return
45
- except FileExistsError:
46
- time.sleep(0.2)
47
- raise InstallLockError(
48
- f"E_CONCURRENCY_LOCK: Another installation process holds the lock file at {self.lock_file}. "
49
- f"If stale, remove {self.lock_file} and retry."
50
- )
51
-
52
- def release(self):
53
- if self._acquired and self.lock_file.exists():
54
- try:
55
- self.lock_file.unlink(missing_ok=True)
56
- except Exception as e:
57
- logger.warning("Failed deleting lock file: %s", e)
58
- self._acquired = False
59
-
60
- def __enter__(self):
61
- self.acquire()
62
- return self
63
-
64
- def __exit__(self, exc_type, exc_val, exc_tb):
65
- self.release()
66
-
67
-
68
18
  def atomic_download_file(
69
19
  url: str,
70
20
  dest_path: Path,
@@ -61,6 +61,12 @@ class ModelDownloadError(TermuxDiffusionError):
61
61
  pass
62
62
 
63
63
 
64
+ class DownloadError(TermuxDiffusionError):
65
+ """Raised when file download, size, or SHA-256 verification fails."""
66
+ def __init__(self, message: str, code: str = ErrorCode.ARTIFACT_DOWNLOAD):
67
+ super().__init__(message, code=code)
68
+
69
+
64
70
  class ProvisioningError(TermuxDiffusionError):
65
71
  """Raised when the native C++ engine (sd-cli) fails to build or provision."""
66
72
  def __init__(self, message: str, code: str = ErrorCode.SOURCE_BUILD):
@@ -67,13 +67,18 @@ class HardwareProfile:
67
67
  # 1. CPU & GPU Feature Detection
68
68
  # ------------------------------------------------------------------------------
69
69
 
70
+ # [중요] Vulkan ICD 탐색 순서: Android Bionic ICD 최우선.
71
+ # Termux Mesa($PREFIX/lib/libvulkan.so)를 시스템 ICD보다 먼저 로드하면
72
+ # Bionic linker 이중 dispatch 테이블 충돌로 SIGABRT 가 발생합니다.
73
+ # ameva-vulkan-runtime 의 ICD 탐색 정책과 동일하게 유지합니다.
70
74
  _VULKAN_LIB_SEARCH_PATHS = [
71
- os.path.join(os.environ.get("PREFIX", "/data/data/com.termux/files/usr"), "lib", "libvulkan.so"),
72
- os.path.join(os.environ.get("PREFIX", "/data/data/com.termux/files/usr"), "lib64", "libvulkan.so"),
73
- "/system/lib64/libvulkan.so",
75
+ "/system/lib64/libvulkan.so", # Android Bionic ICD (최우선 — A35/S25/S21 모두 존재)
74
76
  "/system/lib/libvulkan.so",
75
77
  "/vendor/lib64/libvulkan.so",
76
78
  "/vendor/lib/libvulkan.so",
79
+ # Termux Mesa: 시스템 ICD 가 없는 순수 Linux/PRoot 환경 전용 fallback
80
+ os.path.join(os.environ.get("PREFIX", "/data/data/com.termux/files/usr"), "lib64", "libvulkan.so"),
81
+ os.path.join(os.environ.get("PREFIX", "/data/data/com.termux/files/usr"), "lib", "libvulkan.so"),
77
82
  ]
78
83
 
79
84
  _OPENCL_LIB_SEARCH_PATHS = [
@@ -87,6 +92,18 @@ _OPENCL_LIB_SEARCH_PATHS = [
87
92
  ]
88
93
 
89
94
 
95
+ def _find_vulkan_driver_path() -> Optional[str]:
96
+ """Find the first genuinely accessible Vulkan driver library on the filesystem."""
97
+ for p in _VULKAN_LIB_SEARCH_PATHS:
98
+ p_obj = Path(p)
99
+ try:
100
+ if p_obj.is_file() and p_obj.stat().st_size >= 1024:
101
+ return str(p_obj.resolve())
102
+ except (OSError, PermissionError):
103
+ continue
104
+ return None
105
+
106
+
90
107
  def _read_cpuinfo_features() -> List[str]:
91
108
  """Parse /proc/cpuinfo to extract ARM CPU feature flags."""
92
109
  cpuinfo_path = Path("/proc/cpuinfo")
@@ -106,8 +123,13 @@ def _read_cpuinfo_features() -> List[str]:
106
123
  return []
107
124
 
108
125
 
126
+ from .platform import is_android_termux
127
+
128
+
109
129
  def _detect_soc_name() -> str:
110
130
  """Identify the SoC model from Android system properties."""
131
+ if not is_android_termux():
132
+ return "Unknown"
111
133
  prop_keys = [
112
134
  "ro.soc.model",
113
135
  "ro.chipname",
@@ -132,8 +154,30 @@ def _detect_soc_name() -> str:
132
154
  return "Unknown"
133
155
 
134
156
 
157
+ def _detect_device_model() -> str:
158
+ """Identify Android device model for hardware profile matching."""
159
+ if not is_android_termux():
160
+ return "Unknown"
161
+ for prop in ["ro.product.model", "ro.product.device", "ro.build.product"]:
162
+ try:
163
+ result = subprocess.run(
164
+ ["getprop", prop],
165
+ capture_output=True,
166
+ text=True,
167
+ timeout=2.0,
168
+ )
169
+ val = result.stdout.strip()
170
+ if val and val != "unknown":
171
+ return val
172
+ except Exception:
173
+ continue
174
+ return "Unknown"
175
+
176
+
135
177
  def _detect_gpu_name() -> str:
136
178
  """Identify the GPU model from Android properties and hardware nodes."""
179
+ if not is_android_termux():
180
+ return "Unknown"
137
181
  # 1. Check Adreno kgsl sysfs node
138
182
  kgsl_model = Path("/sys/class/kgsl/kgsl-3d0/gpu_model")
139
183
  if kgsl_model.exists():
@@ -166,33 +210,34 @@ def _detect_gpu_name() -> str:
166
210
 
167
211
 
168
212
  def _probe_vulkan_driver() -> Optional[GPUDriverInfo]:
169
- """Probe for a usable Vulkan driver by checking library paths on disk."""
170
- for lib_path in _VULKAN_LIB_SEARCH_PATHS:
171
- p = Path(lib_path)
172
- if p.is_file():
173
- try:
174
- size = p.stat().st_size
175
- if size < 1024:
176
- continue
177
- gpu_name = _detect_gpu_name()
178
- return GPUDriverInfo(
179
- name=f"Vulkan Driver ({gpu_name})",
180
- vendor=gpu_name,
181
- api="Vulkan",
182
- library_path=lib_path,
183
- version="",
184
- usable=True,
185
- )
186
- except PermissionError as pe:
187
- logger.warning(
188
- "Vulkan driver found at '%s' but access was denied (SELinux permission): %s",
189
- lib_path,
190
- pe,
191
- )
192
- continue
193
- except OSError as oe:
194
- logger.debug("Vulkan driver stat note on '%s': %s", lib_path, oe)
195
- continue
213
+ """Probe for a usable Vulkan driver via ameva-vulkan-runtime SSOT."""
214
+ try:
215
+ import ameva_vulkan_runtime as avr
216
+ report = avr.Doctor().run_self_test(verbose=False)
217
+ if report.overall_success or report.recommended_backend in ("vulkan", "vulkan_driver_only") or getattr(report, "passed_stages", 0) >= 7:
218
+ return GPUDriverInfo(
219
+ name=f"Vulkan Driver ({report.device_name})",
220
+ vendor=report.device_name,
221
+ api=f"Vulkan {getattr(report, 'driver_version', '1.3')}",
222
+ library_path=getattr(report, "loader_path", "") or _find_vulkan_driver_path() or "/system/lib64/libvulkan.so",
223
+ version=getattr(report, "driver_version", ""),
224
+ usable=True,
225
+ )
226
+ except Exception as e:
227
+ logger.debug("[termux-diffusion] ameva-vulkan-runtime probe exception: %s", e)
228
+
229
+ # 안전 폴백: 시스템 기본 Bionic 경로 검사
230
+ detected_path = _find_vulkan_driver_path()
231
+ if detected_path and Path(detected_path).is_file():
232
+ gpu_name = _detect_gpu_name()
233
+ return GPUDriverInfo(
234
+ name=f"Vulkan Driver ({gpu_name})",
235
+ vendor=gpu_name,
236
+ api="Vulkan",
237
+ library_path=detected_path,
238
+ version="",
239
+ usable=True,
240
+ )
196
241
  return None
197
242
 
198
243
 
@@ -428,3 +473,100 @@ def format_hardware_report(profile: HardwareProfile) -> str:
428
473
  lines.append(f"CMake Build Flags: {' '.join(profile.cmake_extra_flags)}")
429
474
  lines.append("=" * 40)
430
475
  return "\n".join(lines)
476
+
477
+
478
+ # ------------------------------------------------------------------------------
479
+ # 4. Profile Gating Manager (Runtime Model/Preset Safety)
480
+ # ------------------------------------------------------------------------------
481
+
482
+ import json
483
+
484
+ @dataclass
485
+ class ProfileGatingManager:
486
+ """Manages runtime matching against validated-vulkan-profiles.json for safe device/model execution."""
487
+ profiles: List[Dict] = field(default_factory=list)
488
+
489
+ @classmethod
490
+ def load_from_json(cls, json_path: Optional[Path] = None) -> "ProfileGatingManager":
491
+ if json_path is None:
492
+ json_path = Path(__file__).parent / "data" / "validated-vulkan-profiles.json"
493
+ if json_path.is_file():
494
+ try:
495
+ data = json.loads(json_path.read_text(encoding="utf-8"))
496
+ return cls(profiles=data.get("profiles", []))
497
+ except Exception as e:
498
+ logger.warning("Failed to load validated-vulkan-profiles.json: %s", e)
499
+ return cls(profiles=[])
500
+
501
+ def find_matching_profile(self, model_name: str, soc_name: str = "", gpu_name: str = "") -> Optional[Dict]:
502
+ model_clean = model_name.strip().upper()
503
+ soc_clean = soc_name.strip().upper()
504
+ gpu_clean = gpu_name.strip().upper()
505
+
506
+ for prof in self.profiles:
507
+ target_model = prof.get("device_model", "").strip().upper()
508
+ if target_model and (target_model == model_clean or target_model in model_clean or model_clean in target_model):
509
+ return prof
510
+ aliases = [a.strip().upper() for a in prof.get("device_aliases", [])]
511
+ if any(a == model_clean or a in model_clean for a in aliases):
512
+ return prof
513
+
514
+ # Fallback matching by SoC / GPU
515
+ for prof in self.profiles:
516
+ prof_soc = prof.get("soc", "").strip().upper()
517
+ prof_gpu = prof.get("gpu", "").strip().upper()
518
+ if prof_soc and soc_clean and (prof_soc in soc_clean or soc_clean in prof_soc):
519
+ return prof
520
+ if prof_gpu and gpu_clean and (prof_gpu in gpu_clean or gpu_clean in prof_gpu):
521
+ return prof
522
+ return None
523
+
524
+ def validate_execution(self, preset_or_model: str, device: str) -> Tuple[bool, Optional[str]]:
525
+ """Validate if the requested model/preset and device backend are safe for current hardware."""
526
+ device_clean = device.lower().strip()
527
+ if device_clean not in ("vulkan", "gpu"):
528
+ return True, None
529
+
530
+ dev_model = _detect_device_model()
531
+ soc_name = _detect_soc_name()
532
+ gpu_name = _detect_gpu_name()
533
+ matched = self.find_matching_profile(dev_model, soc_name, gpu_name)
534
+ if not matched:
535
+ return True, None
536
+
537
+ # 1. Check blocked list
538
+ blocked = matched.get("blocked", [])
539
+ preset_clean = preset_or_model.lower().strip()
540
+ for blk in blocked:
541
+ fam = blk.get("model_family", "").lower()
542
+ backend = blk.get("backend", "").lower()
543
+ if backend in (device_clean, "all") and fam in preset_clean:
544
+ reason = blk.get("reason", "Incompatible hardware backend configuration")
545
+ return False, (
546
+ f"Model '{preset_or_model}' is blocked on device '{matched.get('device_model')}' "
547
+ f"under backend '{device_clean}' due to: {reason}."
548
+ )
549
+
550
+ # 2. Check preset gating if preset exists in profile
551
+ presets = matched.get("presets", {})
552
+ if preset_clean in presets:
553
+ p_info = presets[preset_clean]
554
+ if p_info.get("status") == "pending_device_validation" and not p_info.get("auto_activation", True):
555
+ return False, (
556
+ f"Preset '{preset_clean}' is currently pending device validation on '{matched.get('device_model')}' "
557
+ f"and auto_activation is disabled."
558
+ )
559
+
560
+ return True, None
561
+
562
+
563
+ _gating_manager: Optional[ProfileGatingManager] = None
564
+
565
+
566
+ def get_profile_gating_manager() -> ProfileGatingManager:
567
+ """Get singleton instance of ProfileGatingManager."""
568
+ global _gating_manager
569
+ if _gating_manager is None:
570
+ _gating_manager = ProfileGatingManager.load_from_json()
571
+ return _gating_manager
572
+
@@ -85,32 +85,52 @@ def fetch_prebuilt_binary(backend: str = "auto", install_mode: str = "prebuilt-f
85
85
  lock_file = get_default_cache_dir() / "install.lock"
86
86
 
87
87
  with InstallLock(lock_file, timeout_sec=5.0):
88
- # 1. Try Vulkan Prebuilt (Multi-SoC Aware: Adreno vs Mali)
88
+ # 1. Try Vulkan Prebuilt (Multi-SoC Aware: Adreno vs Mali via validated profiles)
89
89
  if backend in ("auto", "vulkan"):
90
90
  vulkan_bin = bin_dir / "sd-cli-vulkan"
91
91
  print("[termux-diffusion] Attempting Prebuilt Vulkan Engine installation...")
92
92
  try:
93
93
  if not vulkan_bin.is_file():
94
- from .hardware import detect_hardware_profile
94
+ from .hardware import detect_hardware_profile, get_profile_gating_manager, _detect_device_model
95
95
  hw = detect_hardware_profile()
96
- is_mali = "mali" in hw.gpu_name.lower() or "exynos" in hw.soc_name.lower()
96
+ gating_mgr = get_profile_gating_manager()
97
+ dev_model = _detect_device_model()
98
+ matched = gating_mgr.find_matching_profile(dev_model, hw.soc_name, hw.gpu_name)
97
99
 
98
- if is_mali:
99
- # Galaxy S21 / S20 Mali Prebuilt
100
- pkg_url = "https://github.com/uno-km/termux-diffusion/releases/download/v1.3.1-vulkan-mali-experimental/termux-diffusion-vulkan-prebuilt-v1.3.1-android-arm64-mali-compat-v2.tar.gz"
101
- pkg_sha256 = "65e4e305241b22385313e386afbcd12722061041280d00a44dfdc3ff23aa17b8"
100
+ if matched and matched.get("package_tarball") and matched.get("package_sha256"):
101
+ pkg_tag = matched.get("package_tag", "v1.3.1-vulkan-experimental")
102
+ pkg_tarball = matched.get("package_tarball")
103
+ pkg_url = f"https://github.com/uno-km/termux-diffusion/releases/download/{pkg_tag}/{pkg_tarball}"
104
+ pkg_sha256 = matched.get("package_sha256")
102
105
  else:
103
- # Galaxy S25 / Snapdragon Adreno Prebuilt
104
- pkg_url = "https://github.com/uno-km/termux-diffusion/releases/download/v1.3.1-vulkan-experimental/termux-diffusion-vulkan-prebuilt-v1.3.1-android-arm64-adreno.tar.gz"
105
- pkg_sha256 = "d1f0a2656a33d0929cfd3335e01feeabf9c3a1e34a0ae0eacc04ddb3701ece92"
106
+ is_mali = "mali" in hw.gpu_name.lower() or "exynos" in hw.soc_name.lower()
107
+ if is_mali:
108
+ pkg_url = "https://github.com/uno-km/termux-diffusion/releases/download/v1.3.1-vulkan-mali-experimental/termux-diffusion-vulkan-prebuilt-v1.3.1-android-arm64-mali-compat-v2.tar.gz"
109
+ pkg_sha256 = "65e4e305241b22385313e386afbcd12722061041280d00a44dfdc3ff23aa17b8"
110
+ else:
111
+ pkg_url = "https://github.com/uno-km/termux-diffusion/releases/download/v1.3.1-vulkan-experimental/termux-diffusion-vulkan-prebuilt-v1.3.1-android-arm64-adreno.tar.gz"
112
+ pkg_sha256 = "d1f0a2656a33d0929cfd3335e01feeabf9c3a1e34a0ae0eacc04ddb3701ece92"
106
113
 
107
114
  tar_dest = bin_dir / "vulkan-prebuilt.tar.gz"
108
115
  try:
109
116
  atomic_download_file(pkg_url, tar_dest, expected_sha256=pkg_sha256)
110
117
  import tarfile
118
+ extract_target = get_default_cache_dir()
111
119
  with tarfile.open(tar_dest, "r:gz") as tar:
112
- tar.extractall(path=get_default_cache_dir())
120
+ # 경로 탈출(Directory Traversal) 방지 검증
121
+ for member in tar.getmembers():
122
+ member_path = os.path.realpath(
123
+ os.path.join(extract_target, member.name)
124
+ )
125
+ if not member_path.startswith(os.path.realpath(extract_target)):
126
+ raise ProvisioningError(
127
+ f"[termux-diffusion] E_TAR_PATH_ESCAPE: tarball 내 "
128
+ f"경로 탈출 시도가 감지되어 추출을 중단했습니다: {member.name}"
129
+ )
130
+ tar.extractall(path=extract_target)
113
131
  tar_dest.unlink(missing_ok=True)
132
+ except ProvisioningError:
133
+ raise
114
134
  except Exception as dl_err:
115
135
  logger.debug("Vulkan prebuilt download skipped/failed: %s", dl_err)
116
136
 
@@ -302,42 +322,15 @@ def provision_engine(
302
322
  except Exception as e:
303
323
  logger.warning("ggml-vulkan patch note: %s", e)
304
324
 
305
- # Bionic Healer: Patch vulkan-shaders-gen.cpp for Android Termux
325
+ # Bionic Path Fix: Patch vulkan-shaders-gen.cpp for Android Termux temporary directory
306
326
  vulkan_gen = repo_dir / "ggml" / "src" / "ggml-vulkan" / "vulkan-shaders" / "vulkan-shaders-gen.cpp"
307
327
  if vulkan_gen.is_file():
308
328
  try:
309
329
  gen_content = vulkan_gen.read_text(encoding="utf-8")
310
330
  if 'output_dir = "/tmp"' in gen_content:
311
331
  gen_content = gen_content.replace('output_dir = "/tmp"', 'output_dir = "."')
312
-
313
- old_err_block = (
314
- " int exit_code = execute_command(cmd, stdout_str, stderr_str);\n"
315
- " if (exit_code != 0 || !stderr_str.empty()) {\n"
316
- ' std::cerr << "cannot compile " << name << " (exit code " << exit_code << ")\\n\\n";\n'
317
- " for (const auto& part : cmd) {\n"
318
- ' std::cerr << part << " ";\n'
319
- " }\n"
320
- ' std::cerr << "\\n\\n" << stderr_str << std::endl;\n'
321
- " compile_failed = true;\n"
322
- " return;\n"
323
- " }"
324
- )
325
- new_err_block = (
326
- " int exit_code = execute_command(cmd, stdout_str, stderr_str);\n"
327
- " if (exit_code != 0 || !stderr_str.empty()) {\n"
328
- " uint32_t dummy_spv[] = {0x07230203, 0x00010000, 0x00080001, 1, 0};\n"
329
- " std::ofstream f(out_path, std::ios::binary);\n"
330
- " f.write(reinterpret_cast<const char*>(dummy_spv), sizeof(dummy_spv));\n"
331
- " f.close();\n"
332
- " std::lock_guard<std::mutex> guard(lock);\n"
333
- " shader_fnames.push_back(std::make_pair(name, out_path));\n"
334
- " return;\n"
335
- " }"
336
- )
337
- if old_err_block in gen_content:
338
- gen_content = gen_content.replace(old_err_block, new_err_block)
339
332
  vulkan_gen.write_text(gen_content, encoding="utf-8")
340
- logger.info("Patched vulkan-shaders-gen.cpp with Universal Mobile GPU Healer.")
333
+ logger.info("Configured vulkan-shaders-gen.cpp for mobile working directory.")
341
334
  except Exception as e:
342
335
  logger.warning("vulkan-shaders-gen patch note: %s", e)
343
336
 
@@ -0,0 +1,96 @@
1
+ import logging
2
+ import os
3
+ import sys
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from .exceptions import InstallLockError
9
+
10
+ logger = logging.getLogger("termux_diffusion.locking")
11
+
12
+
13
+ class InstallLock:
14
+ """File lock manager controlling full installer state machine execution with stale PID auto-recovery."""
15
+ def __init__(self, lock_file: Path, timeout_sec: float = 5.0):
16
+ self.lock_file = lock_file
17
+ self.timeout_sec = timeout_sec
18
+ self._acquired = False
19
+
20
+ def _is_pid_alive(self, pid: int) -> bool:
21
+ if pid <= 0:
22
+ return False
23
+ if sys.platform == "win32":
24
+ try:
25
+ import ctypes
26
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
27
+ h = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
28
+ if h:
29
+ ctypes.windll.kernel32.CloseHandle(h)
30
+ return True
31
+ return False
32
+ except Exception:
33
+ return True
34
+ else:
35
+ try:
36
+ os.kill(pid, 0)
37
+ return True
38
+ except (OSError, ProcessLookupError):
39
+ return False
40
+
41
+ def _check_and_clear_stale_lock(self) -> bool:
42
+ """Check if existing lock file belongs to a deceased process, removing it if stale."""
43
+ if not self.lock_file.exists():
44
+ return True
45
+ try:
46
+ content = self.lock_file.read_text(encoding="utf-8")
47
+ for line in content.splitlines():
48
+ if line.startswith("pid="):
49
+ pid_str = line.split("=", 1)[1].strip()
50
+ if pid_str.isdigit():
51
+ pid = int(pid_str)
52
+ if not self._is_pid_alive(pid):
53
+ logger.warning(
54
+ "[termux-diffusion] Stale installation lock detected (PID: %d is dead). Auto-reclaiming lock.",
55
+ pid,
56
+ )
57
+ self.lock_file.unlink(missing_ok=True)
58
+ return True
59
+ except Exception as e:
60
+ logger.debug("Stale lock inspection note: %s", e)
61
+ return False
62
+
63
+ def acquire(self):
64
+ self.lock_file.parent.mkdir(parents=True, exist_ok=True)
65
+ self._check_and_clear_stale_lock()
66
+ start = time.time()
67
+ while time.time() - start < self.timeout_sec:
68
+ try:
69
+ fd = os.open(str(self.lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
70
+ os.write(fd, f"pid={os.getpid()}\nstarted_at={time.time()}\n".encode("utf-8"))
71
+ os.close(fd)
72
+ self._acquired = True
73
+ return
74
+ except FileExistsError:
75
+ if self._check_and_clear_stale_lock():
76
+ continue
77
+ time.sleep(0.2)
78
+ raise InstallLockError(
79
+ f"E_INSTALL_LOCKED: Installation process locked by another running process ({self.lock_file})."
80
+ )
81
+
82
+ def release(self):
83
+ if self._acquired and self.lock_file.exists():
84
+ try:
85
+ self.lock_file.unlink(missing_ok=True)
86
+ except Exception as e:
87
+ logger.warning("Failed removing install.lock: %s", e)
88
+ self._acquired = False
89
+
90
+ def __enter__(self):
91
+ self.acquire()
92
+ return self
93
+
94
+ def __exit__(self, exc_type, exc_val, exc_tb):
95
+ self.release()
96
+
@@ -8,8 +8,8 @@ for mobile NPU/TPU chipsets on Android Termux:
8
8
  - Android Standard Neural Networks API (NNAPI)
9
9
 
10
10
  Architectural Note:
11
- In v1.1.x, inference is driven by the native Bionic sd-cli engine using Vulkan GPU
12
- (-ngl 99) and ARM64 NEON CPU acceleration. Direct Qualcomm QNN / Hexagon NPU subgraph
11
+ In v1.3+, inference is driven by the native Bionic sd-cli engine using Vulkan GPU
12
+ (--backend vulkan0) and ARM64 NEON CPU acceleration. Direct Qualcomm QNN / Hexagon NPU subgraph
13
13
  partitioning is the architectural blueprint for the upcoming v2.0 runtime.
14
14
  """
15
15
 
@@ -228,8 +228,8 @@ def get_optimal_heterogeneous_pipeline(device: str = "auto") -> Dict[str, str]:
228
228
  """Determine the compute processor allocation for each diffusion pipeline component.
229
229
 
230
230
  Note:
231
- In v1.1, diffusion inference is executed via the native Bionic sd-cli engine using
232
- Vulkan GPU compute (-ngl 99) and ARM64 NEON CPU math. Direct Qualcomm QNN / Hexagon
231
+ In v1.3+, diffusion inference is executed via the native Bionic sd-cli engine using
232
+ Vulkan GPU compute (--backend vulkan0) and ARM64 NEON CPU math. Direct Qualcomm QNN / Hexagon
233
233
  NPU subgraph partitioning is the architectural target for the upcoming v2.0 runtime.
234
234
  """
235
235
  req = device.lower().strip()
@@ -245,7 +245,7 @@ def get_optimal_heterogeneous_pipeline(device: str = "auto") -> Dict[str, str]:
245
245
  else:
246
246
  return {
247
247
  "text_encoder": "CPU / GPU (ARM NEON / Vulkan)",
248
- "denoiser_unet": "GPU (Vulkan Compute / -ngl 99)",
248
+ "denoiser_unet": "GPU (Vulkan Compute / --backend vulkan0)",
249
249
  "vae_decoder": "GPU (Vulkan Compute FP16)",
250
250
  "scheduler": "CPU (Single-Core Fast Math)",
251
251
  "summary": "GPU-Accelerated [Vulkan Compute Shader Pipeline]"
@@ -19,8 +19,22 @@ TERMUX_PREFIX = os.environ.get("PREFIX", "/data/data/com.termux/files/usr")
19
19
  TERMUX_HOME = os.environ.get("HOME", "/data/data/com.termux/files/home")
20
20
 
21
21
 
22
+ # [B방안] Platform SSOT: ameva-vulkan-runtime.platform 에서 공유 구현을 가져옵니다.
23
+ try:
24
+ from ameva_vulkan_runtime.platform import is_termux as _ameva_is_termux
25
+ _AMEVA_PLATFORM_AVAILABLE = True
26
+ except ImportError:
27
+ _AMEVA_PLATFORM_AVAILABLE = False
28
+
29
+
22
30
  def is_android_termux() -> bool:
23
- """Check whether the current runtime environment is native Android Termux."""
31
+ """Check whether the current runtime environment is native Android Termux.
32
+
33
+ [B방안] ameva-vulkan-runtime.platform.is_termux() 를 SSOT 로 사용하며,
34
+ 미설치 환경에서는 인라인 구현으로 안전하게 폴백합니다.
35
+ """
36
+ if _AMEVA_PLATFORM_AVAILABLE:
37
+ return _ameva_is_termux()
24
38
  if os.environ.get("TERMUX_VERSION") or os.environ.get("TERMUX_APP_PID"):
25
39
  return True
26
40
  if os.path.exists("/data/data/com.termux"):
@@ -113,24 +113,37 @@ def run_binary_self_test(
113
113
  return result
114
114
 
115
115
  # --------------------------------------------------------------------------
116
- # Stage 2 & 3: Backend Probe & Compute Queue Test
116
+ # Stage 2 & 3: Backend Probe & Compute Queue Test (Real Hardware Probe)
117
117
  # --------------------------------------------------------------------------
118
118
  if expected_backend == "cpu":
119
- # Pure CPU binary dependency check
120
119
  has_readelf, deps = check_dynamic_library_dependencies(binary_path)
121
120
  vulkan_deps = [d for d in deps if "libvulkan.so" in d or "libOpenCL.so" in d]
122
121
  if vulkan_deps:
123
122
  logger.warning("CPU binary has unwanted GPU library linkage: %s", vulkan_deps)
124
123
  result.stage2_probe_passed = True
125
124
  result.stage3_compute_passed = True
126
- print("[termux-diffusion] Stage 1-3 Self-Test: CPU Baseline Binary validated successfully.")
127
125
  _SELF_TEST_CACHE[cache_key] = result
128
126
  return result
129
127
 
130
- # Vulkan backend self-test
128
+ # [수정] Vulkan 백엔드 실측 검증: ameva-vulkan-runtime 연동
131
129
  print(f"[termux-diffusion] Stage 2 Self-Test: Vulkan runtime validation ({binary_path.name})...")
132
- result.stage2_probe_passed = True
133
- result.stage3_compute_passed = True
134
- print("[termux-diffusion] Stage 1-3 Self-Test: Vulkan Backend validated successfully.")
130
+ try:
131
+ import ameva_vulkan_runtime as avr
132
+ doc = avr.Doctor()
133
+ if doc.quick_probe():
134
+ result.stage2_probe_passed = True
135
+ result.stage3_compute_passed = True
136
+ print(f"[termux-diffusion] Stage 1-3 Self-Test: Vulkan Backend ({doc.quick_probe_device() or 'GPU'}) validated.")
137
+ else:
138
+ result.stage2_probe_passed = False
139
+ result.stage3_compute_passed = False
140
+ result.error_code = "E_VULKAN_PROBE_FAILED"
141
+ result.error_message = "Vulkan ICD loader or hardware compute queue is unavailable on this device."
142
+ except Exception as exc:
143
+ result.stage2_probe_passed = False
144
+ result.stage3_compute_passed = False
145
+ result.error_code = "E_VULKAN_EXCEPTION"
146
+ result.error_message = f"Vulkan probe exception: {exc}"
147
+
135
148
  _SELF_TEST_CACHE[cache_key] = result
136
149
  return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termux-diffusion
3
- Version: 1.3.1
3
+ Version: 1.3.2
4
4
  Summary: Production On-Device AI Image Generation Framework for Android Termux & Samsung Galaxy (Dual-Engine Python & Node.js)
5
5
  Home-page: https://github.com/uno-km/termux-diffusion
6
6
  Author: uno-km (AMEVA Foundation)
@@ -28,6 +28,7 @@ Classifier: Topic :: Multimedia :: Graphics
28
28
  Requires-Python: >=3.8
29
29
  Description-Content-Type: text/markdown
30
30
  License-File: LICENSE
31
+ Requires-Dist: ameva-vulkan-runtime>=1.0.0
31
32
  Dynamic: author
32
33
  Dynamic: classifier
33
34
  Dynamic: home-page
@@ -75,7 +76,7 @@ termux-setup-storage
75
76
 
76
77
  # 2. Install Toolchains & Provision Native Engine
77
78
  pkg update -y && pkg install python clang make cmake git termux-api wget vulkan-loader vulkan-headers vulkan-tools -y
78
- pip install termux-diffusion && termux-diffusion-install
79
+ pip install termux-diffusion && termux-diffusion install
79
80
  ```
80
81
 
81
82
  #### [Node.js] Node.js / TypeScript:
@@ -88,6 +89,11 @@ pkg update -y && pkg install nodejs-lts clang make cmake git termux-api wget vul
88
89
  npm install -g termux-diffusion && npx termux-diffusion install
89
90
  ```
90
91
 
92
+ #### [Universal] One-Line Automated Bootstrap (curl):
93
+ ```bash
94
+ curl -sL https://raw.githubusercontent.com/uno-km/termux-diffusion/main/scripts/install.sh | bash
95
+ ```
96
+
91
97
  ---
92
98
 
93
99
  ### [Instant] Scenario 2: Instant Generation (Ready to Run)
@@ -416,12 +422,9 @@ adb shell "/system/bin/device_config put activity_manager max_phantom_processes
416
422
 
417
423
  ---
418
424
 
419
- ## 9. Disclaimer (면책 조항)
425
+ ## 9. Disclaimer
420
426
 
421
- > **Disclaimer:**
422
- > *Termux-Diffusion is an independent open-source project developed for the Android Termux environment and is not officially affiliated with, endorsed by, or sponsored by the Termux project.*
423
- >
424
- > *(본 프로젝트는 안드로이드 Termux 환경을 위해 개발된 독립적인 오픈소스 라이브러리이며, Termux 공식 프로젝트와 직접적인 제휴 관계가 아닙니다.)*
427
+ > *This project is an independent open-source library developed for the Android Termux environment. It is not officially affiliated with, endorsed by, or sponsored by the Termux project, stability.ai, or Google LLC.*
425
428
 
426
429
  ---
427
430
 
@@ -21,6 +21,7 @@ termux_diffusion.egg-info/PKG-INFO
21
21
  termux_diffusion.egg-info/SOURCES.txt
22
22
  termux_diffusion.egg-info/dependency_links.txt
23
23
  termux_diffusion.egg-info/entry_points.txt
24
+ termux_diffusion.egg-info/requires.txt
24
25
  termux_diffusion.egg-info/top_level.txt
25
26
  termux_diffusion/data/bootstrap-manifest.json
26
27
  termux_diffusion/data/release-public-keys.json
@@ -0,0 +1 @@
1
+ ameva-vulkan-runtime>=1.0.0
@@ -527,6 +527,7 @@ def test_strict_vulkan_failure_raises(tmp_path):
527
527
  ]
528
528
  mock_proc.returncode = 0
529
529
  mock_proc.wait = lambda timeout=None: 0
530
+ mock_proc.communicate.return_value = ("", "")
530
531
  return mock_proc
531
532
 
532
533
  with patch("termux_diffusion.hardware.resolve_device_backend", return_value=("vulkan", 99)), \
@@ -109,3 +109,20 @@ def test_a53_mali_profile_and_preset_gating():
109
109
  assert "balanced" in a53_prof["presets"]
110
110
  assert a53_prof["presets"]["balanced"]["status"] == "pending_device_validation"
111
111
  assert a53_prof["presets"]["balanced"]["auto_activation"] is False
112
+
113
+
114
+ def test_runtime_profile_gating_manager():
115
+ from termux_diffusion.hardware import get_profile_gating_manager
116
+ mgr = get_profile_gating_manager()
117
+ assert len(mgr.profiles) > 0
118
+
119
+ # 1. Matching Galaxy S25
120
+ s25_prof = mgr.find_matching_profile("SM-S931N")
121
+ assert s25_prof is not None
122
+ assert s25_prof["gpu"] == "Adreno (TM) 830"
123
+
124
+ # 2. Matching Galaxy A35 alias
125
+ a35_prof = mgr.find_matching_profile("a35x")
126
+ assert a35_prof is not None
127
+ assert a35_prof["gpu"] == "Mali-G68"
128
+
@@ -1,50 +0,0 @@
1
- """Process-wide installation lock manager for termux-diffusion."""
2
-
3
- import os
4
- import time
5
- import logging
6
- from pathlib import Path
7
- from typing import Optional
8
-
9
- from .exceptions import InstallLockError
10
-
11
- logger = logging.getLogger("termux_diffusion.locking")
12
-
13
-
14
- class InstallLock:
15
- """File lock manager controlling full installer state machine execution."""
16
- def __init__(self, lock_file: Path, timeout_sec: float = 5.0):
17
- self.lock_file = lock_file
18
- self.timeout_sec = timeout_sec
19
- self._acquired = False
20
-
21
- def acquire(self):
22
- self.lock_file.parent.mkdir(parents=True, exist_ok=True)
23
- start = time.time()
24
- while time.time() - start < self.timeout_sec:
25
- try:
26
- fd = os.open(str(self.lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
27
- os.write(fd, f"pid={os.getpid()}\nstarted_at={time.time()}\n".encode("utf-8"))
28
- os.close(fd)
29
- self._acquired = True
30
- return
31
- except FileExistsError:
32
- time.sleep(0.2)
33
- raise InstallLockError(
34
- f"E_INSTALL_LOCKED: Installation process locked by another running process ({self.lock_file})."
35
- )
36
-
37
- def release(self):
38
- if self._acquired and self.lock_file.exists():
39
- try:
40
- self.lock_file.unlink(missing_ok=True)
41
- except Exception as e:
42
- logger.warning("Failed removing install.lock: %s", e)
43
- self._acquired = False
44
-
45
- def __enter__(self):
46
- self.acquire()
47
- return self
48
-
49
- def __exit__(self, exc_type, exc_val, exc_tb):
50
- self.release()