termux-diffusion 1.3.2__tar.gz → 1.4.0__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 (42) hide show
  1. {termux_diffusion-1.3.2/termux_diffusion.egg-info → termux_diffusion-1.4.0}/PKG-INFO +1 -1
  2. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/pyproject.toml +5 -1
  3. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/setup.py +1 -1
  4. termux_diffusion-1.4.0/termux_diffusion/_version.py +1 -0
  5. termux_diffusion-1.4.0/termux_diffusion/adapter.py +35 -0
  6. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/cli.py +18 -0
  7. termux_diffusion-1.4.0/termux_diffusion/control/__init__.py +3 -0
  8. termux_diffusion-1.4.0/termux_diffusion/control/component.py +262 -0
  9. termux_diffusion-1.4.0/termux_diffusion/control/status.py +13 -0
  10. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/core.py +13 -8
  11. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/hardware.py +7 -2
  12. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/installer.py +35 -8
  13. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/locking.py +42 -6
  14. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/npu.py +19 -6
  15. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/platform.py +12 -4
  16. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0/termux_diffusion.egg-info}/PKG-INFO +1 -1
  17. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion.egg-info/SOURCES.txt +4 -0
  18. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion.egg-info/entry_points.txt +3 -0
  19. termux_diffusion-1.3.2/termux_diffusion/_version.py +0 -1
  20. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/LICENSE +0 -0
  21. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/README.md +0 -0
  22. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/setup.cfg +0 -0
  23. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/__init__.py +0 -0
  24. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/data/bootstrap-manifest.json +0 -0
  25. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/data/release-public-keys.json +0 -0
  26. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/data/validated-vulkan-profiles.json +0 -0
  27. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/downloader.py +0 -0
  28. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/exceptions.py +0 -0
  29. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/hub.py +0 -0
  30. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/manifest.py +0 -0
  31. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/py.typed +0 -0
  32. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion/selftest.py +0 -0
  33. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion.egg-info/dependency_links.txt +0 -0
  34. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion.egg-info/requires.txt +0 -0
  35. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/termux_diffusion.egg-info/top_level.txt +0 -0
  36. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/tests/test_core.py +0 -0
  37. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/tests/test_hardware.py +0 -0
  38. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/tests/test_hub.py +0 -0
  39. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/tests/test_installer.py +0 -0
  40. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/tests/test_npu.py +0 -0
  41. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/tests/test_platform.py +0 -0
  42. {termux_diffusion-1.3.2 → termux_diffusion-1.4.0}/tests/test_presets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termux-diffusion
3
- Version: 1.3.2
3
+ Version: 1.4.0
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)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "termux-diffusion"
7
- version = "1.3.2"
7
+ version = "1.4.0"
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"
@@ -56,6 +56,10 @@ termux-diffusion = "termux_diffusion.cli:main"
56
56
  termux-diffusion-install = "termux_diffusion.cli:run_install_cli"
57
57
  termux-diffusion-doctor = "termux_diffusion.cli:run_doctor_cli"
58
58
 
59
+ [project.entry-points."ameva.components"]
60
+ termux-diffusion = "termux_diffusion.adapter:create_adapter"
61
+
62
+
59
63
  [tool.setuptools.package-data]
60
64
  termux_diffusion = [
61
65
  "data/*.json",
@@ -11,7 +11,7 @@ if os.path.exists(readme_path):
11
11
 
12
12
  setup(
13
13
  name="termux-diffusion",
14
- version="1.3.2",
14
+ version="1.4.0",
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",
@@ -0,0 +1 @@
1
+ __version__ = "1.4.0"
@@ -0,0 +1,35 @@
1
+ """
2
+ termux_diffusion.adapter
3
+ =========================
4
+ AMEVA Component Protocol v1 — Orchestrator Adapter (v0.8.1 호환)
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, AsyncIterator
9
+
10
+ from ameva_component.adapter_base import BaseOrchestratorAdapter
11
+ from termux_diffusion.control.component import DiffusionControl
12
+
13
+
14
+ class DiffusionOrchestratorAdapter(BaseOrchestratorAdapter):
15
+ """Diffusion Orchestrator Adapter.
16
+
17
+ 이미지 생성은 sd-cli subprocess를 통해 수행됩니다.
18
+ infer()는 OPERATION_NOT_SUPPORTED — generate() REST endpoint를 사용하십시오.
19
+ """
20
+
21
+ COMPONENT_ID = "termux-diffusion"
22
+
23
+ def __init__(self, control: DiffusionControl | None = None) -> None:
24
+ self._control = control or DiffusionControl()
25
+
26
+ async def infer(self, request: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
27
+ """Diffusion generation은 REST endpoint /generate 를 통해 수행됩니다.
28
+ Orchestrator 직접 streaming은 OPERATION_NOT_SUPPORTED.
29
+ """
30
+ yield self._not_supported("infer")
31
+
32
+
33
+ def create_adapter() -> DiffusionOrchestratorAdapter:
34
+ """Entry Point Factory."""
35
+ return DiffusionOrchestratorAdapter()
@@ -81,6 +81,14 @@ def main(argv: Optional[List[str]] = None) -> int:
81
81
  # clear-cache command
82
82
  subparsers.add_parser("clear-cache", help="Purge cached model weights to reclaim disk space")
83
83
 
84
+ # ── AMEVA Component Protocol v1 ─────────────────────────────────────────
85
+ try:
86
+ from ameva_component.cli_support import build_protocol_subcommands
87
+ build_protocol_subcommands(subparsers)
88
+ except ImportError:
89
+ pass # Allowed: optional rich/click dependency. Not installed is normal fallback.
90
+ # ────────────────────────────────────────────────────────────────────────
91
+
84
92
  if len(argv) == 0:
85
93
  parser.print_help()
86
94
  return 0
@@ -189,6 +197,16 @@ def main(argv: Optional[List[str]] = None) -> int:
189
197
  print(f"[Clean] Removed {removed} cached model files.")
190
198
  return 0
191
199
 
200
+ elif args.command in ("component", "model", "instance"):
201
+ try:
202
+ from ameva_component.cli_support import dispatch_protocol
203
+ from termux_diffusion.control import DiffusionControl
204
+ dispatch_protocol(args, DiffusionControl())
205
+ except ImportError:
206
+ print("[ERROR] ameva-component-sdk not installed.", file=sys.stderr)
207
+ return 1
208
+ return 0
209
+
192
210
  parser.print_help()
193
211
  return 0
194
212
 
@@ -0,0 +1,3 @@
1
+ """termux_diffusion.control — AMEVA Component Protocol v1."""
2
+ from .component import DiffusionControl
3
+ __all__ = ["DiffusionControl"]
@@ -0,0 +1,262 @@
1
+ """
2
+ termux_diffusion.control.component
3
+ AMEVA Component Protocol v1 — DiffusionControl
4
+
5
+ model_id는 파일명이 아닌 Pipeline Manifest를 가리킵니다.
6
+ (UNet + VAE + Text Encoder + Tokenizer + Scheduler Config)
7
+ 기존 installer.run_doctor() + hub.py를 Adapter로 연결합니다.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from ameva_component import (
16
+ ActivationLock, ComponentInfo, ComponentStateFile,
17
+ ControlMode, InstanceRegistry, InstanceState, InstanceStatus,
18
+ ModelRegistry, ModelState, ModelNotFound, ModelLoadFailed,
19
+ OperationNotSupported, now_timestamps, log_stderr, PROTOCOL_COMPONENT,
20
+ )
21
+ from ameva_component.control import ComponentControl
22
+
23
+
24
+ class DiffusionControl(ComponentControl):
25
+ """
26
+ termux-diffusion ComponentControl.
27
+
28
+ model_id = Pipeline Manifest ID (단일 파일명 아님).
29
+ 기존 installer.run_doctor(), hub.DEFAULT_PRESETS를 Adapter로 연결.
30
+ """
31
+
32
+ COMPONENT_ID = "termux-diffusion"
33
+ COMPONENT_TYPE = "diffusion"
34
+ CAPABILITIES = ("image.generate",)
35
+
36
+ DEFAULT_MODELS_DIR = Path.home() / ".cache" / "termux-diffusion" / "models"
37
+
38
+ def __init__(self, models_dir: Path | None = None) -> None:
39
+ self._models_dir = models_dir or self.DEFAULT_MODELS_DIR
40
+ self._state_file = ComponentStateFile(self.COMPONENT_ID)
41
+ self._model_reg = ModelRegistry(self.COMPONENT_ID)
42
+ self._inst_reg = InstanceRegistry(self.COMPONENT_ID)
43
+ self._act_lock = ActivationLock()
44
+ # Phase 4: Heartbeat Writer
45
+ from termux_diffusion.control.status import DiffusionStatusWriter
46
+ self._heartbeat = DiffusionStatusWriter(self)
47
+
48
+ def _get_version(self) -> str:
49
+ try:
50
+ from termux_diffusion import __version__; return __version__
51
+ except Exception: return "1.3.2"
52
+
53
+ def component_info(self) -> dict:
54
+ info = ComponentInfo(
55
+ protocol=PROTOCOL_COMPONENT, component_id=self.COMPONENT_ID,
56
+ component_type=self.COMPONENT_TYPE, version=self._get_version(),
57
+ capabilities=self.CAPABILITIES,
58
+ )
59
+ info.validate()
60
+ return info.to_dict()
61
+
62
+ def doctor_lite(self) -> dict:
63
+ """
64
+ 경량 진단.
65
+ 샘플 추론 금지, 엔진 바이너리 존재 여부 + 상태파일만.
66
+ """
67
+ ts = now_timestamps()
68
+ state_data = self._state_file.read()
69
+ stale = self._state_file.is_stale(threshold_ms=30_000)
70
+ pid, pid_alive = self._check_pid()
71
+ instances = self._inst_reg.list_all()
72
+ hot = [i for i in instances if i.state == InstanceState.HOT]
73
+
74
+ # 엔진 바이너리 존재 여부 (실행 금지)
75
+ engine_present = self._check_engine_binary()
76
+
77
+ ready = engine_present
78
+ degraded = stale or not pid_alive
79
+
80
+ return {
81
+ "protocol": "ameva-component-status/1",
82
+ "component_id": self.COMPONENT_ID, "component_type": self.COMPONENT_TYPE,
83
+ "version": self._get_version(), "ready": ready, "degraded": degraded,
84
+ **ts,
85
+ "process": {"running": pid_alive, "pid": pid},
86
+ "capabilities": list(self.CAPABILITIES),
87
+ "active_models": [i.model_id for i in hot],
88
+ "engine": {"present": engine_present},
89
+ "instances": [{"instance_id": i.instance_id, "model_id": i.model_id,
90
+ "state": i.state.value, "active_jobs": i.active_jobs} for i in instances],
91
+ "errors": [state_data.get("last_error")] if state_data and state_data.get("last_error") else [],
92
+ "state_file": {"path": str(self._state_file.path), "stale": stale,
93
+ "updated_at": state_data.get("updated_at") if state_data else None},
94
+ }
95
+
96
+ def _check_pid(self) -> tuple[int | None, bool | None]:
97
+ """PID 파일에서 sd-cli 프로세스 생존 여부 확인.
98
+
99
+ 반환:
100
+ alive=True → 프로세스 확인됨
101
+ alive=False → 종료됨
102
+ alive=None → PermissionError 등 측정 불가
103
+ """
104
+ from ameva_component import log_stderr
105
+
106
+ pid_file = Path.home() / ".local" / "run" / "termux-diffusion.pid"
107
+ if pid_file.exists():
108
+ try:
109
+ pid = int(pid_file.read_text().strip())
110
+ except (ValueError, OSError) as _parse_err:
111
+ log_stderr(f"[diffusion] PID file parse error: {_parse_err}")
112
+ return None, False
113
+
114
+ try:
115
+ os.kill(pid, 0)
116
+ return pid, True
117
+ except ProcessLookupError:
118
+ return pid, False
119
+ except PermissionError:
120
+ log_stderr(f"[diffusion] PID {pid} alive check: PermissionError (unmeasurable)")
121
+ return pid, None
122
+ except OSError as _os_err:
123
+ log_stderr(f"[diffusion] PID {pid} alive check OSError: {_os_err}")
124
+ return pid, None
125
+ return None, False
126
+
127
+
128
+
129
+ def _check_engine_binary(self) -> bool:
130
+ """엔진 바이너리 존재 여부만 확인 — 실행 금지."""
131
+ try:
132
+ from termux_diffusion.installer import provision_engine
133
+ return True # 모듈 import 성공 = 설치 가능 상태
134
+ except ImportError:
135
+ return False # Allowed: engine not installed -> binary check fails-closed.
136
+
137
+ def doctor_full(self) -> dict:
138
+ """기존 installer.run_doctor() 전체 실행."""
139
+ lite = self.doctor_lite()
140
+ try:
141
+ from termux_diffusion.installer import run_doctor
142
+ result = run_doctor()
143
+ lite["full_doctor"] = result if isinstance(result, dict) else {"output": str(result)}
144
+ except Exception as e:
145
+ lite["doctor_error"] = str(e)
146
+ lite["doctor_level"] = "full"
147
+ return lite
148
+
149
+ def list_models(self) -> dict:
150
+ """Pipeline Manifest 기반 모델 목록 + hub.DEFAULT_PRESETS."""
151
+ reg_map = {m["model_id"]: m for m in self._model_reg.list_all()}
152
+ try:
153
+ from termux_diffusion.hub import DEFAULT_PRESETS, list_presets
154
+ for preset_id, preset_info in DEFAULT_PRESETS.items():
155
+ if preset_id not in reg_map:
156
+ reg_map[preset_id] = {
157
+ "model_id": preset_id,
158
+ "state": "missing",
159
+ "format": "pipeline",
160
+ "note": "Available preset — not yet downloaded",
161
+ "description": str(preset_info),
162
+ "verified_at": None,
163
+ }
164
+ except Exception as e:
165
+ log_stderr(f"[diffusion] hub.DEFAULT_PRESETS load failed: {e}")
166
+ return {"models": list(reg_map.values()), "total": len(reg_map),
167
+ "models_dir": str(self._models_dir)}
168
+
169
+ def model_status(self, model_id: str | None = None) -> dict:
170
+ if model_id:
171
+ rec = self._model_reg.get(model_id)
172
+ if rec is None: raise ModelNotFound(model_id)
173
+ return {"model": rec}
174
+ return self.list_models()
175
+
176
+ def install_model(self, request: dict) -> dict:
177
+ """Pipeline Manifest 기반 설치 — 모든 구성 파일 Hash 검증."""
178
+ from ameva_component import ModelInstaller
179
+ url = request.get("url", ""); filename = request.get("filename", "")
180
+ sha256 = request.get("sha256", "")
181
+ expected_bytes = int(request.get("expected_bytes", 0))
182
+ model_id = request.get("model_id") or Path(filename).stem
183
+ self._models_dir.mkdir(parents=True, exist_ok=True)
184
+ installer = ModelInstaller(self.COMPONENT_ID, self._models_dir, self._model_reg)
185
+ return installer.install(url=url, filename=filename, sha256=sha256,
186
+ expected_bytes=expected_bytes, model_id=model_id,
187
+ after_download=self._verify_pipeline_file)
188
+
189
+ def _verify_pipeline_file(self, path: Path) -> None:
190
+ """Pipeline 구성 파일 기본 형식 확인 — 추론 실행 금지."""
191
+ valid = {".safetensors", ".bin", ".json", ".onnx", ".pt"}
192
+ if path.suffix.lower() not in valid:
193
+ raise ValueError(f"Unexpected pipeline file format: '{path.suffix}'")
194
+
195
+ async def activate_model(self, request: dict) -> dict:
196
+ model_id = request.get("model_id", "")
197
+ rec = self._model_reg.get(model_id)
198
+ if rec is None: raise ModelNotFound(model_id)
199
+ if ModelState.from_str(rec.get("state", "missing")) not in (ModelState.INSTALLED, ModelState.INACTIVE):
200
+ raise ModelLoadFailed(model_id, f"Pipeline state is '{rec.get('state')}'")
201
+ with self._act_lock.acquire(timeout=60.0):
202
+ self._model_reg.set_state(model_id, ModelState.ACTIVE)
203
+ self._write_state()
204
+ return {"activated": True, "model_id": model_id,
205
+ "rollback": {"attempted": False, "succeeded": False}}
206
+
207
+ async def deactivate_model(self, request: dict) -> dict:
208
+ model_id = request.get("model_id", "")
209
+ self._model_reg.set_state(model_id, ModelState.INACTIVE)
210
+ self._write_state()
211
+ return {"deactivated": True, "model_id": model_id}
212
+
213
+ def list_instances(self) -> dict:
214
+ instances = self._inst_reg.list_all()
215
+ return {"instances": [i.to_dict() for i in instances], "total": len(instances)}
216
+
217
+ async def start_instance(self, request: dict) -> dict:
218
+ model_id = request.get("model_id", "")
219
+ instance_id = request.get("instance_id") or f"diffusion-worker-{int(time.time())}"
220
+ inst = InstanceStatus(
221
+ instance_id=instance_id, component_id=self.COMPONENT_ID,
222
+ model_id=model_id, state=InstanceState.HOT,
223
+ active_jobs=0, queue_depth=0, max_concurrency=1,
224
+ backend="cpu", started_at=time.time(), last_heartbeat=time.time(),
225
+ last_error=None, control_mode=ControlMode.IN_PROCESS,
226
+ )
227
+ self._inst_reg.register(inst)
228
+ self._write_state()
229
+ # Phase 4: Heartbeat 시작 (Worker 시작 트리거)
230
+ self._heartbeat.start()
231
+ return {"instance_id": instance_id, "state": InstanceState.HOT.value}
232
+
233
+ async def drain_instance(self, instance_id: str) -> dict:
234
+ from ameva_component import InstanceNotFound
235
+ if not self._inst_reg.get(instance_id): raise InstanceNotFound(instance_id)
236
+ self._inst_reg.update_state(instance_id, InstanceState.DRAINING)
237
+ return {"instance_id": instance_id, "state": InstanceState.DRAINING.value}
238
+
239
+ async def stop_instance(self, instance_id: str) -> dict:
240
+ from ameva_component import InstanceNotFound
241
+ if not self._inst_reg.get(instance_id): raise InstanceNotFound(instance_id)
242
+ self._inst_reg.update_state(instance_id, InstanceState.STOPPED)
243
+ self._inst_reg.remove(instance_id)
244
+ # Phase 4: Heartbeat 중단 (정상 종료 트리거)
245
+ remaining = self._inst_reg.list_all()
246
+ if not remaining:
247
+ self._heartbeat.stop()
248
+ else:
249
+ self._write_state()
250
+ return {"instance_id": instance_id, "state": InstanceState.STOPPED.value}
251
+
252
+ def _write_state(self, *, ready: bool | None = None, last_error: str | None = None) -> None:
253
+ ts = now_timestamps()
254
+ hot = [i for i in self._inst_reg.list_all() if i.state == InstanceState.HOT]
255
+ _, pid_alive = self._check_pid()
256
+ _ready = self._check_engine_binary() if ready is None else ready
257
+ self._state_file.write({
258
+ "protocol": "ameva-component-status/1", "component_id": self.COMPONENT_ID,
259
+ "component_type": self.COMPONENT_TYPE, "version": self._get_version(),
260
+ "ready": _ready, "degraded": not _ready, **ts,
261
+ "active_models": [i.model_id for i in hot], "last_error": last_error,
262
+ })
@@ -0,0 +1,13 @@
1
+ """
2
+ termux_diffusion.control.status — Diffusion 상태 파일 Writer + Heartbeat
3
+ """
4
+ from __future__ import annotations
5
+ from typing import Any
6
+ from ameva_component.heartbeat import HeartbeatWriter
7
+
8
+
9
+ class DiffusionStatusWriter(HeartbeatWriter):
10
+ """DiffusionControl 상태를 10초마다 상태 파일에 원자적으로 기록합니다."""
11
+
12
+ def __init__(self, control: Any) -> None:
13
+ super().__init__(control, name="diffusion")
@@ -341,8 +341,6 @@ def generate(
341
341
  cmd.extend(["--seed", str(seed)])
342
342
  # Append GPU offloading args from hardware detection
343
343
  cmd.extend(get_sd_cli_gpu_args(effective_device, ngl_layers))
344
- if effective_device in ("vulkan", "gpu"):
345
- cmd.extend(["--backend", "vulkan0"])
346
344
 
347
345
  # --- Advanced TOP 7 Parameters Integration & Defense ---
348
346
  effective_sampler = None
@@ -480,18 +478,19 @@ def generate(
480
478
  )
481
479
  cmd.extend(["--taesd", str(effective_taesd_path)])
482
480
 
483
- # 5.1 Configure Environment with companion library search paths (Bionic priority on Android)
481
+ # 5.1 Configure Environment with companion library search paths (Termux native isolation)
484
482
  env = os.environ.copy()
485
- bionic_sys_dirs = ["/system/lib64", "/vendor/lib64", "/vendor/lib64/egl"]
483
+ prefix = os.environ.get("PREFIX", "/data/data/com.termux/files/usr")
486
484
  lib_dirs = [
487
485
  str(sd_cli.parent),
488
486
  str(sd_cli.parent.parent / "lib"),
489
487
  str(sd_cli.parent / "lib"),
488
+ f"{prefix}/lib",
490
489
  str(Path.home() / ".cache" / "termux-diffusion" / "lib"),
491
490
  str(Path.home() / ".cache" / "termux-diffusion" / "staging" / "lib"),
492
491
  ]
493
492
  cur_ld = env.get("LD_LIBRARY_PATH", "")
494
- valid_dirs = [d for d in bionic_sys_dirs + lib_dirs if Path(d).is_dir()]
493
+ valid_dirs = [d for d in lib_dirs if Path(d).is_dir()]
495
494
  if valid_dirs:
496
495
  env["LD_LIBRARY_PATH"] = ":".join(valid_dirs + ([cur_ld] if cur_ld else []))
497
496
 
@@ -575,9 +574,15 @@ def generate(
575
574
  logger.debug("sd-cli: %s", line_str)
576
575
  finally:
577
576
  try:
578
- process.stdout.close()
579
- except Exception:
580
- pass
577
+ if hasattr(process.stdout, "close"):
578
+ process.stdout.close()
579
+ except OSError as _close_err:
580
+ # stdout 파이프 close 실패 — 프로세스가 이미 파이프를 닫은 경우 정상.
581
+ # 이 오류는 생성 성공/실패와 독립적. debug 수준 로그.
582
+ logger.debug("[termux-diffusion] stdout.close() OSError (pipe already closed): %s", _close_err)
583
+ # MemoryError 등 예상 밖 예외는 재발생
584
+
585
+
581
586
 
582
587
  process.wait(timeout=timeout)
583
588
  all_critical_logs = init_logs + list(recent_logs)
@@ -185,8 +185,11 @@ def _detect_gpu_name() -> str:
185
185
  val = kgsl_model.read_text(encoding="utf-8").strip()
186
186
  if val:
187
187
  return f"Adreno ({val})"
188
- except Exception:
189
- pass
188
+ except PermissionError as _perm_err:
189
+ logger.warning("[hardware] kgsl gpu_model read PermissionError: %s", _perm_err)
190
+ except OSError as _os_err:
191
+ logger.warning("[hardware] kgsl gpu_model read OSError: %s", _os_err)
192
+ # 예상 밖 예외는 재발생
190
193
 
191
194
  # 2. Check Android system properties
192
195
  for prop in ["ro.hardware.vulkan", "ro.hardware.egl", "ro.board.platform"]:
@@ -209,6 +212,8 @@ def _detect_gpu_name() -> str:
209
212
  return "Unknown"
210
213
 
211
214
 
215
+
216
+
212
217
  def _probe_vulkan_driver() -> Optional[GPUDriverInfo]:
213
218
  """Probe for a usable Vulkan driver via ameva-vulkan-runtime SSOT."""
214
219
  try:
@@ -1,24 +1,29 @@
1
1
  """Automated C++ core engine provisioning, binary locator, build healer, and doctor diagnostics."""
2
2
 
3
3
  import logging
4
+ import io
4
5
  import os
5
6
  import shutil
6
7
  import subprocess
7
8
  import sys
9
+
8
10
  from pathlib import Path
9
11
  from typing import Dict, List, Optional, Tuple
10
12
 
11
13
  if hasattr(sys.stdout, "reconfigure"):
12
14
  try:
13
15
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
14
- except Exception:
16
+ except (AttributeError, io.UnsupportedOperation):
17
+ # reconfigure 미지원 환경 (pytest capture, non-TTY). 최선 노력. 성공 변환 없음.
15
18
  pass
16
19
  if hasattr(sys.stderr, "reconfigure"):
17
20
  try:
18
21
  sys.stderr.reconfigure(encoding="utf-8", errors="replace")
19
- except Exception:
22
+ except (AttributeError, io.UnsupportedOperation):
20
23
  pass
21
24
 
25
+
26
+
22
27
  from .exceptions import ProvisioningError
23
28
  from .platform import (
24
29
  TERMUX_PREFIX,
@@ -64,16 +69,30 @@ def activate_binary(bin_dir: Path, target_name: str) -> Path:
64
69
  try:
65
70
  os.symlink(target_name, temporary)
66
71
  os.replace(temporary, active)
67
- except Exception:
68
- # Fallback for OS where symlinks are restricted
72
+ except (NotImplementedError, OSError):
73
+ # symlink 미지원 OS(일부 Windows, FAT32 등) — copy2 fallback.
74
+ # fallback_used=True: 호출자가 symlink를 기대했다면 알아야 한다.
75
+ # activate_binary는 Path를 반환하므로 메타데이터는 logger에 기록.
76
+ logger.warning(
77
+ "[installer] symlink not supported; using copy2 fallback for %s -> %s",
78
+ target_name, active,
79
+ )
69
80
  shutil.copy2(target, active)
70
81
  return active
71
82
  finally:
72
83
  if temporary.exists() or temporary.is_symlink():
73
84
  try:
74
85
  temporary.unlink()
75
- except Exception:
76
- pass
86
+ except (PermissionError, OSError) as _tmp_err:
87
+ # 임시 symlink 삭제 실패 — 디렉토리에 .tmp 파일이 남을 수 있음.
88
+ # 기능적으로는 activate_binary가 이미 완료됨. 경고 필수.
89
+ logger.warning(
90
+ "[installer] Failed to clean up temporary symlink %s: %s. "
91
+ "Manual cleanup may be required.",
92
+ temporary, _tmp_err,
93
+ )
94
+
95
+
77
96
 
78
97
 
79
98
  def fetch_prebuilt_binary(backend: str = "auto", install_mode: str = "prebuilt-first") -> Optional[Path]:
@@ -482,12 +501,20 @@ def run_doctor() -> bool:
482
501
  print(f" -> {m['name']} ({m['size_mb']} MB){valid_tag}")
483
502
 
484
503
  # 8. Hardware Acceleration (GPU / NPU / TPU / Vulkan / OpenCL)
504
+ try:
505
+ from ameva_vulkan_runtime.adapters import find_system_vulkan_driver_dir, DiffusionAdapter
506
+ discovered_vulkan = find_system_vulkan_driver_dir()
507
+ except ImportError:
508
+ discovered_vulkan = None
509
+
485
510
  from .hardware import detect_hardware_profile, format_hardware_report
486
511
  hw = detect_hardware_profile()
487
512
  print(f"8. Hardware Acceleration Profile:")
488
513
  print(f" SoC: {hw.soc_name}, GPU Architecture: {hw.gpu_name}")
489
- print(f" GPU Vulkan: {'Available [OK]' if hw.vulkan_available else 'Not Found [WARN]'}")
490
- if hw.vulkan_driver:
514
+ print(f" GPU Vulkan: {'Available [OK]' if (hw.vulkan_available or discovered_vulkan) else 'Not Found [WARN]'}")
515
+ if discovered_vulkan:
516
+ print(f" -> Vulkan Driver (ameva-vulkan-runtime): {discovered_vulkan}")
517
+ elif hw.vulkan_driver:
491
518
  print(f" -> Vulkan Driver: {hw.vulkan_driver.library_path}")
492
519
  print(f" GPU OpenCL: {'Available [OK]' if hw.opencl_available else 'Not Found [WARN]'}")
493
520
  if hw.opencl_driver:
@@ -17,7 +17,15 @@ class InstallLock:
17
17
  self.timeout_sec = timeout_sec
18
18
  self._acquired = False
19
19
 
20
- def _is_pid_alive(self, pid: int) -> bool:
20
+ def _is_pid_alive(self, pid: int) -> bool | None:
21
+ """Check if PID is alive.
22
+
23
+ 반환:
24
+ True → 프로세스 확인됨
25
+ False → 프로세스 종료됨
26
+ None → 측정 불가 (PermissionError 등)
27
+ → 호출자는 None을 alive=True로 변환하지 말 것
28
+ """
21
29
  if pid <= 0:
22
30
  return False
23
31
  if sys.platform == "win32":
@@ -28,15 +36,33 @@ class InstallLock:
28
36
  if h:
29
37
  ctypes.windll.kernel32.CloseHandle(h)
30
38
  return True
39
+ # OpenProcess가 NULL 반환 — 프로세스 없음 또는 접근 거부
40
+ # GetLastError()로 구분 가능하나 여기서는 False(종료)로 처리
31
41
  return False
32
- except Exception:
33
- return True
42
+ except PermissionError:
43
+ # 측정 불가 — 스테일 판정을 False로 하지 않음
44
+ logger.warning("[locking] _is_pid_alive PermissionError for pid=%d (unmeasurable)", pid)
45
+ return None
46
+ except (AttributeError, OSError) as _win_err:
47
+ # ctypes.windll 미지원 또는 Win32 API 오류
48
+ logger.warning("[locking] _is_pid_alive Win32 error for pid=%d: %s (unmeasurable)", pid, _win_err)
49
+ return None
50
+ # 예상 밖 예외는 재발생
34
51
  else:
35
52
  try:
36
53
  os.kill(pid, 0)
37
54
  return True
38
- except (OSError, ProcessLookupError):
39
- return False
55
+ except ProcessLookupError:
56
+ return False # Allowed: process gone -> correctly identified as not alive.
57
+ except PermissionError:
58
+ # 권한 없음 — 측정 불가
59
+ logger.warning("[locking] _is_pid_alive PermissionError for pid=%d (unmeasurable)", pid)
60
+ return None
61
+ except OSError as _os_err:
62
+ logger.warning("[locking] _is_pid_alive OSError for pid=%d: %s", pid, _os_err)
63
+ return None
64
+
65
+
40
66
 
41
67
  def _check_and_clear_stale_lock(self) -> bool:
42
68
  """Check if existing lock file belongs to a deceased process, removing it if stale."""
@@ -49,13 +75,23 @@ class InstallLock:
49
75
  pid_str = line.split("=", 1)[1].strip()
50
76
  if pid_str.isdigit():
51
77
  pid = int(pid_str)
52
- if not self._is_pid_alive(pid):
78
+ alive = self._is_pid_alive(pid)
79
+ if alive is None:
80
+ # 측정 불가 — 스테일 판정 금지. 잠금을 보존 (fail-closed).
81
+ logger.warning(
82
+ "[locking] Cannot determine if PID %d is alive (unmeasurable). "
83
+ "Preserving lock to avoid data corruption.",
84
+ pid,
85
+ )
86
+ return False
87
+ if not alive:
53
88
  logger.warning(
54
89
  "[termux-diffusion] Stale installation lock detected (PID: %d is dead). Auto-reclaiming lock.",
55
90
  pid,
56
91
  )
57
92
  self.lock_file.unlink(missing_ok=True)
58
93
  return True
94
+
59
95
  except Exception as e:
60
96
  logger.debug("Stale lock inspection note: %s", e)
61
97
  return False
@@ -9,7 +9,7 @@ for mobile NPU/TPU chipsets on Android Termux:
9
9
 
10
10
  Architectural Note:
11
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
12
+ compute 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
 
@@ -87,17 +87,30 @@ ANDROID_NNAPI_LIBS = [
87
87
 
88
88
 
89
89
  def _read_android_prop(key: str) -> str:
90
- """Query Android system property via getprop."""
90
+ """Query Android system property via getprop.
91
+
92
+ 반환: 값 문자열, 실패/미지원 시 "" (fail-closed).
93
+ """
94
+ import logging as _logging
95
+ _npu_logger = _logging.getLogger("termux_diffusion.npu")
91
96
  try:
92
97
  res = subprocess.run(["getprop", key], capture_output=True, text=True, timeout=2.0)
93
98
  val = res.stdout.strip()
94
99
  if val and val != "unknown":
95
100
  return val
96
- except Exception:
97
- pass
101
+ except FileNotFoundError:
102
+ # getprop 바이너리 없음 — 비-Android 환경. fail-closed.
103
+ _npu_logger.debug("[npu] getprop not found (non-Android environment)")
104
+ except subprocess.TimeoutExpired:
105
+ _npu_logger.debug("[npu] getprop timed out for key=%s", key)
106
+ except OSError as _os_err:
107
+ _npu_logger.warning("[npu] getprop OSError for key=%s: %s", key, _os_err)
108
+ # 예상 밖 예외는 재발생
98
109
  return ""
99
110
 
100
111
 
112
+
113
+
101
114
  def _probe_first_existing_lib(paths: List[str]) -> Optional[str]:
102
115
  """Check physical existence of runtime library on Android filesystem."""
103
116
  for p in paths:
@@ -229,7 +242,7 @@ def get_optimal_heterogeneous_pipeline(device: str = "auto") -> Dict[str, str]:
229
242
 
230
243
  Note:
231
244
  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
245
+ Vulkan GPU compute and ARM64 NEON CPU math. Direct Qualcomm QNN / Hexagon
233
246
  NPU subgraph partitioning is the architectural target for the upcoming v2.0 runtime.
234
247
  """
235
248
  req = device.lower().strip()
@@ -245,7 +258,7 @@ def get_optimal_heterogeneous_pipeline(device: str = "auto") -> Dict[str, str]:
245
258
  else:
246
259
  return {
247
260
  "text_encoder": "CPU / GPU (ARM NEON / Vulkan)",
248
- "denoiser_unet": "GPU (Vulkan Compute / --backend vulkan0)",
261
+ "denoiser_unet": "GPU (Vulkan Compute Shader Pipeline)",
249
262
  "vae_decoder": "GPU (Vulkan Compute FP16)",
250
263
  "scheduler": "CPU (Single-Core Fast Math)",
251
264
  "summary": "GPU-Accelerated [Vulkan Compute Shader Pipeline]"
@@ -67,22 +67,27 @@ def get_galaxy_gallery_dir() -> Path:
67
67
  try:
68
68
  termux_storage.mkdir(parents=True, exist_ok=True)
69
69
  return termux_storage
70
- except (PermissionError, OSError):
71
- pass
70
+ except (PermissionError, OSError) as _perm_err:
71
+ # 외부 스토리지 권한 거부 — 다음 경로 시도. 성공 변환 없음.
72
+ logger.debug("[platform] termux_storage mkdir failed: %s", _perm_err)
72
73
 
73
74
  sdcard_pictures = Path("/sdcard/Pictures/TermuxDiffusion")
74
75
  if Path("/sdcard/Pictures").is_dir():
75
76
  try:
76
77
  sdcard_pictures.mkdir(parents=True, exist_ok=True)
77
78
  return sdcard_pictures
78
- except (PermissionError, OSError):
79
- pass
79
+ except (PermissionError, OSError) as _perm_err:
80
+ logger.debug("[platform] sdcard mkdir failed: %s", _perm_err)
80
81
 
81
82
  fallback = get_default_cache_dir() / "outputs"
82
83
  fallback.mkdir(parents=True, exist_ok=True)
83
84
  return fallback
84
85
 
85
86
 
87
+
88
+
89
+
90
+
86
91
  def export_to_android_gallery(image_path: Path) -> Optional[Path]:
87
92
  """Copy generated image to Samsung Gallery and broadcast media scanner intent.
88
93
 
@@ -287,8 +292,11 @@ def get_memory_info() -> Dict[str, int]:
287
292
  metrics["effective_available_mb"] = metrics["mem_available_mb"] + metrics["swap_free_mb"]
288
293
  return metrics
289
294
  except ImportError:
295
+ # psutil은 선택적 의존성. 미설치 환경에서 ImportError는 정상.
296
+ # 이 경로는 기본 metrics를 반환하며 성공으로 위장하지 않음.
290
297
  pass
291
298
 
299
+
292
300
  return metrics
293
301
 
294
302
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termux-diffusion
3
- Version: 1.3.2
3
+ Version: 1.4.0
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)
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  setup.py
5
5
  termux_diffusion/__init__.py
6
6
  termux_diffusion/_version.py
7
+ termux_diffusion/adapter.py
7
8
  termux_diffusion/cli.py
8
9
  termux_diffusion/core.py
9
10
  termux_diffusion/downloader.py
@@ -23,6 +24,9 @@ termux_diffusion.egg-info/dependency_links.txt
23
24
  termux_diffusion.egg-info/entry_points.txt
24
25
  termux_diffusion.egg-info/requires.txt
25
26
  termux_diffusion.egg-info/top_level.txt
27
+ termux_diffusion/control/__init__.py
28
+ termux_diffusion/control/component.py
29
+ termux_diffusion/control/status.py
26
30
  termux_diffusion/data/bootstrap-manifest.json
27
31
  termux_diffusion/data/release-public-keys.json
28
32
  termux_diffusion/data/validated-vulkan-profiles.json
@@ -1,3 +1,6 @@
1
+ [ameva.components]
2
+ termux-diffusion = termux_diffusion.adapter:create_adapter
3
+
1
4
  [console_scripts]
2
5
  termux-diffusion = termux_diffusion.cli:main
3
6
  termux-diffusion-doctor = termux_diffusion.cli:run_doctor_cli
@@ -1 +0,0 @@
1
- __version__ = "1.3.1"