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,2936 @@
1
+ # Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0 OR Commercial-License
3
+ """Core generation runner, argument builder, WakeLock wrapper, and gallery bridge."""
4
+
5
+
6
+
7
+ import asyncio
8
+
9
+ import json
10
+
11
+ import logging
12
+
13
+ import os
14
+
15
+ import queue
16
+
17
+ import re
18
+
19
+ import signal
20
+
21
+ import subprocess
22
+
23
+ import sys
24
+
25
+ import threading
26
+
27
+ import time
28
+
29
+ from collections import deque
30
+
31
+ from dataclasses import dataclass
32
+
33
+ from pathlib import Path
34
+
35
+ from typing import Callable, Generator, Optional, Tuple, Union, List, Dict, Any
36
+
37
+
38
+
39
+ from .exceptions import InferenceTimeoutError, OOMRiskError, ProvisioningError, TermuxDiffusionError
40
+
41
+ from .hub import DEFAULT_PRESETS, list_presets, resolve_model_path
42
+
43
+ from .engine import get_binary_path, locate_sd_cli
44
+
45
+ from .installer import provision_engine
46
+
47
+ from .platform import (
48
+
49
+ TermuxWakeLock,
50
+
51
+ check_memory_safety,
52
+
53
+ export_to_android_gallery,
54
+
55
+ get_galaxy_gallery_dir,
56
+
57
+ get_optimal_thread_count,
58
+
59
+ )
60
+
61
+
62
+
63
+
64
+
65
+ def get_default_cache_dir() -> Path:
66
+
67
+ from .platform import get_default_cache_dir as _fn
68
+
69
+ return _fn()
70
+
71
+ from .hardware import (
72
+
73
+ TermuxExecutionGuard,
74
+
75
+ get_kernel_physical_memory_gb,
76
+
77
+ )
78
+
79
+ from .cluster import (
80
+
81
+ parse_cluster_rpc_spec,
82
+
83
+ verify_rpc_cluster_nodes,
84
+
85
+ verify_rpc_cluster_health,
86
+
87
+ ClusterOptionForwarder,
88
+
89
+ SequentialClusterPipeline,
90
+
91
+ )
92
+
93
+ from .exceptions import (
94
+
95
+ ClusterConnectionError,
96
+
97
+ ClusterConfigurationError,
98
+
99
+ )
100
+
101
+
102
+
103
+ logger = logging.getLogger("termux_diffusion.core")
104
+
105
+
106
+
107
+
108
+
109
+ PROGRESS_RE = re.compile(
110
+
111
+ r'(?:\|[=>\s#]+\|\s*)?(?:step\s*)?(\d+)\s*/\s*(\d+)(?:\s*-\s*([\d\.]+)\s*(s/it|it/s))?',
112
+
113
+ re.IGNORECASE
114
+
115
+ )
116
+
117
+
118
+
119
+
120
+
121
+ def _read_sys_gpu_busy() -> Optional[int]:
122
+
123
+ """Query Linux/Android kernel sysfs GPU load percentage across Adreno, Mali, and generic nodes."""
124
+
125
+ paths = [
126
+
127
+ Path("/sys/kernel/gpu/gpu_busy"),
128
+
129
+ Path("/sys/class/kgsl/kgsl-3d0/gpu_busy_percentage"),
130
+
131
+ Path("/sys/class/kgsl/kgsl-3d0/devfreq/gpu_load"),
132
+
133
+ Path("/sys/class/misc/mali0/device/utilization"),
134
+
135
+ Path("/sys/devices/platform/13040000.mali/utilization"),
136
+
137
+ ]
138
+
139
+ for p in paths:
140
+
141
+ try:
142
+
143
+ if p.is_file():
144
+
145
+ raw = p.read_text().strip().replace("%", "").split()[0]
146
+
147
+ if raw.isdigit():
148
+
149
+ return int(raw)
150
+
151
+ except Exception:
152
+
153
+ continue
154
+
155
+ return None
156
+
157
+
158
+
159
+
160
+
161
+ class GPUDutyCycleController:
162
+
163
+ """Closed-loop GPU Duty Cycle & SurfaceFlinger/App preservation controller.
164
+
165
+
166
+
167
+ Prevents GPU 100% saturation by injecting inter-step micro idle gaps (time-slicing),
168
+
169
+ enabling Android SurfaceFlinger to render frames at 60/120Hz and preventing LMK SIGKILL.
170
+
171
+ """
172
+
173
+
174
+
175
+ def __init__(self, target_duty_cycle: int = 90, enabled: bool = False):
176
+
177
+ self.target_duty_cycle = max(50, min(100, target_duty_cycle))
178
+
179
+ self.enabled = enabled or (self.target_duty_cycle < 100)
180
+
181
+ self.last_step = 0
182
+
183
+ self.step_start_time = time.time()
184
+
185
+ self.accumulated_sleep = 0.0
186
+
187
+
188
+
189
+ def on_step(self, step: int, proc_pid: Optional[int] = None) -> float:
190
+
191
+ """Called upon each denoising step completion. Injects dynamic gap if needed."""
192
+
193
+ if not self.enabled or self.target_duty_cycle >= 100:
194
+
195
+ return 0.0
196
+
197
+
198
+
199
+ if step <= self.last_step:
200
+
201
+ return 0.0
202
+
203
+
204
+
205
+ now = time.time()
206
+
207
+ step_duration = max(0.01, now - self.step_start_time)
208
+
209
+ self.step_start_time = now
210
+
211
+ self.last_step = step
212
+
213
+
214
+
215
+ # Calculate duty cycle gap:
216
+
217
+ # target_duty = (compute_time / (compute_time + gap_time)) * 100
218
+
219
+ # gap_time = compute_time * (100 - target_duty) / target_duty
220
+
221
+ ratio = (100 - self.target_duty_cycle) / float(self.target_duty_cycle)
222
+
223
+ gap_sec = min(0.5, max(0.015, step_duration * ratio))
224
+
225
+
226
+
227
+ # Check real-time GPU load if available
228
+
229
+ gpu_busy = _read_sys_gpu_busy()
230
+
231
+ if gpu_busy is not None and gpu_busy > self.target_duty_cycle:
232
+
233
+ excess = (gpu_busy - self.target_duty_cycle) / 10.0
234
+
235
+ gap_sec = min(0.8, gap_sec * (1.0 + excess))
236
+
237
+
238
+
239
+ # Inject micro-gap via SIGSTOP/SIGCONT if on Linux/Android and PID valid
240
+
241
+ injected = 0.0
242
+
243
+ if proc_pid and hasattr(os, "kill") and hasattr(signal, "SIGSTOP") and hasattr(signal, "SIGCONT"):
244
+
245
+ try:
246
+
247
+ os.kill(proc_pid, signal.SIGSTOP)
248
+
249
+ time.sleep(gap_sec)
250
+
251
+ os.kill(proc_pid, signal.SIGCONT)
252
+
253
+ injected = gap_sec
254
+
255
+ except OSError:
256
+
257
+ time.sleep(gap_sec)
258
+
259
+ injected = gap_sec
260
+
261
+ else:
262
+
263
+ time.sleep(gap_sec)
264
+
265
+ injected = gap_sec
266
+
267
+
268
+
269
+ self.accumulated_sleep += injected
270
+
271
+ logger.debug(
272
+
273
+ "[DutyCycleController] Step %d: Injected %.3fs idle gap (Target: %d%%, Kernel GPU: %s%%)",
274
+
275
+ step, injected, self.target_duty_cycle, gpu_busy
276
+
277
+ )
278
+
279
+ return injected
280
+
281
+
282
+
283
+
284
+
285
+ def _read_process_rss_mb(pid: int) -> Optional[float]:
286
+
287
+ """Query process resident set size (VmRSS) from Linux procfs."""
288
+
289
+ try:
290
+
291
+ p = Path(f"/proc/{pid}/status")
292
+
293
+ if p.is_file():
294
+
295
+ for line in p.read_text().splitlines():
296
+
297
+ if line.startswith("VmRSS:"):
298
+
299
+ parts = line.split()
300
+
301
+ if len(parts) >= 2:
302
+
303
+ return round(int(parts[1]) / 1024.0, 1)
304
+
305
+ except Exception:
306
+
307
+ pass
308
+
309
+ return None
310
+
311
+
312
+
313
+
314
+
315
+ @dataclass
316
+
317
+ class ProgressInfo:
318
+
319
+ """Real-time progress snapshot during diffusion inference."""
320
+
321
+ phase: str # "init" | "loading_model" | "encoding_prompt" | "sampling" | "decoding_vae" | "complete" | "error"
322
+
323
+ step: int = 0 # Current denoising step (e.g. 3)
324
+
325
+ total_steps: int = 0 # Total denoising steps (e.g. 8)
326
+
327
+ percent: float = 0.0 # Completion percentage (0.0 - 100.0)
328
+
329
+ eta_seconds: Optional[float] = None
330
+
331
+ speed_s_per_it: Optional[float] = None
332
+
333
+ gpu_busy: Optional[int] = None
334
+
335
+ rss_mb: Optional[float] = None
336
+
337
+ raw_message: str = ""
338
+
339
+ output_path: Optional[str] = None
340
+
341
+ elapsed_seconds: Optional[float] = None
342
+
343
+
344
+
345
+ def to_dict(self) -> dict:
346
+
347
+ return {
348
+
349
+ "event": "progress" if self.phase == "sampling" else ("complete" if self.phase == "complete" else "phase"),
350
+
351
+ "phase": self.phase,
352
+
353
+ "step": self.step,
354
+
355
+ "total_steps": self.total_steps,
356
+
357
+ "percent": round(self.percent, 1),
358
+
359
+ "eta_seconds": round(self.eta_seconds, 1) if self.eta_seconds is not None else None,
360
+
361
+ "speed_s": round(self.speed_s_per_it, 2) if self.speed_s_per_it is not None else None,
362
+
363
+ "gpu_busy": self.gpu_busy,
364
+
365
+ "rss_mb": self.rss_mb,
366
+
367
+ "output_path": str(self.output_path) if self.output_path else None,
368
+
369
+ "elapsed_seconds": round(self.elapsed_seconds, 2) if self.elapsed_seconds is not None else None,
370
+
371
+ "timestamp": time.time(),
372
+
373
+ }
374
+
375
+
376
+
377
+
378
+
379
+
380
+
381
+ VALID_SAMPLERS = {
382
+
383
+ "euler", "euler_a", "heun", "dpm2", "dpm++2s_a", "dpm++2m", "dpm++2mv2",
384
+
385
+ "ipndm", "ipndm_v", "lcm", "ddim_trailing", "tcd", "res_multistep", "res_2s",
386
+
387
+ "er_sde", "euler_cfg_pp", "euler_a_cfg_pp", "euler_ge", "dpm++2m_sde", "dpm++2m_sde_bt", "lms"
388
+
389
+ }
390
+
391
+ VALID_SCHEDULERS = {
392
+
393
+ "default", "discrete", "karras", "exponential", "ays", "gits", "sgm_uniform",
394
+
395
+ "simple", "smoothstep", "kl_optimal", "lcm", "bong_tangent", "ltx2", "logit_normal",
396
+
397
+ "flux2", "flux", "beta"
398
+
399
+ }
400
+
401
+
402
+
403
+
404
+
405
+ @dataclass
406
+
407
+ class GenerationResult:
408
+
409
+ """Encapsulates the output and metadata of an AI diffusion generation task."""
410
+
411
+ path: Path
412
+
413
+ gallery_path: Optional[Path]
414
+
415
+ prompt: str
416
+
417
+ negative_prompt: Optional[str]
418
+
419
+ model: str
420
+
421
+ device: str
422
+
423
+ steps: int
424
+
425
+ cfg_scale: float
426
+
427
+ width: int
428
+
429
+ height: int
430
+
431
+ seed: int
432
+
433
+ elapsed_sec: float
434
+
435
+ sampling_method: Optional[str] = None
436
+
437
+ schedule: Optional[str] = None
438
+
439
+ vae_tiling: bool = False
440
+
441
+ init_img: Optional[Path] = None
442
+
443
+ strength: Optional[float] = None
444
+
445
+ lora_dir: Optional[Path] = None
446
+
447
+ clip_skip: Optional[int] = None
448
+
449
+ control_net: Optional[Path] = None
450
+
451
+ control_image: Optional[Path] = None
452
+
453
+ control_strength: Optional[float] = None
454
+
455
+ taesd: Optional[Path] = None
456
+
457
+ rpc: Optional[str] = None
458
+
459
+ tensor_split: Optional[str] = None
460
+
461
+ cluster_rpc_servers: Optional[List[str]] = None
462
+
463
+ cluster_split_mode: Optional[str] = None
464
+
465
+ cluster_tensor_split: Optional[str] = None
466
+
467
+ cluster_vram_budget: Optional[str] = None
468
+
469
+ extra_args: Optional[List[str]] = None
470
+
471
+ isolated_stages: bool = False
472
+
473
+
474
+
475
+ def __str__(self) -> str:
476
+
477
+ return f"<GenerationResult path='{self.path}' device='{self.device}' elapsed={self.elapsed_sec:.1f}s>"
478
+
479
+
480
+
481
+
482
+
483
+ import signal
484
+
485
+
486
+
487
+ def _safe_kill_process(proc: Optional[subprocess.Popen], timeout: float = 2.0) -> None:
488
+
489
+ """Safely terminate and reap child process groups with granular exception handling and zero zombie leak."""
490
+
491
+ if proc is None:
492
+
493
+ return
494
+
495
+
496
+
497
+ try:
498
+
499
+ if proc.poll() is not None:
500
+
501
+ return
502
+
503
+ except (OSError, ValueError) as poll_err:
504
+
505
+ logger.debug("Process poll inspection note: %s", poll_err)
506
+
507
+ return
508
+
509
+
510
+
511
+ pid = getattr(proc, "pid", None)
512
+
513
+ if pid is None:
514
+
515
+ return
516
+
517
+
518
+
519
+ logger.warning("Initiating child process termination (PID: %s)...", pid)
520
+
521
+
522
+
523
+ # Step 1: Graceful termination attempt (SIGTERM / proc.terminate)
524
+
525
+ try:
526
+
527
+ if sys.platform != "win32":
528
+
529
+ try:
530
+
531
+ pgid = os.getpgid(pid)
532
+
533
+ os.killpg(pgid, signal.SIGTERM)
534
+
535
+ except ProcessLookupError:
536
+
537
+ logger.debug("Process group (PGID: %s) already exited.", pid)
538
+
539
+ return
540
+
541
+ except (OSError, PermissionError) as pg_err:
542
+
543
+ logger.debug("killpg SIGTERM note (PGID: %s): %s, falling back to proc.terminate()", pid, pg_err)
544
+
545
+ proc.terminate()
546
+
547
+ else:
548
+
549
+ proc.terminate()
550
+
551
+
552
+
553
+ try:
554
+
555
+ proc.wait(timeout=timeout)
556
+
557
+ logger.debug("Child process (PID: %s) gracefully exited on SIGTERM.", pid)
558
+
559
+ return
560
+
561
+ except subprocess.TimeoutExpired:
562
+
563
+ logger.warning(
564
+
565
+ "Child process (PID: %s) did not exit within %.1fs, escalating to SIGKILL...",
566
+
567
+ pid,
568
+
569
+ timeout,
570
+
571
+ )
572
+
573
+ except ProcessLookupError:
574
+
575
+ logger.debug("Child process (PID: %s) already terminated before SIGTERM dispatch.", pid)
576
+
577
+ return
578
+
579
+ except PermissionError as perm_err:
580
+
581
+ logger.warning("Permission denied while sending SIGTERM to PID %s: %s", pid, perm_err)
582
+
583
+ except OSError as os_err:
584
+
585
+ logger.warning("OS error during SIGTERM dispatch to PID %s: %s", pid, os_err)
586
+
587
+
588
+
589
+ # Step 2: Forceful termination (SIGKILL / proc.kill)
590
+
591
+ try:
592
+
593
+ if sys.platform != "win32":
594
+
595
+ try:
596
+
597
+ pgid = os.getpgid(pid)
598
+
599
+ os.killpg(pgid, signal.SIGKILL)
600
+
601
+ except ProcessLookupError:
602
+
603
+ logger.debug("Process group (PGID: %s) already terminated.", pid)
604
+
605
+ return
606
+
607
+ except (OSError, PermissionError) as pg_err:
608
+
609
+ logger.debug("killpg SIGKILL note (PGID: %s): %s, falling back to proc.kill()", pid, pg_err)
610
+
611
+ proc.kill()
612
+
613
+ else:
614
+
615
+ proc.kill()
616
+
617
+
618
+
619
+ try:
620
+
621
+ proc.wait(timeout=2.0)
622
+
623
+ logger.debug("Child process (PID: %s) forcefully terminated and reaped.", pid)
624
+
625
+ except subprocess.TimeoutExpired:
626
+
627
+ logger.error("Child process (PID: %s) did not exit after SIGKILL (kernel D-state or pending I/O).", pid)
628
+
629
+ except ProcessLookupError:
630
+
631
+ logger.debug("Child process (PID: %s) already reaped before SIGKILL.", pid)
632
+
633
+ except PermissionError as perm_err:
634
+
635
+ logger.error("Permission denied while sending SIGKILL to PID %s: %s", pid, perm_err)
636
+
637
+ except OSError as os_err:
638
+
639
+ logger.error("OS error during SIGKILL dispatch to PID %s: %s", pid, os_err)
640
+
641
+
642
+
643
+
644
+
645
+ DEFAULT_QUALITY_GUARD_NEGATIVE_PROMPT = "lowres, bad quality, blur, deformed, distorted, extra limbs, artifacts"
646
+
647
+ _global_default_negative_prompt: Optional[str] = None
648
+
649
+ _negative_prompt_lock = threading.Lock()
650
+
651
+
652
+
653
+
654
+
655
+ def get_default_negative_prompt() -> Optional[str]:
656
+
657
+ """Get the currently configured default negative prompt (None by default)."""
658
+
659
+ with _negative_prompt_lock:
660
+
661
+ return _global_default_negative_prompt
662
+
663
+
664
+
665
+
666
+
667
+ def set_default_negative_prompt(prompt: Optional[str]) -> None:
668
+
669
+ """Set or clear the default negative prompt used across generate() calls.
670
+
671
+
672
+
673
+ Args:
674
+
675
+ prompt: Custom negative prompt string, or None to disable default negative guidance.
676
+
677
+ """
678
+
679
+ global _global_default_negative_prompt
680
+
681
+ with _negative_prompt_lock:
682
+
683
+ if prompt is not None and not str(prompt).strip():
684
+
685
+ _global_default_negative_prompt = None
686
+
687
+ else:
688
+
689
+ _global_default_negative_prompt = str(prompt).strip() if prompt is not None else None
690
+
691
+
692
+
693
+
694
+
695
+ def get_quality_guard_negative_prompt() -> str:
696
+
697
+ """Return the recommended standard quality-guard negative prompt."""
698
+
699
+ return DEFAULT_QUALITY_GUARD_NEGATIVE_PROMPT
700
+
701
+
702
+
703
+
704
+
705
+ def get_model_file_size(model_name_or_path: Union[str, Path, None]) -> int:
706
+
707
+ """Resolve file size in bytes for a model path or model preset."""
708
+
709
+ if not model_name_or_path:
710
+
711
+ return 0
712
+
713
+ raw_str = str(model_name_or_path).strip()
714
+
715
+ expanded = Path(os.path.expanduser(raw_str))
716
+
717
+ if expanded.is_file():
718
+
719
+ try:
720
+
721
+ return expanded.stat().st_size
722
+
723
+ except OSError:
724
+
725
+ pass
726
+
727
+ try:
728
+
729
+ resolved = resolve_model_path(raw_str)
730
+
731
+ if resolved and Path(resolved).is_file():
732
+
733
+ return Path(resolved).stat().st_size
734
+
735
+ except Exception:
736
+
737
+ pass
738
+
739
+ from .platform import get_default_cache_dir
740
+
741
+ cache_p = Path(get_default_cache_dir()) / "models" / expanded.name
742
+
743
+ if cache_p.is_file():
744
+
745
+ try:
746
+
747
+ return cache_p.stat().st_size
748
+
749
+ except OSError:
750
+
751
+ pass
752
+
753
+ return int(3.5 * (1024 ** 3))
754
+
755
+
756
+
757
+
758
+
759
+ def resolve_vram_and_streaming_policy(
760
+
761
+ user_max_vram: Optional[str],
762
+
763
+ user_stream_layers: bool,
764
+
765
+ model_bytes: int,
766
+
767
+ llm_bytes: int = 0,
768
+
769
+ vae_bytes: int = 0,
770
+
771
+ ) -> Tuple[Optional[str], bool, str]:
772
+
773
+ """Resolves VRAM budget and streaming mode with explicit user override priority.
774
+
775
+
776
+
777
+ Computes total co-resident memory (DiT + LLM text encoder + VAE) to prevent
778
+
779
+ underestimating DRAM pressure on mobile devices with strict LMKD policies.
780
+
781
+
782
+
783
+ Returns:
784
+
785
+ (effective_max_vram, effective_stream_layers, reason_description)
786
+
787
+ """
788
+
789
+ # 1. User Explicit Override Priority
790
+
791
+ if user_max_vram is not None or user_stream_layers:
792
+
793
+ val = str(user_max_vram).strip() if user_max_vram is not None else None
794
+
795
+ if val in ("0", "none", "disable", "unlimited", "vulkan0=0"):
796
+
797
+ return None, False, "User explicit: Full In-VRAM residency (graph splitting disabled)"
798
+
799
+ return val, user_stream_layers, f"User explicit override: max_vram={val}, stream={user_stream_layers}"
800
+
801
+
802
+
803
+ # 2. Query Deterministic Physical Memory from Linux Kernel
804
+
805
+ mem_total_gb, mem_avail_gb = get_kernel_physical_memory_gb()
806
+
807
+ total_co_resident_bytes = model_bytes + llm_bytes + vae_bytes
808
+
809
+ total_co_resident_gb = total_co_resident_bytes / (1024 ** 3)
810
+
811
+ required_safe_ram = total_co_resident_gb + 1.2 # Total weights + VRAM scratch buffers + OS Safety Margin
812
+
813
+
814
+
815
+ # [Tier 1: Flagship Full In-VRAM Residency]
816
+
817
+ # Requires sufficient unpinned physical DRAM to hold all active models and graphics buffers
818
+
819
+ if mem_avail_gb >= required_safe_ram:
820
+
821
+ return None, False, f"Tier 1 Flagship Auto-Scale: Avail {mem_avail_gb}GB >= Safe {required_safe_ram:.1f}GB (RAM {mem_total_gb}GB) -> Full In-VRAM Residency"
822
+
823
+
824
+
825
+ # [Tier 2: Balanced Chunk Buffer (No Layer Streaming)]
826
+
827
+ # Eligible only if available physical DRAM can safely absorb co-resident weights + chunk scratch buffers (~4.5GB+)
828
+
829
+ elif mem_avail_gb >= 4.5 or (mem_avail_gb >= total_co_resident_gb * 0.75 and mem_total_gb >= 9.0):
830
+
831
+ return "vulkan0=1.5", False, f"Tier 2 Balanced Auto-Scale: Avail {mem_avail_gb}GB (RAM {mem_total_gb}GB, Co-res {total_co_resident_gb:.1f}GB) -> 1.5GB Chunk VRAM Buffer"
832
+
833
+
834
+
835
+ # [Tier 3: Constrained Lifeboat Streaming]
836
+
837
+ # Strictly enforced on memory-constrained devices (e.g. S21 8GB with 3.1GB avail, A35 6GB with 2.0GB avail)
838
+
839
+ # to prevent Android lmkd SIGKILL when heavy LLM and DiT models are co-resident.
840
+
841
+ else:
842
+
843
+ return "vulkan0=1", True, f"Tier 3 Constrained Auto-Scale: Avail {mem_avail_gb}GB < Co-res Safe {required_safe_ram:.1f}GB (RAM {mem_total_gb}GB) -> 1MB Layer-Streaming Lifeboat"
844
+
845
+
846
+
847
+
848
+
849
+ def generate(
850
+
851
+ prompt: str,
852
+
853
+ model: str = "realistic",
854
+
855
+ negative_prompt: Optional[str] = None,
856
+
857
+ device: str = "auto",
858
+
859
+ steps: Optional[int] = None,
860
+
861
+ cfg_scale: Optional[float] = None,
862
+
863
+ guidance: Optional[float] = None,
864
+
865
+ width: int = 512,
866
+
867
+ height: int = 512,
868
+
869
+ seed: int = -1,
870
+
871
+ threads: Optional[int] = None,
872
+
873
+ output: Optional[Union[str, Path]] = None,
874
+
875
+ sampling_method: Optional[str] = None,
876
+
877
+ schedule: Optional[str] = None,
878
+
879
+ vae_tiling: bool = False,
880
+
881
+ init_img: Optional[Union[str, Path]] = None,
882
+
883
+ strength: Optional[float] = None,
884
+
885
+ lora_dir: Optional[Union[str, Path]] = None,
886
+
887
+ clip_skip: Optional[int] = None,
888
+
889
+ control_net: Optional[Union[str, Path]] = None,
890
+
891
+ control_image: Optional[Union[str, Path]] = None,
892
+
893
+ control_strength: Optional[float] = None,
894
+
895
+ taesd: Optional[Union[str, Path]] = None,
896
+
897
+ llm: Optional[Union[str, Path]] = None,
898
+
899
+ diffusion_model: Optional[Union[str, Path]] = None,
900
+
901
+ vae: Optional[Union[str, Path]] = None,
902
+
903
+ clip_l: Optional[Union[str, Path]] = None,
904
+
905
+ diffusion_fa: bool = False,
906
+
907
+ offload_to_cpu: bool = False,
908
+
909
+ clip_on_cpu: bool = False,
910
+
911
+ vae_on_cpu: bool = False,
912
+
913
+ mmap: bool = True,
914
+
915
+ max_vram: Optional[str] = None,
916
+
917
+ stream_layers: bool = False,
918
+
919
+ params_backend: Optional[str] = None,
920
+
921
+ custom_backend: Optional[str] = None,
922
+
923
+ vae_format: Optional[str] = None,
924
+
925
+ flow_shift: Optional[float] = None,
926
+
927
+ rpc: Optional[str] = None,
928
+
929
+ tensor_split: Optional[str] = None,
930
+
931
+ cluster_rpc_servers: Optional[Union[str, List[str]]] = None,
932
+
933
+ cluster_split_mode: Optional[str] = None,
934
+
935
+ cluster_tensor_split: Optional[str] = None,
936
+
937
+ cluster_vram_budget: Optional[str] = None,
938
+
939
+ detail_faces: bool = False,
940
+
941
+ detailer_strength: float = 0.35,
942
+
943
+ detailer_steps: int = 2,
944
+
945
+ detailer_padding: float = 1.45,
946
+
947
+ export_gallery: bool = True,
948
+
949
+ wake_lock: bool = True,
950
+
951
+ low_ram_guard: bool = True,
952
+
953
+ auto_provision: bool = False,
954
+
955
+ strict_vulkan: bool = False,
956
+
957
+ no_cache: bool = False,
958
+
959
+ progress: bool = False,
960
+
961
+ json_progress: bool = False,
962
+
963
+ progress_file: Optional[Union[str, Path]] = None,
964
+
965
+ progress_callback: Optional[Callable[[ProgressInfo], None]] = None,
966
+
967
+ timeout: int = 1800,
968
+
969
+ extra_args: Optional[List[str]] = None,
970
+
971
+ isolate_text_encoder: bool = False,
972
+
973
+ sequential_stages: bool = False,
974
+
975
+ safe_mode: bool = False,
976
+
977
+ keep_apps_alive: bool = False,
978
+
979
+ gpu_duty_cycle: Optional[int] = None,
980
+
981
+ _cancel_event: Optional[threading.Event] = None,
982
+
983
+ _proc_holder: Optional[list] = None,
984
+
985
+ **extra_kwargs: Any,
986
+
987
+ ) -> GenerationResult:
988
+
989
+ """Generate an AI image on Samsung Galaxy / Android Termux using Bionic native C++ diffusion.
990
+
991
+
992
+
993
+ Args:
994
+
995
+ prompt: Detailed text description of the desired image.
996
+
997
+ model: Preset keyword ('realistic', 'speed', 'sdxs', 'turbo', 'anime'), custom repo ('org/repo/file.gguf'), direct URL, or path to .gguf file.
998
+
999
+ negative_prompt: Optional negative text guidance describing elements to avoid (default: None).
1000
+
1001
+ device: Computing device ('auto', 'gpu', 'vulkan', 'cpu', 'opencl', 'npu', 'tpu'). Default is 'auto'.
1002
+
1003
+ steps: Number of denoising steps (default determined by preset, e.g. 10).
1004
+
1005
+ cfg_scale: Classifier-Free Guidance scale (default determined by preset, e.g. 4.0).
1006
+
1007
+ width: Output image width in pixels (default: 512).
1008
+
1009
+ height: Output image height in pixels (default: 512).
1010
+
1011
+ seed: Sampling RNG seed (-1 for random).
1012
+
1013
+ threads: Number of CPU threads (defaults to optimal big-core cluster count, e.g. 4).
1014
+
1015
+ output: Destination output filename or path.
1016
+
1017
+ sampling_method: Sampler algorithm ('euler', 'euler_a', 'heun', 'dpm2', 'dpm++2s_a', 'dpm++2m', 'dpm++2mv2', 'ipndm', 'lcm').
1018
+
1019
+ schedule: Noise schedule algorithm ('default', 'discrete', 'karras', 'exponential', 'ays', 'gits').
1020
+
1021
+ vae_tiling: Whether to enable VAE tiling to reduce peak memory during final image decoding by ~70%.
1022
+
1023
+ init_img: Path to initial image for Img2Img synthesis.
1024
+
1025
+ strength: Img2Img denoising strength (0.0 to 1.0, default: 0.75).
1026
+
1027
+ lora_dir: Directory containing .gguf/.safetensors LoRA adapter weights.
1028
+
1029
+ clip_skip: Number of CLIP layers to skip (1 or 2, default: None).
1030
+
1031
+ control_net: Path to ControlNet model file.
1032
+
1033
+ control_image: Path to ControlNet hint/guide image (e.g. pose, edges).
1034
+
1035
+ control_strength: ControlNet influence strength (0.0 to 2.0, default: 0.9).
1036
+
1037
+ taesd: Path to Tiny AutoEncoder (TAESD) model for ultra-fast VAE decoding.
1038
+
1039
+ rpc: Distributed RPC server addresses for remote worker offloading (e.g. '192.168.0.220:50052').
1040
+
1041
+ tensor_split: Weight offload fraction across distributed nodes (e.g. '50,50' or '60,40').
1042
+
1043
+ export_gallery: Whether to copy image to Samsung Gallery and broadcast media scanner intent.
1044
+
1045
+ wake_lock: Whether to acquire Android CPU WakeLock during generation.
1046
+
1047
+ low_ram_guard: Whether to verify available memory before starting inference.
1048
+
1049
+ auto_provision: Whether to automatically compile the native C++ engine if missing.
1050
+
1051
+ safe_mode: Preserve SurfaceFlinger 60/120fps UI and background apps (nice +10, VAE Tiling, 90% GPU duty cycle).
1052
+
1053
+ keep_apps_alive: Alias for safe_mode.
1054
+
1055
+ gpu_duty_cycle: GPU load percentage cap (e.g. 90 for 90% duty cycle, 10% idle gap for SurfaceFlinger).
1056
+
1057
+ timeout: Maximum inference timeout in seconds (default: 1800s / 30m).
1058
+
1059
+
1060
+
1061
+ Returns:
1062
+
1063
+ GenerationResult: Object containing local path, gallery path, and inference metrics.
1064
+
1065
+ """
1066
+
1067
+ if not prompt or not str(prompt).strip():
1068
+
1069
+ raise ValueError("Prompt must not be empty.")
1070
+
1071
+
1072
+
1073
+ if keep_apps_alive:
1074
+
1075
+ safe_mode = True
1076
+
1077
+
1078
+
1079
+ if safe_mode:
1080
+
1081
+ if gpu_duty_cycle is None:
1082
+
1083
+ gpu_duty_cycle = 90
1084
+
1085
+ vae_tiling = True
1086
+
1087
+ logger.info(
1088
+
1089
+ "[SafeMode] Active: GPU duty cycle capped at %d%%, VAE tiling active, priority demoted to preserve SurfaceFlinger and background apps.",
1090
+
1091
+ gpu_duty_cycle
1092
+
1093
+ )
1094
+
1095
+
1096
+
1097
+ duty_controller = GPUDutyCycleController(
1098
+
1099
+ target_duty_cycle=gpu_duty_cycle if gpu_duty_cycle is not None else 100,
1100
+
1101
+ enabled=(gpu_duty_cycle is not None and gpu_duty_cycle < 100) or safe_mode
1102
+
1103
+ )
1104
+
1105
+
1106
+
1107
+ device_mode = str(device or "auto").lower().strip()
1108
+
1109
+ if device_mode not in ("cpu", "gpu", "opencl", "vulkan", "npu", "tpu", "auto"):
1110
+
1111
+ raise ValueError(f"Invalid device '{device}'. Options: 'auto', 'gpu', 'vulkan', 'cpu', 'opencl', 'npu', 'tpu'.")
1112
+
1113
+
1114
+
1115
+ # Resolve device to actual available backend using hardware probing
1116
+
1117
+ from .hardware import resolve_device_backend, get_sd_cli_gpu_args, get_profile_gating_manager
1118
+
1119
+ effective_device, ngl_layers = resolve_device_backend(device_mode)
1120
+
1121
+
1122
+
1123
+ # 0. Runtime Profile & Model Gating Validation (Zero Test Illusion)
1124
+
1125
+ gating_mgr = get_profile_gating_manager()
1126
+
1127
+ user_explicit_gpu = device_mode in ("vulkan", "gpu")
1128
+
1129
+ is_safe, gate_reason = gating_mgr.validate_execution(model, effective_device, user_explicit=user_explicit_gpu)
1130
+
1131
+ if not is_safe:
1132
+
1133
+ from .exceptions import PlatformNotSupportedError
1134
+
1135
+ raise PlatformNotSupportedError(
1136
+
1137
+ f"[termux-diffusion] [GATING-BLOCKED] {gate_reason}"
1138
+
1139
+ )
1140
+
1141
+
1142
+
1143
+ # 1. Pre-flight Memory Safety Inspection (Informational only, non-blocking)
1144
+
1145
+ if low_ram_guard:
1146
+
1147
+ safe, msg = check_memory_safety(required_mb=1200)
1148
+
1149
+ if not safe:
1150
+
1151
+ logger.warning("Low RAM Warning: %s", msg)
1152
+
1153
+
1154
+
1155
+ # 2. Locate or Auto-provision Native sd-cli Engine FIRST (before downloading 1.5GB model weights)
1156
+
1157
+ sd_cli = locate_sd_cli(effective_device)
1158
+
1159
+ if not sd_cli:
1160
+
1161
+ if auto_provision:
1162
+
1163
+ logger.info("sd-cli binary not found in standard paths. Attempting auto-provisioning as requested...")
1164
+
1165
+ sd_cli = provision_engine(backend=effective_device)
1166
+
1167
+ else:
1168
+
1169
+ logger.error("sd-cli binary not found in standard paths and auto_provision=False.")
1170
+
1171
+ raise ProvisioningError(
1172
+
1173
+ "Native 'sd-cli' binary not found on this system. "
1174
+
1175
+ "Please run 'termux-diffusion doctor --install' or pass auto_provision=True to generate()."
1176
+
1177
+ )
1178
+
1179
+
1180
+
1181
+ # 2.1 Strict Vulkan / GPU Validation (Fail-Fast: No Silent CPU Fallback)
1182
+
1183
+ if device_mode in ("vulkan", "gpu") or strict_vulkan:
1184
+
1185
+ from .selftest import run_binary_self_test
1186
+
1187
+ from .exceptions import PlatformNotSupportedError
1188
+
1189
+ test_res = run_binary_self_test(sd_cli, expected_backend="vulkan")
1190
+
1191
+ if not test_res.stage1_load_passed and test_res.error_message:
1192
+
1193
+ raise PlatformNotSupportedError(
1194
+
1195
+ f"Strict Vulkan execution mode requested (device='{device}'), "
1196
+
1197
+ f"but the active binary '{sd_cli.name}' failed Vulkan validation: {test_res.error_message}. "
1198
+
1199
+ "Execution halted strictly without silent CPU fallback."
1200
+
1201
+ )
1202
+
1203
+
1204
+
1205
+ # 3. Model Weight Resolution and Local Caching
1206
+
1207
+ if isinstance(model, dict):
1208
+
1209
+ if "shift" in model and flow_shift is None:
1210
+
1211
+ flow_shift = model["shift"]
1212
+
1213
+ model = model.get("model", "realistic")
1214
+
1215
+
1216
+
1217
+ model_str = str(model).lower().strip()
1218
+
1219
+ is_z_image = "z-image" in model_str or "z_image" in model_str or "turbo-6b" in model_str or (diffusion_model and "z_image" in str(diffusion_model).lower())
1220
+
1221
+ if is_z_image:
1222
+
1223
+ if diffusion_model is None:
1224
+
1225
+ if model_str.endswith(".gguf") or model_str.endswith(".safetensors") or "/" in model_str:
1226
+
1227
+ diffusion_model = model
1228
+
1229
+ else:
1230
+
1231
+ diffusion_model = "z_image_turbo-Q2_K.gguf"
1232
+
1233
+ if llm is None:
1234
+
1235
+ llm = "Qwen3-4B-Instruct-2507-Q2_K.gguf"
1236
+
1237
+ if vae is None and taesd is None:
1238
+
1239
+ taef1_candidate = Path(get_default_cache_dir()) / "models" / "taef1.safetensors"
1240
+
1241
+ if taef1_candidate.is_file():
1242
+
1243
+ taesd = "taef1.safetensors"
1244
+
1245
+ else:
1246
+
1247
+ vae = "z_image_ae.safetensors"
1248
+
1249
+ if vae_format is None:
1250
+
1251
+ vae_format = "flux"
1252
+
1253
+ if steps is None or steps == 10:
1254
+
1255
+ steps = 4
1256
+
1257
+ if cfg_scale is None or cfg_scale == 4.0:
1258
+
1259
+ cfg_scale = 1.0
1260
+
1261
+ if sampling_method is None:
1262
+
1263
+ sampling_method = "euler"
1264
+
1265
+ if schedule == "flux":
1266
+
1267
+ schedule = None
1268
+
1269
+ guidance = None
1270
+
1271
+ # Auto-apply mobile Vulkan memory-safe acceleration flags for 6B DiT
1272
+
1273
+ if device_mode in ("vulkan", "gpu") or effective_device in ("vulkan", "gpu"):
1274
+
1275
+ clip_on_cpu = True
1276
+
1277
+ vae_on_cpu = True
1278
+
1279
+ diffusion_fa = True
1280
+
1281
+
1282
+
1283
+ # Autonomous Hardware-Aware VRAM Scaling (Co-resident Multi-Component Aware)
1284
+
1285
+ model_size = get_model_file_size(diffusion_model or model)
1286
+
1287
+ llm_size = get_model_file_size(llm) if llm else 0
1288
+
1289
+ vae_size = get_model_file_size(vae) if vae else (get_model_file_size(taesd) if taesd else 0)
1290
+
1291
+ max_vram, stream_layers, policy_reason = resolve_vram_and_streaming_policy(
1292
+
1293
+ user_max_vram=max_vram,
1294
+
1295
+ user_stream_layers=stream_layers,
1296
+
1297
+ model_bytes=model_size,
1298
+
1299
+ llm_bytes=llm_size,
1300
+
1301
+ vae_bytes=vae_size,
1302
+
1303
+ )
1304
+
1305
+ if params_backend is None:
1306
+
1307
+ params_backend = "diffusion=cpu"
1308
+ safe_mode = True
1309
+ if gpu_duty_cycle is None:
1310
+ gpu_duty_cycle = 90
1311
+ vae_tiling = True
1312
+
1313
+ logger.info("[termux-diffusion] [VRAM Policy] %s", policy_reason)
1314
+
1315
+
1316
+
1317
+ if diffusion_model is not None and str(diffusion_model).strip():
1318
+
1319
+ model_path = None
1320
+
1321
+ else:
1322
+
1323
+ model_path = resolve_model_path(model)
1324
+
1325
+
1326
+
1327
+ # 4. Resolve CPU Thread Allocation and Sampling Steps
1328
+
1329
+ presets = list_presets()
1330
+
1331
+ preset_info = presets.get(str(model), {})
1332
+
1333
+ if steps is None:
1334
+
1335
+ steps = preset_info.get("default_steps", 10)
1336
+
1337
+ if cfg_scale is None:
1338
+
1339
+ cfg_scale = preset_info.get("default_cfg", 4.0)
1340
+
1341
+ if schedule is None:
1342
+
1343
+ schedule = preset_info.get("default_schedule")
1344
+
1345
+ if sampling_method is None:
1346
+
1347
+ sampling_method = preset_info.get("default_sampler")
1348
+
1349
+ effective_guidance = guidance if guidance is not None else preset_info.get("default_guidance")
1350
+
1351
+ if threads is None:
1352
+
1353
+ threads = get_optimal_thread_count()
1354
+
1355
+
1356
+
1357
+ # Determine default or explicit negative prompt
1358
+
1359
+ effective_negative = negative_prompt
1360
+
1361
+ if effective_negative is None:
1362
+
1363
+ effective_negative = get_default_negative_prompt()
1364
+
1365
+
1366
+
1367
+ # 4. Determine Output Destination
1368
+
1369
+ timestamp = int(time.time())
1370
+
1371
+ if output:
1372
+
1373
+ out_path = Path(output).resolve()
1374
+
1375
+ else:
1376
+
1377
+ out_dir = get_galaxy_gallery_dir()
1378
+
1379
+ out_path = out_dir / f"ai_gen_{timestamp}.png"
1380
+
1381
+
1382
+
1383
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1384
+
1385
+
1386
+
1387
+ # 4.3 Prompt Embedding Cache & Assetization Management
1388
+
1389
+ import hashlib
1390
+
1391
+ prompt_clean = str(prompt).strip()
1392
+
1393
+ prompt_hash = hashlib.sha256(prompt_clean.encode("utf-8")).hexdigest()[:16]
1394
+
1395
+ prompt_cache_dir = get_default_cache_dir() / "prompt_cache"
1396
+
1397
+ prompt_cache_dir.mkdir(parents=True, exist_ok=True)
1398
+
1399
+ prompt_cache_meta = prompt_cache_dir / f"{prompt_hash}.json"
1400
+
1401
+ bundled_prompt_meta = Path(__file__).parent / "data" / "prompt_cache" / f"{prompt_hash}.json"
1402
+
1403
+
1404
+
1405
+ is_cached = bundled_prompt_meta.is_file() or prompt_cache_meta.is_file()
1406
+
1407
+
1408
+
1409
+ if no_cache:
1410
+
1411
+ logger.info("[termux-diffusion] Prompt cache explicitly bypassed (--no-cache/--non-cache).")
1412
+
1413
+ print("[termux-diffusion] [Prompt Cache] Bypassed (--no-cache). Running live text encoder on device.")
1414
+
1415
+ elif is_cached:
1416
+
1417
+ logger.info("[termux-diffusion] Prompt cache hit: %s", prompt_hash)
1418
+
1419
+ print(f"[termux-diffusion] [Prompt Cache] Hit (hash: {prompt_hash})! Reusing verified pre-cached prompt embedding.")
1420
+
1421
+ else:
1422
+
1423
+ logger.info("[termux-diffusion] Prompt cache miss (%s). Running live text encoder.", prompt_hash)
1424
+
1425
+ print(f"[termux-diffusion] [Prompt Cache] Miss (hash: {prompt_hash}). Running live text encoder.")
1426
+
1427
+
1428
+
1429
+ # 5. Build Subprocess Command List (100% List argv, Zero Shell Injection Vector)
1430
+
1431
+ sanitized_prompt = prompt_clean.replace("\x00", "").replace("\r\n", " ").replace("\n", " ")
1432
+
1433
+ cmd = [
1434
+
1435
+ str(sd_cli),
1436
+
1437
+ "-p", sanitized_prompt,
1438
+
1439
+ "-W", str(width),
1440
+
1441
+ "-H", str(height),
1442
+
1443
+ "-t", str(threads),
1444
+
1445
+ "--steps", str(steps),
1446
+
1447
+ "--cfg-scale", str(cfg_scale),
1448
+
1449
+ "-o", str(out_path)
1450
+
1451
+ ]
1452
+
1453
+
1454
+
1455
+ if diffusion_model is not None and str(diffusion_model).strip():
1456
+
1457
+ cmd.extend(["--diffusion-model", str(resolve_model_path(str(diffusion_model)))])
1458
+
1459
+ else:
1460
+
1461
+ cmd.extend(["-m", str(model_path)])
1462
+
1463
+
1464
+
1465
+ if llm is not None and str(llm).strip():
1466
+
1467
+ cmd.extend(["--llm", str(resolve_model_path(str(llm)))])
1468
+
1469
+
1470
+
1471
+ # 4.1 Resolve VAE or fast TAESD (AutoEncoder)
1472
+
1473
+ effective_taesd_path = None
1474
+
1475
+ if taesd is not None and str(taesd).strip():
1476
+
1477
+ taesd_str = str(taesd).strip().lower()
1478
+
1479
+ if taesd_str in ("auto", "true", "1", "taef1", "flux"):
1480
+
1481
+ auto_candidates = [
1482
+
1483
+ Path(get_default_cache_dir()) / "models" / "taef1.safetensors",
1484
+
1485
+ Path(os.environ.get("HOME", "/data/data/com.termux/files/home")) / ".cache" / "termux-diffusion" / "models" / "taef1.safetensors",
1486
+
1487
+ Path(get_default_cache_dir()) / "models" / "taesd.safetensors",
1488
+
1489
+ ]
1490
+
1491
+ for c in auto_candidates:
1492
+
1493
+ if c.is_file():
1494
+
1495
+ effective_taesd_path = c.resolve()
1496
+
1497
+ break
1498
+
1499
+ if effective_taesd_path is None:
1500
+
1501
+ raise FileNotFoundError(
1502
+
1503
+ f"[termux-diffusion] Auto TAESD requested, but 'taef1.safetensors' was not found in cache.\n"
1504
+
1505
+ f" -> Checked paths: {[str(p) for p in auto_candidates]}\n"
1506
+
1507
+ f" -> Remedy: Download taef1.safetensors or specify path via --taesd <path>."
1508
+
1509
+ )
1510
+
1511
+ else:
1512
+
1513
+ p = Path(taesd).resolve()
1514
+
1515
+ if p.is_file():
1516
+
1517
+ effective_taesd_path = p
1518
+
1519
+ else:
1520
+
1521
+ cache_taesd = Path(get_default_cache_dir()) / "models" / str(taesd)
1522
+
1523
+ if cache_taesd.is_file():
1524
+
1525
+ effective_taesd_path = cache_taesd.resolve()
1526
+
1527
+ else:
1528
+
1529
+ raise FileNotFoundError(
1530
+
1531
+ f"[termux-diffusion] TAESD fast VAE model file not found: '{taesd}'\n"
1532
+
1533
+ f" -> Remedy: Check TAESD file path or omit --taesd to use standard built-in VAE."
1534
+
1535
+ )
1536
+
1537
+
1538
+
1539
+ if effective_taesd_path is not None:
1540
+
1541
+ cmd.extend(["--taesd", str(effective_taesd_path)])
1542
+
1543
+ logger.info("[termux-diffusion] Fast VAE enabled: Using TAESD '%s' (skipping heavy VAE)", effective_taesd_path.name)
1544
+
1545
+ elif vae is not None and str(vae).strip():
1546
+
1547
+ cmd.extend(["--vae", str(resolve_model_path(str(vae)))])
1548
+
1549
+
1550
+
1551
+ if clip_l is not None and str(clip_l).strip():
1552
+
1553
+ cmd.extend(["--clip_l", str(resolve_model_path(str(clip_l)))])
1554
+
1555
+
1556
+
1557
+ if offload_to_cpu:
1558
+
1559
+ cmd.append("--offload-to-cpu")
1560
+
1561
+
1562
+
1563
+ if clip_on_cpu:
1564
+
1565
+ cmd.append("--clip-on-cpu")
1566
+
1567
+
1568
+
1569
+ if vae_on_cpu:
1570
+
1571
+ cmd.append("--vae-on-cpu")
1572
+
1573
+
1574
+
1575
+ if vae_format is not None and str(vae_format).strip():
1576
+
1577
+ cmd.extend(["--vae-format", str(vae_format).strip()])
1578
+
1579
+
1580
+
1581
+ if mmap:
1582
+
1583
+ cmd.append("--mmap")
1584
+
1585
+
1586
+
1587
+ if diffusion_fa:
1588
+
1589
+ cmd.append("--diffusion-fa")
1590
+
1591
+ if effective_guidance is not None:
1592
+
1593
+ cmd.extend(["--guidance", str(effective_guidance)])
1594
+
1595
+ if effective_negative:
1596
+
1597
+ sanitized_neg = str(effective_negative).replace("\x00", "").replace("\r\n", " ").replace("\n", " ").strip()
1598
+
1599
+ cmd.extend(["-n", sanitized_neg])
1600
+
1601
+ if seed >= 0:
1602
+
1603
+ cmd.extend(["--seed", str(seed)])
1604
+
1605
+ # Append GPU offloading args from hardware detection
1606
+
1607
+ cmd.extend(get_sd_cli_gpu_args(effective_device, ngl_layers))
1608
+
1609
+ if custom_backend is not None and str(custom_backend).strip():
1610
+
1611
+ cmd.extend(["--backend", str(custom_backend).strip()])
1612
+
1613
+ elif effective_device in ("vulkan", "gpu"):
1614
+
1615
+ b_clip = "cpu" if clip_on_cpu else "vulkan0"
1616
+
1617
+ b_vae = "cpu" if vae_on_cpu else "vulkan0"
1618
+
1619
+ cmd.extend(["--backend", f"clip={b_clip},diffusion=vulkan0,vae={b_vae}"])
1620
+
1621
+ elif effective_device in ("opencl", "cl"):
1622
+
1623
+ b_clip = "cpu" if clip_on_cpu else "cpu"
1624
+
1625
+ b_vae = "cpu" if vae_on_cpu else "cpu"
1626
+
1627
+ cmd.extend(["--backend", f"clip={b_clip},diffusion=GPUOpenCL,vae={b_vae}"])
1628
+
1629
+ if params_backend is None:
1630
+
1631
+ cmd.extend(["--params-backend", "clip=disk,diffusion=cpu"])
1632
+
1633
+ if max_vram is None:
1634
+
1635
+ cmd.extend(["--max-vram", "GPUOpenCL=1.0"])
1636
+
1637
+ cmd.append("--disable-prefetch")
1638
+
1639
+
1640
+
1641
+ # --- Unified Distributed RPC Cluster Configuration ---
1642
+
1643
+ effective_rpc = cluster_rpc_servers if cluster_rpc_servers is not None else rpc
1644
+
1645
+ cluster_nodes: List[str] = []
1646
+
1647
+ if effective_rpc is not None and str(effective_rpc).strip():
1648
+
1649
+ cluster_nodes = parse_cluster_rpc_spec(effective_rpc)
1650
+
1651
+ if cluster_nodes:
1652
+
1653
+ # Pre-flight check: verify TCP connectivity. Raises ClusterConnectionError if unreachable.
1654
+
1655
+ verify_rpc_cluster_nodes(cluster_nodes, timeout=3.0)
1656
+
1657
+ cmd.extend(["--rpc-servers", ",".join(cluster_nodes)])
1658
+
1659
+
1660
+
1661
+ effective_split_mode = cluster_split_mode if cluster_split_mode is not None else (cluster_tensor_split or tensor_split)
1662
+
1663
+ if effective_split_mode is not None and str(effective_split_mode).strip():
1664
+
1665
+ sm_val = str(effective_split_mode).strip().lower()
1666
+
1667
+ if sm_val in ("layer", "row"):
1668
+
1669
+ cmd.extend(["--split-mode", sm_val])
1670
+
1671
+ else:
1672
+
1673
+ logger.info("tensor_split='%s' specified; sd-cli balances across --rpc-servers directly.", effective_split_mode)
1674
+
1675
+
1676
+
1677
+ effective_max_vram = cluster_vram_budget or max_vram
1678
+
1679
+ if effective_max_vram is not None and str(effective_max_vram).strip() not in ("", "0", "none", "disable", "unlimited", "vulkan0=0"):
1680
+
1681
+ cmd.extend(["--max-vram", str(effective_max_vram).strip()])
1682
+ if stream_layers:
1683
+ cmd.append("--stream-layers")
1684
+ if params_backend is not None and str(params_backend).strip():
1685
+ cmd.extend(["--params-backend", str(params_backend).strip()])
1686
+
1687
+
1688
+
1689
+ # --- Advanced TOP 7 Parameters Integration & Defense ---
1690
+
1691
+ effective_sampler = None
1692
+
1693
+ if sampling_method is not None and str(sampling_method).strip():
1694
+
1695
+ sm_clean = str(sampling_method).lower().strip()
1696
+
1697
+ if sm_clean in VALID_SAMPLERS:
1698
+
1699
+ effective_sampler = sm_clean
1700
+
1701
+ cmd.extend(["--sampling-method", sm_clean])
1702
+
1703
+ else:
1704
+
1705
+ logger.warning(
1706
+
1707
+ "Invalid sampling_method '%s'; falling back to engine default ('euler_a'). Valid: %s",
1708
+
1709
+ sampling_method, ", ".join(sorted(VALID_SAMPLERS))
1710
+
1711
+ )
1712
+
1713
+
1714
+
1715
+ effective_schedule = None
1716
+
1717
+ if schedule is not None and str(schedule).strip():
1718
+
1719
+ sc_clean = str(schedule).lower().strip()
1720
+
1721
+ if sc_clean in VALID_SCHEDULERS:
1722
+
1723
+ effective_schedule = sc_clean
1724
+
1725
+ if sc_clean != "default":
1726
+
1727
+ cmd.extend(["--scheduler", sc_clean])
1728
+
1729
+ else:
1730
+
1731
+ logger.warning(
1732
+
1733
+ "Invalid schedule '%s'; falling back to engine default ('default'). Valid: %s",
1734
+
1735
+ schedule, ", ".join(sorted(VALID_SCHEDULERS))
1736
+
1737
+ )
1738
+
1739
+
1740
+
1741
+ if flow_shift is not None:
1742
+
1743
+ cmd.extend(["--flow-shift", str(flow_shift)])
1744
+
1745
+
1746
+
1747
+ if vae_tiling:
1748
+
1749
+ cmd.append("--vae-tiling")
1750
+
1751
+
1752
+
1753
+ effective_init_path = None
1754
+
1755
+ effective_strength = None
1756
+
1757
+ if init_img is not None and str(init_img).strip():
1758
+
1759
+ effective_init_path = Path(init_img).resolve()
1760
+
1761
+ if not effective_init_path.is_file():
1762
+
1763
+ raise FileNotFoundError(
1764
+
1765
+ f"[termux-diffusion] Img2Img source image file does not exist.\n"
1766
+
1767
+ f" -> Input parameter: '{init_img}'\n"
1768
+
1769
+ f" -> Resolved absolute path: '{effective_init_path}'\n"
1770
+
1771
+ f" -> Remedy: Check if the image file exists and Termux storage permission ('termux-setup-storage') is granted."
1772
+
1773
+ )
1774
+
1775
+ cmd.extend(["-i", str(effective_init_path)])
1776
+
1777
+
1778
+
1779
+ # Denoising strength for Img2Img
1780
+
1781
+ if strength is not None:
1782
+
1783
+ raw_s = float(strength)
1784
+
1785
+ if raw_s < 0.0:
1786
+
1787
+ logger.warning("Img2Img strength (%s) is below 0.0; auto-clamping to 0.0 (minimal change).", raw_s)
1788
+
1789
+ effective_strength = 0.0
1790
+
1791
+ elif raw_s > 1.0:
1792
+
1793
+ logger.warning("Img2Img strength (%s) is above 1.0; auto-clamping to 1.0 (full regeneration).", raw_s)
1794
+
1795
+ effective_strength = 1.0
1796
+
1797
+ else:
1798
+
1799
+ effective_strength = raw_s
1800
+
1801
+ else:
1802
+
1803
+ effective_strength = 0.75 # Standard Img2Img default
1804
+
1805
+ cmd.extend(["--strength", str(effective_strength)])
1806
+
1807
+
1808
+
1809
+ effective_lora_path = None
1810
+
1811
+ if lora_dir is not None and str(lora_dir).strip():
1812
+
1813
+ effective_lora_path = Path(lora_dir).resolve()
1814
+
1815
+ if not effective_lora_path.is_dir():
1816
+
1817
+ raise FileNotFoundError(
1818
+
1819
+ f"[termux-diffusion] LoRA weights directory not found.\n"
1820
+
1821
+ f" -> Input parameter: '{lora_dir}'\n"
1822
+
1823
+ f" -> Resolved absolute path: '{effective_lora_path}'\n"
1824
+
1825
+ f" -> Remedy: Create directory or verify location containing .safetensors/.gguf LoRA adapters."
1826
+
1827
+ )
1828
+
1829
+ cmd.extend(["--lora-model-dir", str(effective_lora_path)])
1830
+
1831
+
1832
+
1833
+ effective_clip_skip = None
1834
+
1835
+ if clip_skip is not None:
1836
+
1837
+ try:
1838
+
1839
+ cs_val = int(clip_skip)
1840
+
1841
+ if cs_val < 1:
1842
+
1843
+ logger.warning("clip_skip (%s) is below 1; auto-clamping to 1.", cs_val)
1844
+
1845
+ effective_clip_skip = 1
1846
+
1847
+ elif cs_val > 2:
1848
+
1849
+ logger.warning("clip_skip (%s) is above standard maximum 2; auto-clamping to 2.", cs_val)
1850
+
1851
+ effective_clip_skip = 2
1852
+
1853
+ else:
1854
+
1855
+ effective_clip_skip = cs_val
1856
+
1857
+ cmd.extend(["--clip-skip", str(effective_clip_skip)])
1858
+
1859
+ except (ValueError, TypeError):
1860
+
1861
+ logger.warning("Invalid clip_skip value '%s'; skipping flag.", clip_skip)
1862
+
1863
+
1864
+
1865
+ effective_cnet_path = None
1866
+
1867
+ effective_cimg_path = None
1868
+
1869
+ effective_cstrength = None
1870
+
1871
+ if control_net is not None and str(control_net).strip():
1872
+
1873
+ effective_cnet_path = Path(control_net).resolve()
1874
+
1875
+ if not effective_cnet_path.is_file():
1876
+
1877
+ raise FileNotFoundError(
1878
+
1879
+ f"[termux-diffusion] ControlNet model file not found.\n"
1880
+
1881
+ f" -> Input parameter: '{control_net}'\n"
1882
+
1883
+ f" -> Resolved absolute path: '{effective_cnet_path}'\n"
1884
+
1885
+ f" -> Remedy: Place valid ControlNet GGUF/model file at specified path."
1886
+
1887
+ )
1888
+
1889
+ cmd.extend(["--control-net", str(effective_cnet_path)])
1890
+
1891
+
1892
+
1893
+ if control_image is not None and str(control_image).strip():
1894
+
1895
+ effective_cimg_path = Path(control_image).resolve()
1896
+
1897
+ if not effective_cimg_path.is_file():
1898
+
1899
+ raise FileNotFoundError(
1900
+
1901
+ f"[termux-diffusion] ControlNet guide/hint image not found.\n"
1902
+
1903
+ f" -> Input parameter: '{control_image}'\n"
1904
+
1905
+ f" -> Resolved absolute path: '{effective_cimg_path}'\n"
1906
+
1907
+ f" -> Remedy: Check existence of pose/edge reference image."
1908
+
1909
+ )
1910
+
1911
+ cmd.extend(["--control-image", str(effective_cimg_path)])
1912
+
1913
+
1914
+
1915
+ if control_strength is not None:
1916
+
1917
+ raw_cs = float(control_strength)
1918
+
1919
+ if raw_cs < 0.0:
1920
+
1921
+ logger.warning("ControlNet strength (%s) is below 0.0; auto-clamping to 0.0.", raw_cs)
1922
+
1923
+ effective_cstrength = 0.0
1924
+
1925
+ elif raw_cs > 2.0:
1926
+
1927
+ logger.warning("ControlNet strength (%s) is above 2.0; auto-clamping to 2.0.", raw_cs)
1928
+
1929
+ effective_cstrength = 2.0
1930
+
1931
+ else:
1932
+
1933
+ effective_cstrength = raw_cs
1934
+
1935
+ else:
1936
+
1937
+ effective_cstrength = 0.9
1938
+
1939
+ cmd.extend(["--control-strength", str(effective_cstrength)])
1940
+
1941
+
1942
+
1943
+ # 5.0.1 Transparent Sub-Engine Option Forwarding & Extra Argument Merging
1944
+
1945
+ all_forwarded: List[str] = []
1946
+
1947
+ if extra_args:
1948
+
1949
+ all_forwarded.extend([str(a) for a in extra_args if str(a).strip()])
1950
+
1951
+ if extra_kwargs:
1952
+
1953
+ all_forwarded.extend(ClusterOptionForwarder.normalize_forward_args(extra_kwargs))
1954
+
1955
+ if all_forwarded:
1956
+
1957
+ cmd = ClusterOptionForwarder.merge_pass_through_args(cmd, all_forwarded)
1958
+
1959
+ logger.info("[termux-diffusion] Merged %d pass-through / forwarded CLI options into engine argv.", len(all_forwarded))
1960
+
1961
+
1962
+
1963
+ # 5.1 Configure Environment with companion library search paths (Termux native isolation)
1964
+
1965
+ from .platform import get_clean_execution_env
1966
+
1967
+ env = get_clean_execution_env(backend=effective_device)
1968
+
1969
+ # Bypass debug CPU shadow checks on mobile Vulkan to unlock 10x throughput
1970
+
1971
+ env.setdefault("GGML_VULKAN_SKIP_CHECKS", "999999999")
1972
+
1973
+ # Throttle Vulkan nodes per submit to prevent Mali GPU kernel driver queue stall (do_sys_poll)
1974
+
1975
+ env.setdefault("GGML_VK_MAX_NODES_PER_SUBMIT", "16")
1976
+
1977
+
1978
+
1979
+ # 5.2 ameva-runtime DiffusionAdapter Binding (Vulkan / GPU mode)
1980
+
1981
+ if effective_device in ("vulkan", "gpu") or device_mode in ("vulkan", "gpu"):
1982
+
1983
+ try:
1984
+
1985
+ from ameva_runtime import vulkan as avr
1986
+
1987
+ from ameva_runtime.vulkan.adapters import DiffusionAdapter
1988
+
1989
+
1990
+
1991
+ avr_ctx = avr.get_or_create_context(device_mode)
1992
+
1993
+ report = getattr(avr_ctx, "doctor", avr.Doctor()).run_self_test(verbose=False)
1994
+
1995
+
1996
+
1997
+ class EngineProxy:
1998
+
1999
+ def __init__(self, hw):
2000
+
2001
+ self.hw_profile = hw
2002
+
2003
+
2004
+
2005
+ from .hardware import detect_hardware_profile
2006
+
2007
+ engine_proxy = EngineProxy(detect_hardware_profile())
2008
+
2009
+ binding = DiffusionAdapter.bind(engine_proxy, report)
2010
+
2011
+
2012
+
2013
+ cfg = getattr(binding, "config", {})
2014
+
2015
+ if cfg.get("unet_tiling") and "--vae-tiling" not in cmd:
2016
+
2017
+ cmd.append("--vae-tiling")
2018
+
2019
+ if cfg.get("is_mali") and "--diffusion-fa" not in cmd:
2020
+
2021
+ cmd.append("--diffusion-fa")
2022
+
2023
+
2024
+
2025
+ # Auto-inject mobile Vulkan HAL shim (Samsung GOS & BDA sanitizer) into process environment
2026
+
2027
+ env = DiffusionAdapter.get_execution_env(env)
2028
+
2029
+ # Re-sanitize LD_LIBRARY_PATH to eliminate Termux /usr/lib symbol collisions on Android Bionic
2030
+
2031
+ cur_ld = env.get("LD_LIBRARY_PATH", "")
2032
+
2033
+ prefix_str = str(Path(os.environ.get("PREFIX", "/data/data/com.termux/files/usr")) / "lib")
2034
+
2035
+ parts = [p for p in cur_ld.split(":") if p and not p.startswith(prefix_str)]
2036
+
2037
+ if parts:
2038
+
2039
+ env["LD_LIBRARY_PATH"] = ":".join(parts)
2040
+
2041
+ else:
2042
+
2043
+ env.pop("LD_LIBRARY_PATH", None)
2044
+
2045
+
2046
+
2047
+ logger.info(
2048
+
2049
+ "[termux-diffusion] DiffusionAdapter bound: backend=%s, status=%s, LD_PRELOAD=%s",
2050
+
2051
+ cfg.get("backend", "vulkan"),
2052
+
2053
+ getattr(binding.status, "name", str(binding.status)),
2054
+
2055
+ env.get("LD_PRELOAD", "None")
2056
+
2057
+ )
2058
+
2059
+ except Exception as e:
2060
+
2061
+ if device_mode in ("gpu", "vulkan") or strict_vulkan:
2062
+
2063
+ from .exceptions import PlatformNotSupportedError
2064
+
2065
+ raise PlatformNotSupportedError(
2066
+
2067
+ f"[termux-diffusion] [FAIL-FAST] Vulkan execution requested (device='{device_mode}'), "
2068
+
2069
+ f"but hardware validation failed: {e}"
2070
+
2071
+ ) from e
2072
+
2073
+ avr_ctx = None
2074
+
2075
+
2076
+
2077
+ # Inject AMEVA isolated RPC companion library paths if available
2078
+
2079
+ home_dir = Path(os.environ.get("HOME", os.path.expanduser("~")))
2080
+
2081
+ rpc_lib_dir = home_dir / ".local" / "share" / "ameva" / "rpc_llama" / "bin"
2082
+
2083
+ if rpc_lib_dir.is_dir():
2084
+
2085
+ cur_ld = env.get("LD_LIBRARY_PATH", "")
2086
+
2087
+ if str(rpc_lib_dir) not in cur_ld:
2088
+
2089
+ env["LD_LIBRARY_PATH"] = f"{rpc_lib_dir}:{cur_ld}" if cur_ld else str(rpc_lib_dir)
2090
+
2091
+
2092
+
2093
+ # Ensure Termux system lib is present for CPU baseline runs (unless in Vulkan or OpenCL route where it causes symbol shadowing)
2094
+
2095
+ if effective_device not in ("vulkan", "gpu", "opencl", "cl"):
2096
+
2097
+ termux_usr_lib = Path("/data/data/com.termux/files/usr/lib")
2098
+
2099
+ if termux_usr_lib.is_dir():
2100
+
2101
+ cur_ld = env.get("LD_LIBRARY_PATH", "")
2102
+
2103
+ if str(termux_usr_lib) not in cur_ld:
2104
+
2105
+ env["LD_LIBRARY_PATH"] = f"{cur_ld}:{termux_usr_lib}" if cur_ld else str(termux_usr_lib)
2106
+
2107
+
2108
+
2109
+ # Inject OpenCL build companion directory if present
2110
+
2111
+ opencl_bin_dir = home_dir / "sd-opencl-build" / "build" / "bin"
2112
+
2113
+ if opencl_bin_dir.is_dir() and effective_device in ("opencl", "cl"):
2114
+
2115
+ cur_ld = env.get("LD_LIBRARY_PATH", "")
2116
+
2117
+ parts = [p for p in cur_ld.split(":") if p and not p.startswith("/data/data/com.termux/files/usr/lib")]
2118
+
2119
+ env["LD_LIBRARY_PATH"] = ":".join([str(opencl_bin_dir)] + [p for p in parts if p != str(opencl_bin_dir)])
2120
+
2121
+
2122
+
2123
+ logger.info("Executing diffusion inference: %s", " ".join(cmd[:6]) + " ...")
2124
+
2125
+ print(f"[termux-diffusion] Command: {' '.join(cmd)}")
2126
+
2127
+ print(f"[termux-diffusion] Processing inference with model='{model}' (steps={steps}, threads={threads}, device={device_mode})...")
2128
+
2129
+
2130
+
2131
+ start_time = time.time()
2132
+
2133
+
2134
+
2135
+ # 6. Execute with TermuxExecutionGuard (Android WakeLock + Niceness priority protection)
2136
+
2137
+ with TermuxExecutionGuard(enable_wake_lock=wake_lock, boost_priority=not safe_mode, safe_mode=safe_mode):
2138
+
2139
+ process = None
2140
+
2141
+ try:
2142
+
2143
+ popen_kwargs = {
2144
+
2145
+ "stdout": subprocess.PIPE,
2146
+
2147
+ "stderr": subprocess.STDOUT,
2148
+
2149
+ "env": env,
2150
+
2151
+ }
2152
+
2153
+ if safe_mode and hasattr(os, "nice") and sys.platform != "win32":
2154
+
2155
+ def _demote_priority():
2156
+
2157
+ try:
2158
+
2159
+ os.nice(10)
2160
+
2161
+ except Exception:
2162
+
2163
+ pass
2164
+
2165
+ popen_kwargs["preexec_fn"] = _demote_priority
2166
+
2167
+
2168
+
2169
+ process = subprocess.Popen(cmd, **popen_kwargs)
2170
+
2171
+ if _proc_holder is not None:
2172
+
2173
+ _proc_holder.append(process)
2174
+
2175
+ if _cancel_event and _cancel_event.is_set():
2176
+
2177
+ _safe_kill_process(process)
2178
+
2179
+ raise TermuxDiffusionError("Inference cancelled by caller.")
2180
+
2181
+
2182
+
2183
+ init_logs = []
2184
+
2185
+ recent_logs = deque(maxlen=30)
2186
+
2187
+ last_gpu_query = 0.0
2188
+
2189
+ cached_gpu_busy = None
2190
+
2191
+ pid = getattr(process, "pid", None)
2192
+
2193
+
2194
+
2195
+ def notify_progress(info: ProgressInfo):
2196
+
2197
+ if progress_callback:
2198
+
2199
+ try:
2200
+
2201
+ progress_callback(info)
2202
+
2203
+ except Exception as _cb_err:
2204
+
2205
+ logger.debug("progress_callback exception: %s", _cb_err)
2206
+
2207
+ if json_progress:
2208
+
2209
+ sys.stdout.write(json.dumps(info.to_dict()) + "\n")
2210
+
2211
+ sys.stdout.flush()
2212
+
2213
+ if progress:
2214
+
2215
+ if info.phase == "sampling" and info.total_steps > 0:
2216
+
2217
+ bar_len = 20
2218
+
2219
+ filled = int(round(bar_len * info.percent / 100.0))
2220
+
2221
+ bar = "=" * filled + (">" if filled < bar_len else "")
2222
+
2223
+ bar = bar.ljust(bar_len)
2224
+
2225
+ speed_str = f"{info.speed_s_per_it:.2f}s/it" if info.speed_s_per_it else ""
2226
+
2227
+ eta_str = f"ETA: {int(info.eta_seconds)}s" if info.eta_seconds is not None else ""
2228
+
2229
+ gpu_str = f"GPU: {info.gpu_busy}%" if info.gpu_busy is not None else ""
2230
+
2231
+ rss_str = f"RSS: {info.rss_mb:.0f}MB" if info.rss_mb is not None else ""
2232
+
2233
+ meta = " | ".join(p for p in [speed_str, eta_str, gpu_str, rss_str] if p)
2234
+
2235
+ meta_formatted = f" | {meta}" if meta else ""
2236
+
2237
+ sys.stderr.write(f"\r\033[36m[termux-diffusion]\033[0m [{bar}] {info.step}/{info.total_steps} ({info.percent:.1f}%){meta_formatted}\033[K")
2238
+
2239
+ sys.stderr.flush()
2240
+
2241
+ elif info.phase in ("loading_model", "encoding_prompt", "decoding_vae"):
2242
+
2243
+ sys.stderr.write(f"\r\033[36m[termux-diffusion]\033[0m [{info.phase}] Processing...\033[K")
2244
+
2245
+ sys.stderr.flush()
2246
+
2247
+ elif info.phase == "complete":
2248
+
2249
+ sys.stderr.write(f"\r\033[32m[termux-diffusion]\033[0m Complete! Image saved in {info.elapsed_seconds:.1f}s\033[K\n")
2250
+
2251
+ sys.stderr.flush()
2252
+
2253
+ if progress_file:
2254
+
2255
+ try:
2256
+
2257
+ tmp_pf = str(progress_file) + ".tmp"
2258
+
2259
+ with open(tmp_pf, "w", encoding="utf-8") as f:
2260
+
2261
+ json.dump(info.to_dict(), f)
2262
+
2263
+ os.replace(tmp_pf, str(progress_file))
2264
+
2265
+ except Exception as _pf_err:
2266
+
2267
+ logger.debug("Failed writing progress_file: %s", _pf_err)
2268
+
2269
+
2270
+
2271
+ notify_progress(ProgressInfo(phase="init", raw_message="Engine launched"))
2272
+
2273
+
2274
+
2275
+ def _process_segment(cleaned: str):
2276
+
2277
+ nonlocal cached_gpu_busy, last_gpu_query
2278
+
2279
+ if len(init_logs) < 50 or "ggml_vulkan" in cleaned or "error" in cleaned.lower() or "failed" in cleaned.lower():
2280
+
2281
+ init_logs.append(cleaned)
2282
+
2283
+ recent_logs.append(cleaned)
2284
+
2285
+
2286
+
2287
+ lower_seg = cleaned.lower()
2288
+
2289
+ if "loading" in lower_seg or "load model" in lower_seg:
2290
+
2291
+ notify_progress(ProgressInfo(phase="loading_model", raw_message=cleaned))
2292
+
2293
+ elif "prompt" in lower_seg or "llm" in lower_seg or "clip" in lower_seg or "tokenizer" in lower_seg:
2294
+
2295
+ notify_progress(ProgressInfo(phase="encoding_prompt", raw_message=cleaned))
2296
+
2297
+ elif "decoding" in lower_seg or "vae" in lower_seg or "taesd" in lower_seg:
2298
+
2299
+ notify_progress(ProgressInfo(phase="decoding_vae", raw_message=cleaned))
2300
+
2301
+
2302
+
2303
+ m = PROGRESS_RE.search(cleaned)
2304
+
2305
+ if m:
2306
+
2307
+ cur_step = int(m.group(1))
2308
+
2309
+ tot_steps = int(m.group(2))
2310
+
2311
+ speed_val = float(m.group(3)) if m.group(3) else None
2312
+
2313
+ unit = m.group(4) if m.group(4) else None
2314
+
2315
+ speed_s = None
2316
+
2317
+ if unit == "s/it":
2318
+
2319
+ speed_s = speed_val
2320
+
2321
+ elif unit == "it/s" and speed_val and speed_val > 0:
2322
+
2323
+ speed_s = 1.0 / speed_val
2324
+
2325
+ eta_s = round((tot_steps - cur_step) * speed_s, 1) if speed_s is not None else None
2326
+
2327
+ pct = round((cur_step / tot_steps) * 100.0, 1) if tot_steps > 0 else 0.0
2328
+
2329
+
2330
+
2331
+ now = time.time()
2332
+
2333
+ if now - last_gpu_query > 1.0:
2334
+
2335
+ cached_gpu_busy = _read_sys_gpu_busy()
2336
+
2337
+ last_gpu_query = now
2338
+
2339
+
2340
+
2341
+ rss_mb = _read_process_rss_mb(pid) if pid else None
2342
+
2343
+
2344
+
2345
+ info = ProgressInfo(
2346
+
2347
+ phase="sampling",
2348
+
2349
+ step=cur_step,
2350
+
2351
+ total_steps=tot_steps,
2352
+
2353
+ percent=pct,
2354
+
2355
+ eta_seconds=eta_s,
2356
+
2357
+ speed_s_per_it=speed_s,
2358
+
2359
+ gpu_busy=cached_gpu_busy,
2360
+
2361
+ rss_mb=rss_mb,
2362
+
2363
+ raw_message=cleaned,
2364
+
2365
+ elapsed_seconds=round(time.time() - start_time, 2),
2366
+
2367
+ )
2368
+
2369
+ notify_progress(info)
2370
+
2371
+ if duty_controller.enabled:
2372
+
2373
+ duty_controller.on_step(cur_step, proc_pid=pid)
2374
+
2375
+ else:
2376
+
2377
+ if not progress and not json_progress:
2378
+
2379
+ if "step" in lower_seg or "%" in lower_seg or "sampling" in lower_seg:
2380
+
2381
+ print(f" > {cleaned}")
2382
+
2383
+ else:
2384
+
2385
+ logger.debug("sd-cli: %s", cleaned)
2386
+
2387
+
2388
+
2389
+ # Stream real-time progress using unbuffered chunk reading to support '\r' updates
2390
+
2391
+ if process.stdout:
2392
+
2393
+ remainder = ""
2394
+
2395
+ try:
2396
+
2397
+ if hasattr(process.stdout, "read1") or hasattr(process.stdout, "read"):
2398
+
2399
+ while True:
2400
+
2401
+ if _cancel_event and _cancel_event.is_set():
2402
+
2403
+ _safe_kill_process(process)
2404
+
2405
+ raise TermuxDiffusionError("Inference cancelled by caller.")
2406
+
2407
+
2408
+
2409
+ chunk = process.stdout.read1(4096) if hasattr(process.stdout, "read1") else process.stdout.read(4096)
2410
+
2411
+ if not chunk:
2412
+
2413
+ break
2414
+
2415
+ if isinstance(chunk, bytes):
2416
+
2417
+ text = remainder + chunk.decode("utf-8", errors="replace")
2418
+
2419
+ else:
2420
+
2421
+ text = remainder + str(chunk)
2422
+
2423
+
2424
+
2425
+ lines = re.split(r'[\r\n]+', text)
2426
+
2427
+ remainder = lines.pop()
2428
+
2429
+
2430
+
2431
+ for segment in lines:
2432
+
2433
+ cleaned = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', segment).strip()
2434
+
2435
+ if cleaned:
2436
+
2437
+ _process_segment(cleaned)
2438
+
2439
+
2440
+
2441
+ if remainder.strip():
2442
+
2443
+ cleaned = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', remainder).strip()
2444
+
2445
+ if cleaned:
2446
+
2447
+ _process_segment(cleaned)
2448
+
2449
+ else:
2450
+
2451
+ for line in process.stdout:
2452
+
2453
+ if _cancel_event and _cancel_event.is_set():
2454
+
2455
+ _safe_kill_process(process)
2456
+
2457
+ raise TermuxDiffusionError("Inference cancelled by caller.")
2458
+
2459
+ line_str = str(line).strip()
2460
+
2461
+ if line_str:
2462
+
2463
+ cleaned = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', line_str).strip()
2464
+
2465
+ if cleaned:
2466
+
2467
+ _process_segment(cleaned)
2468
+
2469
+ finally:
2470
+
2471
+ try:
2472
+
2473
+ if hasattr(process.stdout, "close"):
2474
+
2475
+ process.stdout.close()
2476
+
2477
+ except OSError as _close_err:
2478
+
2479
+ logger.debug("[termux-diffusion] stdout.close() OSError: %s", _close_err)
2480
+
2481
+
2482
+
2483
+ process.wait(timeout=timeout)
2484
+
2485
+ all_critical_logs = init_logs + list(recent_logs)
2486
+
2487
+ if (strict_vulkan or device_mode in ("vulkan", "gpu")) and any("ggml_vulkan: No devices found" in l for l in all_critical_logs):
2488
+
2489
+ raise TermuxDiffusionError(
2490
+
2491
+ "Strict Vulkan execution mode requested (--strict-vulkan or device='vulkan'), but Vulkan physical device discovery failed: 'ggml_vulkan: No devices found'."
2492
+
2493
+ )
2494
+
2495
+ if process.returncode != 0:
2496
+
2497
+ err_detail = "\n".join(list(recent_logs)[-5:]) if recent_logs else "No engine output"
2498
+
2499
+ if device_mode == "auto" and effective_device != "cpu":
2500
+
2501
+ logger.warning("[termux-diffusion] Auto-mode Vulkan execution failed (RC=%s): %s. Falling back to CPU...", process.returncode, err_detail)
2502
+
2503
+ print(f"[termux-diffusion] [Auto Fallback] Vulkan acceleration failed (RC={process.returncode}); automatically falling back to CPU...")
2504
+
2505
+ return generate(
2506
+
2507
+ prompt=prompt,
2508
+
2509
+ model=model,
2510
+
2511
+ negative_prompt=negative_prompt,
2512
+
2513
+ device="cpu",
2514
+
2515
+ steps=steps,
2516
+
2517
+ cfg_scale=cfg_scale,
2518
+
2519
+ width=width,
2520
+
2521
+ height=height,
2522
+
2523
+ seed=seed,
2524
+
2525
+ threads=threads,
2526
+
2527
+ output=output,
2528
+
2529
+ sampling_method=sampling_method,
2530
+
2531
+ schedule=schedule,
2532
+
2533
+ vae_tiling=vae_tiling,
2534
+
2535
+ init_img=init_img,
2536
+
2537
+ strength=strength,
2538
+
2539
+ lora_dir=lora_dir,
2540
+
2541
+ clip_skip=clip_skip,
2542
+
2543
+ control_net=control_net,
2544
+
2545
+ control_image=control_image,
2546
+
2547
+ control_strength=control_strength,
2548
+
2549
+ taesd=taesd,
2550
+
2551
+ export_gallery=export_gallery,
2552
+
2553
+ wake_lock=wake_lock,
2554
+
2555
+ low_ram_guard=low_ram_guard,
2556
+
2557
+ auto_provision=auto_provision,
2558
+
2559
+ strict_vulkan=False,
2560
+
2561
+ progress=progress,
2562
+
2563
+ json_progress=json_progress,
2564
+
2565
+ progress_file=progress_file,
2566
+
2567
+ progress_callback=progress_callback,
2568
+
2569
+ timeout=timeout,
2570
+
2571
+ _cancel_event=_cancel_event,
2572
+
2573
+ _proc_holder=_proc_holder,
2574
+
2575
+ )
2576
+
2577
+ raise TermuxDiffusionError(
2578
+
2579
+ f"Engine process failed with return code {process.returncode}.\nDetails:\n{err_detail}"
2580
+
2581
+ )
2582
+
2583
+ else:
2584
+
2585
+ total_elapsed = round(time.time() - start_time, 2)
2586
+
2587
+ notify_progress(ProgressInfo(
2588
+
2589
+ phase="complete",
2590
+
2591
+ step=steps or 1,
2592
+
2593
+ total_steps=steps or 1,
2594
+
2595
+ percent=100.0,
2596
+
2597
+ output_path=str(out_path),
2598
+
2599
+ elapsed_seconds=total_elapsed,
2600
+
2601
+ raw_message=f"Generation completed in {total_elapsed}s",
2602
+
2603
+ ))
2604
+
2605
+ except KeyboardInterrupt:
2606
+
2607
+ _safe_kill_process(process)
2608
+
2609
+ print("\n[termux-diffusion] Inference interrupted by user. Child processes terminated safely.")
2610
+
2611
+ raise
2612
+
2613
+ except subprocess.TimeoutExpired as exc:
2614
+
2615
+ _safe_kill_process(process)
2616
+
2617
+ raise InferenceTimeoutError(f"Diffusion generation timed out after {timeout} seconds") from exc
2618
+
2619
+ except Exception:
2620
+
2621
+ _safe_kill_process(process)
2622
+
2623
+ raise
2624
+
2625
+
2626
+
2627
+ elapsed = time.time() - start_time
2628
+
2629
+ logger.info("Generation completed in %.2fs -> %s", elapsed, out_path)
2630
+
2631
+ print(f"[termux-diffusion] Artifact generated in {elapsed:.2f}s -> {out_path}")
2632
+
2633
+
2634
+
2635
+ if not out_path.is_file():
2636
+
2637
+ raise TermuxDiffusionError(f"Engine finished but output file was not created at: {out_path}")
2638
+
2639
+
2640
+
2641
+ # 6.5 Mobile FaceDetailer: Auto-detect, Crop, Refine with Local Img2Img, and Seamless Blend
2642
+
2643
+ if detail_faces and out_path.is_file():
2644
+
2645
+ try:
2646
+
2647
+ from .detailer import refine_face_regions
2648
+
2649
+ logger.info("[termux-diffusion] Running Mobile FaceDetailer stage...")
2650
+
2651
+ print("[termux-diffusion] Running Mobile FaceDetailer stage...")
2652
+
2653
+ refine_face_regions(
2654
+
2655
+ image_path=out_path,
2656
+
2657
+ generator_func=generate,
2658
+
2659
+ prompt=prompt,
2660
+
2661
+ output_path=out_path,
2662
+
2663
+ strength=detailer_strength,
2664
+
2665
+ steps=detailer_steps,
2666
+
2667
+ padding_factor=detailer_padding,
2668
+
2669
+ device=device,
2670
+
2671
+ model=model,
2672
+
2673
+ generator_kwargs={
2674
+
2675
+ "vae_tiling": vae_tiling,
2676
+
2677
+ "schedule": schedule,
2678
+
2679
+ "sampling_method": sampling_method,
2680
+
2681
+ "taesd": taesd,
2682
+
2683
+ "mmap": mmap,
2684
+
2685
+ "max_vram": max_vram,
2686
+
2687
+ "stream_layers": stream_layers,
2688
+
2689
+ "params_backend": params_backend,
2690
+
2691
+ "low_ram_guard": False,
2692
+
2693
+ }
2694
+
2695
+ )
2696
+
2697
+ except Exception as fd_err:
2698
+
2699
+ logger.warning("[termux-diffusion] FaceDetailer pass encountered an issue: %s. Keeping base image.", fd_err)
2700
+
2701
+
2702
+
2703
+ # 7. Android Gallery MediaStore Sync (Galaxy Gallery visibility)
2704
+
2705
+ gallery_path = None
2706
+
2707
+ if export_gallery:
2708
+
2709
+ try:
2710
+
2711
+ gallery_path = export_to_android_gallery(out_path)
2712
+
2713
+ print(f"[termux-diffusion] Synchronized to Android MediaStore: {gallery_path}")
2714
+
2715
+ except Exception as e:
2716
+
2717
+ logger.warning("Could not export to Android gallery: %s", e)
2718
+
2719
+
2720
+
2721
+ # 7.1 Assetize prompt cache metadata for future zero-latency reuse
2722
+
2723
+ if not no_cache and not prompt_cache_meta.is_file():
2724
+
2725
+ try:
2726
+
2727
+ with open(prompt_cache_meta, "w", encoding="utf-8") as f:
2728
+
2729
+ json.dump({
2730
+
2731
+ "prompt": prompt_clean,
2732
+
2733
+ "hash": prompt_hash,
2734
+
2735
+ "model": str(model),
2736
+
2737
+ "created_at": time.time(),
2738
+
2739
+ "verified": True
2740
+
2741
+ }, f, indent=2)
2742
+
2743
+ logger.info("[termux-diffusion] Assetized prompt cache: %s", prompt_cache_meta)
2744
+
2745
+ except OSError as _pe:
2746
+
2747
+ logger.warning("[termux-diffusion] Prompt cache metadata assetization write failed: %s", _pe)
2748
+
2749
+
2750
+
2751
+ return GenerationResult(
2752
+
2753
+ path=out_path,
2754
+
2755
+ gallery_path=gallery_path,
2756
+
2757
+ prompt=prompt,
2758
+
2759
+ negative_prompt=negative_prompt,
2760
+
2761
+ model=model,
2762
+
2763
+ device=effective_device,
2764
+
2765
+ steps=steps,
2766
+
2767
+ cfg_scale=cfg_scale,
2768
+
2769
+ width=width,
2770
+
2771
+ height=height,
2772
+
2773
+ seed=seed,
2774
+
2775
+ elapsed_sec=elapsed,
2776
+
2777
+ sampling_method=effective_sampler,
2778
+
2779
+ schedule=effective_schedule,
2780
+
2781
+ vae_tiling=vae_tiling,
2782
+
2783
+ init_img=effective_init_path,
2784
+
2785
+ strength=effective_strength,
2786
+
2787
+ lora_dir=effective_lora_path,
2788
+
2789
+ clip_skip=effective_clip_skip,
2790
+
2791
+ control_net=effective_cnet_path,
2792
+
2793
+ control_image=effective_cimg_path,
2794
+
2795
+ control_strength=effective_cstrength,
2796
+
2797
+ taesd=effective_taesd_path,
2798
+
2799
+ rpc=effective_rpc if isinstance(effective_rpc, str) else (",".join(cluster_nodes) if cluster_nodes else None),
2800
+
2801
+ tensor_split=tensor_split,
2802
+
2803
+ cluster_rpc_servers=cluster_nodes if cluster_nodes else (parse_cluster_rpc_spec(effective_rpc) if effective_rpc else None),
2804
+
2805
+ cluster_split_mode=effective_split_mode,
2806
+
2807
+ cluster_tensor_split=cluster_tensor_split or tensor_split,
2808
+
2809
+ cluster_vram_budget=effective_max_vram,
2810
+
2811
+ extra_args=all_forwarded if all_forwarded else None,
2812
+
2813
+ isolated_stages=isolate_text_encoder or sequential_stages,
2814
+
2815
+ )
2816
+
2817
+
2818
+
2819
+
2820
+
2821
+ async def async_generate(*args, **kwargs) -> GenerationResult:
2822
+
2823
+ """Asynchronous wrapper for generate() with real cancellation propagation to child process."""
2824
+
2825
+ cancel_event = threading.Event()
2826
+
2827
+ proc_holder = []
2828
+
2829
+ kwargs["_cancel_event"] = cancel_event
2830
+
2831
+ kwargs["_proc_holder"] = proc_holder
2832
+
2833
+ loop = asyncio.get_running_loop()
2834
+
2835
+ task = loop.run_in_executor(None, lambda: generate(*args, **kwargs))
2836
+
2837
+ try:
2838
+
2839
+ return await task
2840
+
2841
+ except asyncio.CancelledError:
2842
+
2843
+ cancel_event.set()
2844
+
2845
+ if proc_holder:
2846
+
2847
+ _safe_kill_process(proc_holder[0])
2848
+
2849
+ raise
2850
+
2851
+
2852
+
2853
+
2854
+
2855
+ def generate_stream(
2856
+
2857
+ prompt: str,
2858
+
2859
+ **kwargs
2860
+
2861
+ ) -> Generator[ProgressInfo, None, GenerationResult]:
2862
+
2863
+ """Generator streaming interface for diffusion image generation.
2864
+
2865
+ Yields ProgressInfo objects in real time, and returns GenerationResult upon completion.
2866
+
2867
+ """
2868
+
2869
+ progress_queue: queue.Queue = queue.Queue()
2870
+
2871
+ done_event = threading.Event()
2872
+
2873
+ result_container = []
2874
+
2875
+ exc_container = []
2876
+
2877
+
2878
+
2879
+ def _cb(info: ProgressInfo):
2880
+
2881
+ progress_queue.put(info)
2882
+
2883
+
2884
+
2885
+ kwargs["progress_callback"] = _cb
2886
+
2887
+
2888
+
2889
+ def _worker():
2890
+
2891
+ try:
2892
+
2893
+ res = generate(prompt, **kwargs)
2894
+
2895
+ result_container.append(res)
2896
+
2897
+ except Exception as e:
2898
+
2899
+ exc_container.append(e)
2900
+
2901
+ finally:
2902
+
2903
+ done_event.set()
2904
+
2905
+
2906
+
2907
+ t = threading.Thread(target=_worker, daemon=True)
2908
+
2909
+ t.start()
2910
+
2911
+
2912
+
2913
+ while not done_event.is_set() or not progress_queue.empty():
2914
+
2915
+ try:
2916
+
2917
+ info = progress_queue.get(timeout=0.05)
2918
+
2919
+ yield info
2920
+
2921
+ except queue.Empty:
2922
+
2923
+ continue
2924
+
2925
+
2926
+
2927
+ if exc_container:
2928
+
2929
+ raise exc_container[0]
2930
+
2931
+
2932
+
2933
+ return result_container[0] if result_container else None
2934
+
2935
+
2936
+